- Published on
Rust Tokio runtime 멈춤? 블로킹 I/O 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 잘 돌다가 어느 순간 요청이 멈추고, CPU 사용률은 낮은데 응답이 오지 않는 상황을 Tokio 기반 Rust 서비스에서 종종 만납니다. 로그도 더 이상 찍히지 않고, 타임아웃만 늘어나는 느낌이죠. 이런 증상은 실제로 런타임이 "다운"된 게 아니라, 런타임 워커 스레드가 블로킹 작업에 붙잡혀서 다른 async 태스크가 스케줄링되지 못하는 경우가 많습니다.
이 글은 "Tokio runtime 멈춤"으로 보이는 현상을 블로킹 I/O 관점에서 재현하고, 어떤 지표로 의심하고, 어떤 도구로 확증하며, 어떤 방식으로 고치는지까지 실무 흐름으로 정리합니다.
비슷한 성격의 진단 글로는 런타임에서 long task를 찾아내는 접근이 도움이 됩니다. 프론트 영역이지만 분석 사고방식은 유사하니 참고로 같이 보셔도 좋습니다: Chrome INP 급락 원인 찾기 - Long Task·TBT 분석
1) "멈춤"처럼 보이는 대표 증상 패턴
다음 패턴이 함께 나타나면 블로킹 I/O를 가장 먼저 의심하는 게 좋습니다.
- 요청이 특정 시점부터 전반적으로 느려지거나 멈춤
- CPU 사용률은 낮거나 들쭉날쭉하지만, 평균은 높지 않음
- 메모리는 안정적이며 OOM 흔적이 없음
- 특정 엔드포인트 또는 특정 작업 이후부터 전체가 영향을 받음
- 로그가 "중간"에서 끊기고, 이후 진행 로그가 안 찍힘
- 스레드 덤프를 보면 워커 스레드가
std::fs또는 동기 네트워크 호출에서 대기
Tokio는 기본적으로 워커 스레드(멀티 스레드 런타임)에서 async 태스크를 협력적(cooperative)으로 실행합니다. 즉, 태스크가 await 지점에서 양보해야 다른 태스크가 실행됩니다. 그런데 워커 스레드에서 동기 블로킹 호출을 해버리면, 그 스레드는 양보하지 못하고 그대로 멈춰 있게 됩니다.
2) 가장 흔한 원인: async 안에서 동기 I/O 호출
2-1. 파일 I/O: std::fs 사용
아래 코드는 컴파일도 되고, 작은 트래픽에서는 문제 없어 보일 수 있지만, 워커 스레드에서 파일 읽기가 블로킹으로 수행됩니다.
use std::fs;
async fn load_config_bad(path: &str) -> anyhow::Result<String> {
// async 함수 안에서 동기 파일 I/O
let s = fs::read_to_string(path)?;
Ok(s)
}
특히 네트워크 파일시스템, 느린 디스크, 컨테이너 스토리지, 혹은 파일 락 경합이 있는 상황에서 이 한 줄이 워커 스레드를 오래 붙잡을 수 있습니다.
2-2. 동기 네트워크 클라이언트 호출
예를 들어 reqwest도 blocking API를 쓰면 동일한 문제가 납니다.
async fn call_api_bad() -> anyhow::Result<String> {
let body = reqwest::blocking::get("https://example.com")?.text()?;
Ok(body)
}
2-3. 동기 락: std::sync::Mutex 장시간 홀드
std::sync::Mutex 자체가 항상 나쁜 것은 아니지만, 락을 잡은 상태에서 오래 걸리는 작업을 하거나 await를 만나면 문제가 커집니다.
use std::sync::{Arc, Mutex};
async fn lock_bad(shared: Arc<Mutex<Vec<u8>>>) {
let mut g = shared.lock().unwrap();
// 오래 걸리는 계산 또는 I/O를 여기서 수행하면 워커 스레드가 묶임
g.push(1);
}
Tokio에서는 대개 tokio::sync::Mutex 또는 설계를 바꿔 락 홀드 시간을 줄이는 것이 안전합니다.
3) 재현: 워커 스레드를 묶어 런타임이 굳는 느낌 만들기
아래 예시는 "파일 읽기" 대신 일부러 std::thread::sleep을 넣어 블로킹을 재현합니다. 멀티 스레드 런타임에서 워커 스레드 수가 적을수록 증상이 명확해집니다.
use tokio::time::{sleep, Duration};
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
// 주기적으로 살아있음을 찍는 태스크
tokio::spawn(async {
loop {
println!("tick");
sleep(Duration::from_millis(200)).await;
}
});
// 워커 스레드를 블로킹으로 붙잡는 태스크를 여러 개 실행
for i in 0..10 {
tokio::spawn(async move {
println!("start blocking {}", i);
std::thread::sleep(Duration::from_secs(5));
println!("end blocking {}", i);
});
}
// 메인도 대기
sleep(Duration::from_secs(30)).await;
}
위 코드를 실행하면 tick이 끊기거나 매우 드물게 찍히는 구간이 생깁니다. 실제 서비스에서는 이게 "헬스체크 멈춤" 또는 "전체 요청 지연"으로 관측됩니다.
4) 진단 1단계: tokio-console로 태스크/워크 스레드 상태 보기
Tokio 생태계에서 가장 빠르게 "블로킹"을 의심할 수 있는 도구가 tokio-console입니다. 태스크가 얼마나 오래 실행 중인지, 깨어나지 못하고 있는지, 워커 스레드가 바쁜지 등을 시각적으로 볼 수 있습니다.
4-1. 의존성 추가
Cargo.toml에 다음을 추가합니다.
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
console-subscriber = "0.4"
4-2. subscriber 초기화
fn init_tracing() {
console_subscriber::init();
}
#[tokio::main]
async fn main() {
init_tracing();
// ...
}
그 다음 tokio-console을 실행해 태스크 목록에서 "오래 Running" 상태로 붙어 있는 태스크, 혹은 폴링이 안 되는 태스크를 찾습니다.
- 특정 태스크가 계속 Running인데
await로 넘어가지 않음 - 워커 스레드가 유휴인데도 진행이 안 되면 데드락/락 경합도 의심
- Running 태스크의 스택/스팬 정보를 통해 어떤 함수에서 멈췄는지 추적
5) 진단 2단계: tracing 스팬으로 블로킹 지점 좁히기
tokio-console이 없거나, 프로덕션에서 최소 침습으로 확인해야 한다면 tracing 스팬과 타이밍 로그를 촘촘히 넣는 것이 현실적입니다.
use tracing::{info, instrument};
use std::time::Instant;
#[instrument(skip_all)]
async fn handler() -> anyhow::Result<()> {
let t0 = Instant::now();
info!("before step1");
step1().await?;
info!(elapsed_ms = t0.elapsed().as_millis(), "after step1");
info!("before step2");
step2().await?;
info!(elapsed_ms = t0.elapsed().as_millis(), "after step2");
Ok(())
}
중요 포인트는 "어디까지 로그가 찍히고 어디서 끊기는지"입니다. 끊기는 지점 바로 아래 호출 경로에 동기 I/O나 동기 락이 숨어있는 경우가 많습니다.
6) 해결 1순위: 진짜 async I/O로 바꾸기
파일 I/O라면 tokio::fs를 사용합니다.
use tokio::fs;
async fn load_config_ok(path: &str) -> anyhow::Result<String> {
let s = fs::read_to_string(path).await?;
Ok(s)
}
네트워크도 async 클라이언트 API를 사용하고, 커넥션 풀/타임아웃을 명시합니다.
use std::time::Duration;
async fn call_api_ok() -> anyhow::Result<String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()?;
let body = client
.get("https://example.com")
.send()
.await?
.text()
.await?;
Ok(body)
}
7) 해결 2순위: 어쩔 수 없는 블로킹은 spawn_blocking으로 격리
이미 존재하는 동기 라이브러리(예: 이미지 처리, 압축, 레거시 DB 드라이버, 동기 파일 API 등)를 당장 교체하기 어렵다면 tokio::task::spawn_blocking으로 런타임 워커와 분리해야 합니다.
use tokio::task;
async fn cpu_or_blocking_work_ok(path: String) -> anyhow::Result<String> {
let s = task::spawn_blocking(move || {
// 여기서는 동기 I/O 또는 CPU 바운드 작업을 수행해도 됨
std::fs::read_to_string(path)
})
.await??;
Ok(s)
}
주의할 점도 있습니다.
spawn_blocking은 별도의 블로킹 스레드 풀을 사용하지만 무한정이 아닙니다- 블로킹 작업이 폭주하면 블로킹 풀에서 대기열이 생기고, 결국 지연이 누적됩니다
- 따라서 "격리"는 응급처치이고, 장기적으로는 async 대체 또는 작업량 제한이 필요합니다
8) 해결 3순위: 동시성 제한으로 블로킹 폭주를 막기
블로킹을 완전히 제거하기 전까지는 "최대 동시 실행 수"를 제한해 전체 런타임이 굳는 것을 방지할 수 있습니다. 대표적으로 Semaphore를 씁니다.
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task;
async fn guarded_blocking(
sem: Arc<Semaphore>,
path: String,
) -> anyhow::Result<String> {
let _permit = sem.acquire().await?;
let out = task::spawn_blocking(move || std::fs::read_to_string(path))
.await??;
Ok(out)
}
이 패턴은 "느려도 전체가 멈추진 않게" 만들어 줍니다. 특히 외부 의존성이 느려질 때 장애 전파를 완화하는 데 유용합니다. 비슷한 맥락으로 타임아웃과 동시성 제어가 왜 중요한지에 대한 관점은 서버리스/컨테이너 런타임에서도 자주 반복됩니다: GCP Cloud Run 503/504 원인별 해결 - 타임아웃·동시성
9) 런타임 설정 관점 체크리스트
"블로킹"이 맞는데도 재현이 들쭉날쭉하다면 런타임/환경도 함께 봐야 합니다.
9-1. 워커 스레드 수
기본은 CPU 코어 수 기반입니다. 컨테이너에서 CPU limit이 낮게 잡혀 있거나, 노드에서 CPU throttling이 심하면 워커 스레드가 충분히 돌지 못해 증상이 커집니다.
- 워커 스레드가 너무 적으면 블로킹 1~2개로 전체가 멈춘 듯 보임
- 너무 많다고 해결되진 않음. 블로킹은 격리가 우선
9-2. 타임아웃 부재
블로킹 I/O가 외부 시스템에 의해 길어질 때, 타임아웃이 없으면 워커 스레드가 영구 대기 상태에 빠집니다.
- HTTP 클라이언트 timeout
- DB query timeout
- 파일/락 대기 시간 제한
9-3. 백프레셔 부재
요청이 몰릴 때 무제한으로 작업을 생성하면, 블로킹 풀 대기열과 메모리 사용량이 함께 증가합니다.
- 큐 길이 제한
- 세마포어로 동시 실행 제한
- 요청 레벨에서 429 또는 빠른 실패
10) 프로덕션에서의 빠른 의사결정 흐름
현장에서 "멈춘 것 같다"가 들어오면, 다음 순서가 효율적입니다.
- 증상 분류: CPU 낮고 응답 없음이면 블로킹/데드락 우선
- tokio-console 또는 tracing으로 "오래 Running" 태스크 찾기
- 해당 태스크의 코드 경로에서
std::fs,reqwest::blocking, 동기 락, 무한 루프를 확인 - 즉시 완화:
spawn_blocking격리, 세마포어로 동시성 제한, 타임아웃 추가 - 근본 해결: async I/O로 전환, 라이브러리 교체, 설계 변경
11) 자주 하는 실수 모음
- async 함수라고 해서 내부가 자동으로 non-blocking이 되는 것은 아님
tokio::fs로 바꿨는데도 느리면, 실제 병목은 "디스크" 또는 "락"일 수 있음spawn_blocking을 남발하면 블로킹 풀에서 또 병목이 생김- 락을 잡은 채로
await를 하는 구조는 데드락/지연을 유발하기 쉬움 - 타임아웃이 없는 외부 호출은 언젠가 런타임을 멈춘 것처럼 보이게 만듦
12) 마무리
Tokio 런타임 "멈춤"의 상당수는 런타임 자체 문제가 아니라, 워커 스레드를 점유하는 블로킹 I/O 또는 동기 락 경합에서 시작합니다. 핵심은 두 가지입니다.
- 진단: tokio-console과 tracing으로 "누가 얼마나 오래 워커를 붙잡는지"를 본다
- 처방: 가능한 async I/O로 전환하고, 불가피하면
spawn_blocking과 동시성 제한으로 격리한다
이 두 축을 잡으면, 재현이 어려운 간헐 멈춤도 원인-해결 루프를 빠르게 돌릴 수 있습니다.