Published on

Rust async 데드락? Tokio Mutex·spawn 원인 7가지

Authors

서버가 "가끔" 멈춥니다. CPU는 낮고, 로그도 끊기고, 요청은 타임아웃만 늘어납니다. Rust async에서는 이런 증상이 전통적인 데드락(락 순환 대기)뿐 아니라, await 지점에서의 락 홀드, spawn으로 인한 작업 유실, 블로킹 호출 혼입 등으로도 쉽게 나타납니다.

이 글은 Tokio 런타임에서 특히 자주 만나는 "async 데드락처럼 보이는 멈춤" 원인 7가지를 tokio::sync::Mutextokio::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().awaitNone으로 끝나야 하는데, 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가지입니다.

  1. MutexGuard를 잡은 채로 await하지 않는다(스코프로 강제).
  2. 다중 락은 순서를 고정하거나 구조를 합친다.
  3. 블로킹은 런타임 밖으로(spawn_blocking), std::sync::Mutex는 async 경로에 두지 않는다.
  4. spawn은 관찰 가능하게 만든다(JoinHandle 수집, 타임아웃, 종료 로깅).

이 규칙만 지켜도 "가끔 멈추는" Tokio 서비스의 상당수를 안정화할 수 있습니다. 다음 단계로는 tracing 기반 태스크 관찰과, 공유 상태를 메시지 패싱(채널) 중심으로 재설계하는 방법을 추천합니다.