Published on

Rust Tokio 런타임 패닉 원인 7가지와 해결법

Authors
Binance registration banner

서버나 워커를 Rust로 만들다 보면, 어느 순간 tokio 런타임에서 갑자기 패닉이 터지면서 프로세스가 종료되는 경험을 하게 됩니다. 특히 개발 환경에서는 잘 돌다가도, 테스트/CI 또는 컨테이너 환경에서만 재현되는 경우가 많아 원인 파악이 어렵습니다.

이 글은 Tokio 런타임 패닉을 “왜 나는지”를 증상 중심으로 7가지로 정리하고, 각 케이스별로 재현 예제와 해결책을 함께 제공합니다. 마지막에는 운영 환경에서 패닉을 빠르게 좁히는 체크리스트도 넣었습니다.

참고로, 런타임 패닉이 메모리 압박이나 컨테이너 OOM으로 보이는 경우도 있습니다. 컨테이너에서 Exit 137 같은 형태로 죽는다면 이 글도 같이 보면 도움이 됩니다: GitLab Runner Docker executor OOM·Exit 137 해결

1) 런타임 중첩 생성: Cannot start a runtime from within a runtime

전형적인 증상

  • 에러 메시지에 Cannot start a runtime from within a runtime 또는 유사 문구
  • 보통 라이브러리 내부에서 tokio::runtime::Runtime::new().block_on(...) 을 호출할 때 발생

재현 코드

use tokio::runtime::Runtime;

async fn work() {
    // ...
}

#[tokio::main]
async fn main() {
    // 이미 Tokio 런타임 안인데,
    // 또 런타임을 만들어 block_on 하면 패닉 가능
    let rt = Runtime::new().unwrap();
    rt.block_on(work());
}

해결책

  • 원칙: 애플리케이션 최상단(엔트리포인트)에서만 런타임을 만들고, 나머지는 async 를 전파합니다.
  • 라이브러리라면 block_on 제공을 지양하고 async fn API를 제공하세요.
  • 정말 동기 API가 필요하면, 호출자가 런타임 밖에서 사용하도록 문서화하거나, 별도 스레드에서 런타임을 구동하는 어댑터를 분리합니다.

권장 패턴

async fn work() {
    // ...
}

#[tokio::main]
async fn main() {
    work().await;
}

2) 런타임 컨텍스트 밖에서 Tokio 기능 호출: there is no reactor running

전형적인 증상

  • 메시지에 there is no reactor running 또는 not currently running on a Tokio runtime
  • tokio::spawn, tokio::time::sleep, TcpStream::connect 등을 런타임 밖에서 호출

재현 코드

fn main() {
    // 런타임 없이 Tokio 타이머를 사용하면 실패
    let _ = tokio::time::sleep(std::time::Duration::from_millis(10));
}

해결책

  • #[tokio::main] 또는 명시적 런타임을 통해 컨텍스트를 보장합니다.
  • 테스트에서는 #[tokio::test] 를 사용합니다.
#[tokio::main]
async fn main() {
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}

테스트 예시:

#[tokio::test]
async fn test_sleep() {
    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}

3) spawn한 태스크의 패닉이 JoinHandle로 전파됨

Tokio 자체가 “런타임이 패닉”이라기보다, 태스크 내부 패닉이 JoinError로 돌아오고 이를 unwrap() 하면서 프로세스가 죽는 형태가 매우 흔합니다.

전형적인 증상

  • 로그에 task panicked 또는 JoinError::Panic 관련 표시
  • 호출 코드에서 handle.await.unwrap() 같은 패턴

재현 코드

#[tokio::main]
async fn main() {
    let h = tokio::spawn(async {
        panic!("boom");
    });

    // 여기서 unwrap이 다시 패닉을 유발
    h.await.unwrap();
}

해결책

  • JoinHandle은 반드시 에러를 분기 처리합니다.
  • 태스크 경계에서 패닉이 나지 않도록 Result 기반으로 설계하고, 필요한 경우 catch_unwind도 고려합니다.
#[tokio::main]
async fn main() {
    let h = tokio::spawn(async {
        // panic 대신 Result로
        anyhow::Result::<()>::Ok(())
    });

    match h.await {
        Ok(Ok(())) => {}
        Ok(Err(e)) => eprintln!("task error: {e:#}"),
        Err(join_err) => eprintln!("task join error: {join_err}"),
    }
}

