- Published on
Rust Tokio runtime panic - blocking 호출 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 Rust와 Tokio로 구성하다 보면 어느 순간 아래와 비슷한 메시지로 런타임 패닉을 만나는 경우가 있습니다.
Cannot block the current thread from within a runtimepanicked at ...와 함께blocking관련 문구- 혹은 패닉은 없지만 처리량이 급락하고 지연이 급증하는 현상
이 글은 “Tokio 런타임 스레드에서 블로킹 호출이 실행되었다”라는 문제를 정확히 이해하고, 상황별로 안전하게 분리하는 패턴을 정리합니다. 단순히 spawn_blocking 한 줄로 끝나는 케이스도 있지만, DB 드라이버/파일 IO/CPU 바운드/락 경합이 섞이면 구조를 바꾸지 않으면 재발합니다.
왜 Tokio에서 blocking 호출이 문제인가
Tokio의 기본 실행 모델은 “소수의 워커 스레드에서 많은 비동기 태스크를 스케줄링”하는 구조입니다. 워커 스레드가 블로킹되면 해당 스레드에서 돌던 다른 태스크들이 함께 굶게 됩니다. 결과적으로 다음 문제가 생깁니다.
- 타이머 지연:
sleep같은 타이머가 늦게 깨어남 - 네트워크 지연: 소켓 읽기/쓰기 폴링이 밀림
- 처리량 감소: 워커 스레드 수만큼 병목이 즉시 발생
- 최악의 경우 패닉: 특정 API는 런타임 컨텍스트에서 블로킹을 감지하면 패닉을 유발
특히 다음 상황에서 패닉을 자주 봅니다.
#[tokio::main]또는 런타임 내부에서futures::executor::block_on같은 “동기 대기”를 호출tokio::task::block_in_place를current_thread런타임에서 호출std::sync::Mutex를 오래 잡고, 그 안에서.await를 하거나, 반대로 async 코드에서 동기 락 경합이 커짐
가장 흔한 재현 코드: 런타임 안에서 block_on
아래는 “이미 Tokio 런타임 안인데 또 다른 block_on을 돌리는” 대표적인 안티패턴입니다.
use tokio::runtime::Runtime;
#[tokio::main]
async fn main() {
// 이미 Tokio 런타임 안
let rt = Runtime::new().unwrap();
// 런타임 안에서 다시 block_on: 상황에 따라 패닉 또는 교착/지연
rt.block_on(async {
println!("nested runtime");
});
}
해결
- “비동기 함수는 비동기로 끝까지 전파”가 원칙입니다.
- 동기 함수 경계에서만 비동기 진입이 필요하면, 런타임을 외부에서 소유하거나, 채널로 넘겨 처리합니다.
블로킹의 종류를 먼저 분류하자
해결책은 “무엇이 블로킹인가”에 따라 달라집니다.
- CPU 바운드(압축, 해시, 이미지 리사이즈, 대량 JSON 파싱 등)
- 동기 IO(파일 읽기/쓰기, 동기 HTTP 클라이언트, 동기 DB 드라이버)
- 락/대기(큰 크리티컬 섹션,
std::sync::Mutex경합) - 외부 프로세스 호출(명령 실행 후 대기)
Tokio에서의 기본 처방은 다음과 같습니다.
- CPU 바운드 또는 동기 IO는
tokio::task::spawn_blocking - “짧은” 블로킹을 꼭 런타임 워커에서 해야 하면
tokio::task::block_in_place(제약 많음) - 장시간 블로킹/제3자 라이브러리 호출은 전용 스레드 풀 또는 별도 서비스로 분리
정석 1: spawn_blocking 으로 분리
spawn_blocking 은 Tokio의 blocking 스레드 풀에서 클로저를 실행합니다. 런타임 워커 스레드를 막지 않기 때문에 가장 안전한 기본 해법입니다.
use tokio::task;
fn heavy_cpu_work(input: Vec<u8>) -> usize {
// CPU 바운드 작업 가정
input.iter().map(|b| *b as usize).sum()
}
#[tokio::main]
async fn main() {
let data = vec![1u8; 10_000_000];
let handle = task::spawn_blocking(move || heavy_cpu_work(data));
let result = handle.await.expect("blocking task panicked");
println!("result={}", result);
}
운영 팁
spawn_blocking도 무한정 쓰면 blocking 풀에서 병목이 납니다.- 대량 요청이 들어오는 API라면, “동시 실행 제한”을 반드시 걸어야 합니다.
예를 들어 세마포어로 제한합니다.
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task;
#[tokio::main]
async fn main() {
let sem = Arc::new(Semaphore::new(8)); // blocking 동시 실행 8개로 제한
let mut joins = Vec::new();
for _ in 0..100 {
let permit = sem.clone().acquire_owned().await.unwrap();
joins.push(task::spawn(async move {
let _permit = permit; // 스코프 종료까지 유지
task::spawn_blocking(move || {
// 블로킹 작업
std::thread::sleep(std::time::Duration::from_millis(50));
})
.await
.unwrap();
}));
}
for j in joins {
j.await.unwrap();
}
}
정석 2: 동기 라이브러리 IO를 async 로 교체
가장 좋은 해결은 “블로킹 호출 자체를 없애는 것”입니다.
- 파일 IO:
std::fs대신tokio::fs - 프로세스 실행:
std::process::Command대신tokio::process::Command - 동기 HTTP 클라이언트: async 지원 클라이언트로 교체
예시로 파일 읽기를 바꿉니다.
use tokio::fs;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = fs::read_to_string("./Cargo.toml").await?;
println!("{}", content.lines().next().unwrap_or(""));
Ok(())
}
이런 교체가 가능한데도 spawn_blocking 으로 덮어버리면, 잠재적으로는 blocking 풀 병목만 다른 형태로 옮겨갈 수 있습니다.
주의: block_in_place 는 만능이 아니다
tokio::task::block_in_place 는 “현재 워커 스레드에서 잠깐 블로킹이 필요할 때” Tokio가 워커 스레드를 보정할 수 있도록 힌트를 주는 API입니다.
하지만 제약이 큽니다.
current_thread런타임에서는 사용할 수 없거나, 기대와 다르게 동작할 수 있음- 블로킹 시간이 길면 결국 성능 문제
- 호출 위치가 깊어지면 추적이 어려움
짧은 레거시 호출을 감싸는 정도로만 제한하는 편이 안전합니다.
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let v = tokio::task::block_in_place(|| {
// 정말 짧게 끝나는 동기 호출만
1 + 2
});
println!("{}", v);
}
실전에서 자주 터지는 케이스 1: std::sync::Mutex 와 await 혼합
아래 패턴은 런타임 패닉보다는 “멈춘 것처럼 보이는 지연”을 만들기 쉽습니다.
std::sync::Mutex를 잠근 채로.await를 수행- 다른 태스크가 같은 락을 기다리면서 워커 스레드가 점점 막힘
문제 예시(안티패턴):
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() {
let shared = Arc::new(Mutex::new(0u64));
let s1 = shared.clone();
let t1 = tokio::spawn(async move {
let mut g = s1.lock().unwrap();
// 락 잡은 채로 await: 위험
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
*g += 1;
});
t1.await.unwrap();
}
해결
- async 환경에서는
tokio::sync::Mutex를 고려 - 락을 잡는 범위를 최소화하고,
.await는 락 밖에서 수행
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let shared = Arc::new(Mutex::new(0u64));
let s1 = shared.clone();
let t1 = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut g = s1.lock().await;
*g += 1;
});
t1.await.unwrap();
}
실전에서 자주 터지는 케이스 2: CPU 바운드가 이벤트 루프를 잠식
Tokio 태스크는 기본적으로 협력적 스케줄링 성격이 강합니다. 즉, 태스크가 오래 CPU를 잡고 있으면 다른 태스크가 실행될 기회를 잃습니다.
- 대량 정규식 처리
- 큰 벡터 정렬
- 이미지 처리
- 암호화/서명
이런 작업은 .await 가 없어서 “스스로 양보”하지 않습니다. 따라서 spawn_blocking 이나 Rayon 같은 CPU 풀로 보내는 것이 맞습니다.
진단: 지금 blocking 이 어디서 발생하는지 찾기
1) 로그로 패닉 메시지의 스택을 확보
패닉이 발생하면 우선 백트레이스를 켭니다.
RUST_BACKTRACE=1- 더 자세히는
RUST_BACKTRACE=full
컨테이너/서비스 환경이라면 실행 환경 변수로 넣고, 해당 스택 프레임에서 동기 호출 경계를 찾습니다.
2) Tokio 콘솔로 태스크 지연 관측
tokio-console 은 태스크가 얼마나 오래 폴링되지 못했는지, 어떤 리소스에서 대기 중인지 관측하는 데 도움이 됩니다.
Cargo.toml 예시:
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
console-subscriber = "0.2"
초기화:
fn init_console() {
console_subscriber::init();
}
#[tokio::main]
async fn main() {
init_console();
// ...
}
3) 현상 기반으로는 Long Task 접근이 유효
blocking 문제는 결국 “이벤트 루프를 오래 점유하는 작업”이라는 점에서 프런트엔드의 Long Task와 디버깅 사고방식이 닮아 있습니다. 지연이 스파이크 형태로 튄다면, 어떤 작업이 워커를 오래 잡는지 추적해야 합니다.
관련해서 Long Task 추적 관점이 궁금하다면 Chrome INP 튀는 원인 찾기 - Long Task 추적법 도 함께 참고하면 문제를 구조적으로 보는 데 도움이 됩니다.
아키텍처 처방: “동기 경계”를 명확히 분리하기
코드가 커질수록 다음 원칙이 중요해집니다.
- async 레이어에서는 async API만 호출한다
- 동기 라이브러리는 “어댑터 레이어”에서만 사용하고
spawn_blocking으로 격리한다 - 동기 경계는 동시성 제한(세마포어, 큐)과 타임아웃을 기본으로 둔다
예시로, 동기 라이브러리를 감싸는 어댑터를 만들어 호출부가 단순해지도록 구성할 수 있습니다.
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task;
struct LegacyClient {
// 동기 클라이언트라고 가정
}
impl LegacyClient {
fn fetch_sync(&self, key: String) -> String {
// 블로킹 작업 가정
std::thread::sleep(std::time::Duration::from_millis(30));
format!("value:{}", key)
}
}
struct LegacyAdapter {
client: Arc<LegacyClient>,
sem: Arc<Semaphore>,
}
impl LegacyAdapter {
fn new(client: LegacyClient, max_concurrency: usize) -> Self {
Self {
client: Arc::new(client),
sem: Arc::new(Semaphore::new(max_concurrency)),
}
}
async fn fetch(&self, key: String) -> String {
let permit = self.sem.clone().acquire_owned().await.unwrap();
let client = self.client.clone();
let out = task::spawn_blocking(move || {
let _permit = permit;
client.fetch_sync(key)
})
.await
.unwrap();
out
}
}
#[tokio::main]
async fn main() {
let adapter = LegacyAdapter::new(LegacyClient {}, 16);
let v = adapter.fetch("abc".to_string()).await;
println!("{}", v);
}
이렇게 해두면 “어디에서 blocking 이 발생하는지”가 구조적으로 드러나고, 동시성 제한과 관측 포인트도 한 군데로 모입니다.
체크리스트: Tokio blocking 패닉/지연을 줄이는 실무 점검
- 런타임 내부에서 또 다른 런타임을 만들고
block_on하지 않았는가 - async 함수에서
std::thread::sleep같은 호출이 남아있지 않은가 - 파일/프로세스/네트워크가 동기 API로 호출되고 있지 않은가
std::sync::Mutex를 잡고.await하는 코드가 없는가- CPU 바운드 작업이 async 태스크에서 그대로 돌고 있지 않은가
spawn_blocking사용 시 동시성 제한이 있는가- blocking 풀 포화 시의 타임아웃/폴백 전략이 있는가
마무리
Tokio 런타임에서 blocking 호출로 패닉이 난다는 것은 “비동기 스케줄러가 보장해야 할 진행이 깨졌다”는 신호입니다. 해결의 핵심은 단순합니다.
- 블로킹을 async 워커에서 제거한다
- 제거할 수 없으면
spawn_blocking으로 격리한다 - 격리한 뒤에는 동시성 제한과 관측을 붙인다
이 3가지만 지켜도 패닉은 대부분 사라지고, 남는 문제는 “병목의 위치가 어디인가”로 단순화됩니다. 그렇게 되면 튜닝과 확장(스레드 풀 크기, 큐잉, 캐시, 분산 처리)이 훨씬 쉬워집니다.