Published on

Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking

Authors

서버 사이드 Rust에서 tokio를 쓰다 보면 어느 날 갑자기 아래와 같은 메시지와 함께 프로세스가 죽는 경우가 있습니다.

  • Cannot start a runtime from within a runtime
  • Cannot block the current thread (문맥에 따라 함께 등장)
  • 또는 동기 라이브러리 내부에서 block_on/Runtime::new()를 호출하며 터지는 panic

이 글은 Tokio 런타임 중첩(nested runtime) panic의 원인을 명확히 분해하고, 실무에서 가장 현실적인 해법인 **tokio::task::spawn_blocking**로 안전하게 감싸는 패턴을 정리합니다. 또한 “그냥 spawn_blocking으로 감싸면 되지”에서 끝나지 않도록, 언제 감싸야 하고, 무엇을 바꿔야 하며, 대안은 무엇인지까지 다룹니다.

> 운영 환경에서 이런 panic은 종종 “컨테이너가 갑자기 죽고 503이 난다” 형태로 관측됩니다. 런타임 panic은 재시작 루프를 유발할 수 있으니, 증상이 503/재시작으로 보일 때는 인프라 관점 점검도 함께 필요합니다. 참고: systemd 서비스가 계속 재시작될 때 원인 9가지, Cloud Run 503·컨테이너 미기동 원인 7가지

Tokio 런타임 중첩 panic이란?

Tokio는 비동기 실행을 위해 런타임(스케줄러+I/O 드라이버+타이머)을 제공합니다. 일반적으로 애플리케이션은 프로세스당 하나의 런타임(혹은 스레드별 런타임이 필요하면 매우 의도적으로)만 구성합니다.

그런데 이미 Tokio 런타임 안에서 실행 중인 코드(즉, async fn 내부)에서 다음 같은 동작을 하면 문제가 생깁니다.

  • 또 다른 Tokio 런타임을 만들고(tokio::runtime::Runtime::new())
  • 그 런타임으로 block_on을 호출하거나
  • 현재 런타임 스레드를 직접 블로킹

Tokio는 기본적으로 런타임 스레드(특히 worker thread)를 블로킹하는 행위를 금지/제한하며, 런타임을 중첩해서 시작하는 것도 안전하지 않기 때문에 panic으로 막습니다.

전형적인 재현 코드

아래 코드는 “동기 함수가 내부에서 runtime을 만들고 block_on” 하는 전형적인 안티패턴입니다.

use tokio::runtime::Runtime;

fn sync_wrapper_calls_async() -> String {
    // 안티패턴: 런타임 내부에서 런타임 생성 + block_on
    let rt = Runtime::new().unwrap();
    rt.block_on(async { "hello".to_string() })
}

#[tokio::main]
async fn main() {
    // 이미 Tokio 런타임 위
    let v = sync_wrapper_calls_async();
    println!("{v}");
}

실제로는 직접 이렇게 쓰기보다, **외부 크레이트(동기 API)**가 내부에서 이런 일을 하면서 터지는 경우가 많습니다.

왜 이런 일이 자주 발생하나: 경계가 섞이기 때문

서비스 코드는 보통 axum/warp/tonic 같은 async 프레임워크 위에서 돌아가는데, 다음 요소들이 섞이면서 문제가 발생합니다.

  • 레거시 동기 라이브러리(예: 오래된 DB/SDK, 파일/압축/암호화 라이브러리)
  • CPU 바운드 작업(대용량 JSON 파싱/서명 검증/이미지 처리)
  • 블로킹 I/O(표준 라이브러리의 std::fs, 동기 네트워크 호출)
  • “동기 함수에서 async를 호출하고 싶다”는 욕구로 인한 block_on 남용

핵심은 async 컨텍스트에서 블로킹을 하면 런타임이 멈춘다는 점입니다. 멈추면 타임아웃이 늘고, 요청이 밀리고, 결국 장애로 번집니다. (ALB/Ingress 기준으로는 502/504로 보이기도 합니다. 참고: AWS ALB 502·504 난사 - 원인별 해결 체크리스트)

