Published on

Tokio runtime 패닉 - blocking_in_place 원인·해결

Authors

서버를 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 버전에 따라 메시지는 조금씩 다르지만, 실무에서 자주 마주치는 범주는 대체로 아래 중 하나입니다.

  1. 현재 런타임이 current_thread(싱글 스레드)인데 block_in_place를 호출
  • 싱글 스레드 런타임은 “양보할 다른 워커”가 없기 때문에 block_in_place의 전제가 깨집니다.
  1. 런타임 컨텍스트 밖에서 호출(즉, Tokio가 관리하는 태스크가 아닌 곳)
  • 예: plain thread, 외부 콜백, Drop 구현, sync 함수 등에서 호출
  1. 잘못된 위치(특히 async 컨텍스트에서 ‘블로킹’을 너무 길게 수행)
  • 패닉이 아니라도, 요청 지연/타임아웃/스루풋 급락으로 이어짐
  1. 테스트에서만 터짐
  • #[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;
    }
}

디버깅 체크리스트(재현이 어려울 때)

  1. 런타임 flavor 확인
  • #[tokio::main] / #[tokio::test]flavor = "current_thread"가 숨어있지 않은지
  1. 패닉 스택에서 호출 위치 확인
  • 어디서 block_in_place가 호출되는지(특히 sync 함수, Drop, 콜백)
  1. 블로킹 작업의 성격 확인
  • CPU 바운드인지, I/O 바운드인지, 평균/최악 시간이 얼마인지
  1. 동시성 상한 설정 여부
  • spawn_blocking + 세마포어로 상한을 걸었는지
  1. 운영에서 “멈춤→재시작”이라면 외부 증상도 같이 보기

결론: block_in_place는 ‘최후의 짧은 우회로’

  • blocking_in_place는 멀티스레드 런타임의 워커 스레드에서, 정말 짧고 불가피한 블로킹을 처리할 때만 쓰는 편이 안전합니다.
  • 길거나 빈번한 블로킹/CPU 작업은 spawn_blocking(또는 전용 스레드 풀)로 격리하고, 필요하면 세마포어로 동시성을 제한하세요.
  • 테스트에서만 터진다면 #[tokio::test(flavor = "multi_thread")] 여부를 가장 먼저 확인하는 것이 빠릅니다.

현장에서 “Tokio 패닉”으로 보이는 문제의 상당수는 결국 블로킹 경계 설계 문제로 수렴합니다. block_in_place를 줄이고, 블로킹을 격리하는 방향으로 구조를 바꾸면 패닉도 줄고 지연도 안정화됩니다.