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

> Clean Markdown view of GeekNews topic #24070. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=24070](https://news.hada.io/topic?id=24070)
- GeekNews Markdown: [https://news.hada.io/topic/24070.md](https://news.hada.io/topic/24070.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2025-11-01T11:34:03+09:00
- Updated: 2025-11-01T11:34:03+09:00
- Original source: [rfd.shared.oxide.computer](https://rfd.shared.oxide.computer/rfd/0609)
- Points: 2
- Comments: 1

## Topic Body

- **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)** 형태로 악용될 수 있으나, 본질적으로 **비정상 동작**이므로 예방이 필요

## Comments



### Comment 45737

- Author: neo
- Created: 2025-11-01T11:34:05+09:00
- Points: 1

###### [Hacker News 의견](https://news.ycombinator.com/item?id=45774086) 
- 문서를 훑어보니 꽤 **투명하고 철저한 보고서**처럼 느껴졌음  
  특히 [각주 부분](https://rfd.shared.oxide.computer/rfd/397#_external_references)이 흥미로웠음  
  Rust의 **cancellation safety** 문제를 몰랐던 사람들이 많았고, Omicron 전반에 이런 문제가 퍼져 있을 가능성이 높다는 점이 인상적이었음  
  Rust를 선택한 이유가 C의 **메모리 안전성 문제**를 피하기 위해서였는데, 이번엔 런타임에서 잡기 어려운 cancellation 버그가 생긴다는 점이 아이러니하게 느껴졌음  
  컴파일러가 도와주지 못하는 동적 속성을 프로그래머가 직접 보장해야 한다는 점이 특히 답답했음  

  - 이런 문제를 피하기 위해 **상위 추상화 계층**이 필요하지 않을까 생각함  
    Rust의 동시성 모델에서도 여전히 **데드락 가능성**이 존재하는 것 같음  
    RAII 스타일의 자원 관리가 이런 문제를 막아줄 것 같은데, 실제로는 그렇지 않다는 점이 혼란스러움  
    이게 단순한 구현상의 우연인지, 아니면 Rust/Tokio 모델의 구조적 한계인지 궁금함  

- 이건 withoutboats의 [FuturesUnordered 글](https://without.boats/blog/futures-unordered/)에서 설명된 데드락의 미묘한 변형처럼 보임  
  “intra-task” 동시성을 사용할 때는 어떤 future도 **기아 상태**에 빠지지 않도록 주의해야 함  
  기본적으로는 task를 spawn하는 게 안전하고, `tokio::select!`로 timeout을 처리하되 모든 pending future를 그 안에서 관리해야 함  
  `FuturesUnordered`는 정말 모든 엣지 케이스를 테스트하지 않는 이상 추천하지 않음  

- 이건 **우선순위 역전(priority inversion)** 문제와 유사하게 들림  
  OS에서는 낮은 우선순위 스레드가 락을 잡고 있을 때 높은 우선순위 스레드가 기다리면, 낮은 쪽이 **우선순위를 상속**받아 실행됨  
  Tokio에서도 비슷한 개념을 적용할 수 있을지 궁금함 — 예를 들어, 실행 불가능한 future가 Mutex를 잡고 있다면 그 future를 대신 poll하는 식으로  
  다만 “실행 불가능” 상태를 감지하려면 오버헤드가 꽤 클 것 같음  

  - 이런 접근은 Tokio의 **task 단위**에서는 가능할 수도 있음  
    하지만 **task 내부의 future**에는 적용할 수 없음  
    async Rust의 기본 설계가 “futures are inert”이기 때문임 — future는 단순한 구조체일 뿐이고, 런타임은 그 내부를 알 수 없음  
    런타임이 아는 건 task 단위뿐이라, 내부 future의 상태는 전혀 추적하지 않음  

  - Rust의 async는 **stackless coroutine** 모델이라, 이미 실행 중인 async 함수의 실행을 임의로 이어가는 건 안전하지 않음  
    stackless 모델은 로컬 상태를 공유 스택에 저장하기 때문에 LIFO 순서로만 안전하게 실행 가능함  
    그래서 coloring이 필요하고, stackful coroutine처럼 자유롭게 yield할 수 없음  

  - 코드가 너무 복잡하게 느껴짐  
    Erlang, Elixir, Go, 심지어 C로 쓸 때보다 훨씬 장황해 보임  

  - 이건 기본적인 **2락 데드락**과 유사하다고 생각함  
    Tokio의 Mutex 대기 큐와 task 스케줄링이 서로 얽혀서 교착 상태를 만드는 구조임  
    OS Mutex였다면 다른 대기 스레드를 깨워서 해결할 수 있었겠지만, async Rust에서는 future의 상태 머신 구조 때문에 어렵다고 봄  
    대기 큐의 future들을 순차적으로 poll하는 식으로 풀 수도 있겠지만, 그건 또 **예상치 못한 부작용**을 낳을 수 있음  

- async Rust 생태계에서 이런 문제를 함께 다뤘던 경험이 있음  
  `select!`에서 참조를 사용할 수 없게 하면 이런 문제를 피할 수 있지만, 그럼 **큐 위치를 잃지 않고 반복적으로 select!** 를 돌리는 패턴이 불가능해짐  
  cancellation 문제와 함께 이런 점이 Rust 전문가에게도 **예상치 못한 함정**이 될 수 있음  
  그래도 콜백 기반 코드보다 놀라운 일은 훨씬 적음  

  - 맞음, 우리 팀도 이 데드락을 분석한 뒤 “이걸 어떻게 예방할 수 있었을까?”를 논의했지만, **누구의 잘못도 아니었다**는 결론에 도달했음  
    Tokio의 모든 프리미티브가 의도대로 작동했고, 코드도 올바르게 작성되었지만, **서로의 상호작용이 예기치 않은 데드락**을 만든 것임  
    `&mut future`를 `select!`에서 금지하면 막을 수 있지만, 그건 또 많은 정상적인 코드를 막게 됨  
    결국 “그냥 조심해야 하는 부분”이라는 씁쓸한 결론에 도달했음  
    관련 논의는 [이 댓글](https://news.ycombinator.com/item?id=45776868)에서도 이어짐  

  - `select!`가 선택되지 않은 future들을 **drop하지 않고 반환**하도록 하면 상태를 잃지 않을 수 있음  
    다만 이건 불편하고, 근본적인 해결책은 아님  
    진짜 원인은 [이 스레드](https://news.ycombinator.com/item?id=45777234)에 설명된 대로 cancellation 처리의 불완전성에 있음  

- FAQ에 나온 “future1이 취소되지 않나?” 질문이 흥미로웠음  
  cancellation에는 두 단계가 있음 — **poll 중단**과 **drop**  
  이 예시에서는 drop이 지연되어 guard를 계속 쥐고 있어서 부작용이 생김  
  두 동작이 항상 동시에 일어나도록 보장할 수 있을지 고민됨  

- Rust 설계자에게 묻고 싶음 — 왜 **actor 모델** 대신 async 패턴을 선택했는지  
  Erlang을 써보면 actor 모델이 훨씬 **깨끗하고 안전**하게 느껴짐  
  JS는 언어 구조상 async를 쓸 수밖에 없었지만, Rust는 새 언어였는데 왜 그 길을 택했는지 궁금함  

  - Rust의 async 설계는 **임베디드 환경 지원**이 큰 이유였음  
    malloc이나 스레드를 쓰지 않고도 동작해야 했기 때문에 actor 모델은 불가능했음  
    Tokio로 actor 스타일 코드를 쓸 수는 있지만 자연스럽지는 않음  

  - 또 하나의 이유는 **성능**임  
    actor 모델은 메시지 복사 비용이 크고, Rust는 성능이 중요한 시스템 언어라 async state machine으로 **zero-cost abstraction**을 추구했음  
    Erlang이나 Go는 다른 트레이드오프를 택한 언어임  

  - Rust가 C FFI 호출 시 오버헤드를 허용하지 않으려 했기 때문에 **green thread** 기반 모델은 배제되었음  
    async/await은 상태 머신으로 컴파일되어 오버헤드가 적음  
    Go도 초창기엔 preemption이 없어 비슷한 **기아 문제**가 있었고, 나중에 스케줄러가 이를 해결했음  
    결국 언어마다 다른 목표와 제약이 있었던 셈임  

  - 나도 Oxide가 async를 채택한 건 의외였음  
    임베디드나 HTTP 서버 쪽에서는 익숙하지만, Oxide 같은 시스템 회사에서도 이렇게 깊게 쓸 줄은 몰랐음  

- 문서를 읽으면서 이해가 안 된 부분은, 왜 **락을 잡은 future가 아닌 메인 스레드**가 깨어나는지였음  
  공정한 락이라면 future1이 깨어나는 게 맞는데, 런타임이 왜 다른 스레드를 선택했는지 의문이었음  

- 글이 정말 흥미로웠음  
  예제 코드도 명확했고, 이런 버그를 찾는 건 악몽 같지만, 찾고 나면 **퍼즐 조각이 맞춰지는 쾌감**이 있음  

  - 우리 회사는 **모든 회의와 디버깅 세션을 녹화**하는데, 바로 그 “퍼즐이 맞춰지는 순간”이 영상에 남아 있음  
    Eliza, Sean, John, Dave 네 명이 함께 브레인스토밍하며 원인을 찾아가는 장면이 인상적이었음  
    월요일에 이 내용을 다룬 **팟캐스트 에피소드**를 공개할 예정임  
    관련 영상은 [RFD 537](https://rfd.shared.oxide.computer/rfd/0537)과 [이 이벤트 링크](https://discord.gg/QrcKGTTPrF?event=1433923627988029462)에서 볼 수 있음  

- Rust가 모든 활성 task가 동시에 진행되도록 하지 않는 건 **이해하기 어렵고 버그를 양산하는 설계**처럼 보임  
  Python의 Trio처럼 **structured concurrency**를 도입하면 더 직관적일 것 같음  
  Rust도 이런 모델을 도입할 수 있을까 궁금함  

  - Rust에서도 structured concurrency는 가능하지만, **task 단위**에서만 적용됨  
    future는 단순히 poll되어야만 진행되는 구조체일 뿐이라 “활성 future”라는 개념이 없음  
    모든 걸 task로 spawn하면 해결되는 듯하지만, 그 역시 일부 유용한 패턴을 막음  

  - **task와 future의 구분**이 중요함  
    future는 poll되지 않으면 아무 일도 하지 않음  
    cancellation을 “drop되기 전까지 poll되지 않는 상태”로 정의하면, 이번 문제처럼 **락을 쥔 채로 멈춘 future**가 생김  
    Rust의 RAII 철학상 drop 시 정리(cleanup)를 기대하지만, poll이 멈춘 상태는 그조차 일어나지 않음  

- 요즘 보면 Rust의 async가 **너무 서둘러 출시된 것 아닌가** 하는 생각이 듦  

  - 나도 개선할 점은 많다고 생각하지만, **기본 설계 자체는 훌륭한 토대**라고 봄  
    Pin이나 문법 일부는 다듬을 수 있겠지만, 근본적인 구조는 바꿀 필요가 없음  
    아직 “집을 완성하지 못한 기초공사” 단계일 뿐, 서두른 결과는 아님  
    다만 **일반화된 coroutine** 같은 하위 계층이 더 필요하다고 생각함