해결의 기본 원칙 3가지

  1. 가능하면 끝까지 async를 유지한다: 동기 wrapper에서 async를 억지로 block_on 하지 말고, 호출 경계를 async로 끌어올립니다.
  2. 블로킹/CPU 바운드 작업은 별도 풀로 격리한다: Tokio의 spawn_blocking 또는 전용 스레드풀.
  3. 외부 라이브러리가 런타임을 만들면, 그 호출을 런타임 밖으로 빼거나 격리한다: 가장 현실적인 방법이 spawn_blocking입니다.

spawn_blocking으로 런타임 중첩 panic을 우회하는 방법

tokio::task::spawn_blocking블로킹 작업 전용 스레드풀에서 클로저를 실행합니다. 즉, Tokio의 async worker thread를 막지 않습니다.

  • 장점: 기존 동기 라이브러리를 큰 리팩터 없이 감쌀 수 있음
  • 주의: 무한정 남용하면 blocking 풀 고갈, 컨텍스트 스위칭 증가

패턴 1) 동기 함수(내부에서 block_on/런타임 생성)를 spawn_blocking으로 감싸기

아래는 “문제 있는 동기 함수”를 손대기 어렵다는 가정 하에, 호출부에서 감싸는 방식입니다.

use tokio::task;

fn legacy_sync_call() -> anyhow::Result<String> {
    // 예: 내부적으로 Runtime::new() + block_on 을 한다고 가정
    // 혹은 std::fs, 동기 HTTP, 무거운 CPU 작업 등을 수행
    Ok("ok".to_string())
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let handle = task::spawn_blocking(|| legacy_sync_call());

    // JoinHandle<Result<T>> 형태이므로 await 후 ?
    let result = handle.await??;
    println!("{result}");

    Ok(())
}

이 방식은 현재 런타임 내부에서 또 다른 런타임을 시작하려는 시도를 “blocking 풀의 스레드”로 옮겨서 회피합니다. 즉, nested runtime 자체를 없애는 게 아니라, 안전한 영역에서 실행되도록 격리하는 것입니다.

패턴 2) 블로킹 I/O(std::fs 등)를 spawn_blocking으로 격리

Tokio는 비동기 파일 I/O를 일부 제공하지만, 여전히 std::fs를 쓰는 코드가 많습니다. 이 경우도 마찬가지로 격리합니다.

use tokio::task;
use std::path::PathBuf;

async fn read_file_blocking(path: PathBuf) -> anyhow::Result<Vec<u8>> {
    let bytes = task::spawn_blocking(move || std::fs::read(path)).await??;
    Ok(bytes)
}

패턴 3) CPU 바운드(예: 압축/암호화/대용량 파싱)도 spawn_blocking

CPU 바운드 작업은 async가 아닙니다. async는 I/O 대기에 강한 모델이지, CPU를 더 빠르게 해주지 않습니다.

use tokio::task;

fn expensive_cpu_work(input: Vec<u8>) -> Vec<u8> {
    // 예: 압축/해시/서명 검증/이미지 리사이즈
    input.into_iter().rev().collect()
}

async fn do_work(input: Vec<u8>) -> Vec<u8> {
    task::spawn_blocking(move || expensive_cpu_work(input))
        .await
        .expect("blocking task panicked")
}

spawn_blocking 사용 시 실무 주의사항

1) blocking 풀 고갈과 지연 전파

spawn_blocking은 내부적으로 별도 풀을 쓰지만, 무한정 확장되는 마법의 풀은 아닙니다. 요청당 spawn_blocking을 남발하면 다음이 발생합니다.

  • blocking 풀 큐가 쌓임
  • 응답 지연 증가
  • 타임아웃(504 등)로 관측

따라서 다음을 고려하세요.

  • 정말 블로킹이 필요한 구간만 최소화
  • 가능하면 async 대체 라이브러리 사용(예: reqwest async, sqlx 등)
  • CPU 바운드는 rayon 같은 전용 CPU 풀을 고려

2) 취소(cancellation) 전파가 약하다

async task는 drop되면 취소되는 것처럼 보이지만, spawn_blocking으로 넘긴 클로저는 이미 OS 스레드에서 실행 중이면 즉시 중단되지 않습니다.

  • 요청이 취소되어도 작업은 계속 돌 수 있음
  • 외부 리소스(파일 핸들, 네트워크)를 오래 잡을 수 있음

