- Published on
Tokio runtime 패닉 - blocking_in_place 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 Tokio 위에서 돌리다 보면, CPU를 오래 잡거나 동기 I/O를 해야 하는 순간이 반드시 옵니다. 그때 많은 개발자가 tokio::task::block_in_place를 떠올리는데, 특정 조건에서 이 함수는 런타임 패닉을 일으키거나(혹은 패닉처럼 보이는 치명적 에러를 남기거나) 성능을 급격히 떨어뜨립니다. 특히 “왜 여기서만 터지지?” 같은 재현이 어려운 형태로 나타나 디버깅 시간이 길어집니다.
이 글은 blocking_in_place가 어떤 전제 위에서만 안전한지, 패닉 메시지의 의미가 무엇인지, 그리고 실무에서 가장 안전하게 고치는 패턴은 무엇인지 정리합니다. (관련해서 런타임 중첩 패닉 계열은 Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking도 함께 보면 맥락이 잘 이어집니다.)
blocking_in_place는 정확히 무엇을 보장하나
tokio::task::block_in_place는 “현재 실행 중인 async 작업이 오래 블로킹될 것”을 런타임에게 알려서, Tokio가 **워커 스레드(worker thread)**를 다른 작업에 양보할 수 있게 하는 장치입니다.
핵심은 다음과 같습니다.
- Tokio의 멀티스레드 런타임(multi-thread runtime) 에서만 의미가 큼
- 호출한 태스크는 블로킹 구간 동안 사실상 워커에서 빠지고, 런타임은 다른 워커/스케줄링으로 전체 정체를 줄이려 함
- “블로킹을 허용”하는 것이지, “아무 데서나 써도 안전”을 보장하지 않음
즉, block_in_place는 “블로킹을 async로 감싸는 만능 도구”가 아니라, 특정 스케줄러/컨텍스트에서만 동작 가능한 런타임 내부 최적화 API에 가깝습니다.
자주 보는 패닉/증상 패턴
환경과 Tokio 버전에 따라 메시지는 조금씩 다르지만, 실무에서 자주 마주치는 범주는 대체로 아래 중 하나입니다.
- 현재 런타임이 current_thread(싱글 스레드)인데 block_in_place를 호출
- 싱글 스레드 런타임은 “양보할 다른 워커”가 없기 때문에
block_in_place의 전제가 깨집니다.
- 런타임 컨텍스트 밖에서 호출(즉, Tokio가 관리하는 태스크가 아닌 곳)
- 예: plain thread, 외부 콜백, Drop 구현, sync 함수 등에서 호출
- 잘못된 위치(특히 async 컨텍스트에서 ‘블로킹’을 너무 길게 수행)
- 패닉이 아니라도, 요청 지연/타임아웃/스루풋 급락으로 이어짐
- 테스트에서만 터짐
#[tokio::test(flavor = "current_thread")]또는 기본 설정이 current_thread인 경우
원인 1: current_thread 런타임에서 호출
가장 흔한 케이스입니다. 예를 들어 테스트나 간단한 CLI에서 아래처럼 구성하면:
#[tokio::main(flavor = "current_thread")]
async fn main() {
tokio::task::block_in_place(|| {
// 무거운 동기 작업
std::thread::sleep(std::time::Duration::from_millis(200));
});
}
이 코드는 런타임 flavor에 따라 패닉이 나거나, 동작하더라도 의도와 다르게 전체가 멈춘 것처럼 보일 수 있습니다.
해결: 멀티스레드 런타임으로 전환
서버/워커처럼 동시성이 중요한 앱이면 멀티스레드 런타임이 일반적으로 적합합니다.
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
tokio::task::block_in_place(|| {
// 짧은 블로킹 구간(정말 필요한 최소만)
std::thread::sleep(std::time::Duration::from_millis(50));
});
}
하지만 “멀티스레드로 바꾸면 끝”은 아닙니다. 블로킹이 길거나 빈번하면 결국 워커가 잠식되고, tail latency가 튑니다. 이때는 아래 원인 3/해결책(특히 spawn_blocking) 쪽이 더 중요합니다.
원인 2: 런타임 컨텍스트 밖에서 호출
다음처럼 async 함수가 아닌 곳에서 호출하거나, Tokio가 관리하지 않는 스레드에서 호출하면 런타임 핸들을 찾지 못해 문제가 됩니다.
fn do_sync_work() {
// 여기엔 Tokio 컨텍스트가 없을 수 있음
tokio::task::block_in_place(|| {
// ...
});
}
해결 A: 호출 지점을 async 쪽으로 끌어올리기
가장 바람직한 방법은 “블로킹 작업을 해야 하는 지점”을 async 경로 안으로 옮기고, 블로킹은 spawn_blocking으로 격리하는 것입니다.
async fn do_work() -> anyhow::Result<()> {
let out = tokio::task::spawn_blocking(|| {
// 동기 I/O 또는 CPU 작업
std::fs::read_to_string("/etc/hosts")
})
.await??;
println!("{}", out);
Ok(())
}
해결 B: 런타임 핸들을 명시적으로 전달
라이브러리 코드처럼 구조상 sync 함수가 필요하다면, tokio::runtime::Handle을 주입받아 async 경로로 브리지하는 방식이 안전합니다.
use tokio::runtime::Handle;
fn do_sync_with_handle(handle: Handle) -> anyhow::Result<String> {
handle.block_on(async {
let s = tokio::task::spawn_blocking(|| std::fs::read_to_string("/etc/hosts"))
.await??;
Ok::<_, anyhow::Error>(s)
})
}
단, 이 패턴은 “호출 스레드를 블로킹”한다는 점에서 서버 요청 처리 경로에는 조심해야 합니다. (런타임 중첩/블로킹 이슈는 Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking에서 더 깊게 다룹니다.)
원인 3: block_in_place로 긴 블로킹을 처리
block_in_place는 “짧고 불가피한 블로킹”에 가까운 도구입니다. 예를 들어:
- 짧은 구간의 legacy 동기 API 호출
- 락을 잠깐 잡고 임계영역을 처리
반대로 다음은 위험합니다.
- 수백 ms~초 단위 CPU 연산
- 대용량 파일 I/O
- 네트워크 동기 호출
- DB 동기 드라이버 호출
이런 작업을 block_in_place로 감싸면, 런타임은 워커를 우회하려고 해도 결국 전체 자원(스레드)을 잠식당해 지연이 폭발합니다. 운영에서는 이게 종종 “프로세스가 멈춘 것 같다” → systemd/k8s 재시작 → 장애로 이어집니다. (재시작 루프를 겪고 있다면 systemd 서비스가 계속 재시작될 때 진단 체크리스트도 함께 참고하면 좋습니다.)
해결: spawn_blocking으로 블로킹 풀에 격리
Tokio는 블로킹 작업을 위해 별도의 스레드 풀을 제공합니다. 정석은 다음입니다.
use anyhow::Context;
async fn hash_big_buffer(buf: Vec<u8>) -> anyhow::Result<String> {
let digest = tokio::task::spawn_blocking(move || {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&buf);
format!("{:x}", hasher.finalize())
})
.await
.context("join error")?;
Ok(digest)
}
이 패턴의 장점:
- async 워커 스레드를 점유하지 않음
- 블로킹 작업이 많아져도 스케줄링이 상대적으로 안정적
spawn_blocking은 “이 작업은 블로킹이다”를 런타임에 명확히 전달
주의: spawn_blocking도 무제한은 아니다
spawn_blocking을 남발하면 블로킹 풀 스레드가 늘거나 큐가 쌓여, 결국 메모리/스레드 자원 문제가 됩니다. 특히 CPU 코어 수 대비 과도한 블로킹 작업은 처리량을 떨어뜨립니다.
실무 팁:
- CPU 바운드 작업은 Rayon 같은 전용 풀을 고려
- 동기 I/O는 가능하면 async I/O 라이브러리로 전환
- 블로킹 작업에 동시성 제한(세마포어) 적용
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn limited_blocking_read(path: String, sem: Arc<Semaphore>) -> anyhow::Result<String> {
let _permit = sem.acquire().await?;
let s = tokio::task::spawn_blocking(move || std::fs::read_to_string(path))
.await??;
Ok(s)
}
원인 4: Drop/락/콜백과 섞이며 교착 또는 패닉처럼 보이는 문제
blocking_in_place 자체 패닉이 아니라도, 아래 조합은 “패닉/멈춤”으로 관찰되기 쉽습니다.
- async 태스크가
Mutex를 잡은 상태에서block_in_place로 긴 작업 - Drop 구현에서 블로킹 정리 작업 수행
- 외부 라이브러리 콜백에서 Tokio API 호출
해결: 경계(락/Drop) 밖으로 블로킹을 이동
- 락은 최소 범위로
- Drop에서는 블로킹 정리를 하지 말고, 명시적
shutdown()API를 두기 - 콜백은 채널로 이벤트만 전달하고, Tokio 태스크에서 처리
use tokio::sync::mpsc;
fn external_callback(tx: mpsc::UnboundedSender<Vec<u8>>, data: Vec<u8>) {
// 콜백에서는 가볍게 전달만
let _ = tx.send(data);
}
async fn run(mut rx: mpsc::UnboundedReceiver<Vec<u8>>) {
while let Some(data) = rx.recv().await {
// 무거운 처리는 async 영역에서 spawn_blocking
let _ = tokio::task::spawn_blocking(move || {
// parse/encode/압축 등
let _len = data.len();
})
.await;
}
}
디버깅 체크리스트(재현이 어려울 때)
- 런타임 flavor 확인
#[tokio::main]/#[tokio::test]에flavor = "current_thread"가 숨어있지 않은지
- 패닉 스택에서 호출 위치 확인
- 어디서
block_in_place가 호출되는지(특히 sync 함수, Drop, 콜백)
- 블로킹 작업의 성격 확인
- CPU 바운드인지, I/O 바운드인지, 평균/최악 시간이 얼마인지
- 동시성 상한 설정 여부
spawn_blocking+ 세마포어로 상한을 걸었는지
- 운영에서 “멈춤→재시작”이라면 외부 증상도 같이 보기
- systemd/journal 로그, OOM, watchdog, health check 실패 등
- 재시작 루프가 관측된다면 systemd 서비스 자동 재시작 무한루프 진단 가이드 같은 체크리스트가 원인 분리에 도움이 됩니다.
결론: block_in_place는 ‘최후의 짧은 우회로’
blocking_in_place는 멀티스레드 런타임의 워커 스레드에서, 정말 짧고 불가피한 블로킹을 처리할 때만 쓰는 편이 안전합니다.- 길거나 빈번한 블로킹/CPU 작업은
spawn_blocking(또는 전용 스레드 풀)로 격리하고, 필요하면 세마포어로 동시성을 제한하세요. - 테스트에서만 터진다면
#[tokio::test(flavor = "multi_thread")]여부를 가장 먼저 확인하는 것이 빠릅니다.
현장에서 “Tokio 패닉”으로 보이는 문제의 상당수는 결국 블로킹 경계 설계 문제로 수렴합니다. block_in_place를 줄이고, 블로킹을 격리하는 방향으로 구조를 바꾸면 패닉도 줄고 지연도 안정화됩니다.