Future에는 '끝났음' 상태가 없다 — Yosh Wuyts의 「The Missing Future State」 분석

값을 낸 future를 다시 poll하면 — panic, hang, 그리고 비어 있는 Done 자리 값을 낸 future를 다시 poll하면? ① Poll::Ready(T) — 값은 하나 T future impl Future 이미 완료됨 ② 다시 poll() panic! 모든 async 블록·async fn의 동작 Done ? 비어 있는 자리 Poll에는 '끝났음' 변형이 없다 Pending z z Fuse → hang 끝난 Fuse의 Pending — 깨우지 않음
값 하나를 낸 future를 다시 두드리면 panic하거나 영원히 잠든다. 그 사이에 있어야 할 'Done' 자리는 비어 있다.

원문 정보

Rust async 설계를 오래 다뤄 온 Yosh Wuyts의 “The Future Trait Series” 네 번째 글이다. 언어 핵심 추상화 하나의 상태 공간 설계를 짚는 글이라 시스템 프로그래밍 아티클로 분류했다.

한 줄 요약 (TL;DR)

Future::poll이 돌려주는 Poll에는 Ready와 Pending 두 상태만 있어서 “이미 값을 냈고 더 낼 것이 없다(Done)”는 상태를 표현할 수 없다. 그래서 완료된 future를 다시 poll하면 panic하거나 멈추고, 생태계는 MaybeDone·Fuse 같은 우회책을 쓴다. Poll은 이제 고칠 수 없지만, 아직 unstable인 CoroutineState와 gen에서는 같은 실수를 되풀이하지 말자는 주장이다.

왜 이 글을 골랐나

enum 하나에 변형이 하나 빠졌을 때 그 영향이 어디까지 번지는지를 보여 주는 좋은 사례이기 때문이다. 이 글에서 빠진 Done 하나는 다음 세 군데에 흔적을 남긴다.

  • API 계약: 완료 후 poll은 “무슨 일이 일어나도 모른다”는 문서상 계약
  • 생태계 우회책: MaybeDone, FusedFuture/Fuse 같은 보조 타입과 부기(bookkeeping) 코드
  • 코드 생성: 모든 async 블록에 들어가는 panic(unwinding) 경로와 그만큼 늘어나는 바이너리 크기

Rust 구조체, 열거형, 패턴 매칭에서 다룬 “열거형으로 상태를 모델링하고 match로 빠짐없이 처리한다”는 원칙이 표준 라이브러리 수준에서 어떻게 어긋났고, 그 대가가 무엇인지 볼 수 있다. 바로 전날 다룬 stable Rust는 무엇을 딛고 서 있나와 함께 읽으면, 안정화(stable)된 API는 되돌리기 어렵다는 점이 더 분명해진다.

한눈에 보기 — future의 생애

글 전체가 다루는 future의 상태를 하나의 상태 기계로 그리면 다음과 같다. 현재 Poll에서는 Ready(T) 이후의 poll이 panic이나 hang으로 빠지고, 제안안에서는 Done으로 수렴한다.

stateDiagram-v2
    state "초기화 이전 — IntoFuture (async {}엔 아직 없음)" as Pre
    state "Start / Init — 첫 poll 전, 내부 전용" as Init
    state "Poll::Pending" as Pending
    state "Poll::Ready(T)" as Ready
    state "현재 — panic 또는 hang" as Broken
    state "제안 — Poll::Done" as Done
    [*] --> Pre
    Pre --> Init: into_future()
    Init --> Pending: poll()
    Init --> Ready: poll()
    Pending --> Pending: wake 후 다시 poll()
    Pending --> Ready: poll()
    Ready --> Broken: 지금의 Poll로 다시 poll()
    Ready --> Done: 제안안에서 다시 poll()
    Done --> Done: 몇 번을 poll해도 Done

핵심 내용

Introduction — “값 하나”라는 약속과 그 이후

async Rust에서 Future::poll은 future를 진행시키고, 그 결과를 Poll enum으로 보고한다.

  • Poll::Pending: 아직 값을 낼 준비가 안 됐다. 나중에 다시 poll해야 한다.
  • Poll::Ready(T): 값 T를 낼 준비가 됐다.

저자는 future라는 이름의 뜻을 짚는다. future는 미래에 값으로 결정된다. 이때 값은 단수다. 표준 라이브러리 문서가 이 보장을 분명히 적지는 않았고(적어야 한다고 본다) 생태계가 늘 지키지도 않지만(지켜야 한다고 본다), 의도는 분명하다. 한 future는 값 하나만 낸다.

