- Published on
Rust Tokio runtime dropped 패닉 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Rust tokio로 만들다 보면 어느 순간 다음과 비슷한 패닉을 만나게 됩니다.
thread '...' panicked at 'Cannot drop a runtime in a context where blocking is not allowed'thread '...' panicked at 'runtime dropped the dispatch task'- 혹은 로그에
runtime dropped가 포함된 메시지
표면적으로는 “런타임이 드롭됐다”는 말이지만, 실제 원인은 대부분 런타임의 수명(lifetime)과 그 위에서 실행 중인 작업(task)의 수명이 엇갈린 구조입니다. 특히 tokio::spawn으로 띄운 작업이 런타임보다 오래 살아남거나, 런타임 내부에서 런타임을 다시 만들고 드롭하는 식의 설계가 겹치면 재현됩니다.
이 글에서는 흔한 재현 패턴부터, 코드 구조를 바꿔서 근본적으로 해결하는 방법까지 단계적으로 정리합니다.
왜 runtime dropped가 발생하나: 핵심 원리
Tokio 런타임은 크게 두 가지를 관리합니다.
- 스케줄러와 워커 스레드(멀티스레드 런타임의 경우)
- 드라이버(타이머, I/O, 신호 처리 등)
tokio::spawn으로 만들어진 task는 “어딘가의 런타임”에 의해 폴링되어야만 진행됩니다. 그런데 다음 상황이 생기면 문제가 됩니다.
- 런타임이 먼저 종료(드롭)되어 task를 더 이상 폴링할 수 없다
- 런타임을 드롭하는 과정에서 내부적으로 블로킹이 필요한데, 현재 컨텍스트가 블로킹을 허용하지 않는다
- 런타임이 없는 스레드에서
Handle::current()같은 API를 호출한다
즉, 패닉 메시지는 다르게 보일 수 있지만, 결론은 비슷합니다.
- “런타임의 생명주기와 task 생명주기를 명확히 맞춰라”
가장 흔한 재현 1: 함수 내부에서 런타임을 만들고 spawn만 하고 끝내기
다음 코드는 얼핏 정상처럼 보이지만, 매우 위험합니다.
use tokio::runtime::Runtime;
fn fire_and_forget() {
let rt = Runtime::new().unwrap();
rt.spawn(async {
// 오래 걸리는 작업
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
println!("done");
});
// 함수가 끝나면서 rt가 drop됨
}
fn main() {
fire_and_forget();
std::thread::sleep(std::time::Duration::from_secs(2));
}
여기서 spawn은 task를 런타임에 등록만 합니다. 하지만 fire_and_forget()이 끝나는 순간 런타임이 드롭되고, task는 더 이상 실행될 기반이 사라집니다. 상황에 따라 조용히 작업이 취소되거나, 특정 조건에서 runtime dropped 계열 패닉으로 이어질 수 있습니다.
해결: spawn의 JoinHandle을 기다리거나, 런타임을 더 오래 살려라
가장 단순한 해결은 JoinHandle을 await하는 것입니다.
use tokio::runtime::Runtime;
fn run_and_wait() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let h = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
println!("done");
});
// task 완료를 보장
h.await.unwrap();
});
}
fn main() {
run_and_wait();
}
하지만 애초에 “함수 내부에서 런타임을 만들었다가 바로 버리는 구조”는 유지보수 관점에서 좋지 않습니다. 보통은 프로세스 전체에서 런타임을 하나만 두고, 그 위에서 서버/워커를 운용하는 쪽이 안정적입니다.
가장 흔한 재현 2: tokio::spawn을 런타임 밖에서 호출
Tokio의 spawn은 현재 실행 중인 런타임 컨텍스트가 필요합니다. 다음처럼 런타임이 없는 스레드에서 호출하면 문제가 됩니다.
fn main() {
std::thread::spawn(|| {
// 런타임 컨텍스트가 없는데 spawn을 호출
tokio::spawn(async {
println!("hello");
});
})
.join()
.unwrap();
}
이 경우 보통은 즉시 패닉(예: “no reactor running”)이 나지만, 코드가 복잡해지면 런타임 핸들 전달 실수로 runtime dropped류 문제까지 섞여 디버깅이 어려워집니다.
해결: Handle을 전달하거나, 해당 스레드에서 런타임을 명시적으로 실행
런타임이 이미 있다면 tokio::runtime::Handle을 전달해 그 핸들로 스폰하세요.
use tokio::runtime::Handle;
#[tokio::main]
async fn main() {
let handle = Handle::current();
std::thread::spawn(move || {
handle.spawn(async {
println!("hello from thread");
});
})
.join()
.unwrap();
// 스폰된 작업이 실행될 시간을 조금 줌 (데모)
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
실서비스에서는 “잠깐 sleep” 대신, 종료 시점에 JoinHandle을 모아 await하거나, shutdown 시그널을 만들어 정상 종료를 보장해야 합니다.
가장 흔한 재현 3: 런타임 내부에서 런타임을 만들고 드롭
비동기 함수 안에서 동기 API를 쓰고 싶어서, 다음 같은 코드를 넣는 경우가 있습니다.
use tokio::runtime::Runtime;
#[tokio::main]
async fn main() {
// 이미 런타임 위에서 실행 중
let rt = Runtime::new().unwrap();
// 런타임을 중첩해서 쓰려는 시도
rt.block_on(async {
println!("nested");
});
}
이런 중첩은 Tokio가 강하게 권장하지 않습니다. 런타임 드롭 과정에서 블로킹이 필요하거나, 드라이버/스케줄러 정리 순서가 꼬이면서 runtime dropped 혹은 “blocking not allowed” 류 패닉으로 이어질 수 있습니다.
해결: 런타임은 하나만, 동기-비동기 경계는 spawn_blocking으로
비동기 컨텍스트에서 CPU 바운드/블로킹 작업을 돌리고 싶다면 tokio::task::spawn_blocking을 사용합니다.
#[tokio::main]
async fn main() {
let result = tokio::task::spawn_blocking(|| {
// 블로킹 I/O 또는 무거운 CPU 작업
std::thread::sleep(std::time::Duration::from_millis(200));
42
})
.await
.unwrap();
println!("result={}", result);
}
이 패턴은 런타임을 중첩하지 않으면서도, 블로킹 작업을 안전하게 분리합니다.
구조적으로 안전한 해결책: “수명”을 코드로 고정하기
runtime dropped를 근본적으로 없애려면, 런타임과 task의 수명을 다음 중 하나로 고정해야 합니다.
#[tokio::main]을 사용해 런타임 수명을main에 묶는다- 수동 런타임을 만들더라도
block_on내부에서만 task를 만들고, 종료 전에 모두await한다 - 백그라운드 task가 있다면 shutdown 시그널과
JoinSet으로 종료를 수렴시킨다
패턴 A: JoinSet과 shutdown 채널로 정상 종료 보장
서버/컨슈머 같은 장기 실행 앱에서 가장 실용적인 패턴입니다.
use tokio::task::JoinSet;
#[tokio::main]
async fn main() {
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let mut set = JoinSet::new();
// 워커 1
{
let mut rx = shutdown_rx.clone();
set.spawn(async move {
loop {
tokio::select! {
_ = rx.changed() => {
if *rx.borrow() { break; }
}
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
// 주기 작업
}
}
}
});
}
// 워커 2
{
let mut rx = shutdown_rx.clone();
set.spawn(async move {
loop {
tokio::select! {
_ = rx.changed() => {
if *rx.borrow() { break; }
}
_ = tokio::time::sleep(std::time::Duration::from_millis(150)) => {
// 주기 작업
}
}
}
});
}
// 데모: 잠깐 돌리고 종료
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let _ = shutdown_tx.send(true);
// 런타임이 drop되기 전에 task를 확실히 회수
while let Some(res) = set.join_next().await {
res.unwrap();
}
}
핵심은 이것입니다.
- 런타임이 끝나기 전에(즉
main이 리턴하기 전에) 모든 task가 종료되도록 만든다 - “fire-and-forget”을 시스템 핵심 경로에 두지 않는다
이 방식은 운영 환경에서 종료 신호 처리와도 자연스럽게 연결됩니다. 쿠버네티스에서 SIGTERM을 받았는데 프로세스가 즉시 죽어 버리거나, 종료 중 작업이 유실되는 문제는 애플리케이션 레벨 shutdown 설계가 없어서 생깁니다. 이 주제는 종료/수렴 관점에서 Kubernetes HPA가 0으로 안 줄 때 - PDB·윈도우·종료에서도 비슷한 결을 참고할 수 있습니다.
디버깅 체크리스트: 어디서 런타임이 먼저 끝나는지 찾기
runtime dropped류 문제는 “어디선가 런타임이 예상보다 빨리 끝난다”가 본질이라서, 다음 순서로 좁히는 게 좋습니다.
1) 런타임 생성 위치를 전부 검색
#[tokio::main]이 이미 있는데Runtime::new()를 또 만들고 있지 않은가- 라이브러리 코드가 내부에서 런타임을 만들고 있지 않은가
특히 “동기 함수에서 비동기 호출을 하고 싶다”는 이유로 런타임을 매번 만드는 코드는 장기적으로 사고를 부릅니다.
2) spawn된 task의 JoinHandle이 유실되는지 확인
let _ = tokio::spawn(...)처럼 핸들을 버리고 있지 않은가- 구조체 내부에서
JoinHandle을 필드로 들고 있다가 drop되며 작업이 취소되지 않는가
의도적으로 백그라운드로 돌리더라도, 최소한 종료 시점에 join하거나 abort 정책을 명확히 해야 합니다.
3) std::thread::spawn과 Tokio task의 경계를 점검
- OS 스레드에서
tokio::spawn을 호출하고 있지 않은가 - 반대로 Tokio task 안에서 오래 블로킹하는
std::thread::sleep이나 동기 I/O를 호출하고 있지 않은가
동기 블로킹이 섞이면 런타임 종료 타이밍이 꼬여 증상이 더 불규칙해집니다. 블로킹은 spawn_blocking으로 격리하세요.
4) 테스트 코드에서 특히 조심
테스트는 함수 단위로 런타임을 짧게 만들고 부수는 일이 많습니다.
#[tokio::test]안에서 백그라운드 task를 띄워놓고 테스트가 끝나버리면, 런타임 종료와 함께 task가 강제 종료됩니다.- 테스트 종료 직전에 task가 아직 리소스를 잡고 있다면, 드롭 순서에 따라
runtime dropped가 튀어나올 수 있습니다.
테스트에서도 JoinSet으로 회수하거나, 최소한 JoinHandle을 await하는 습관이 필요합니다.
실전 팁: “런타임을 소유하는 계층”을 하나로 만들기
대부분의 팀 코드베이스에서 안정적인 구조는 다음처럼 정리됩니다.
- 바이너리 크레이트(예:
main.rs)만 런타임을 소유한다 - 라이브러리 크레이트는
async fn을 제공하고, 런타임 생성/종료를 책임지지 않는다 - 백그라운드 워커가 필요하면 “시작 함수가 핸들을 반환”하고, 호출자가 종료 시점에 join한다
예시 인터페이스는 다음처럼 설계할 수 있습니다.
use tokio::task::JoinHandle;
pub fn start_worker() -> (tokio::sync::watch::Sender<bool>, JoinHandle<()>) {
let (tx, mut rx) = tokio::sync::watch::channel(false);
let handle = tokio::spawn(async move {
while !*rx.borrow() {
tokio::select! {
_ = rx.changed() => {}
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
// do work
}
}
}
});
(tx, handle)
}
호출 측에서 수명을 통제합니다.
#[tokio::main]
async fn main() {
let (shutdown_tx, h) = start_worker();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let _ = shutdown_tx.send(true);
h.await.unwrap();
}
이렇게 하면 런타임 드롭과 task 수명이 뒤엉킬 여지가 크게 줄어듭니다.
운영 환경에서 더 자주 보이는 케이스: 리소스 압박과 결합될 때
runtime dropped 자체는 수명 문제이지만, 운영에서는 다음과 결합되어 더 자주 터집니다.
- OOM 직전 스레드/태스크가 비정상 종료되며 런타임 정리 루틴이 꼬임
- 컨테이너 종료 신호를 받았는데 graceful shutdown이 없어 작업이 중간에 끊김
특히 CI나 러너 환경에서 메모리 압박은 “원인처럼 보이지 않는 2차 증상”을 많이 만듭니다. OOM과 프로세스 종료 패턴은 GitLab Runner Docker executor OOM·Exit 137 해결도 함께 참고하면, 런타임 이슈와 별개로 인프라 레벨에서 어떤 일이 벌어지는지 감을 잡는 데 도움이 됩니다.
정리: runtime dropped를 없애는 3가지 규칙
- 런타임은 가능한 한 프로세스당 1개, 소유 계층을
main으로 고정한다 spawn한 작업은 반드시 회수한다:JoinHandle을await하거나JoinSet으로 관리한다- 동기-비동기 경계는 런타임 중첩이 아니라
spawn_blocking과 shutdown 시그널로 푼다
위 3가지만 지켜도 runtime dropped 패닉은 대부분 사라집니다. 그래도 남는다면, 런타임 생성 위치와 task 종료 경로(특히 테스트/종료 시그널)를 중심으로 “누가 먼저 끝나는지”를 추적해 보세요.