Futurelock: 비동기 Rust에서의 미묘한 교착 위험

5 hours ago 2

  • Futurelock은 하나의 태스크가 여러 Future를 동시에 관리할 때, 그 중 하나가 다른 Future의 리소스를 필요로 하지만 더 이상 폴링되지 않아 발생하는 교착(deadlock) 현상
  • tokio::select! 구문에서 참조된 Future(&mut future)await를 포함한 분기가 함께 사용될 때 쉽게 발생
  • 이 문제는 태스크와 Future의 책임 분리 실패에서 비롯되며, 동일 태스크가 두 Future를 모두 기다리지만 한쪽만 폴링하는 구조로 인해 정지 상태에 빠짐
  • FuturesUnordered, bounded channel, Stream 등에서도 유사한 형태로 발생 가능
  • 안전한 비동기 설계를 위해 tokio::spawn으로 Future를 별도 태스크로 분리하거나, select 내에서 await 사용을 피하는 것이 핵심

Futurelock의 개념과 예시

  • Futurelock은 Future A가 보유한 리소스Future B가 필요로 하지만, 두 Future를 담당하는 태스크가 A를 더 이상 폴링하지 않는 상황에서 발생
  • 예시 코드에서는 tokio::select! 내에서 &mut future1과 sleep을 동시에 기다리며, sleep이 먼저 완료되면 future1은 여전히 잠금 대기 상태로 남음
  • 이후 future3이 같은 락을 요청하지만, 락은 future1에게 할당되어 있고 future1은 폴링되지 않으므로 프로그램이 영구 정지

tokio::select!와 Mutex의 상호작용

  • tokio::sync::Mutex는 공정(fair) 락으로, 대기 순서대로 락을 부여
  • 락은 future1에게 전달되지만, 태스크는 이미 future3만 폴링 중이므로 future1은 실행되지 않음
  • Mutex는 다음 대기 태스크를 깨우는 역할만 하며, 어떤 Future가 실제로 폴링되는지는 알 수 없음

Futurelock의 일반적 원인

  • 태스크 T가 Future F1을 기다리고, F1이 F2에 의존하며, F2가 다시 T의 폴링을 필요로 하는 순환 의존 구조
  • 주로 다음 상황에서 발생
    • tokio::select!에서 &mut future 사용 후 다른 분기에서 await 수행
    • FuturesOrdered 또는 FuturesUnordered에서 일부 Future 완료 후 다른 비동기 작업 수행
    • 수동 구현된 Future에서 유사한 동작

Streams 및 기타 구조에서의 발생 사례

  • FuturesOrdered나 FuturesUnordered에서 Future를 꺼낸 뒤, 그와 관련된 리소스를 사용하는 다른 Future를 기다릴 때 Futurelock 발생
  • join_all은 모든 Future를 계속 폴링하므로 Futurelock이 발생하지 않음

실제 사례와 디버깅

  • Omicron#9259 사례에서 모든 데이터베이스 접근 Future가 Futurelock에 걸려 HTTP 요청이 무한 대기
  • mpsc 채널 송신이 차단되었지만 수신 측은 비어 있는 상태로 확인되어 원인 파악이 어려움
  • 디버깅 시 tokio-console 같은 도구가 도움이 될 수 있으나, 대부분의 경우 원인 추적이 매우 어려움

Futurelock 방지 지침

  • 한 태스크가 여러 Future를 폴링할 때, 이미 시작한 Future의 폴링을 중단하지 않도록 주의
  • 가능하면 Future를 새로운 태스크로 spawn하여 독립 실행
    • JoinHandle을 tokio::select!에 전달하면 Futurelock 위험 제거
  • tokio::select! 사용 시 주의할 점
    • &mut future와 await를 동시에 사용하지 말 것
    • 두 조건이 모두 존재하면 Futurelock 위험이 높음
  • Stream 사용 시 JoinSet을 활용해 각 Future를 별도 태스크로 실행
  • bounded channel의 용량을 늘리는 것은 근본적 해결책이 아님
    • 대신 try_send() 사용으로 블로킹 회피 가능

잘못된 회피 패턴

  • 채널 용량을 무한히 늘리는 방법은 비현실적이며 부작용(지연, 메모리 증가) 초래
  • Future 간 의존성 제거 시도는 유지보수 중 새 의존성이 생길 수 있어 취약
  • 유일하게 안전한 방법은 tokio::spawn을 통한 태스크 분리

향후 개선 및 보안 고려

  • Clippy 린트를 통해 tokio::select! 내 &mut future 사용이나 await 포함 시 경고 제공 가능성 제시
  • Futurelock은 서비스 거부(DoS) 형태로 악용될 수 있으나, 본질적으로 비정상 동작이므로 예방이 필요

Read Entire Article