그렇다면 이미 Ready를 돌려준 future를 다시 poll하면 어떻게 될까? Future trait 문서는 이렇게 말한다.

Once a future has completed (returned Ready from poll), calling its poll method again may panic, block forever, or cause other kinds of problems; the Future trait places no requirements on the effects of such a call.

즉 지원하지 않는 동작이고 future가 무엇을 해도 된다. 가장 흔한 동작은 panic!이며, 모든 async 블록과 async fn이 이렇게 동작한다. 저자는 이게 아쉽다고 한다. Ready 이후에 다시 poll할 수 있다면 실제로 유용하고, 본인도 자주 그렇게 하고 싶었다는 것이다.

MaybeDone — 슬롯 하나로 ‘끝남’을 기록하는 우회책

async 동시성 프리미티브를 직접 작성해 본 사람이라면 MaybeDone을 안다. future를 들고 있다가, 완료되면 그 출력(Future::Output)을 들고 있는 래퍼다. 원문 예제는 다음과 같다(Apache 2.0, 한국어 주석은 추가).

use pin_project::pin_project;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

#[pin_project(project = DoneState)]
enum MaybeDone<F: Future> {
    Future(#[pin] F),
    Done(Option<F::Output>),
}

impl<F: Future> MaybeDone<F> {
    pub fn take(self: Pin<&mut Self>) -> Option<F::Output> {
        match self.project() {
            DoneState::Done(output) => output.take(),
            DoneState::Future(_) => None,
        }
    }
}

impl<F: Future> Future for MaybeDone<F> {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        match self.as_mut().project() {
            DoneState::Future(future) => match future.poll(cx) {
                Poll::Pending => Poll::Pending,
                Poll::Ready(output) => {
                    // 완료되면 자기 자신을 Done 슬롯으로 교체한다
                    self.set(Self::Done(Some(output)));
                    Poll::Ready(())
                }
            },
            // 이미 끝났으면 몇 번을 poll해도 Ready(())
            DoneState::Done(_) => Poll::Ready(()),
        }
    }
}

핵심 트릭은 두 가지다.

  1. future의 출력 타입을 ()로 바꾸고, 실제 값은 별도 메서드 take로 꺼내게 한다.
  2. 그 결과 완료 후에 poll하면 계속 Poll::Ready(())를 돌려주고, 값을 한 번 꺼낸 뒤의 take는 계속 None을 돌려준다.

그래서 호출자가 Poll::Ready(()) + None 조합을 보면 “이 future는 끝났고 다시 값을 내지 않는다”는 것을 알 수 있다. 원래 의도는 future나 그 출력을 담는 슬롯일 뿐이었지만, 부수 효과로 완료 이후 상태를 전달할 수 있게 됐다.

MaybeDone 슬롯의 상태 전이 — Future(F) → Done(Some(output)) → Done(None) poll(): 내부 future가 Ready(output) self.set(Self::Done(Some(output))) take(): 값을 꺼냄 output.take() ① 기다리는 중 Future(F) poll() → Pending take() → None ② 값을 들고 있음 Done(Some(output)) poll() → Ready(()) take() → Some(output) ③ 끝남 (값을 꺼낸 뒤) Done(None) poll() → Ready(()) take() → None 계속 계속 ③의 Ready(()) + None 조합 = "끝났고, 다시 값을 내지 않는다"
MaybeDone은 출력을 ()로 바꾸고 값은 take()로 꺼내게 해서, 부수 효과로 '완료 이후' 상태를 호출자에게 전달한다.

Fixing Poll — Pending을 ‘끝남’으로 쓰는 것은 계약 위반

futures 크레이트에는 완료 상태를 추적하는 API가 더 있다. FusedFuture trait은 is_terminated 메서드를 제공하고, Fuse 어댑터는 내부 future가 Ready를 낸 뒤 계속 Poll::Pending을 돌려준다. 문제는 이것이 Pending의 정의와 맞지 않는다는 점이다.

When a function returns Pending, the function must also ensure that the current task is scheduled to be awoken when progress can be made.

Pending은 “진행할 수 있게 되면 깨워 주겠다”는 약속이다. Fuse를 표준 라이브러리로 가져오려면 Pending의 의미를 “대기 중”과 “끝남” 둘 다로 재정의해야 한다. 그러면 is_terminated를 먼저 확인하지 않은 호출자는 영원히 멈춘다. 저자는 다른 우회책도 가능하겠지만, 올바른 해법은 Done을 Poll 자체의 상태로 만드는 것이라고 본다.

