- Published on
Rust Tokio 런타임 패닉 원인 7가지와 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Rust로 만들다 보면, 어느 순간 tokio 런타임에서 갑자기 패닉이 터지면서 프로세스가 종료되는 경험을 하게 됩니다. 특히 개발 환경에서는 잘 돌다가도, 테스트/CI 또는 컨테이너 환경에서만 재현되는 경우가 많아 원인 파악이 어렵습니다.
이 글은 Tokio 런타임 패닉을 “왜 나는지”를 증상 중심으로 7가지로 정리하고, 각 케이스별로 재현 예제와 해결책을 함께 제공합니다. 마지막에는 운영 환경에서 패닉을 빠르게 좁히는 체크리스트도 넣었습니다.
참고로, 런타임 패닉이 메모리 압박이나 컨테이너 OOM으로 보이는 경우도 있습니다. 컨테이너에서 Exit 137 같은 형태로 죽는다면 이 글도 같이 보면 도움이 됩니다: GitLab Runner Docker executor OOM·Exit 137 해결
1) 런타임 중첩 생성: Cannot start a runtime from within a runtime
전형적인 증상
- 에러 메시지에
Cannot start a runtime from within a runtime또는 유사 문구 - 보통 라이브러리 내부에서
tokio::runtime::Runtime::new().block_on(...)을 호출할 때 발생
재현 코드
use tokio::runtime::Runtime;
async fn work() {
// ...
}
#[tokio::main]
async fn main() {
// 이미 Tokio 런타임 안인데,
// 또 런타임을 만들어 block_on 하면 패닉 가능
let rt = Runtime::new().unwrap();
rt.block_on(work());
}
해결책
- 원칙: 애플리케이션 최상단(엔트리포인트)에서만 런타임을 만들고, 나머지는
async를 전파합니다. - 라이브러리라면
block_on제공을 지양하고async fnAPI를 제공하세요. - 정말 동기 API가 필요하면, 호출자가 런타임 밖에서 사용하도록 문서화하거나, 별도 스레드에서 런타임을 구동하는 어댑터를 분리합니다.
권장 패턴
async fn work() {
// ...
}
#[tokio::main]
async fn main() {
work().await;
}
2) 런타임 컨텍스트 밖에서 Tokio 기능 호출: there is no reactor running
전형적인 증상
- 메시지에
there is no reactor running또는not currently running on a Tokio runtime tokio::spawn,tokio::time::sleep,TcpStream::connect등을 런타임 밖에서 호출
재현 코드
fn main() {
// 런타임 없이 Tokio 타이머를 사용하면 실패
let _ = tokio::time::sleep(std::time::Duration::from_millis(10));
}
해결책
#[tokio::main]또는 명시적 런타임을 통해 컨텍스트를 보장합니다.- 테스트에서는
#[tokio::test]를 사용합니다.
#[tokio::main]
async fn main() {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
테스트 예시:
#[tokio::test]
async fn test_sleep() {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
3) spawn한 태스크의 패닉이 JoinHandle로 전파됨
Tokio 자체가 “런타임이 패닉”이라기보다, 태스크 내부 패닉이 JoinError로 돌아오고 이를 unwrap() 하면서 프로세스가 죽는 형태가 매우 흔합니다.
전형적인 증상
- 로그에
task panicked또는JoinError::Panic관련 표시 - 호출 코드에서
handle.await.unwrap()같은 패턴
재현 코드
#[tokio::main]
async fn main() {
let h = tokio::spawn(async {
panic!("boom");
});
// 여기서 unwrap이 다시 패닉을 유발
h.await.unwrap();
}
해결책
JoinHandle은 반드시 에러를 분기 처리합니다.- 태스크 경계에서 패닉이 나지 않도록
Result기반으로 설계하고, 필요한 경우catch_unwind도 고려합니다.
#[tokio::main]
async fn main() {
let h = tokio::spawn(async {
// panic 대신 Result로
anyhow::Result::<()>::Ok(())
});
match h.await {
Ok(Ok(())) => {}
Ok(Err(e)) => eprintln!("task error: {e:#}"),
Err(join_err) => eprintln!("task join error: {join_err}"),
}
}
4) 블로킹 작업을 런타임 워커 스레드에서 실행 (교착·타임아웃·연쇄 실패)
이 케이스는 즉시 패닉이 아니라, 시간이 지나면서 타임아웃/리소스 고갈을 유발하고 결국 다른 곳에서 패닉이 터지는 형태로 나타납니다. 특히 CPU 바운드 연산, 파일 I/O, 동기 락 대기, 외부 프로세스 실행 등을 워커 스레드에서 돌리면 런타임이 “멈춘 것처럼” 보입니다.
재현 코드(나쁜 예)
#[tokio::main]
async fn main() {
// 워커 스레드를 장시간 점유
std::thread::sleep(std::time::Duration::from_secs(2));
// 이 뒤의 async 작업들이 밀리면서 연쇄 장애
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
해결책
- 블로킹은
tokio::task::spawn_blocking으로 격리합니다. - CPU 바운드가 많다면 별도 스레드풀(예: rayon) 또는 워커 프로세스로 분리합니다.
#[tokio::main]
async fn main() {
let v = tokio::task::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_secs(1));
42
})
.await
.unwrap();
println!("{v}");
}
운영에서 “블로킹으로 인한 고갈”은 DB 커넥션 풀 고갈과 매우 비슷한 양상으로 나타납니다. 리소스가 대기열에 쌓이면 결국 타임아웃과 패닉이 터집니다. 커넥션 풀 관점의 진단 프레임은 이 글도 참고할 만합니다: Spring Boot HikariCP 커넥션 고갈 원인과 해결
5) block_on 사용 실수: 현재 스레드 런타임과의 상호작용 문제
block_on 자체는 나쁜 도구가 아니지만, 사용 위치/스레드에 따라 의도치 않은 상호작용을 만들 수 있습니다.
전형적인 증상
- 특정 경로에서만 데드락/패닉
current_thread런타임에서, 내부적으로 같은 스레드에 의존하는 작업을 기다리며 멈춤
재현 코드(개념 예시)
use tokio::runtime::Builder;
fn main() {
let rt = Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
// current_thread 런타임은 한 스레드에서만 폴링
// 여기서 블로킹/락/대기 잘못 섞이면 멈추기 쉽다
tokio::task::yield_now().await;
});
}
해결책
- 서버/고동시성 워크로드는 기본적으로 멀티스레드 런타임을 권장합니다.
block_on은 “최상단 1회” 또는 “동기 진입점에서 짧게 async를 감싸는 용도”로 제한합니다.- 동기 코드에서 async를 호출해야 한다면, 장기적으로는 호출 체인을 async로 전환하는 것이 안전합니다.
6) Drop 시점에 런타임 의존 작업 수행: 런타임 종료 이후 사용
Drop 구현에서 tokio::spawn 을 하거나, async 정리 작업을 억지로 수행하려다 런타임이 이미 내려간 상태면 패닉/에러가 발생할 수 있습니다. 특히 전역(static) 객체, lazy 초기화 싱글톤, 로깅/메트릭 플러셔 등이 흔한 원인입니다.
전형적인 증상
- 프로그램 종료 시점에만 터짐
- 테스트 종료 시점에만 간헐적으로 터짐
재현 코드(나쁜 예)
struct Bad;
impl Drop for Bad {
fn drop(&mut self) {
// 드롭 시점에 런타임이 없을 수 있음
tokio::spawn(async {
// cleanup
});
}
}
#[tokio::main]
async fn main() {
let _b = Bad;
}
해결책
- 정리 작업은
Drop이 아니라 명시적shutdown()메서드로 수행하고, 호출자가await하게 만듭니다. - 종료 훅이 필요하면
tokio::signal을 이용해 종료 시퀀스를 명확히 구성합니다.
struct Good;
impl Good {
async fn shutdown(self) {
// async cleanup
}
}
#[tokio::main]
async fn main() {
let g = Good;
// ...
g.shutdown().await;
}
7) 스레드/메모리 리소스 한계: 스레드 생성 실패, OOM, 너무 큰 스택
Tokio 런타임은 워커 스레드, 블로킹 스레드풀, 네트워크 버퍼 등 다양한 리소스를 씁니다. 컨테이너에서 메모리 제한이 빡빡하거나, 시스템 스레드 제한이 낮거나, 무한 태스크 생성으로 메모리를 잡아먹으면 “런타임 패닉처럼 보이는 종료”가 발생합니다.
전형적인 증상
- 컨테이너가
OOMKilled또는Exit 137 failed to spawn thread류 메시지- 부하가 증가할수록 재현
재현 코드(태스크 폭증)
#[tokio::main]
async fn main() {
loop {
tokio::spawn(async {
// 종료되지 않는 태스크가 계속 쌓임
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
});
}
}
해결책
- 태스크 생성에 상한을 둡니다.
Semaphore로 동시성 제한을 걸어 폭증을 막습니다. - 큐 기반 워커 패턴을 사용해 생산자/소비자 속도를 제어합니다.
- 런타임 설정을 워크로드에 맞게 조정합니다. 예를 들어 워커 스레드 수를 명시하거나, 블로킹 스레드풀 사용을 점검합니다.
동시성 제한 예시:
use std::sync::Arc;
use tokio::sync::Semaphore;
#[tokio::main]
async fn main() {
let sem = Arc::new(Semaphore::new(100));
for _i in 0..10_000 {
let permit = sem.clone().acquire_owned().await.unwrap();
tokio::spawn(async move {
let _permit = permit; // 작업 중 permit 유지
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
});
}
}
컨테이너/쿠버네티스에서 “로그 없이 죽는” 느낌이라면, 앱 레벨 패닉 이전에 OOM이나 강제 종료일 가능성도 큽니다. 운영 진단 관점에서는 이 글의 접근이 유용합니다: EKS Pod CrashLoopBackOff 로그 없을 때 7단계 진단
패닉을 빨리 좁히는 실전 체크리스트
1) 패닉 메시지를 분류
Cannot start a runtime from within a runtime이면 런타임 중첩there is no reactor running이면 런타임 컨텍스트 누락task panicked또는JoinError이면 태스크 내부 패닉 전파
2) unwrap() 의 위치를 찾기
JoinHandle.await.unwrap()Result를 반환하는 I/O 호출의unwrap()- 채널 송수신(
send().await.unwrap())의 수신자 종료
가능하면 다음처럼 컨텍스트를 붙여 에러를 남깁니다.
use anyhow::Context;
async fn run() -> anyhow::Result<()> {
let _ = tokio::fs::read("missing.txt")
.await
.context("failed to read config file")?;
Ok(())
}
3) 블로킹 호출이 섞였는지 점검
std::thread::sleep- 동기 DB 클라이언트
- 무거운 CPU 연산
- mutex를 오래 잡는 코드
발견되면 spawn_blocking 또는 아키텍처 분리로 격리합니다.
4) 종료 시퀀스를 명시적으로 설계
Drop에서 async 정리 금지shutdown().await를 제공tokio::signal로 종료 이벤트를 받아 정리 후 종료
마무리
Tokio 런타임 패닉은 대부분 “런타임을 어디서 만들었는지”, “런타임 컨텍스트가 보장되는지”, “태스크 경계에서 패닉을 어떻게 다루는지”, “블로킹/리소스 폭증이 있는지” 네 축으로 정리됩니다. 위 7가지는 실무에서 재현 빈도가 높은 패턴이므로, 에러 메시지와 코드 구조를 대조해보면 원인을 빠르게 좁힐 수 있습니다.
원하시면 실제 패닉 로그(메시지 전문)와 런타임 생성 코드, Cargo.toml 의 tokio feature 설정을 알려주시면, 어떤 케이스에 가장 가까운지 기준을 잡아 구체적으로 디버깅 플랜까지 제안해드릴게요.