비동기 Rust에서의 취소 처리
(sunshowers.io)- 비동기 Rust 환경에서의 취소 처리는 편리하지만, 잘못 다루면 예기치 않은 버그와 어려움이 발생함
- 동기 Rust에서는 명시적인 플래그 체크나 프로세스 종료 등이 필요하지만, 비동기 Rust에서는 future를 드롭하는 것만으로 취소가 매우 쉽게 가능함
- 취소 안전성(cancel safety)과 취소 정합성(cancel correctness)은 서로 다른 개념으로, 한 future의 취소가 시스템 전반에 문제를 초래할 수 있음
- 취소와 관련된 주요 문제 패턴으로는 Tokio mutex, select 매크로, try_join, 그리고 future 사용 실수 등이 있음
- 완벽한 해법은 없지만, 취소-안전 API 사용, future 핀 고정, task 분리 등으로 취소로 인한 문제를 줄일 수 있음
서론
- 본 게시물은 비동기 Rust의 취소 처리(cancellation) 에 관한 RustConf 2025 발표 내용을 기반으로 함
- 일반적인 Rust 비동기 코드 예시에서 메시지 수신 또는 전송 루프에 timeout을 추가하면 종종 메시지가 유실되는 문제를 발견할 수 있음
- Oxide Computer Company 등 실제 대규모 시스템에서 async Rust를 활용하며 겪은 취소 문제 및 실제 버그 사례를 다룸
- 글은 1) 취소 개념, 2) 취소 분석, 3) 실제적인 해결책 세 부분으로 구성됨
- 필자는 Rust signal handling, cargo-nextest 개발 등을 통해 비동기 Rust의 장점과 어려움을 경험한 바 있음
1. 취소란 무엇인가?
취소의 의미
- 취소(cancellation) 란 어떤 비동기 작업을 시작했다가 도중에 그 작업을 중단하는 상황임
- 예시: 대용량 다운로드/네트워크 요청, 부분 파일 읽기 등에서 중간에 취소 가능
동기 Rust에서의 취소 방법
- 일반적으로 원자성 플래그를 통해 주기적으로 취소 여부를 체크하거나, 특수한 예외(panic) 활용, 프로세스 전체 강제 종료 등 방식 존재
- 일부 프레임워크(Salsa 등)는 panic payload 사용하지만, Rust의 모든 플랫폼에서 동작하지 않음(특히 Wasm 환경 등)
- 스레드만 강제로 종료하는 것은 Rust 안전성 및 뮤텍스 구조상 허용되지 않음
- 요약하면, 동기 Rust에서는 범용적·안전한 취소 프로토콜이 존재하지 않음
비동기 Rust: Future란 무엇인가?
- Future는 Rust 컴파일러가 생성한 상태기계(state machine) 로 메모리 내 단순 데이터에 불과함
- 생성만으로 실행되지 않고, await 또는 poll 호출 시에만 진행됨
- Rust의 Future는 수동적(inert)이며, 명시적 poll/await가 없으면 아무 작업도 처리하지 않음
- Go/JavaScript/C# 등은 future 생성 시 즉시 실행 개시하는 것과 대조적
비동기 Rust의 취소 프로토콜
- Future 취소는 단순히 드롭(drop)하거나 poll/await를 더 이상 호출하지 않는 방식임
- State machine이므로 언제든지 Future를 버릴 수 있음
- 비동기 Rust에서 취소가 매우 강력하면서도 쉽게 적용됨
- 그러나 너무 쉽게 조용히 future가 드롭되어, 자식 future까지 연쇄적으로 취소됨(소유권 모델에 따라)
- 이러한 특성 때문에 취소가 비지역적(non-local) 현상이 되어, 전체 호출 체인에 영향을 끼침
2. 취소 분석
취소 안전성과 취소 정합성
-
취소 안전성(cancel safety) : 개별 future가 부작용 없이 안전하게 취소될 수 있는 속성
- 예: Tokio의 sleep future는 취소 안전함
- 반면, Tokio의 MPSC send는 drop 시 메시지 유실 위험 존재(취소 안전성 없음)
-
취소 정합성(cancel correctness) : 시스템 전체가 취소 상황에서 본질적 속성을 유지하는 글로벌 성질임
- 취소-안전 future가 시스템 내에 없다면 정합성 문제 없음
- 취소-안전하지 않은 future가 실제로 취소되어야만 문제가 발생함
- 취소로 인해 데이터 유실, 불변성 위반, 누락된 클린업 발생 시 취소 정합성 위반됨
Tokio mutex의 어려움
- Tokio mutex는 락을 가져와 데이터 보정 후 해제를 통해 동작함
- 문제: lock 안에서 상태를 임시로 위반(예: Option<T>를 None으로 변경) 후, await 경유 시 future가 취소되면 잘못된 상태에 데이터가 고정됨
- 실제 현업(예: Oxide에서 sled 상태 관리)에서 await 포인트에서 취소로 인해 불안정 상태 발생함
- 이처럼 비동기 코드의 상태 관리에서 취소가 매우 위험한 결함의 원인이 됨
취소 발생 패턴 및 예시
- .await 빠진 future 호출: Rust는 미사용 future에 경고하지만, Result 반환값을 _로 받는 경우 미경고(Clippy 최신 린트 적용 필요)
- try_join 등 try 연산: 하나의 future 실패 시 나머지 취소 발생(실제 서비스 중지 로직에서 버그로 연결)
- select 매크로: 여러 future 병렬 처리 후, 완료된 이외의 future를 모두 취소(select 루프에서 데이터 유실 등 위험 커짐)
- 이러한 패턴은 문서상 언급되어 있지만, 실제로 다양한 곳에서 비동기 취소가 암묵적으로 발생할 수 있음
3. 무엇을 할 수 있을까?
- 취소 정합성 관련 문제의 근본적·완전한 해법은 아직 없음
- 다만 실무적으로, 다음과 같은 방법들로 취소 결함 가능성을 줄일 수 있음
취소-안전 future로 재구성
-
MPSC send 예시: 예약(reserve) 후 실제 전송(send) 분리해 부분 취소-안전성 확보
- 예약 작업은 취소해도 관련 메시지가 유실되지 않음
- permit 획득 후엔 취소 걱정 없이 전송 가능
-
AsyncWrite의 write_all: 전체 버퍼 write_all은 취소 불안정, write_all_buf는 버퍼 커서를 활용해 취소 시 진행 상태 추적 가능
- 루프 내에서 write_all_buf로 부분 진행 상태를 안전하게 재개 가능
취소를 피하는 future 운용
-
future pinning: select 루프 등에서 future를 pin으로 고정해 취소되지 않게 참조로 poll 하여 대기
- 예: reserve future를 재사용하면 예약 대기 순번 유지
-
task 활용: tokio::spawn 등으로 future를 task로 실행하면, 핸들을 드롭해도 task 자체는 런타임에서 별도 관리되어 강제 취소되지 않음
- Oxide Dropshot HTTP 서버 등에서 각 요청을 별도 task로 실행, 클라이언트 연결이 끊겨도 요청 처리의 완료 보장
체계적인 해법?
- 현재 safe Rust 차원에서는 제약적이나, 논의되고 있는 접근 방법 있음
- Async drop: future가 취소될 때 비동기 클린업 코드 실행 허용
- 선형 타입(linear types) : drop 시 특정 코드 강제 실행 또는 특정 future를 취소 불가능하게 표시
- 위 방식들 모두 구현상 어려움 존재
결론 및 권장 사항
- Future는 수동적(passive)이라는 특성을 근본적으로 인지해야 함
- 취소 안전성(cancel safety) , 취소 정합성(cancel correctness) 개념 숙지 필요
- 주요 취소 버그 사례 및 코드 패턴을 파악해, 대응 전략 미리 준비 필요
- 일부 실전 권고
- Tokio mutex 사용을 피하고 대안을 고려
- 부분-완성 API 또는 취소-안전 API 설계/활용
- 취소-안전하지 않은 future에는 꼭 완료를 보장하는 코드 구조 채택
- 추가로, cooperative cancellation, actor 모델, structured concurrency, panic safety, mutex poisoning 등 심화 주제도 검토 권장
- 관련 자료는 sunshowers/cancelling-async-rust에서 참고 가능
읽어주셔서 감사함. 발표 및 참고 자료 검토와 피드백을 제공한 Oxide 동료들에게 감사를 표함
Hacker News 의견
- send/recv에 timeout을 두는 예제가 매우 흥미롭다고 생각함, 실행되지 않은 상태에서 바로 폴링 없이 future가 실행되는 언어에서는 오히려 반대 상황이 나올 수 있음을 알게 됨, send에 timeout을 두면 timeout 이후에도 메시지가 전송될 수 있으나 메시지가 유실되지는 않아 안전하지만, recv에 timeout을 두면 채널에서 메시지를 읽은 뒤 timeout이 선택되는 상황에서 메시지를 그냥 버려서 안전하지 않을 수 있음, 해결책은 timeout이나 채널에서 '무언가 사용 가능함'을 선택하도록 하고, 후자의 경우 peek을 통해 데이터를 안전하게 보는 것임
- 이게 바로 cancellation-safety의 핵심임이 아닌지 생각 중임
- 좋은 지적이라 생각함
- 이 주제에 관해 내가 쓴 자료 몇 가지를 소개하고 싶음
- async 함수가 반드시 끝까지 실행돼야 한다는 제안서를 2020년에 작성했었음, graceful cancellation 기능이 포함되고, 아직까지 더 나은 아이디어가 나오지 않았다고 생각함 제안서 링크
- sync와 async Rust 전반에 걸쳐 unified cancellation을 위한 제안도 있음 ("A case for CancellationTokens") gist 링크
- 위 내용을 실제 구현한 사례도 있음 min_cancel_token
- futures가 취소되는 게 무슨 문제인지 잘 모르겠음, futures는 task가 아니고, 해당 글에서도 내부적으로 이 점을 인정함, 그렇다면 future가 끝까지 실행되지 않더라도 원래 그런 것 아닌지, 그리고 그런 상황이 왜 문제인지 이해 못 하겠음, 예제에서 "cancel unsafe" future라 주장하지만, 핵심은 기대와 현실의 오해라 생각됨
- 예제1은 try_join 중 하나가 에러로 cancel
- 예제2는 취소될 때 데이터 미기록
이런 사례 모두 context가 cancel되어서 작업이 완료되지 않는 것은 당연한 동작임, 작업이 반드시 끝나야 한다면 독립 task로 분리하면 될 일임, 나는 뭔가 중요한 nuance를 놓치고 있는 것일지 의문임, 원래 work가 cancellation에 의해 없어지는 게 futures의 설계 의도라고 이해하는데, 뭐가 문제인지 다시 짚어주면 좋겠음
- 맞는 말임! 실제로 Oxide에서 이로 인해 많은 버그가 생긴 적 있음, futures가 passive하게 await 지점마다 언제든 취소될 수 있다는 점을 충분히 이해하면 남는 건 세세한 테크닉임
- RustConf에서 이 발표를 정말 재미있게 들었음, cancel safety와 cancel correctness의 개념 구분이 정말 유용함, 발표가 블로그 포스트로도 올라와서 너무 좋음, 발표는 좋지만 블로그로 정리된 게 공유와 참조에 더 용이함
- "cancel correctness"라는 표현이 cancellation의 맥락을 잘 잡아줘서 마음에 듦, 반면 "cancel safety"라는 용어는 별로 좋아하지 않음, Rust의 safety 개념과도 딱 맞지 않고, 불필요하게 판단적인 느낌임, safe/unsafe가 더 좋거나 나쁜 걸 암시하나 cancel 동작의 바람직함은 상황마다 다름, 예를 들면, spawn된 task를 기다리는 future는 "cancellation safe"라 불리지만 drop 시 task가 계속 실행되면 필요 없는 일이 쌓이고 lock이나 port도 점유해서 문제될 수 있음, 오히려 drop 시 task를 멈추는 spawn handle은 "cancellation unsafe"라곤 하지만 dependent task의 cleanup엔 매우 중요한 패턴임
- 블로그 글이 더 읽기 쉽고 좋다고 생각함, 공감함
-
https://sunshowers.io/posts/cancelling-async-rust/…의 내용이 특히 흥미로웠음, 나도 쉽게 저런 실수를 할 것 같음
- 나는 Go 개발자임에도 이런 부분이 도움됨, Rust는 도구가 더 엄격하게 도와주지만, goroutines, 채널, select, 그 외 동시성 primitives에서 Go에서도 같은 함정에 빠지기 쉬움
- 처음 예제에서 원하는 동작이 뭔지 불명확함, 큐가 꽉 차면 드롭, 대기, 패닉 중에서 선택 필요함, 블로킹에 타임아웃 거는 건 주로 데드락 감지임, 코드가 "모든 메시지가 채널로 가지 않는다"고 말하는데, 당연히 리소스가 부족하면 그럴 수밖에 없음, 목적이 뭐지? 깔끔한 프로그램 종료? 그건 스레드 환경에서 상당히 어려움, async에서도 쉽지 않음, 실제 유즈케이스는 원격지와의 메시지 교환에서 상대가 끊길 때 내 쪽 상태를 정리하는 것임
- 이상적으로는 채널에 공간이 날 때까지 메시지를 버퍼에 보관하고 싶음, 이 내용은 발표 후반부 "What can be done"에서 다룸
- 예시에 답이 있음, 5초간 공간 없을 때 로깅하는 코드는 진단 용도인데, 이게 은근 데이터 유실로 이어질 위험이 있음, 조금 인위적이긴 하지만 실제로 "왜 동작 안 하지?" 같은 문제 대응 코드로 시스템 곳곳에 붙이기 쉬움
- 참고로 이 글 작성자는 they/she 대명사를 사용함 about
- await는 언제나 잠재적 리턴 포인트임을 항상 염두에 둬야 함, 반드시 같이 atomic하게 실행돼야 하는 두 액션 사이에 await를 두는 것은 피하는 게 좋음
- 이게 실제로 어떻게 문제를 일으키는지 궁금함 예를 들면,
이 코드에서 어떤 식으로 d가 호출되지 않는 문제가 생기는지? c에서 취소가 발생해서? 아니면 a에서 상위에서 뭔가 생겨서?async fn a() { b().await } async fn b() { c().await d().await } async fn c() {} async fn d() {}
- 그럼 이거 좀 위험함 아닌지? 물론 어쩔 수 없는 부분이겠지만 "critical section"에 await가 두 번 있으면 그 사이에 일시정지되지만 결국 이어서 실행돼야만 하는 상황이 있을 수 있음, 예를 들어 DB 변경 후 audit log를 남길 때 둘 다 꼭 Execution되어야 한다면, 그냥 do not cancel 주석 달기밖에 답이 없는지 궁금함
- 이게 실제로 어떻게 문제를 일으키는지 궁금함 예를 들면,
- Rust의 Future는 C++에서 move semantics처럼, Future가 끝난 뒤에는 invalid state가 될 수도 있음, Rust는 stackless coroutine 설계이기 때문에 poll 기반 async 구조를 직접 구현할 때 상태를 struct에 직접 관리해야 함, 이런 점이 모두 흔한 함정임, 그리고 최근 async Rust에서 cancellation은 state management에 새로운 변수임, 내가 mea(Make Easy Async) 라이브러리를 개발할 때도 cancel safety가 trivial하지 않으면 꼭 문서화함, 그리고 경솔한 async cancellation로 IO stack에 문제가 생긴 사례가 기억남 mea reddit 사례
- 정말 좋은 발표였음! 완전 초짜인 나는 SOP에서는 Future를 cancel할 수 없다는 점을 미리 강조해 줬으면 했음, .await가 future를 소유해서 drop() 불가, future가 lazy하니 .await 뒤에는 취소가 어떻게 되는지 명확하지 않았음, 이후 select!와 Abortable()를 조사해보고 이해했지만, 앞으로 발표한다면 맨 처음에 이 부분도 콜아웃 해주면 완벽할 것 같음
- 질문. 여기서 SOP가 뭘 의미하는지 궁금함
- 정말 타이밍이 좋았음, 오늘 막 새로운 함수의 doc comment에 "이 함수는 cancel safe임"을 붙이고 있었는데, 이런 고민을 함, 어서 async drop이 가능해졌으면 함
- 그 함수가 궁금함, 좀 더 설명해 줄 수 있는지 curiosity 있음