Published on

Rust Tokio - runtime dropped 패닉 원인과 해결

Authors

서론

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가 나왔을 때는 아래 질문 순서로 보면 빠릅니다.

  1. 패닉이 난 스레드/태스크는 어디서 만들어졌나?
    • tokio::spawn 호출 위치
    • std::thread::spawn 호출 위치
  2. 런타임은 어디서 생성/종료되나?
    • #[tokio::main]이면 main 종료 시점
    • Runtime::new()면 스코프 종료 시점
  3. 태스크를 await하고 있는가?
    • JoinHandle을 버리고 있지 않은가
  4. Drop에서 async를 호출하고 있지 않은가?
  5. 테스트라면 전역 싱글톤/백그라운드 스레드가 다음 테스트까지 살아남지 않는가?

추적이 어렵다면 RUST_BACKTRACE=1 또는 RUST_BACKTRACE=full로 스택을 확인하고, 런타임 drop 지점(대개 main 종료/스코프 종료)을 먼저 찾는 게 효율적입니다.


자주 묻는 함정: “spawn 했으니 알아서 돌아가겠지”

Tokio의 태스크는 OS 스레드처럼 독립 실행되는 것이 아니라, 런타임이 폴링해줘야만 진행됩니다. 따라서 런타임이 내려가면 태스크는 더 이상 진행할 수 없습니다.

  • “백그라운드로 계속 돌아야 하는 작업”이 있다면
    • 런타임을 프로세스 수명 전체로 유지하거나
    • 종료 프로토콜(채널/토큰)과 함께 태스크 수명을 관리해야 합니다.

운영 환경에서 이런 종료 순서 문제는 CI에서도 재현되곤 합니다. 특히 러너가 멈추거나 타임아웃이 걸릴 때 종료 시퀀스가 꼬여 증상이 확대될 수 있어, 장애 관점에서 함께 보면 좋습니다: GitHub Actions self-hosted runner 멈춤 원인 8가지


결론

Rust Tokio: runtime dropped 패닉은 Tokio 자체의 버그라기보다, 런타임 수명보다 더 오래 살려고 하는 비동기 작업이 만들어낸 구조적 문제인 경우가 대부분입니다.

정리하면 해결의 우선순위는 다음과 같습니다.

  1. 스폰한 태스크는 반드시 소유하고(JoinHandle 수집) 종료 시점에 await한다.
  2. 영구 태스크에는 종료 신호(채널/토큰)를 설계해 런타임 drop 전에 빠져나오게 한다.
  3. Drop에서 async를 하지 말고, 명시적 shutdown().await를 제공한다.
  4. 런타임 경계(스레드/블로킹/테스트)를 명확히 하고, 핸들 전달을 통제한다.

이 네 가지만 지켜도 runtime dropped는 “가끔 터지는 미스터리”에서 “구조적으로 예방되는 클래스의 문제”로 바뀝니다.