- Published on
Rust tokio 런타임 패닉 - block_on 중첩 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 CLI 도구를 Rust로 만들다 보면, Tokio 런타임을 이미 실행 중인 상태에서 다시 block_on 을 호출해 버려 런타임 패닉을 마주치는 경우가 많습니다. 대표적으로 다음과 같은 메시지입니다.
Cannot start a runtime from within a runtime- 또는
block_on호출 지점에서의 패닉
이 문제는 단순히 “block_on 을 쓰지 마” 수준으로 끝나지 않습니다. 라이브러리 설계, 동기 API와 비동기 API의 경계, 테스트/벤치/FFI 같은 특수한 실행 환경에서 자주 재발합니다. 이 글에서는 패닉이 발생하는 구조적 원인을 분해하고, 실무에서 재사용 가능한 해결 패턴을 코드로 정리합니다.
참고로, 런타임/환경 차이로 발생하는 에러를 빠르게 좁혀가는 접근은 다른 분야에서도 비슷합니다. 예를 들어 Edge 런타임 환경 차이로 생기는 이슈를 다룬 글인 Next.js 14 Edge 런타임 crypto is not defined 해결법도 “실행 컨텍스트를 먼저 확인하라”는 점에서 결이 같습니다.
왜 중첩 block_on 이 패닉을 일으키나
Tokio 런타임은 내부적으로 스케줄러, I/O 드라이버, 타이머 등을 관리합니다. Runtime::block_on 은 현재 스레드에서 런타임을 구동하고 Future 가 완료될 때까지 “동기적으로” 기다립니다.
문제는 이미 Tokio 런타임이 구동 중인 스레드(예: #[tokio::main] 이 만든 런타임 워커 스레드)에서 다시 Runtime::new() 를 만들고 그 위에서 block_on 을 호출하는 경우입니다. 이때 Tokio 는 “런타임 안에서 런타임을 시작할 수 없다”라고 판단해 패닉을 냅니다.
즉, 핵심은 다음 한 줄입니다.
- 비동기 컨텍스트에서 동기 대기(
block_on)를 다시 걸면 런타임이 중첩된다.
흔한 재현 패턴 4가지
1) #[tokio::main] 내부에서 다시 Runtime::new().block_on
아래 코드는 거의 100% 패닉으로 이어집니다.
use tokio::runtime::Runtime;
#[tokio::main]
async fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("nested block_on");
});
}
2) async 함수에서 동기 래퍼가 block_on 을 호출
“동기 API도 제공해야 해서” 라는 이유로 async 내부에서 동기 래퍼를 호출하는 경우입니다.
use tokio::runtime::Runtime;
fn do_work_sync() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
// ...
});
}
async fn handler() {
// 이미 런타임 위에서 실행 중
do_work_sync();
}
3) 라이브러리가 내부적으로 런타임을 생성
사용자는 async 환경인지 모르고 호출했는데, 라이브러리가 내부에서 런타임을 띄워버리는 설계입니다. 이 경우 호출자 입장에서는 원인 파악이 더 어렵습니다.
4) 테스트에서 런타임 매크로와 수동 런타임을 혼용
#[tokio::test] 가 이미 런타임을 제공하는데, 테스트 본문에서 또 Runtime::new() 를 만들어 block_on 을 호출하는 경우입니다.
해결의 큰 원칙: async 경계를 “위로” 올린다
가장 좋은 해결책은 단순합니다.
- 가능하면
block_on은 프로그램 진입점(최상단)에서 한 번만 쓴다. - 라이브러리/모듈은
async fn을 제공하고, 호출자가 await 하게 만든다.
올바른 구조 예시
동기 main 에서 한 번만 런타임을 만들고 block_on 을 호출하거나, 더 흔하게는 #[tokio::main] 을 쓰고 내부는 전부 await 로 연결합니다.
#[tokio::main]
async fn main() {
run().await;
}
async fn run() {
do_work().await;
}
async fn do_work() {
println!("ok");
}
이 구조에서는 중첩 런타임이 생길 여지가 크게 줄어듭니다.
패턴 1: 동기 API가 꼭 필요하면 Handle 로 “현재 런타임”을 사용
이미 런타임이 존재한다면 새 런타임을 만들지 말고, 현재 런타임의 핸들을 활용해야 합니다.
다만 주의할 점이 있습니다.
Handle::block_on은 “현재 런타임이 없는 스레드”에서 호출해야 안전합니다.- 런타임 워커 스레드(즉 async 컨텍스트)에서 다시 block 하면 교착/패닉 위험이 큽니다.
그럼에도 실무에서는 다음 두 가지 형태가 많이 쓰입니다.
(A) 런타임 밖 스레드에서만 동기 대기
예를 들어, 별도 스레드(동기 환경)에서 핸들을 받아 동기 대기를 수행합니다.
use tokio::runtime::Handle;
pub fn do_work_sync(handle: Handle) -> i32 {
handle.block_on(async {
// async 로직
42
})
}
호출자는 다음처럼 “런타임 밖”에서만 이 함수를 사용하도록 규칙을 둡니다.
(B) async 환경에서는 동기 래퍼를 호출하지 않도록 분리
동기/비동기 API를 이름부터 분리해 실수 가능성을 낮춥니다.
pub async fn do_work_async() -> i32 {
42
}
pub fn do_work_sync() -> i32 {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(do_work_async())
}
이 구조는 “동기 프로그램에서만” do_work_sync 를 호출한다는 전제가 있을 때 안전합니다. 핵심은 async 컨텍스트에서 do_work_sync 를 부르지 않게 설계/문서화하는 것입니다.
패턴 2: async 컨텍스트에서는 spawn 으로 넘기고 await 한다
async 함수 안에서 어떤 작업을 “분리”하고 싶다면 block_on 이 아니라 tokio::spawn 과 await 를 사용합니다.
async fn handler() -> anyhow::Result<()> {
let join = tokio::spawn(async {
// 병렬로 실행할 작업
123
});
let value = join.await?;
println!("value={}", value);
Ok(())
}
이 방식은 런타임의 스케줄링 모델을 그대로 따르기 때문에 중첩 런타임 문제가 발생하지 않습니다.
패턴 3: 블로킹 작업은 spawn_blocking 으로 격리
가장 많이 헷갈리는 지점이 “동기 함수가 필요해서 block_on” 이 아니라, 사실은 “CPU 바운드/블로킹 I/O 때문에 async 를 막고 있었다” 인 경우입니다.
이때는 block_on 중첩을 만들기보다 spawn_blocking 으로 블로킹 작업을 전용 스레드풀로 격리합니다.
async fn handler() -> anyhow::Result<()> {
let result = tokio::task::spawn_blocking(|| {
// 예: 큰 압축 해제, 이미지 처리, 레거시 블로킹 API 호출
7 * 6
})
.await?;
println!("result={}", result);
Ok(())
}
이 패턴은 성능/안정성 면에서도 중요합니다. 블로킹 작업이 async 워커 스레드를 점유하면 레이턴시가 튀고, 타임아웃/헬스체크 실패가 연쇄적으로 발생할 수 있습니다. 이런 “실행 환경과 스케줄링을 분리해 문제를 줄이는” 접근은 인프라에서도 유사하게 나타납니다. 예를 들어 GCP Cloud Run 503·콜드스타트 줄이는 튜닝에서처럼 병목을 만드는 구간을 격리/완화하는 전략이 핵심입니다.
패턴 4: 라이브러리 설계는 “런타임을 만들지 않는 것”이 기본
라이브러리(크레이트)는 원칙적으로 다음 중 하나를 택하는 편이 좋습니다.
- 순수 async API 제공:
async fn과 Future 기반으로 제공하고 호출자가 await - 동기 API 제공 시 런타임 주입:
Handle또는Runtime을 인자로 받음
런타임 주입 예시
use tokio::runtime::Handle;
pub struct Client {
handle: Handle,
}
impl Client {
pub fn new(handle: Handle) -> Self {
Self { handle }
}
pub fn fetch_sync(&self) -> String {
self.handle.block_on(async {
// 네트워크 요청 등
"ok".to_string()
})
}
pub async fn fetch_async(&self) -> String {
"ok".to_string()
}
}
이 설계의 장점은 다음과 같습니다.
- 호출자가 어떤 런타임을 쓰는지(Tokio 멀티스레드/커런트스레드 등) 통제 가능
- 라이브러리가 런타임 중첩을 “몰래” 만들지 않음
- 테스트에서 런타임 구성/시간 제어가 쉬움
디버깅 체크리스트: 어디서 block_on 이 호출되는지 찾기
패닉이 나면 먼저 “어느 스레드/어느 컨텍스트에서 block_on 이 호출됐는지”를 찾아야 합니다.
- 패닉 스택트레이스에서
Runtime::block_on또는Handle::block_on위치 확인 #[tokio::main],#[tokio::test]사용 여부 확인- 동기 래퍼 함수가 async 경로에서 호출되는지 grep
- 의존 라이브러리가 런타임을 내부 생성하는지 릴리즈 노트/소스 확인
특히 2번이 흔합니다. #[tokio::test] 는 이미 런타임이므로, 테스트 안에서 또 런타임을 만들면 바로 중첩이 됩니다.
자주 묻는 케이스 정리
Q1. 정말로 async 안에서 “동기적으로” 결과가 필요해요
대부분은 설계를 다시 잡아야 합니다.
- async 함수라면
await로 결과를 받는 것이 정상 - 병렬성이 필요하면
spawn후await - 블로킹 작업이면
spawn_blocking
“동기적으로 기다려야 한다”는 요구는 보통 상위 레이어(예: FFI, 레거시 프레임워크 콜백)에서 발생합니다. 그 레이어에서만 block_on 을 쓰고, 아래로는 async 를 유지하는 게 안전합니다.
Q2. std::sync::Mutex 때문에 막혀서 block_on 을 썼어요
async 환경에서는 tokio::sync::Mutex 같은 async 친화적 동기화를 쓰거나, 락 점유 시간을 줄여야 합니다. 락을 잡은 채로 await 하거나, 반대로 await 를 피하려고 block_on 을 중첩하면 둘 다 문제를 키웁니다.
Q3. 웹 프레임워크(예: Axum) 핸들러에서 동기 함수 호출이 필요해요
핸들러는 이미 런타임 위에서 실행됩니다. 동기 함수가 CPU 바운드면 spawn_blocking 으로, I/O 바운드면 async 라이브러리로 교체하는 게 정석입니다.
결론: block_on 은 “최상단에서 한 번”만
Tokio 런타임 패닉의 본질은 block_on 이 나쁘다기보다, 비동기 실행 모델 안에 동기 대기를 끼워 넣으면서 런타임을 중첩시키는 데 있습니다.
실무에서 가장 재발 방지 효과가 큰 규칙은 다음입니다.
block_on은 진입점(프로그램 최상단)에서만 사용- 라이브러리는 런타임을 만들지 말고 async API 또는 런타임 주입
- async 컨텍스트에서는
spawn, 블로킹은spawn_blocking
이 원칙대로 경계를 정리하면, 패닉을 없애는 것뿐 아니라 성능(스레드 점유/레이턴시)과 유지보수성(테스트/확장성)까지 함께 좋아집니다.