- Published on
Rust Tokio 런타임 중첩 panic 해결 - spawn_blocking
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Rust에서 tokio를 쓰다 보면 어느 날 갑자기 아래와 같은 메시지와 함께 프로세스가 죽는 경우가 있습니다.
Cannot start a runtime from within a runtimeCannot 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가지
- 가능하면 끝까지 async를 유지한다: 동기 wrapper에서 async를 억지로
block_on하지 말고, 호출 경계를 async로 끌어올립니다. - 블로킹/CPU 바운드 작업은 별도 풀로 격리한다: Tokio의
spawn_blocking또는 전용 스레드풀. - 외부 라이브러리가 런타임을 만들면, 그 호출을 런타임 밖으로 빼거나 격리한다: 가장 현실적인 방법이
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로 바꾸기:
fn→async 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 스케줄링)을 제대로 살릴 수 있습니다.