- Published on
Rust async 데드락? Tokio Mutex·spawn 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 "가끔" 멈춥니다. CPU는 낮고, 로그도 끊기고, 요청은 타임아웃만 늘어납니다. Rust async에서는 이런 증상이 전통적인 데드락(락 순환 대기)뿐 아니라, await 지점에서의 락 홀드, spawn으로 인한 작업 유실, 블로킹 호출 혼입 등으로도 쉽게 나타납니다.
이 글은 Tokio 런타임에서 특히 자주 만나는 "async 데드락처럼 보이는 멈춤" 원인 7가지를 tokio::sync::Mutex와 tokio::spawn 관점에서 정리하고, 각 케이스마다 재현 코드와 안전한 대안을 제시합니다.
참고로 현상 진단에서 "타임아웃" 설계가 핵심인 경우도 많습니다. 분산 호출이 엮인 시스템이라면 gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계도 같이 읽으면 원인 좁히는 데 도움이 됩니다.
먼저 확인: 정말 데드락인가, 그냥 멈춘 것처럼 보이나
Rust async에서 "멈춤"을 만들 수 있는 대표적인 분류는 아래 3가지입니다.
- 진짜 데드락: 락 A를 잡은 채로 락 B를 기다리고, 다른 태스크는 반대로 B를 잡고 A를 기다리는 순환 대기
- 기아(Starvation): 특정 태스크가 계속 실행 기회를 못 얻음(특히 단일 스레드 런타임, 블로킹 혼입)
- 대기 누수:
JoinHandle을 버리거나 채널/Notify를 잘못 써서 영원히 깨어나지 않음
"데드락"이라고 단정하기 전에, 최소한 아래를 먼저 넣어두면 추적이 쉬워집니다.
tokio-console또는tracing으로 태스크 상태 관찰- 모든 외부 I/O에
tokio::time::timeout적용 spawn한 태스크의JoinHandle을 가능한 한 수집/관찰
원인 1) MutexGuard를 잡은 채로 await하기
가장 흔한 패턴입니다. tokio::sync::Mutex는 async 락이기 때문에 lock().await가 가능하지만, 락을 잡은 상태로 다시 await하면 다른 태스크가 해당 락을 영원히 기다리며 시스템이 멈춘 것처럼 보일 수 있습니다.
나쁜 예
use std::sync::Arc;
use tokio::sync::Mutex;
async fn do_io() {
// 네트워크/DB 호출이라고 가정
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
async fn handler(state: Arc<Mutex<u64>>) {
let mut guard = state.lock().await;
*guard += 1;
// 락을 쥔 채로 await
do_io().await;
*guard += 1;
}
이 코드가 단독으로는 "데드락"이 아닐 수 있지만, do_io()가 길어지거나 내부에서 다시 state.lock().await가 일어나면 쉽게 교착 상태로 변합니다.
안전한 패턴
await전에 guard 범위를 끝내기(스코프 분리)- 필요한 값만 복사해 두고 락을 빨리 놓기
use std::sync::Arc;
use tokio::sync::Mutex;
async fn handler(state: Arc<Mutex<u64>>) {
let current = {
let mut guard = state.lock().await;
*guard += 1;
*guard
}; // 여기서 guard drop
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let _ = current;
}
원인 2) 중첩 락 획득(락 순서 불일치)
두 개 이상의 Mutex를 동시에 다룰 때, 태스크마다 락을 잡는 순서가 다르면 전통적인 데드락이 발생합니다.
재현 코드
use std::sync::Arc;
use tokio::sync::Mutex;
async fn task1(a: Arc<Mutex<()>>, b: Arc<Mutex<()>>) {
let _ga = a.lock().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let _gb = b.lock().await;
}
async fn task2(a: Arc<Mutex<()>>, b: Arc<Mutex<()>>) {
let _gb = b.lock().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let _ga = a.lock().await;
}
#[tokio::main]
async fn main() {
let a = Arc::new(Mutex::new(()));
let b = Arc::new(Mutex::new(()));
let t1 = tokio::spawn(task1(a.clone(), b.clone()));
let t2 = tokio::spawn(task2(a.clone(), b.clone()));
let _ = tokio::join!(t1, t2);
}
해결 전략
- 락 획득 순서를 전역 규칙으로 고정 (예: 항상
a다음b) - 가능하면 락을 합치기 (한 구조체 안에 넣고 단일
Mutex로 보호) - 읽기 위주면
RwLock고려(단,await와 결합 시 주의는 동일)
원인 3) std::sync::Mutex를 async 컨텍스트에서 사용
Rust에서 std::sync::Mutex는 스레드를 블로킹합니다. Tokio 워커 스레드에서 이를 잡고 오래 걸리면, 런타임 스레드가 멈추고 다른 태스크가 진행되지 못해 "데드락"처럼 보입니다.
나쁜 예
use std::sync::{Arc, Mutex};
async fn handler(state: Arc<Mutex<u64>>) {
let mut guard = state.lock().unwrap();
*guard += 1;
// guard가 살아있는 동안 이 await는 매우 위험
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
권장
- async 공유 상태는
tokio::sync::Mutex사용 - CPU 바운드/블로킹 작업은
tokio::task::spawn_blocking로 격리
use std::sync::{Arc, Mutex};
async fn handler(state: Arc<Mutex<u64>>) {
let _ = tokio::task::spawn_blocking(move || {
let mut guard = state.lock().unwrap();
*guard += 1;
})
.await;
}
원인 4) 단일 스레드 런타임에서 블로킹 호출 혼입
#[tokio::main(flavor = "current_thread")] 또는 워커 수가 적은 환경에서 std::thread::sleep, 무거운 CPU 연산, 동기 I/O를 섞으면 런타임이 진행을 못 합니다.
재현 패턴
#[tokio::main(flavor = "current_thread")]
async fn main() {
tokio::spawn(async {
// 절대 금지: 런타임 스레드를 통째로 멈춤
std::thread::sleep(std::time::Duration::from_secs(2));
});
// 이 타이머조차 제때 실행되지 않음
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
println!("tick");
}
해결
- 블로킹은 무조건
spawn_blocking로 - I/O는 async 버전 사용
- 런타임 스레드 수(워커)도 점검(특히 컨테이너 CPU 제한 환경)
원인 5) spawn한 태스크의 JoinHandle을 버려서 실패를 놓침
tokio::spawn은 "백그라운드에서 돌겠지"라는 착각을 만들기 쉽습니다. 하지만 태스크는 패닉할 수 있고, 조용히 종료될 수도 있으며, 채널을 닫고 다른 태스크를 영원히 대기시키는 트리거가 되기도 합니다.
특히 아래 상황이 많습니다.
- producer 태스크가 패닉해서 채널이 닫힘
- consumer는
recv().await에서 영원히 기다림(송신자가 없으니) - 외부에서는 "데드락"처럼 보임
나쁜 예
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(1);
tokio::spawn(async move {
let _ = tx;
panic!("boom");
});
// 영원히 대기할 수 있음
let _msg = rx.recv().await;
}
개선
- 중요한 태스크는
JoinHandle을 저장하고 종료/패닉을 감시 tokio::select!로 타임아웃 또는 종료 신호를 함께 대기
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(1);
let handle = tokio::spawn(async move {
let _ = tx;
panic!("boom");
});
tokio::select! {
biased;
res = handle => {
// JoinError를 반드시 로깅
println!("task finished: {:?}", res);
}
msg = rx.recv() => {
println!("received: {:?}", msg);
}
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {
println!("timeout");
}
}
}
원인 6) spawn 안에서 락을 오래 잡고, 밖에서 그 태스크를 기다림
"락을 잡고 있는 태스크"를 spawn으로 분리한 뒤, 호출자가 그 태스크의 완료를 기다리면서 같은 락을 필요로 하면 교착이 만들어집니다.
전형적인 실수
- 메인 흐름이 락을 잡음
spawn으로 락이 필요한 작업을 실행- 메인 흐름이
handle.await로 완료를 기다림 - 스폰된 태스크는 락을 얻지 못해 대기
- 메인은 태스크 완료를 기다리며 대기
재현 코드
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let state = Arc::new(Mutex::new(0u64));
let guard = state.lock().await;
let st2 = state.clone();
let handle = tokio::spawn(async move {
let mut g = st2.lock().await;
*g += 1;
});
// guard를 쥔 채로 handle을 기다리면 교착
let _ = handle.await;
drop(guard);
}
해결
spawn전에 락을 해제- 또는
spawn태스크가 락이 필요 없도록 입력을 미리 준비
원인 7) Notify/채널/세마포어의 "신호 유실"로 영원히 대기
락이 아니라도 데드락처럼 보이는 대표 케이스가 신호 유실입니다.
Notify는 카운팅 세마포어가 아니라서, 알림 타이밍이 어긋나면 대기가 풀리지 않을 수 있습니다(사용 패턴에 따라).mpsc채널은 모든 sender가 drop되면recv().await가None으로 끝나야 하는데, sender를 어딘가에 쥐고 있으면 영원히 안 닫힙니다.Semaphore는 permit을 획득하고 반환을 못 하면(가드 누수) 전체가 멈출 수 있습니다.
Semaphore permit 누수 예
use std::sync::Arc;
use tokio::sync::Semaphore;
#[tokio::main]
async fn main() {
let sem = Arc::new(Semaphore::new(1));
let p = sem.acquire_owned().await.unwrap();
// p를 drop하지 않으면 permit이 반환되지 않음
let sem2 = sem.clone();
let h = tokio::spawn(async move {
// 여기서 영원히 대기
let _p2 = sem2.acquire_owned().await.unwrap();
});
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), h).await;
drop(p);
}
해결
- 대기에는 항상
timeout을 붙여 "영원"을 제거 - 소유권/스코프를 이용해 가드가 확실히 drop되게 설계
- 채널 sender는 수명 관리(필요 시 명시적으로 drop)
실전 디버깅 체크리스트
1) 락 홀드 시간부터 줄이기
- 락 안에서는 값 읽고/쓰기만 하고, I/O와
await는 밖으로 - 공유 상태는 "작게" 유지: 큰 구조체를 통째로
Mutex로 감싸면 경쟁이 폭발
2) 타임아웃을 기본값으로
- 외부 호출, 채널 대기, 락 대기(간접적으로)는 모두 타임아웃 고려
- 타임아웃이 많아지면 "데드락"과 "느림"을 구분할 수 있음
타임아웃 설계 자체가 어려운 경우는 gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계의 데드라인 전파 아이디어를 Rust 서비스에도 그대로 적용할 수 있습니다.
3) spawn은 "에러를 숨기는 도구"가 되기 쉽다
- 중요한 태스크는
JoinHandle을 모으고, 종료/패닉을 로깅 - 백그라운드 워커는 "슈퍼바이저" 태스크로 재시작/종료를 관리
4) DB 데드락과 혼동하지 않기
애플리케이션이 멈춘 것 같아도 실제로는 DB 트랜잭션 데드락/락 대기일 수 있습니다. Rust 쪽에서는 그냥 await로 보이기 때문에 더 헷갈립니다. MySQL을 쓴다면 MySQL InnoDB Deadlock 로그로 원인 SQL 찾기처럼 DB 레벨도 같이 확인해야 합니다.
결론: Tokio Mutex와 spawn에서 "멈춤"을 없애는 규칙
정리하면, Tokio async에서 데드락처럼 보이는 멈춤을 줄이는 가장 강력한 규칙은 아래 4가지입니다.
MutexGuard를 잡은 채로await하지 않는다(스코프로 강제).- 다중 락은 순서를 고정하거나 구조를 합친다.
- 블로킹은 런타임 밖으로(
spawn_blocking),std::sync::Mutex는 async 경로에 두지 않는다. spawn은 관찰 가능하게 만든다(JoinHandle수집, 타임아웃, 종료 로깅).
이 규칙만 지켜도 "가끔 멈추는" Tokio 서비스의 상당수를 안정화할 수 있습니다. 다음 단계로는 tracing 기반 태스크 관찰과, 공유 상태를 메시지 패싱(채널) 중심으로 재설계하는 방법을 추천합니다.