4) 블로킹 작업을 런타임 워커 스레드에서 실행 (교착·타임아웃·연쇄 실패)

이 케이스는 즉시 패닉이 아니라, 시간이 지나면서 타임아웃/리소스 고갈을 유발하고 결국 다른 곳에서 패닉이 터지는 형태로 나타납니다. 특히 CPU 바운드 연산, 파일 I/O, 동기 락 대기, 외부 프로세스 실행 등을 워커 스레드에서 돌리면 런타임이 “멈춘 것처럼” 보입니다.

재현 코드(나쁜 예)

#[tokio::main]
async fn main() {
    // 워커 스레드를 장시간 점유
    std::thread::sleep(std::time::Duration::from_secs(2));

    // 이 뒤의 async 작업들이 밀리면서 연쇄 장애
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}

해결책

  • 블로킹은 tokio::task::spawn_blocking 으로 격리합니다.
  • CPU 바운드가 많다면 별도 스레드풀(예: rayon) 또는 워커 프로세스로 분리합니다.
#[tokio::main]
async fn main() {
    let v = tokio::task::spawn_blocking(|| {
        std::thread::sleep(std::time::Duration::from_secs(1));
        42
    })
    .await
    .unwrap();

    println!("{v}");
}

운영에서 “블로킹으로 인한 고갈”은 DB 커넥션 풀 고갈과 매우 비슷한 양상으로 나타납니다. 리소스가 대기열에 쌓이면 결국 타임아웃과 패닉이 터집니다. 커넥션 풀 관점의 진단 프레임은 이 글도 참고할 만합니다: Spring Boot HikariCP 커넥션 고갈 원인과 해결

5) block_on 사용 실수: 현재 스레드 런타임과의 상호작용 문제

block_on 자체는 나쁜 도구가 아니지만, 사용 위치/스레드에 따라 의도치 않은 상호작용을 만들 수 있습니다.

전형적인 증상

  • 특정 경로에서만 데드락/패닉
  • current_thread 런타임에서, 내부적으로 같은 스레드에 의존하는 작업을 기다리며 멈춤

재현 코드(개념 예시)

use tokio::runtime::Builder;

fn main() {
    let rt = Builder::new_current_thread().enable_all().build().unwrap();

    rt.block_on(async {
        // current_thread 런타임은 한 스레드에서만 폴링
        // 여기서 블로킹/락/대기 잘못 섞이면 멈추기 쉽다
        tokio::task::yield_now().await;
    });
}

해결책

  • 서버/고동시성 워크로드는 기본적으로 멀티스레드 런타임을 권장합니다.
  • block_on 은 “최상단 1회” 또는 “동기 진입점에서 짧게 async를 감싸는 용도”로 제한합니다.
  • 동기 코드에서 async를 호출해야 한다면, 장기적으로는 호출 체인을 async로 전환하는 것이 안전합니다.

6) Drop 시점에 런타임 의존 작업 수행: 런타임 종료 이후 사용

Drop 구현에서 tokio::spawn 을 하거나, async 정리 작업을 억지로 수행하려다 런타임이 이미 내려간 상태면 패닉/에러가 발생할 수 있습니다. 특히 전역(static) 객체, lazy 초기화 싱글톤, 로깅/메트릭 플러셔 등이 흔한 원인입니다.

전형적인 증상

  • 프로그램 종료 시점에만 터짐
  • 테스트 종료 시점에만 간헐적으로 터짐

재현 코드(나쁜 예)

struct Bad;

impl Drop for Bad {
    fn drop(&mut self) {
        // 드롭 시점에 런타임이 없을 수 있음
        tokio::spawn(async {
            // cleanup
        });
    }
}

#[tokio::main]
async fn main() {
    let _b = Bad;
}

해결책

  • 정리 작업은 Drop 이 아니라 명시적 shutdown() 메서드로 수행하고, 호출자가 await 하게 만듭니다.
  • 종료 훅이 필요하면 tokio::signal 을 이용해 종료 시퀀스를 명확히 구성합니다.