pub enum Poll<T> {
    Ready(T),
    Pending,
    Done,    // ← new
}

안타깝게도 이 변경은 불가능하다. Poll에 #[non_exhaustive]가 붙어 있지 않아서, 변형을 추가하면 Poll을 빠짐없이 match하는 기존 코드가 모두 깨진다. 저자는 이 변경이 가능했다면 얻었을 이점을 이렇게 정리한다.

  • 책임의 이동: “완료 후 poll”을 어떻게 처리할지 정하는 쪽이 피호출자(callee)에서 호출자(caller)로 넘어간다. 호출자는 여전히 panic을 고를 수도 있지만, Done을 위로 전달할 수도 있고, join 같은 동시성 연산에서 MaybeDone 없이 어떤 future가 끝났는지 추적할 수도 있다.
  • async 효과와 panic 효과의 분리: 컴파일러는 .await가 Ready 이후에 다시 poll하지 않는다는 것을 보장할 수 있으므로 Done을 처리할 필요가 없다. 그러면 async 함수와 블록이 모든 경로에 unwinding 지원을 넣을 필요가 없어지고, 코드 생성 크기가 줄어든다. 임베디드 타깃에 특히 이롭다.
  • no-panic 도구와의 궁합: crates.io의 no_panic 크레이트나, 앞으로 생길 수 있는 함수·블록 단위 no-panic 표기와 잘 맞는다.

Fixing CoroutineState — 아직 고칠 수 있는 곳

CoroutineState도 Poll과 똑같이 최종 Done 상태를 표현하지 못한다. 차이는 Coroutine trait과 CoroutineState enum이 아직 stable이 아니어서 기존 코드를 깨지 않고 고칠 수 있다는 점이다. Coroutine은 컴파일러의 디슈가링(desugaring)에 내부적으로 쓰이므로 쉽지는 않겠지만, 적어도 async에서 한 실수를 이터레이션 쪽에서 되풀이하지는 않을 수 있다.

현재 정의는 다음과 같다.

pub enum CoroutineState<Y, R> {
    Yielded(Y),
    Complete(R),
}

문서는 Complete에 대해 “Once a coroutine has returned Complete it is considered a programmer error to call resume again.”이라고 한다. Coroutine::resume은 Iterator::next, Future::poll처럼 진행을 구동하는 메서드인데, future와 마찬가지로 “끝남”을 표현할 수 없다.

이터레이터는 상황이 조금 다르다. 원문 각주에 따르면 Iterator::next가 None을 돌려줬다고 해서 이후에도 계속 None이 나온다는 보장은 없다. 그래도 None을 반복해서 돌려주는 것은 정상적이고 예상되는 동작이라, None으로 “Done”을 표현할 수 있다. FusedIterator가 바로 이 방식이다. 반면 코루틴에는 그런 수단이 없어서 FusedCoroutine 같은 일반적인 trait을 정의할 수 없다. Complete(Option<R>)처럼 감쌀 수도 있지만, 저자는 그것도 문제를 피해 가는 것일 뿐이라고 본다. 문제가 무엇인지, 어떻게 풀지 이미 알고 있으니 제대로 풀자는 것이다.

pub enum CoroutineState<Y, R> {
    Yielded(Y),
    Complete(R),
    Done,         // ← new
}

이렇게 하면 Complete와 Done이 서로 다른 뜻이라 이름이 헷갈린다. 그래서 저자는 ControlFlow(Break/Continue)처럼 변형 이름을 그 변형이 나타내는 연산 이름으로 바꾸자고 제안한다.

pub enum CoroutineState<Y, R> {
    Yield(Y),   // ← renamed
    Return(R),  // ← renamed
    Done,
}

Even more states — 바깥에 드러나지 않는 상태들

