- Published on
Rust Tokio - runtime dropped 패닉 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Tokio를 쓰다 보면 어느 날 갑자기 아래와 비슷한 패닉을 만날 수 있습니다.
thread '...' panicked at '... runtime dropped ...'- 또는
there is no reactor running, must be called from the context of a Tokio 1.x runtime
대부분의 경우 “비동기 작업이 아직 실행 중인데 런타임이 먼저 종료됐다”는 수명(lifetime) 문제입니다. 특히 tokio::spawn으로 백그라운드 태스크를 띄워놓고 main이 끝나거나, 런타임 핸들을 다른 스레드로 넘긴 뒤 원래 런타임이 drop되는 경우에 자주 터집니다.
이 글에서는 runtime dropped가 왜 발생하는지(구조적 원인), 어떤 코드 패턴에서 재현되는지, 그리고 실무에서 가장 안전한 해결책들을 정리합니다. 문제를 “증상”이 아니라 “수명/소유권 설계”로 다루는 것이 핵심입니다.
> 운영 중 장애를 추적하는 관점은 systemd 재시작 원인 추적과도 유사합니다. 로그/스택트레이스를 기반으로 종료 타이밍을 역추적하는 습관이 도움이 됩니다: systemd 서비스가 계속 재시작될 때 원인 추적
runtime dropped가 의미하는 것
Tokio 런타임은 크게 다음 리소스를 소유합니다.
- 스케줄러(멀티스레드 워커/로컬 스케줄러)
- 타이머 드라이버(시간 관련)
- I/O 드라이버(네트워크, 파일 등 비동기 I/O)
- 태스크 큐 및 태스크 실행 컨텍스트
Runtime이 drop되면 위 리소스가 정리됩니다. 그런데 이미 생성된 future/태스크가 이후에 폴링되거나, I/O/타이머 기능을 사용하려고 하면 “런타임이 없다”는 상황이 되어 패닉 또는 에러가 발생합니다.
여기서 중요한 포인트:
tokio::spawn으로 띄운 태스크는 기본적으로 런타임에 종속됩니다.JoinHandle을 들고 있어도, 런타임이 drop되면 그 태스크는 정상 완료를 보장하지 않습니다.Handle::current()로 얻은 핸들도 런타임과 수명적으로 연결되어 있으며, 런타임 drop 이후 사용하면 문제가 됩니다.
가장 흔한 원인 5가지
1) main이 너무 빨리 종료됨 (spawn만 하고 await 안 함)
가장 흔한 패턴입니다. 아래 코드는 종종 “가끔은 되는데 가끔은 패닉/미실행” 같은 비결정적 증상으로 나타납니다.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
tokio::spawn(async {
sleep(Duration::from_millis(200)).await;
println!("background done");
});
// main이 즉시 종료되면 런타임도 drop
println!("main exit");
}
해결
JoinHandle을 저장하고await하여 작업 종료를 보장합니다.- 또는 종료 신호/워커 관리 구조를 도입합니다.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let j = tokio::spawn(async {
sleep(Duration::from_millis(200)).await;
println!("background done");
});
// 백그라운드 태스크가 끝날 때까지 기다림
j.await.unwrap();
println!("main exit");
}
2) Runtime::new()를 지역 변수로 만들고, 다른 스레드/태스크가 계속 사용
런타임을 수동 생성하는 경우, 스코프를 벗어나면서 drop되는 문제가 자주 발생합니다.
use tokio::runtime::Runtime;
use std::thread;
fn main() {
let rt = Runtime::new().unwrap();
let handle = rt.handle().clone();
// 다른 스레드에서 런타임 핸들을 사용
thread::spawn(move || {
handle.spawn(async {
println!("hello from spawned task");
});
});
// 여기서 main이 끝나면 rt drop
// 스레드가 늦게 실행되면 runtime dropped 류 문제가 발생 가능
}
해결
- 런타임의 수명을 프로그램 전체로 확장합니다(예: main 스레드에서 join).
- 스레드를 join하거나, 런타임을 Arc로 감싸고 명시적으로 관리합니다.
use tokio::runtime::Runtime;
use std::thread;
fn main() {
let rt = Runtime::new().unwrap();
let handle = rt.handle().clone();
let t = thread::spawn(move || {
let j = handle.spawn(async {
println!("hello from spawned task");
});
// JoinHandle은 런타임 컨텍스트에서 await해야 하므로
// 여기서는 block_on을 쓰기 어렵습니다.
// 대신 태스크 내부에서 필요한 일을 끝내고 반환만 하게 설계하거나,
// 채널로 완료 신호를 보내도록 합니다.
drop(j);
});
t.join().unwrap();
// 런타임에서 실제 async 작업을 수행해야 한다면
// 이 스코프에서 rt.block_on(...)으로 수행하고 끝낸 뒤 drop
}
> 핵심은 “런타임을 사용하는 작업이 끝나기 전에 런타임이 drop되지 않게” 구조를 바꾸는 것입니다.
3) Drop 구현(impl Drop)에서 async/Tokio API 호출
동기 drop 컨텍스트에서 tokio::spawn, tokio::time::sleep, 소켓 close/flush 같은 async 의존 로직을 호출하면 런타임 컨텍스트가 없거나 이미 내려간 상태일 수 있습니다.
struct Foo;
impl Drop for Foo {
fn drop(&mut self) {
// 위험: drop 시점에 런타임이 없을 수 있음
tokio::spawn(async {
println!("cleanup");
});
}
}
해결
- Drop에서 async를 하지 말고, 명시적 종료 메서드(
shutdown().await)를 제공하세요. - 또는
Drop은 순수 동기 정리만 하고, async 정리는 별도 태스크/관리자에게 위임합니다.
struct Foo;
impl Foo {
async fn shutdown(self) {
// async 정리는 여기서 수행
println!("async cleanup");
}
}
#[tokio::main]
async fn main() {
let foo = Foo;
foo.shutdown().await;
}
4) tokio::spawn이 아닌 곳에서 Handle::current()를 호출
Handle::current()는 “현재 스레드가 Tokio 런타임 컨텍스트 안에 있다”는 전제가 있습니다. 예를 들어 일반 스레드에서 호출하면 패닉이 날 수 있고, 런타임 drop 이후에도 문제가 됩니다.
use tokio::runtime::Handle;
fn not_async_context() {
let _h = Handle::current(); // 런타임 컨텍스트 없으면 패닉 가능
}
해결
- 런타임 내부에서만
Handle::current()를 사용합니다. - 외부 스레드라면
Runtime::handle()을 명시적으로 전달받아 사용하세요. - 또는
tokio::task::spawn_blocking으로 런타임 내부로 진입하는 구조를 고려합니다.
5) 테스트/벤치에서 런타임 수명 관리 실패
#[tokio::test]는 테스트 함수 스코프가 끝나면 런타임이 정리됩니다. 테스트 안에서 별도 스레드/전역 싱글톤이 런타임 핸들을 잡고 계속 작업하면 다음 테스트에서 예측 불가한 패닉이 납니다.
해결
- 테스트에서 전역 작업자를 만들었다면
shutdown을 명시적으로 호출하고 종료를 기다립니다. - 테스트 간 공유 자원을 피하고, 각 테스트 스코프에 종속되게 만듭니다.
실무에서 안전한 해결 전략
1) “태스크 소유자”를 만들고 JoinHandle을 수집/await
백그라운드 태스크를 여러 개 띄우는 서비스라면, 스폰한 태스크를 흩어지게 두지 말고 한 곳에서 수집해 종료 시점에 정리하세요.
use tokio::task::JoinHandle;
struct TaskGroup {
handles: Vec<JoinHandle<()>>,
}
impl TaskGroup {
fn new() -> Self {
Self { handles: vec![] }
}
fn spawn(&mut self, h: JoinHandle<()>) {
self.handles.push(h);
}
async fn shutdown(self) {
for h in self.handles {
// 태스크 패닉도 여기서 감지 가능
let _ = h.await;
}
}
}
#[tokio::main]
async fn main() {
let mut g = TaskGroup::new();
g.spawn(tokio::spawn(async {
// worker
}));
// ...
g.shutdown().await;
}
이 구조는 runtime dropped를 “운 좋으면 안 터지는 문제”에서 “항상 종료 순서가 보장되는 문제”로 바꿉니다.
2) 종료 신호(Graceful shutdown)를 채널로 설계
서비스형 애플리케이션에서는 태스크가 영원히 도는 경우가 많습니다. 이때는 JoinHandle.await만으로는 종료가 안 되므로 종료 신호를 줘야 합니다.
use tokio::{select, sync::watch, time::{sleep, Duration}};
async fn worker(mut shutdown: watch::Receiver<bool>) {
loop {
select! {
_ = shutdown.changed() => {
if *shutdown.borrow() {
break;
}
}
_ = sleep(Duration::from_millis(100)) => {
// do periodic work
}
}
}
}
#[tokio::main]
async fn main() {
let (tx, rx) = watch::channel(false);
let j = tokio::spawn(worker(rx));
// ... run service
// 종료 시그널
let _ = tx.send(true);
j.await.unwrap();
}
포인트는 “런타임 drop 전에 태스크가 스스로 빠져나오게” 만드는 것입니다.
3) 라이브러리 코드에서는 tokio::spawn을 숨기지 말기
라이브러리 내부에서 무심코 tokio::spawn을 해버리면, 호출자는 “내가 런타임을 언제까지 유지해야 하지?”를 알기 어렵습니다. 이때 runtime dropped는 사용자에게 폭탄처럼 터집니다.
권장 패턴:
- 라이브러리는
async fn start(...) -> JoinHandle또는async fn run(...)형태로 제공 - 호출자가 스폰/수명/종료를 제어하도록 설계
pub async fn run_forever(mut shutdown: tokio::sync::watch::Receiver<bool>) {
while !*shutdown.borrow() {
// ...
let _ = shutdown.changed().await;
}
}
// 애플리케이션에서
let j = tokio::spawn(run_forever(rx));
4) “블로킹 + 런타임” 혼합 시 경계 명확히 하기
std::thread::spawn/CPU 바운드 작업과 Tokio를 섞을 때 런타임 경계를 흐리면 drop 타이밍이 꼬입니다.
- 런타임 밖의 블로킹 작업은
spawn_blocking으로 런타임이 관리하게 하거나 - 반대로 런타임을 명시적으로 소유한 스레드(전용 런타임 스레드)를 두고, 다른 스레드는 채널로 요청만 보내게 합니다.
이런 “경계 설계”는 Kubernetes에서 리소스가 부족해 Pending이 발생할 때 원인을 구조적으로 분리하는 접근과 비슷합니다(증상만 보지 말고 병목/제약을 분리): EKS Pod Pending(Insufficient cpu) 원인과 해결
디버깅 체크리스트 (스택트레이스 기반)
runtime dropped가 나왔을 때는 아래 질문 순서로 보면 빠릅니다.
- 패닉이 난 스레드/태스크는 어디서 만들어졌나?
tokio::spawn호출 위치std::thread::spawn호출 위치
- 런타임은 어디서 생성/종료되나?
#[tokio::main]이면 main 종료 시점Runtime::new()면 스코프 종료 시점
- 태스크를
await하고 있는가?- JoinHandle을 버리고 있지 않은가
- Drop에서 async를 호출하고 있지 않은가?
- 테스트라면 전역 싱글톤/백그라운드 스레드가 다음 테스트까지 살아남지 않는가?
추적이 어렵다면 RUST_BACKTRACE=1 또는 RUST_BACKTRACE=full로 스택을 확인하고, 런타임 drop 지점(대개 main 종료/스코프 종료)을 먼저 찾는 게 효율적입니다.
자주 묻는 함정: “spawn 했으니 알아서 돌아가겠지”
Tokio의 태스크는 OS 스레드처럼 독립 실행되는 것이 아니라, 런타임이 폴링해줘야만 진행됩니다. 따라서 런타임이 내려가면 태스크는 더 이상 진행할 수 없습니다.
- “백그라운드로 계속 돌아야 하는 작업”이 있다면
- 런타임을 프로세스 수명 전체로 유지하거나
- 종료 프로토콜(채널/토큰)과 함께 태스크 수명을 관리해야 합니다.
운영 환경에서 이런 종료 순서 문제는 CI에서도 재현되곤 합니다. 특히 러너가 멈추거나 타임아웃이 걸릴 때 종료 시퀀스가 꼬여 증상이 확대될 수 있어, 장애 관점에서 함께 보면 좋습니다: GitHub Actions self-hosted runner 멈춤 원인 8가지
결론
Rust Tokio: runtime dropped 패닉은 Tokio 자체의 버그라기보다, 런타임 수명보다 더 오래 살려고 하는 비동기 작업이 만들어낸 구조적 문제인 경우가 대부분입니다.
정리하면 해결의 우선순위는 다음과 같습니다.
- 스폰한 태스크는 반드시 소유하고(
JoinHandle수집) 종료 시점에await한다. - 영구 태스크에는 종료 신호(채널/토큰)를 설계해 런타임 drop 전에 빠져나오게 한다.
- Drop에서 async를 하지 말고, 명시적
shutdown().await를 제공한다. - 런타임 경계(스레드/블로킹/테스트)를 명확히 하고, 핸들 전달을 통제한다.
이 네 가지만 지켜도 runtime dropped는 “가끔 터지는 미스터리”에서 “구조적으로 예방되는 클래스의 문제”로 바뀝니다.