struct Good;

impl Good {
    async fn shutdown(self) {
        // async cleanup
    }
}

#[tokio::main]
async fn main() {
    let g = Good;
    // ...
    g.shutdown().await;
}

7) 스레드/메모리 리소스 한계: 스레드 생성 실패, OOM, 너무 큰 스택

Tokio 런타임은 워커 스레드, 블로킹 스레드풀, 네트워크 버퍼 등 다양한 리소스를 씁니다. 컨테이너에서 메모리 제한이 빡빡하거나, 시스템 스레드 제한이 낮거나, 무한 태스크 생성으로 메모리를 잡아먹으면 “런타임 패닉처럼 보이는 종료”가 발생합니다.

전형적인 증상

  • 컨테이너가 OOMKilled 또는 Exit 137
  • failed to spawn thread 류 메시지
  • 부하가 증가할수록 재현

재현 코드(태스크 폭증)

#[tokio::main]
async fn main() {
    loop {
        tokio::spawn(async {
            // 종료되지 않는 태스크가 계속 쌓임
            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
        });
    }
}

해결책

  • 태스크 생성에 상한을 둡니다. Semaphore 로 동시성 제한을 걸어 폭증을 막습니다.
  • 큐 기반 워커 패턴을 사용해 생산자/소비자 속도를 제어합니다.
  • 런타임 설정을 워크로드에 맞게 조정합니다. 예를 들어 워커 스레드 수를 명시하거나, 블로킹 스레드풀 사용을 점검합니다.

동시성 제한 예시:

use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let sem = Arc::new(Semaphore::new(100));

    for _i in 0..10_000 {
        let permit = sem.clone().acquire_owned().await.unwrap();
        tokio::spawn(async move {
            let _permit = permit; // 작업 중 permit 유지
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        });
    }
}

컨테이너/쿠버네티스에서 “로그 없이 죽는” 느낌이라면, 앱 레벨 패닉 이전에 OOM이나 강제 종료일 가능성도 큽니다. 운영 진단 관점에서는 이 글의 접근이 유용합니다: EKS Pod CrashLoopBackOff 로그 없을 때 7단계 진단

패닉을 빨리 좁히는 실전 체크리스트

1) 패닉 메시지를 분류

  • Cannot start a runtime from within a runtime 이면 런타임 중첩
  • there is no reactor running 이면 런타임 컨텍스트 누락
  • task panicked 또는 JoinError 이면 태스크 내부 패닉 전파

2) unwrap() 의 위치를 찾기

  • JoinHandle.await.unwrap()
  • Result 를 반환하는 I/O 호출의 unwrap()
  • 채널 송수신(send().await.unwrap())의 수신자 종료

가능하면 다음처럼 컨텍스트를 붙여 에러를 남깁니다.

use anyhow::Context;

async fn run() -> anyhow::Result<()> {
    let _ = tokio::fs::read("missing.txt")
        .await
        .context("failed to read config file")?;
    Ok(())
}

3) 블로킹 호출이 섞였는지 점검

  • std::thread::sleep
  • 동기 DB 클라이언트
  • 무거운 CPU 연산
  • mutex를 오래 잡는 코드

발견되면 spawn_blocking 또는 아키텍처 분리로 격리합니다.

4) 종료 시퀀스를 명시적으로 설계

  • Drop 에서 async 정리 금지
  • shutdown().await 를 제공
  • tokio::signal 로 종료 이벤트를 받아 정리 후 종료

마무리

Tokio 런타임 패닉은 대부분 “런타임을 어디서 만들었는지”, “런타임 컨텍스트가 보장되는지”, “태스크 경계에서 패닉을 어떻게 다루는지”, “블로킹/리소스 폭증이 있는지” 네 축으로 정리됩니다. 위 7가지는 실무에서 재현 빈도가 높은 패턴이므로, 에러 메시지와 코드 구조를 대조해보면 원인을 빠르게 좁힐 수 있습니다.

원하시면 실제 패닉 로그(메시지 전문)와 런타임 생성 코드, Cargo.tomltokio feature 설정을 알려주시면, 어떤 케이스에 가장 가까운지 기준을 잡아 구체적으로 디버깅 플랜까지 제안해드릴게요.