지금까지 다룬 Ready, Pending, Done은 외부로 전달되는 세 상태다. 저자는 여기에 더해 다음 상태들도 짚는다.

  • Start/Init (내부 상태): 처음 poll되기 전 future가 있는 상태다. 외부로 yield되지 않으므로 내부 전용이다.
  • 초기화 이전 상태: .await는 poll하기 전에 IntoFuture::into_future를 호출한다. for..in이 Iterator::next 전에 IntoIterator::into_iter를 호출하는 것과 같다. 저자는 이 상태를 뭐라고 불러야 할지 모르겠다고 하면서, 그 목적을 “지역 변수를 만들지 않고 입력만 붙잡아 두는 것”으로 설명한다. 그런데 async {}는 impl IntoFuture가 아니라 impl Future를 반환한다. 저자는 시리즈의 이전 글 “The Gen Auto-Trait Problem”에서 이것을 바꾸면 더 정밀한 bound를 만들 수 있고 Send/Sync 관련 문제 여럿이 풀린다고 설명했다. async {}의 디슈가링을 바꾸기는 어렵지만, 아직 unstable인 gen {}은 훨씬 쉽게 바꿀 수 있다.
  • Snoozed (있어서는 안 되는 상태): Jack O’Connor의 “Never Snooze a Future”에서 나온 개념이다. 진행할 준비가 됐는데 poll되지 않는 future는 “snoozed” 상태다. 저자는 이것이 wg-async의 “Barbara Battles Buffered Streams”(bbbs) 시나리오를 활성(liveness) 속성으로 옮긴 것이라고 본다. future는 자연스럽게 트리를 이루고, 어떤 자식 future의 waker가 불렸는데 부모만 poll되고 그 자식은 poll되지 않으면 snoozed 상태가 된다. 그래서 모든 future에 재귀적으로 적용할 규칙이 나온다.

“When a future is polled, it must also poll all child futures that have had their waker called.”

모든 future가 이 규칙을 지키면 어떤 future도 snoozed 상태가 되지 않는다.

future 트리의 snooze — wake된 자식을 건너뛰는 경우와 규칙대로 poll하는 경우 규칙 위반 — B가 snoozed poll() 부모 future poll poll A B C wake B: 진행할 준비가 됐는데 poll되지 않음 bbbs 시나리오를 활성(liveness) 속성으로 옮긴 것 규칙 준수 — wake된 B까지 poll poll() 부모 future poll A B C wake waker가 호출된 자식은 반드시 poll 모든 future가 재귀적으로 지키면 snoozed는 없다
부모가 poll될 때 waker가 울린 자식을 건너뛰면 그 자식은 snoozed 상태가 된다. 규칙대로 wake된 자식까지 poll하면 트리 전체에서 snooze가 사라진다.

Conclusion

  • future가 완료를 알릴 수 없다는 것은 실제 문제이고, 좋은 우회책이 없다. MaybeDone 같은 타입은 사용하는 쪽에서 이 한계를 피해 가지만, 그 대가로 중복된 부기 코드가 생긴다.
  • 거의 모든 async 동시성 연산은 future의 완료 상태를 추적해야 한다. 그런데 async {} 블록은 완료 후 다시 poll하면 panic하는 future로 디슈가링되고, 이는 코드 크기가 중요한 임베디드와 웹 환경에서 문제가 된다.
  • 기존 Future::poll을 deprecate하지 않고는 Future trait을 고칠 수 없다. 저자는 시리즈 네 번째 글인 지금도 그럴 만큼 근거가 쌓였다고 보지는 않는다. 대신 Coroutine trait과 gen 키워드에서 같은 실수를 되풀이하지 말자는 것이 결론이다.

분석과 인사이트

여기부터는 원문 요약이 아니라 나의 해석이다.

1. “정의되지 않음”은 비용을 피호출자 전체에 떠넘긴다. 원문에서 가장 인상적인 부분은 Done 하나가 빠지면서 생긴 결과가 코드 크기로 나타난다는 지적이다. 계약이 “완료 후 poll은 아무 일이나 일어나도 된다”로 열려 있으니, 컴파일러가 만드는 모든 async 상태 기계는 방어적으로 panic 경로를 넣어야 한다. 호출자 한 곳에서 처리하면 될 일을 모든 피호출자가 나눠서 부담하는 셈이다. API 설계에서 “UB는 아니지만 미정의인 동작”을 남기면 그 대가는 보통 구현 쪽 모든 곳에서 조금씩 치르게 된다.

2. 센티넬 값을 재활용하면 의미가 겹친다. Fuse가 Pending을 “끝남”으로 재활용하는 것은 전형적인 센티넬 남용이다. Pending에는 “깨워 주겠다”는 약속이 묶여 있으므로, 깨우지 않을 Pending은 곧 hang이다. 반면 Iterator의 None은 원래부터 “더 없음”을 뜻하므로 반복해서 돌려줘도 의미가 겹치지 않는다. 같은 “fused” 개념이 이터레이터에서는 자연스럽고 future에서는 억지스러운 이유가 여기 있다. enum을 설계할 때는 각 변형이 어떤 의무를 함께 지는지 따져 보고, 의무가 다른 상태는 별도 변형으로 두는 편이 맞다.