해결책은 보통 “작업을 쪼개고 중간중간 취소 플래그 확인”이지만, 동기 라이브러리라면 어렵습니다. 이 경우 타임아웃을 걸고 결과를 버리는 전략을 씁니다.

use tokio::{task, time::{timeout, Duration}};

async fn guarded_blocking_call() -> anyhow::Result<String> {
    let h = task::spawn_blocking(|| {
        // 오래 걸릴 수 있는 동기 작업
        std::thread::sleep(Duration::from_secs(5));
        "done".to_string()
    });

    let v = timeout(Duration::from_secs(1), h).await;
    match v {
        Ok(joined) => Ok(joined?),
        Err(_) => anyhow::bail!("timeout"),
    }
}

3) 런타임 중첩을 “근본 해결”하려면 경계를 재설계해야 한다

spawn_blocking은 훌륭한 응급처치이지만, 근본적으로는 아래 중 하나가 더 좋습니다.

  • 동기 wrapper를 async로 바꾸기: fnasync fn으로 경계 올리기
  • 라이브러리 교체: 내부에서 런타임을 만드는 크레이트는 피하기
  • 런타임 핸들 주입: 라이브러리가 런타임을 만들지 말고 Handle을 받도록(가능한 경우)

예를 들어, “동기 API에서 async를 호출하고 싶어서 block_on”이 필요했다면, 보통은 호출자 쪽을 async로 바꾸는 게 정답입니다.

자주 묻는 케이스별 처방전

케이스 A) #[tokio::main]이 여러 번 등장한다

바이너리 엔트리포인트는 하나여야 합니다. 라이브러리 코드에 #[tokio::main]/#[tokio::test]가 섞여 있으면 런타임을 중첩 생성할 확률이 큽니다.

  • 라이브러리: async fn만 제공
  • 바이너리(main): #[tokio::main]로 런타임 시작

케이스 B) 동기 SDK가 내부에서 tokio runtime을 만든다

  • 가능하면 async 버전 SDK 사용
  • 불가하면 호출을 spawn_blocking으로 감싸서 격리

케이스 C) 핸들러에서 std::sync::Mutex를 오래 잡고 블로킹

이 경우는 런타임 중첩 panic이 아니라도 성능/교착 문제가 나기 쉽습니다.

  • async에서는 tokio::sync::Mutex 고려
  • 락을 잡은 채로 await/블로킹 호출하지 않기
  • CPU/블로킹은 spawn_blocking으로 락 밖에서 실행

디버깅 팁: panic을 “증상”이 아닌 “원인”으로 좁히기

런타임 중첩 panic은 스택트레이스가 길고, 종종 서드파티 코드에서 터집니다. 다음이 유용합니다.

  • RUST_BACKTRACE=1 또는 full로 스택 확인
  • panic 메시지에 runtime/block_on/enter 같은 키워드가 있는지 확인
  • “어떤 경로에서 동기 호출이 async 핸들러로 들어왔는지”를 추적

운영에서는 panic으로 프로세스가 죽으면 곧바로 503/재시작으로 이어질 수 있습니다. 로그 수집과 재시작 루프 분석은 인프라 레이어에서도 함께 봐야 합니다(예: systemd/Kubernetes/Cloud Run).

정리

  • Tokio 런타임 중첩 panic은 런타임 안에서 또 런타임을 시작하거나, 런타임 스레드를 블로킹할 때 발생합니다.
  • 가장 현실적인 해결책은 블로킹/동기/CPU 바운드 작업을 spawn_blocking으로 격리하는 것입니다.
  • 다만 spawn_blocking은 만능이 아니며, 남용 시 blocking 풀 고갈, 취소 전파 한계가 있습니다.
  • 가능하면 경계를 async로 재설계하고, 서드파티 라이브러리 선택 시 “내부에서 런타임을 만들지 않는지”를 점검하세요.

이 패턴을 적용하면 “갑자기 죽는 503”의 원인이 되는 런타임 panic을 줄이고, Tokio 런타임의 장점(고성능 I/O 스케줄링)을 제대로 살릴 수 있습니다.