
비동기 Rust의 Future는 값 하나를 반환한 뒤 완료 상태를 나타낼 방법이 없다. 글은 Poll과 CoroutineState에 Done 상태를 추가해 이 문제를 해결하자고 제안하며, 이를 통해 비동기 동시성 연산의 상태 추적과 제한된 환경의 코드 크기 문제를 개선할 수 있다고 주장한다.
Future::poll은 퓨처의 진행 상태를 Poll 열거형으로 알린다. Poll::Pending은 아직 값을 내놓을 준비가 되지 않아 나중에 다시 poll해야 한다는 뜻이고, Poll::Ready(T)는 값 T를 반환할 준비가 됐다는 뜻이다. 글은 퓨처가 결국 하나의 값을 반환해야 하지만, 표준 라이브러리 문서가 이 보장을 분명히 밝히지 않고 생태계의 구현도 항상 이를 지키지는 않는다고 지적한다.
poll이 Poll::Ready를 반환한 뒤 다시 호출하는 상황에 대해 Future 문서는 패닉이나 무한 대기 등 여러 문제가 생길 수 있으며, 그 결과를 규정하지 않는다고 설명한다. 글에 따르면 흔한 동작은 패닉이며, 모든 async 블록과 함수가 이 방식으로 동작한다. 하지만 완료 뒤에도 poll을 호출할 수 있으면 유용한 경우가 있다고 주장한다.
MaybeDone이 추적하는 상태
MaybeDone은 퓨처나 그 퓨처가 완료된 뒤의 출력값을 보관하는 래퍼다. 다음 코드는 pin_project를 이용한 구현 예다.
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) => { self.set(Self::Done(Some(output))); Poll::Ready(()) } }, DoneState::Done(_) => Poll::Ready(()), } } }
이 래퍼는 내부 출력 타입을 ()로 바꾸고, 실제 출력값을 꺼내는 take 메서드를 제공한다. 완료 뒤 poll을 다시 호출하면 계속 Poll::Ready(())를 반환한다. 출력값을 한 번 꺼낸 뒤 take를 다시 호출하면 이후에는 계속 None을 반환한다. 따라서 호출자는 Poll::Ready(())와 None을 함께 관찰해 퓨처가 완료됐고 다시 값을 내놓지 않는다는 사실을 알 수 있다.
futures 크레이트의 FusedFuture 트레이트도 is_terminated 메서드로 완료 여부를 추적한다. Fuse 어댑터는 내부 퓨처가 Poll::Ready를 반환한 뒤 Poll::Pending을 계속 반환한다. 그러나 Pending은 진행할 수 있게 되면 현재 작업이 깨어나도록 예약해야 한다는 뜻이므로, 글은 Fuse의 사용 방식이 이 정의와 맞지 않는다고 지적한다. is_terminated를 먼저 확인하지 않으면 퓨처가 영원히 멈출 수도 있다.
글은 Poll에 Done을 추가하는 방안을 제안한다.
pub enum Poll<T> { Ready(T), Pending, Done, // ← new }
하지만 Poll은 #[non_exhaustive]로 표시되지 않아 이 변경을 적용할 수 없다. 글의 제안대로라면 퓨처가 완료 뒤 더 이상 보고할 것이 없다는 상태를 직접 알릴 수 있다. 호출자는 이를 패닉으로 처리하거나 다른 곳에 전달할 수 있고, join 같은 동시성 연산은 MaybeDone 없이도 완료된 퓨처를 추적할 수 있다.
글은 이 변경이 패닉 처리에 대한 암묵적 의존도 없앨 수 있다고 주장한다. 컴파일러는 .await가 Ready를 받은 뒤 다시 폴링하지 않도록 보장할 수 있으므로 Done을 처리할 필요가 없다는 설명이다. 이에 따라 async 함수와 블록이 모든 경로에 언와인딩 지원을 넣지 않아도 돼 코드 생성 크기가 줄어들 수 있으며, 특히 임베디드 대상에 도움이 될 수 있다고 덧붙인다. no_panic 크레이트나 향후 함수·블록 단위의 패닉 금지 주석과도 함께 쓸 수 있다고 제안한다.
CoroutineState에도 완료 상태가 필요하다는 제안
글은 CoroutineState도 Poll처럼 최종 완료 상태를 표현하지 못한다고 본다. 다만 Coroutine 트레이트와 CoroutineState 열거형은 안정화되지 않았으므로, 기존 호환성을 깨지 않고 변경할 수 있다고 설명한다. 현재 정의는 다음과 같다.
pub enum CoroutineState<Y, R> { Yielded(Y), Complete(R), }
문서는 코루틴이 Complete를 반환한 뒤 다시 resume을 호출하는 일을 프로그래머 오류로 규정한다. resume은 Iterator::next나 Future::poll처럼 코루틴을 진행시키는 메서드다. 글은 반복자와 달리 코루틴도 완료 상태를 표현할 수 없다고 지적한다. 반복자는 next가 None을 반환한 뒤 다시 None을 반환할 수 있으며, FusedIterator는 이를 완료 상태를 나타내는 방식으로 활용한다.
이 때문에 FusedIterator와 같은 일반적인 FusedCoroutine 트레이트를 정의할 수 없다는 설명이다. Complete(Option<R>)를 요구할 수도 있지만, 글은 이를 문제를 우회하는 방법으로 본다. 대신 Done 변형을 추가하는 방안을 제안한다.
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, }
초기 상태와 폴링되지 않은 상태
퓨처가 외부에 알리는 상태는 Ready, Pending, Done 세 가지다. 퓨처가 처음 폴링되기 전에는 내부적으로 Start 또는 Init 상태도 있지만, 외부에 반환하지 않으므로 내부 상태로 남는다.
글은 퓨처나 코루틴이 초기화되기 전의 상태도 별도로 다룬다. 비동기 Rust에서 .await는 퓨처를 폴링하기 전에 IntoFuture::into_future를 호출한다. 이는 for..in 구문이 Iterator::next를 호출하기 전에 IntoIterator::into_iter를 부르는 것과 비슷하다. 이 사전 상태는 지역 변수를 만들지 않고 입력을 보관하는 데 쓰일 수 있다고 설명한다.
현재 async {}는 impl IntoFuture가 아니라 impl Future를 반환한다. 글은 gen 자동 트레이트 문제에서 이 방식을 바꾸면 더 정밀한 바운드를 만들고 Send·Sync 관련 문제를 해결하는 데 도움이 된다고 설명했다고 밝힌다. async {}의 디슈거링을 바꾸기는 까다롭지만, 아직 안정화되지 않은 gen {}은 변경하기 더 쉽다는 주장이다.
잭 오코너(Jack O’Connor)는 Never Snooze a Future에서 유효하지 않아야 할 퓨처 상태로 ‘Snoozed’를 설명한다. 퓨처가 진행할 준비가 됐지만 폴링되지 않은 상태를 가리킨다. 글은 이를 “bbbs” 상황과 연결해 활성 상태 유지(liveness) 조건으로 본다. 퓨처는 자연스럽게 트리 구조를 이루며, 자식 퓨처의 웨이커가 호출된 뒤 부모가 폴링되면서도 그 자식은 폴링되지 않을 때 ‘Snoozed’ 상태가 생긴다. 이를 막기 위한 규칙으로, 퓨처가 폴링될 때 웨이커가 호출된 모든 자식 퓨처도 폴링해야 한다고 제안한다.
글은 퓨처가 완료 상태를 알릴 수 없는 문제에 좋은 우회책이 없으며, MaybeDone 같은 타입은 중복 상태 관리를 추가해 이를 보완한다고 결론짓는다. 거의 모든 비동기 동시성 연산에서 완료 상태 추적이 필요하지만, async {} 블록은 완료 뒤 다시 폴링되면 패닉을 일으키는 퓨처로 변환된다는 설명이다. 이는 코드 크기가 중요한 임베디드 및 웹 환경에서 문제가 될 수 있다고 주장한다.
기존 Future::poll 메서드를 폐기하지 않고는 Future 트레이트를 고칠 수 없다고 글은 덧붙인다. 이 글은 Future 트레이트의 문제를 분석하는 연재의 네 번째 글이며, 지금의 트레이트를 교체할 만큼 충분한 근거가 모였다고 보지는 않는다. 대신 Coroutine 트레이트와 gen 키워드에서는 같은 실수를 되풀이하지 말아야 한다고 제안한다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요