- Published on
Rust Tokio runtime dropped 패닉 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Rust Tokio로 운영하다 보면 어느 날 갑자기 runtime dropped 혹은 Cannot drop a runtime in a context where blocking is not allowed 류의 패닉을 만나곤 합니다. 대부분은 런타임의 수명(lifetime)보다 오래 살아남은 작업이 있거나, Drop 과정에서 런타임이 필요한 동작을 수행하면서 발생합니다.
이 글에서는 runtime dropped 패닉이 왜 뜨는지, 어떤 코드 패턴이 위험한지, 그리고 실무에서 가장 많이 쓰는 해결책을 재현 코드와 함께 정리합니다.
비슷한 계열의 문제로 “종료 시점에 작업이 남아 경고/에러가 나는 현상”은 언어를 가리지 않고 자주 나옵니다. Python 쪽 사례가 궁금하면 Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법도 함께 보면 원인 구조를 더 빨리 이해할 수 있습니다.
runtime dropped는 어떤 상황에서 발생하나
Tokio 런타임은 내부적으로 스케줄러, 타이머, I/O 드라이버 등을 가지고 있고, tokio::spawn으로 만든 태스크는 그 런타임 위에서 폴링됩니다. 그런데 다음 중 하나가 발생하면 문제가 됩니다.
- 런타임이 Drop 되었는데 태스크가 아직 실행 중이거나, 이후에 런타임을 참조하려고 함
- 런타임 Drop 중에 블로킹이 금지된 컨텍스트에서 블로킹 동작이 발생
- 런타임이 없는 스레드에서
tokio::spawn/tokio::time등을 호출하거나, 런타임을 중첩 생성/파괴하는 구조
실제로는 “어딘가에서 런타임이 먼저 죽었다”가 핵심이고, 그 원인은 수명 관리/종료 시퀀스/Drop 설계에 있습니다.
가장 흔한 재현: 런타임을 함수 안에서 만들고 태스크를 밖으로 흘려보내기
아래는 실무에서도 종종 보이는 형태입니다. 런타임을 함수 안에서 만들고, 그 안에서 스폰한 작업이 런타임의 수명 밖까지 살아남는 구조입니다.
use tokio::runtime::Runtime;
use tokio::time::{sleep, Duration};
fn start_background_work() {
let rt = Runtime::new().unwrap();
rt.spawn(async {
// 런타임이 Drop 된 뒤에도 이 작업은 계속 돌고 싶어함
loop {
sleep(Duration::from_secs(1)).await;
println!("tick");
}
});
// 여기서 함수가 끝나면 rt가 Drop 됨
}
fn main() {
start_background_work();
std::thread::sleep(Duration::from_secs(3));
}
이 코드는 환경/버전에 따라 즉시 패닉이 나거나, 종료 시점에 패닉/에러가 터지는 식으로 나타납니다. 본질은 하나입니다. 백그라운드 작업이 런타임보다 오래 살려고 한다는 점입니다.
해결 1: 런타임 수명을 애플리케이션 수명으로 끌어올리기
가장 간단한 해결은 런타임을 main에서 생성하고, 프로그램 종료까지 유지하는 것입니다.
use tokio::runtime::Runtime;
use tokio::time::{sleep, Duration};
fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
tokio::spawn(async {
loop {
sleep(Duration::from_secs(1)).await;
println!("tick");
}
});
sleep(Duration::from_secs(3)).await;
});
}
다만 이 방식은 “정상 종료 시 백그라운드 태스크를 어떻게 정리할지”를 별도로 설계해야 합니다. 아래의 “정상 종료 패턴”에서 다룹니다.
Drop 시점에 비동기/블로킹을 섞어서 터지는 케이스
runtime dropped와 함께 자주 엮이는 게 “Drop 중에 뭔가를 기다렸다가(join) 죽는” 패턴입니다.
예를 들어 어떤 구조체의 Drop에서 JoinHandle을 회수하려고 하거나, block_on을 호출해 정리 작업을 수행하려고 하면 런타임 컨텍스트/스레드 제약 때문에 문제가 생깁니다.
위험 패턴: Drop에서 강제 동기화
use tokio::task::JoinHandle;
struct Worker {
handle: Option<JoinHandle<()>>,
}
impl Drop for Worker {
fn drop(&mut self) {
// Drop에서 join을 "동기적으로" 처리하려고 하면 위험
// 런타임 컨텍스트에 따라 패닉이 날 수 있음
if let Some(_h) = self.handle.take() {
// 여기서 await을 못하니 무리수를 두게 됨
}
}
}
해결 2: Drop이 아닌 "명시적 종료" API 제공
정리(cleanup)는 Drop에 숨기기보다 명시적으로 노출하는 편이 안전합니다.
use tokio::task::JoinHandle;
use tokio::sync::oneshot;
struct Worker {
shutdown_tx: Option<oneshot::Sender<()>>,
handle: JoinHandle<()>,
}
impl Worker {
fn start() -> Self {
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
let handle = tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut shutdown_rx => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_millis(200)) => {
// do work
}
}
}
});
Self {
shutdown_tx: Some(shutdown_tx),
handle,
}
}
async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
let _ = self.handle.await;
}
}
#[tokio::main]
async fn main() {
let worker = Worker::start();
// ...
worker.shutdown().await;
}
핵심은 이겁니다.
- 종료 신호를 보내고
- 태스크가 빠져나오게 만들고
await로 join은 async 컨텍스트에서만 수행
이렇게 하면 런타임 Drop 시점에 “아직 런타임이 필요한 작업”이 남지 않게 만들 수 있습니다.
런타임 중첩 생성: #[tokio::main] 안에서 또 Runtime::new()
#[tokio::main]은 이미 런타임을 만들어 main을 실행합니다. 그런데 내부에서 다시 런타임을 만들고 block_on하거나, 스레드마다 런타임을 만들었다가 파괴하는 구조는 패닉의 지뢰밭이 됩니다.
위험 패턴
#[tokio::main]
async fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// ...
});
}
해결 3: 하나의 런타임만 사용하고, 경계에서는 채널로 통신
- 애플리케이션은 보통 런타임 1개로 충분합니다.
- 동기 코드와 비동기 코드의 경계는
mpsc/oneshot/watch같은 채널로 넘기세요.
예: 동기 함수에서 비동기 작업을 “요청”만 하고, 실제 실행은 async 쪽에서 처리.
use tokio::sync::{mpsc, oneshot};
#[derive(Debug)]
struct Job {
payload: String,
reply: oneshot::Sender<String>,
}
fn submit_job(tx: mpsc::Sender<Job>, payload: String) -> String {
let (reply_tx, reply_rx) = oneshot::channel();
// 동기 함수라 await 불가: 대신 try_send나 별도 스레드에서 send
tx.blocking_send(Job { payload, reply: reply_tx }).unwrap();
// 응답도 동기적으로 기다려야 한다면 blocking_recv
reply_rx.blocking_recv().unwrap()
}
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(64);
let worker = tokio::spawn(async move {
while let Some(job) = rx.recv().await {
let _ = job.reply.send(format!("ok: {}", job.payload));
}
});
let r = submit_job(tx, "hello".to_string());
println!("{r}");
drop(worker);
}
주의: blocking_send/blocking_recv는 “정말로 동기 경계에서만” 쓰는 게 좋습니다. async 컨텍스트에서 호출하면 또 다른 패닉 원인이 됩니다.
tokio::spawn이 런타임 밖에서 호출되는 문제
가끔 라이브러리/모듈 설계상 “어디서 호출될지 모르는 함수” 안에서 tokio::spawn을 해버리는 경우가 있습니다. 호출자가 런타임 밖(일반 스레드)이라면 there is no reactor running 류의 에러가 나거나, 런타임 수명과 엮여 종료 시 패닉으로 이어집니다.
해결 4: 스폰은 상위 레이어에서만, 하위는 async fn으로 제공
- 하위 모듈은
async fn run(...)처럼 “실행될 작업”만 제공 - 스폰 여부/수명 관리는 애플리케이션 레이어에서 결정
// library layer
pub async fn run_once() {
// do async work
}
// app layer
#[tokio::main]
async fn main() {
let h = tokio::spawn(async {
run_once().await;
});
let _ = h.await;
}
이 원칙 하나로 런타임 관련 오류의 절반 이상이 줄어듭니다.
정상 종료(Graceful shutdown) 패턴: 패닉을 "없애는" 가장 확실한 방법
runtime dropped를 근본적으로 없애려면, 런타임이 Drop 되기 전에 다음을 보장해야 합니다.
- 더 이상 새 태스크를 만들지 않는다
- 실행 중인 태스크가 종료 신호를 받고 빠져나온다
- 모든
JoinHandle을await해서 회수한다
실무에서 흔한 패턴은 ctrl_c 혹은 서버 종료 이벤트를 받아 watch나 broadcast로 종료를 전파하는 방식입니다.
use tokio::sync::watch;
async fn worker(mut shutdown: watch::Receiver<bool>) {
loop {
tokio::select! {
_ = shutdown.changed() => {
if *shutdown.borrow() {
break;
}
}
_ = tokio::time::sleep(std::time::Duration::from_millis(300)) => {
// do work
}
}
}
}
#[tokio::main]
async fn main() {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let h1 = tokio::spawn(worker(shutdown_rx.clone()));
let h2 = tokio::spawn(worker(shutdown_rx.clone()));
// 예: Ctrl+C 시그널
let _ = tokio::signal::ctrl_c().await;
let _ = shutdown_tx.send(true);
let _ = h1.await;
let _ = h2.await;
}
이 구조를 잡아두면 런타임이 Drop 되기 전에 태스크가 스스로 종료하므로, runtime dropped 패닉이 끼어들 여지가 크게 줄어듭니다.
트러블슈팅 체크리스트
패닉이 발생했을 때 아래를 순서대로 확인하면 원인을 빠르게 좁힐 수 있습니다.
- 런타임이 어디서 생성되고 어디서 Drop 되는가
- 함수 로컬
Runtime::new()가 있는지 - 스레드마다 런타임을 만들고 버리는지
- 함수 로컬
Drop구현에서 비동기 작업을 정리하려고 하는가Drop에서 join,block_on,blocking_*를 호출하는지
- 스폰된 태스크의 종료 조건이 있는가
- 무한 루프인데 종료 신호가 없는지
- 채널이 닫혀도 루프가 안 끝나는지
- 런타임 중첩이 있는가
#[tokio::main]내부에서 또 런타임 생성
- 테스트 코드에서 런타임 수명이 짧지 않은가
#[tokio::test]에서 백그라운드 태스크를 스폰하고 테스트가 끝나는지
운영 환경에서 이런 종료/수명 문제는 “재시작 루프”로도 관측됩니다. 서비스가 계속 재시작되는 상황에서 원인 추적 흐름은 systemd 서비스가 반복 재시작될 때 원인 추적법도 참고할 만합니다.
마무리: 결론은 "수명"과 "종료"다
Tokio의 runtime dropped는 Tokio가 까다로워서라기보다, 비동기 작업이 런타임이라는 실행 기반에 강하게 의존하기 때문에 생기는 전형적인 수명 문제입니다.
- 런타임은 앱 수명만큼 유지
- 태스크는 종료 신호를 받아 스스로 빠져나오게 설계
Drop에서 비동기 정리를 억지로 하지 말고shutdown().await같은 명시적 API로 회수
이 3가지만 지켜도 runtime dropped 류의 패닉은 대부분 사라지고, 남더라도 원인을 찾기 쉬운 구조가 됩니다.