Futurelock: 비동기 Rust에서의 미묘한 교착 위험
(rfd.shared.oxide.computer)- 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) 형태로 악용될 수 있으나, 본질적으로 비정상 동작이므로 예방이 필요
Hacker News 의견
-
문서를 훑어보니 꽤 투명하고 철저한 보고서처럼 느껴졌음
특히 각주 부분이 흥미로웠음
Rust의 cancellation safety 문제를 몰랐던 사람들이 많았고, Omicron 전반에 이런 문제가 퍼져 있을 가능성이 높다는 점이 인상적이었음
Rust를 선택한 이유가 C의 메모리 안전성 문제를 피하기 위해서였는데, 이번엔 런타임에서 잡기 어려운 cancellation 버그가 생긴다는 점이 아이러니하게 느껴졌음
컴파일러가 도와주지 못하는 동적 속성을 프로그래머가 직접 보장해야 한다는 점이 특히 답답했음- 이런 문제를 피하기 위해 상위 추상화 계층이 필요하지 않을까 생각함
Rust의 동시성 모델에서도 여전히 데드락 가능성이 존재하는 것 같음
RAII 스타일의 자원 관리가 이런 문제를 막아줄 것 같은데, 실제로는 그렇지 않다는 점이 혼란스러움
이게 단순한 구현상의 우연인지, 아니면 Rust/Tokio 모델의 구조적 한계인지 궁금함
- 이런 문제를 피하기 위해 상위 추상화 계층이 필요하지 않을까 생각함
-
이건 withoutboats의 FuturesUnordered 글에서 설명된 데드락의 미묘한 변형처럼 보임
“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!에서 금지하면 막을 수 있지만, 그건 또 많은 정상적인 코드를 막게 됨
결국 “그냥 조심해야 하는 부분”이라는 씁쓸한 결론에 도달했음
관련 논의는 이 댓글에서도 이어짐 -
select!가 선택되지 않은 future들을 drop하지 않고 반환하도록 하면 상태를 잃지 않을 수 있음
다만 이건 불편하고, 근본적인 해결책은 아님
진짜 원인은 이 스레드에 설명된 대로 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이 깨어나는 게 맞는데, 런타임이 왜 다른 스레드를 선택했는지 의문이었음 -
글이 정말 흥미로웠음
예제 코드도 명확했고, 이런 버그를 찾는 건 악몽 같지만, 찾고 나면 퍼즐 조각이 맞춰지는 쾌감이 있음 -
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 같은 하위 계층이 더 필요하다고 생각함
- 나도 개선할 점은 많다고 생각하지만, 기본 설계 자체는 훌륭한 토대라고 봄