3. #[non_exhaustive]를 쓸지는 사실상 미래에 대한 판단이다. Poll에 #[non_exhaustive]가 없어서 변형을 추가할 수 없다는 대목은 라이브러리 작성자에게 바로 와닿는다. 공개 enum이 프로토콜 상태를 표현하고, 그 상태 공간을 아직 완전히 알지 못한다면 처음부터 #[non_exhaustive]를 붙여 둘 이유가 충분하다. 반대로 붙이면 사용자 쪽 match에 와일드카드 팔이 강제되니, 정말 닫힌 집합인지 판단하는 것이 핵심이다.

4. 이견이라기보다 남는 질문. Poll에 Done이 생기면 호출자가 처리해야 할 경우가 하나 늘어난다. 저자는 .await처럼 컴파일러가 보장하는 경로에서는 Done을 처리할 필요가 없다고 했지만, 손으로 작성한 combinator나 executor는 모두 Done 팔을 다뤄야 한다. “unwinding 경로 제거”라는 이득과 “모든 수동 poll 지점에 처리 분기 추가”라는 비용이 실제로 어떻게 균형을 이루는지는 원문에 정량적인 근거가 없다. 코드 크기가 얼마나 줄어드는지 수치도 제시되지 않았다. 방향은 설득력 있지만, 이 부분은 측정이 필요한 주장으로 읽는 게 맞다.

5. 다른 언어와 비교하면. (원문에 없는 내 비교다.) Python 제너레이터는 소진된 뒤에도 next()를 호출하면 계속 StopIteration을 던진다. FusedIterator처럼 끝난 상태가 안정적이다. 반면 이미 await한 코루틴 객체를 다시 await하면 RuntimeError가 난다. Rust async 블록의 panic과 같은 태도다. 언어마다 “한 번만 결정되는 값”을 다시 요청받았을 때 어떻게 할지 정책이 다르다. Rust의 차이는 그 정책이 코드 크기와 no-panic 보장이라는 시스템 수준의 비용으로 바로 이어진다는 점이다. asyncio 이벤트 루프 최적화에서 본 Python의 런타임 중심 모델과 비교하면, Rust는 상태 기계를 컴파일 타임에 펼치기 때문에 상태 하나를 빼먹은 대가가 바이너리 크기에 그대로 나타난다.

적용 포인트

  • 완료된 future를 다시 poll하지 마라. select!·join 같은 루프를 직접 작성할 때 끝난 future를 다시 poll하지 않도록 MaybeDone/Fuse 또는 명시적인 Option<F> 슬롯으로 상태를 관리한다.
  • Fuse를 쓸 때는 is_terminated를 먼저 확인한다. 끝난 Fuse가 돌려주는 Pending은 아무도 깨우지 않는다. 확인하지 않으면 hang으로 이어진다.
  • 공개 상태 enum에는 #[non_exhaustive]를 검토한다. 프로토콜이나 상태 기계를 나타내는 enum이고 상태 공간이 바뀔 수 있다면, 1.0 전에 결정한다.
  • 변형마다 짊어지는 의무를 문서화한다. “이 값을 돌려주면 호출자는 무엇을 기대해도 되는가”를 변형 단위로 적어 두면 센티넬을 재활용하려는 유혹을 막을 수 있다.
  • 변형 이름은 연산 기준으로 짓는다. Yield/Return/Done, Break/Continue처럼 상태가 아니라 그 상태를 만든 연산을 이름으로 쓰면 비슷한 의미의 변형끼리 혼동이 줄어든다.
  • future 트리를 직접 다룬다면 snooze 규칙을 지킨다. 부모가 poll될 때 waker가 울린 자식은 반드시 poll한다. 버퍼링된 스트림을 처리할 때 특히 주의한다.
  • 임베디드·Wasm 타깃이라면 async 코드의 panic 경로를 의식한다. 코드 크기를 측정할 때 async 상태 기계에 들어가는 unwinding 비용도 확인 대상에 넣는다.

마무리

「The Missing Future State」는 enum 변형 하나가 빠진 일이 문서 계약, 생태계 우회책, 컴파일러 코드 생성까지 번진 과정을 차분하게 추적한다. 결론은 현실적이다. stable인 Poll은 deprecate 없이는 고칠 수 없으니, 아직 열려 있는 Coroutine과 gen에서 Done을 처음부터 일급 상태로 넣자는 것이다. 상태 기계를 설계하는 사람이라면 “값을 다 낸 다음에는 어떤 상태인가?”를 처음부터 물어야 한다는 것이 이 글의 교훈이다.

더 읽어보기