- Published on
Rust tokio runtime dropped 패닉 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Rust tokio로 작성하다 보면 어느 순간 아래와 비슷한 메시지로 패닉이 터질 때가 있습니다.
thread '...' panicked at '... runtime dropped ...'Cannot drop a runtime in a context where blocking is not allowedA Tokio 1.x runtime was dropped from within an asynchronous context
이 글은 이 문제를 단순히 "런타임이 먼저 죽었다"로 끝내지 않고, 어떤 코드 구조에서, 왜, 어떻게 재현되며, 어떤 방식으로 고치는 게 안전한지를 패턴 중심으로 정리합니다.
비슷하게 "타임아웃이나 런타임 조건이 바뀌면서 갑자기 터지는" 유형의 장애 대응 감각이 필요하다면, 원인 재현과 해결을 체계적으로 다룬 글도 함께 참고하면 좋습니다: OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드
Tokio runtime dropped 패닉이 의미하는 것
Tokio 런타임은 크게 다음을 책임집니다.
- 비동기 태스크 스케줄링(
spawn된 작업 실행) - 타이머/IO 드라이버(소켓, DNS, 타임아웃 등)
spawn_blocking용 블로킹 스레드풀
따라서 런타임이 드롭(drop)되는 시점에 아직 다음이 남아 있다면 문제가 됩니다.
- 런타임 컨텍스트를 필요로 하는 코드가 실행 중
- 런타임이 관리하는 드라이버/스레드가 종료 중인데, 그 안에서 다시 런타임을 정리하려 함
- 비동기 컨텍스트에서 런타임을 새로 만들거나(혹은 드롭) 블로킹 작업을 잘못 수행
핵심은 런타임의 수명(lifetime) 과 비동기 작업의 수명이 어긋나는 것입니다.
가장 흔한 원인 1: 비동기 컨텍스트에서 런타임을 만들고 드롭함
예를 들어 아래처럼 async fn 안에서 Runtime::new()를 만들면, 함수가 끝날 때 런타임이 드롭됩니다. 문제는 그 함수가 이미 Tokio 런타임 위에서 실행 중일 수 있다는 점입니다.
재현 코드
use tokio::runtime::Runtime;
async fn bad() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("nested runtime");
});
}
#[tokio::main]
async fn main() {
bad().await;
}
이 패턴은 다음을 동시에 위반할 가능성이 큽니다.
- 이미 런타임 컨텍스트인데 또 런타임을 중첩 생성
block_on을 런타임 내부에서 호출- 스코프를 벗어나며 런타임이 비동기 컨텍스트에서 드롭
해결
- 런타임은 보통 프로세스 진입점에서 한 번만 만들고(
#[tokio::main]또는Runtime::new()), 그 안에서 모든async를 실행합니다. - 특정 라이브러리가
block_on을 요구한다면, 호출 위치를 런타임 바깥(동기 컨텍스트) 으로 옮기거나, 구조를 바꿔await기반으로 맞춥니다.
권장 구조는 아래 둘 중 하나입니다.
패턴 A: #[tokio::main] 사용
#[tokio::main]
async fn main() {
run().await;
}
async fn run() {
// 모든 비동기 로직은 여기서 await
}
패턴 B: 명시적으로 런타임 생성(동기 main)
use tokio::runtime::Runtime;
fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
run().await;
});
}
async fn run() {
// ...
}
가장 흔한 원인 2: Drop 구현에서 비동기/블로킹 정리를 시도
Rust에서는 자원 정리를 위해 Drop을 구현하는 경우가 많습니다. 하지만 Drop은 async가 될 수 없고, Tokio 환경에서는 Drop 시점이 런타임 내부일 수도 있습니다.
대표적인 위험 패턴은 다음입니다.
Drop에서tokio::runtime::Handle::current()를 잡고block_on유사 동작을 하려는 시도Drop에서spawn_blocking또는block_in_place같은 블로킹 전환을 시도Drop에서 채널 종료 후 태스크JoinHandle을 기다리는 동작을 동기적으로 수행
재현에 가까운 예시
use tokio::runtime::Handle;
struct Client {
// 예: 백그라운드 태스크, 커넥션 등
}
impl Drop for Client {
fn drop(&mut self) {
// 위험: Drop에서 현재 런타임에 의존
let _h = Handle::current();
// 여기서 어떤 "기다림"을 시도하면 런타임 드롭/블로킹 관련 패닉이 날 수 있음
}
}
#[tokio::main]
async fn main() {
let _c = Client {};
}
해결: Drop은 "신호"만 보내고, 진짜 정리는 async fn shutdown()에서
가장 안전한 패턴은 다음입니다.
Drop에서는 즉시 반환 가능한 최소 작업만 수행(예: 종료 플래그 설정, 채널 close)- 종료를 보장해야 한다면, 호출자가 명시적으로
shutdown().await를 호출하게 설계
use tokio::task::JoinHandle;
use tokio::sync::oneshot;
struct Worker {
tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
}
impl Worker {
fn new() -> Self {
let (tx, rx) = oneshot::channel();
let handle = tokio::spawn(async move {
let _ = rx.await;
// 리소스 정리 로직
});
Self { tx: Some(tx), handle: Some(handle) }
}
async fn shutdown(mut self) {
if let Some(tx) = self.tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.handle.take() {
let _ = handle.await;
}
}
}
impl Drop for Worker {
fn drop(&mut self) {
// Drop에서는 "종료 신호" 정도만 시도하고 끝내기
if let Some(tx) = self.tx.take() {
let _ = tx.send(());
}
}
}
#[tokio::main]
async fn main() {
let w = Worker::new();
w.shutdown().await;
}
이렇게 하면 런타임 수명과 종료 순서를 호출자가 통제할 수 있어, "런타임이 먼저 죽어서 정리 실패" 같은 문제를 크게 줄일 수 있습니다.
가장 흔한 원인 3: spawn한 태스크가 런타임보다 오래 살도록 만들어짐
아래 상황에서 자주 터집니다.
main이 너무 빨리 종료되어 런타임이 드롭되는데, 백그라운드 태스크는 아직 실행 중- 라이브러리/서비스 객체가
tokio::spawn으로 태스크를 올려두고, 그JoinHandle을 보관하지 않음
재현 코드
#[tokio::main]
async fn main() {
tokio::spawn(async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
println!("still running");
}
});
// main이 바로 끝나면 런타임이 드롭
}
이 경우는 패닉이 안 나고 조용히 종료되기도 하지만, 리소스 정리/드롭 경로에서 런타임 의존 코드가 있으면 "runtime dropped" 류 패닉으로 이어질 수 있습니다.
해결: JoinHandle을 보관하고 종료 시점에 await 또는 취소
use tokio::task::JoinHandle;
#[tokio::main]
async fn main() {
let handle: JoinHandle<()> = tokio::spawn(async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
});
// 서비스 로직 수행
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// 종료: abort 후 join(에러는 무시하거나 로깅)
handle.abort();
let _ = handle.await;
}
프로덕션에서는 보통 CancellationToken(예: tokio-util)을 붙여서 "정상 종료"를 구현합니다.
가장 흔한 원인 4: std::thread::spawn에서 Tokio API를 호출
Tokio API는 대체로 "현재 런타임 컨텍스트"를 요구합니다. 그런데 OS 스레드를 직접 만들고 그 안에서 tokio::spawn 같은 걸 호출하면, 그 스레드에는 런타임이 없어서 문제가 됩니다.
잘못된 예
fn main() {
std::thread::spawn(|| {
// 여긴 Tokio 런타임 컨텍스트가 없음
tokio::spawn(async {
println!("hi");
});
}).join().unwrap();
}
해결 1: 스레드 안에서 Runtime을 만들고 그 스레드에서만 사용
use tokio::runtime::Runtime;
fn main() {
std::thread::spawn(|| {
let rt = Runtime::new().unwrap();
rt.block_on(async {
tokio::spawn(async {
println!("hi");
}).await.unwrap();
});
}).join().unwrap();
}
해결 2: 런타임 Handle을 전달하고 spawn은 그 핸들로
use tokio::runtime::Handle;
#[tokio::main]
async fn main() {
let handle = Handle::current();
std::thread::spawn(move || {
handle.spawn(async {
println!("hi from handle");
});
}).join().unwrap();
}
단, 이 경우에도 런타임이 먼저 종료되면 핸들로 스폰한 태스크가 의미 없거나, 종료 경로에서 문제를 만들 수 있으니, 종료 순서를 설계해야 합니다.
가장 흔한 원인 5: 테스트 코드에서 런타임 수명이 꼬임
#[tokio::test]는 테스트마다 런타임을 만들어줍니다. 여기서 전역 싱글톤/정적 변수에 Tokio 객체(예: Handle, Client 내부 태스크)를 저장하면, 테스트 종료 시 런타임이 드롭되며 다음 테스트에서 이상한 패닉이 터질 수 있습니다.
해결 체크리스트
- 전역(static)으로 Tokio 의존 객체를 저장하지 않기
- 테스트 간 공유가 필요하면 프로세스 단일 런타임을 쓰는 테스트 하네스 고려
- 테스트 종료 전에
shutdown().await같은 명시적 종료 수행
실전 디버깅: 어디서 런타임이 드롭되는지 찾는 방법
1) 패닉 스택트레이스 확보
- 로컬에서는
RUST_BACKTRACE=1또는RUST_BACKTRACE=full로 실행 - 컨테이너/쿠버네티스 환경에서는 동일한 환경변수를 매니페스트에 추가
2) 드롭 순서 로깅
- 중요한 객체의
Drop에 로그를 넣어 "누가 먼저 죽는지" 확인 - 백그라운드 태스크 시작/종료 시점도 함께 로깅
3) 종료 시퀀스 설계
서버라면 보통 다음 순서가 안전합니다.
- 종료 신호 수신(SIGTERM 등)
- 신규 요청/작업 수락 중단
- 백그라운드 태스크에 취소/종료 신호
JoinHandle을await하여 정리 완료 확인- 마지막에
main종료(런타임 드롭)
이건 "캐시/리밸리데이트 충돌"처럼 종료 조건이 얽혀 장애가 나는 케이스와도 유사합니다. 종료 순서와 책임 경계를 명확히 하는 접근은 다른 런타임 이슈에도 도움이 됩니다: Next.js ISR 500 - revalidate·캐시 충돌 해결
추천 해결 패턴 요약
패턴 1: 런타임은 진입점에서 하나만
#[tokio::main]선호- 라이브러리 코드에서 런타임 생성/드롭을 숨기지 말기
패턴 2: 백그라운드 태스크는 반드시 "소유"하고 "종료"할 것
JoinHandle을 구조체에 저장shutdown().await제공Drop은 최후의 안전망(신호만)으로 최소화
패턴 3: 블로킹은 spawn_blocking으로, 단 종료를 고려
- CPU/블로킹 IO는
tokio::task::spawn_blocking으로 격리 - 종료 시점에 해당 작업이 어떻게 끝나는지(취소 가능한지) 설계
패턴 4: 스레드와 런타임 경계를 명확히
std::thread::spawn내부에서 Tokio를 쓰려면Handle전달 또는 전용 런타임- 런타임 수명보다 오래 사는 스레드/태스크를 만들지 않기
결론
tokio runtime dropped 패닉은 단순한 "Tokio 버그"가 아니라, 대부분 수명/종료 순서/컨텍스트(동기 vs 비동기) 설계 문제에서 발생합니다. 특히 Drop에서 정리를 시도하거나, 태스크를 스폰해놓고 소유하지 않는 구조에서 재현이 쉽습니다.
정리하면 아래 2가지만 지켜도 재발률이 크게 줄어듭니다.
- 런타임은 한 번 만들고, 런타임이 살아있는 동안 태스크를 확실히 종료한다
- 정리는
Drop이 아니라shutdown().await같은 명시적 프로토콜로 수행한다
이 원칙으로 코드를 재구성한 뒤에도 여전히 패닉이 난다면, 스택트레이스와 함께 "어떤 객체의 Drop에서 무엇을 하려 했는지"를 먼저 의심해보는 것이 가장 빠른 접근입니다.