- Published on
Rust Tokio join! 교착? spawn·Mutex 오용 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 Tokio로 옮기고 나서 가장 당황스러운 순간 중 하나가 tokio::join!이 “교착(데드락)”처럼 멈춰 보이는 상황입니다. 로그도 더 안 찍히고, CPU도 놀고, 요청도 안 끝나고, 마치 런타임이 죽은 것처럼 보입니다.
하지만 대부분의 경우 join! 자체가 교착을 만들지는 않습니다. join!은 단지 여러 Future를 같은 태스크에서 폴링해서 “모두 끝날 때까지” 기다릴 뿐입니다. 진짜 원인은 다음 중 하나로 수렴하는 경우가 많습니다.
Mutex(혹은RwLock)를 잡은 채로await해서 다른 작업이 락을 못 얻음spawn한 태스크를 기다리는 쪽과, 기다림을 해소해줄 이벤트를 발생시키는 쪽이 서로 막힘std::sync::Mutex를 async 컨텍스트에서 써서 스레드 자체를 막음- 블로킹 I/O, 무거운 CPU 작업을 async 태스크에서 그대로 돌려 런타임 워커가 고갈됨
이 글에서는 “join!이 멈춘다”라는 증상을 재현 가능한 코드로 설명하고, spawn과 Mutex 오용을 어떻게 구조적으로 고칠지 정리합니다.
또한 교착/지연 문제는 언어를 가리지 않고 비슷한 패턴으로 나타납니다. JVM 가상 스레드에서도 유사한 현상이 발생하니, 원인 분석 관점은 아래 글도 함께 참고하면 좋습니다.
1) join!은 왜 “교착처럼” 보일까
tokio::join!(a, b)는 a와 b를 병렬 스레드에서 실행하는 게 아니라, 현재 태스크에서 두 Future를 번갈아 폴링합니다.
- 둘 중 하나가
Pending이면 다른 하나를 폴링 - 둘 다
Pending이면 현재 태스크는 잠들고, 누군가가 깨워야 다음 폴링이 진행
즉 “멈춤”은 보통 다음 의미입니다.
- 둘 다
Pending인데, 깨워줄 이벤트가 더 이상 오지 않음 - 혹은 깨워줄 이벤트가 오려면 어떤 락/리소스가 필요한데, 그 락이
await너머로 잡혀 있음
2) 가장 흔한 원인: 락을 잡은 채로 await
문제 코드: tokio::sync::Mutex + await로 자기 발목 잡기
아래 코드는 전형적으로 join!이 멈춘 것처럼 보이게 만듭니다.
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let shared = Arc::new(Mutex::new(0));
let a = {
let shared = shared.clone();
async move {
let mut guard = shared.lock().await;
*guard += 1;
// 락을 잡은 채로 await
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
*guard += 1;
}
};
let b = {
let shared = shared.clone();
async move {
// a가 락을 잡고 sleep하는 동안 여기서 대기
let mut guard = shared.lock().await;
*guard += 10;
}
};
tokio::join!(a, b);
}
이 코드는 “진짜 데드락”은 아닙니다. a가 1초 뒤 풀리면 b도 진행됩니다. 하지만 실제 서비스에서는 sleep이 아니라
- 네트워크 호출
- DB 호출
- 채널 수신
같은 “언제 깨어날지 모르는 await”가 들어가면 사실상 교착처럼 보일 수 있습니다.
해결 원칙: 락 구간을 최소화하고, await 전에 drop
락을 잡고 해야 하는 일(공유 상태의 읽기/쓰기)을 짧게 끝내고, await가 필요한 작업은 락 밖으로 빼세요.
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let shared = Arc::new(Mutex::new(0));
let a = {
let shared = shared.clone();
async move {
{
let mut guard = shared.lock().await;
*guard += 1;
} // 여기서 guard drop
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
{
let mut guard = shared.lock().await;
*guard += 1;
}
}
};
let b = {
let shared = shared.clone();
async move {
let mut guard = shared.lock().await;
*guard += 10;
}
};
tokio::join!(a, b);
}
실무 팁
- “락을 잡은 상태에서
await가 없다”를 팀 규칙으로 두면 사고가 급감합니다. - 공유 상태는 가능하면
Mutex대신 메시지 패싱(mpsc)으로 바꾸는 것이 장기적으로 더 안전합니다.
3) std::sync::Mutex를 async에서 쓰면 생기는 진짜 문제
tokio::sync::Mutex는 락을 못 얻으면 태스크를 Pending으로 만들고 다른 태스크가 실행될 수 있게 합니다.
반면 std::sync::Mutex는 락을 못 얻으면 현재 스레드를 블로킹합니다. Tokio 멀티스레드 런타임이라도, 워커 스레드 수가 제한되어 있으면 워커 고갈로 전체가 멈춘 것처럼 보일 수 있습니다.
문제 코드: 워커 스레드 블로킹
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() {
let shared = Arc::new(Mutex::new(0));
let a = {
let shared = shared.clone();
async move {
let mut g = shared.lock().unwrap();
*g += 1;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
*g += 1;
}
};
let b = {
let shared = shared.clone();
async move {
// 여기서 lock()이 스레드를 막음
let mut g = shared.lock().unwrap();
*g += 10;
}
};
tokio::join!(a, b);
}
해결: tokio::sync로 바꾸거나, 블로킹 구간을 spawn_blocking
- 공유 상태 보호가 목적이면
tokio::sync::Mutex로 전환 - 정말로 표준 락이 필요하고 블로킹이 unavoidable면
tokio::task::spawn_blocking으로 격리
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() {
let shared = Arc::new(Mutex::new(0));
let shared2 = shared.clone();
let handle = tokio::task::spawn_blocking(move || {
let mut g = shared2.lock().unwrap();
*g += 1;
// 블로킹 작업을 여기서 수행
std::thread::sleep(std::time::Duration::from_secs(1));
*g += 1;
});
handle.await.unwrap();
}
4) spawn과 join!을 섞을 때 생기는 “기다림 역전”
join!은 현재 태스크에서 두 Future를 폴링합니다. 그런데 그 Future 내부에서 spawn을 하고, 다시 그 JoinHandle을 await하는 구조가 들어가면, 누가 누구를 깨우는지가 꼬이면서 멈춘 것처럼 보이기도 합니다.
문제 패턴: 이벤트 생산자가 같은 락/리소스를 기다림
아래 예시는 “핸들을 기다리는 쪽”이 어떤 락을 잡고 있고, “일을 하는 spawned 태스크”도 같은 락이 필요해서 진행을 못 하는 상황입니다.
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let state = Arc::new(Mutex::new(0));
let f1 = {
let state = state.clone();
async move {
let guard = state.lock().await;
// guard를 잡은 채로 spawn한 작업의 완료를 기다림
let h = tokio::spawn({
let state = state.clone();
async move {
let mut g = state.lock().await;
*g += 1;
}
});
// spawned 태스크는 lock을 못 얻어 멈추고,
// 여기서는 handle을 await하며 멈춤
h.await.unwrap();
drop(guard);
}
};
let f2 = async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
tokio::join!(f1, f2);
}
이건 join!이 아니라 락 보유 + spawn 대기의 조합이 만든 구조적 교착입니다.
해결: spawn을 락 밖으로 빼거나, 필요한 데이터만 복사해서 넘기기
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let state = Arc::new(Mutex::new(0));
let f1 = {
let state = state.clone();
async move {
// 락은 짧게
{
let mut g = state.lock().await;
*g += 100;
}
// spawn 및 await는 락 밖에서
let h = tokio::spawn({
let state = state.clone();
async move {
let mut g = state.lock().await;
*g += 1;
}
});
h.await.unwrap();
}
};
let f2 = async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
tokio::join!(f1, f2);
}
5) 채널(mpsc)로 Mutex를 제거하면 교착 확률이 급감
공유 상태를 락으로 보호하는 대신, 상태를 한 태스크(액터)로 몰아넣고 메시지로 업데이트하면 await와 락의 상호작용이 사라집니다.
예시: 카운터 액터
use tokio::sync::{mpsc, oneshot};
enum Msg {
Add(i64),
Get(oneshot::Sender<i64>),
}
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(64);
let actor = tokio::spawn(async move {
let mut value: i64 = 0;
while let Some(msg) = rx.recv().await {
match msg {
Msg::Add(x) => value += x,
Msg::Get(reply) => {
let _ = reply.send(value);
}
}
}
});
let tx1 = tx.clone();
let t1 = tokio::spawn(async move {
tx1.send(Msg::Add(1)).await.unwrap();
tx1.send(Msg::Add(2)).await.unwrap();
});
let tx2 = tx.clone();
let t2 = tokio::spawn(async move {
tx2.send(Msg::Add(10)).await.unwrap();
});
let (reply_tx, reply_rx) = oneshot::channel();
tx.send(Msg::Get(reply_tx)).await.unwrap();
let (_r1, _r2, value) = tokio::join!(t1, t2, async move { reply_rx.await.unwrap() });
println!("value={}", value);
drop(tx);
actor.await.unwrap();
}
이 구조는
- 락이 없고
- 상태 업데이트가 단일 태스크에서 직렬화되며
await가 어디에 있어도 교착으로 연결될 여지가 훨씬 줄어듭니다.
6) “join!이 느리다”는 착시: 블로킹 작업으로 런타임이 굶는 경우
join!이 멈춘 것처럼 보이는데 사실은 런타임 워커가 블로킹 작업으로 점유되어 “진행이 매우 느린” 상황도 흔합니다.
- JSON 대용량 파싱
- 암호화/압축
- 이미지 처리
- 동기 파일 I/O
이런 작업을 async 태스크에서 그대로 돌리면, 다른 태스크가 폴링될 기회를 잃습니다.
해결: CPU/블로킹은 spawn_blocking 또는 전용 스레드풀
use tokio::task;
#[tokio::main]
async fn main() {
let f1 = async {
let out = task::spawn_blocking(move || {
// 무거운 CPU 작업
let mut s = 0u64;
for i in 0..50_000_000u64 {
s = s.wrapping_add(i);
}
s
})
.await
.unwrap();
out
};
let f2 = async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
42u64
};
let (a, b) = tokio::join!(f1, f2);
println!("a={}, b={}", a, b);
}
7) 디버깅 체크리스트: join! 교착처럼 보일 때
7.1 락과 await부터 의심
lock().await이후await가 있는지 검색RwLock도 동일- 특히 “락을 잡고 채널 recv/send 기다림”은 위험
7.2 std::sync 사용 여부 확인
- async 함수 내부에서
std::sync::Mutex또는std::sync::RwLock을 쓰고 있지 않은지 확인
7.3 spawn한 태스크를 락 안에서 await하지 않기
- 락 안에서
JoinHandle.await금지 - 락 안에서 “다른 태스크가 수행해야 하는 작업의 완료”를 기다리지 않기
7.4 블로킹 호출 유무
std::thread::sleep- 동기 파일 I/O
- 동기 네트워크 클라이언트
이런 것들이 있으면 spawn_blocking으로 격리하세요.
8) 마무리: join!은 범인이 아니라 증폭기다
정리하면, tokio::join!은 단지 “같은 태스크에서 여러 Future를 함께 기다리는 도구”입니다. 멈춤이 보인다면 대개 다음 중 하나가 근본 원인입니다.
- 락을 잡은 채
await spawn과 락의 조합으로 기다림 역전std::sync락/블로킹 호출로 런타임 워커 고갈
해결의 핵심은 간단합니다.
- 락 구간을 짧게,
await는 락 밖에서 - 가능하면 메시지 패싱으로 공유 상태 제거
- 블로킹/CPU 작업은
spawn_blocking으로 분리
이 원칙만 지켜도 “join!이 교착 같다”는 류의 장애는 눈에 띄게 줄어듭니다.