Published on

Rust Tokio join! 교착? spawn·Mutex 오용 해결

Authors

서버를 Tokio로 옮기고 나서 가장 당황스러운 순간 중 하나가 tokio::join!이 “교착(데드락)”처럼 멈춰 보이는 상황입니다. 로그도 더 안 찍히고, CPU도 놀고, 요청도 안 끝나고, 마치 런타임이 죽은 것처럼 보입니다.

하지만 대부분의 경우 join! 자체가 교착을 만들지는 않습니다. join!은 단지 여러 Future를 같은 태스크에서 폴링해서 “모두 끝날 때까지” 기다릴 뿐입니다. 진짜 원인은 다음 중 하나로 수렴하는 경우가 많습니다.

  • Mutex(혹은 RwLock)를 잡은 채로 await해서 다른 작업이 락을 못 얻음
  • spawn한 태스크를 기다리는 쪽과, 기다림을 해소해줄 이벤트를 발생시키는 쪽이 서로 막힘
  • std::sync::Mutex를 async 컨텍스트에서 써서 스레드 자체를 막음
  • 블로킹 I/O, 무거운 CPU 작업을 async 태스크에서 그대로 돌려 런타임 워커가 고갈됨

이 글에서는 “join!이 멈춘다”라는 증상을 재현 가능한 코드로 설명하고, spawnMutex 오용을 어떻게 구조적으로 고칠지 정리합니다.

또한 교착/지연 문제는 언어를 가리지 않고 비슷한 패턴으로 나타납니다. JVM 가상 스레드에서도 유사한 현상이 발생하니, 원인 분석 관점은 아래 글도 함께 참고하면 좋습니다.

1) join!은 왜 “교착처럼” 보일까

tokio::join!(a, b)ab를 병렬 스레드에서 실행하는 게 아니라, 현재 태스크에서 두 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) spawnjoin!을 섞을 때 생기는 “기다림 역전”

join!은 현재 태스크에서 두 Future를 폴링합니다. 그런데 그 Future 내부에서 spawn을 하고, 다시 그 JoinHandleawait하는 구조가 들어가면, 누가 누구를 깨우는지가 꼬이면서 멈춘 것처럼 보이기도 합니다.

문제 패턴: 이벤트 생산자가 같은 락/리소스를 기다림

아래 예시는 “핸들을 기다리는 쪽”이 어떤 락을 잡고 있고, “일을 하는 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!이 교착 같다”는 류의 장애는 눈에 띄게 줄어듭니다.