Future 트레이트가 완료 상태를 표현하지 못하는 문제와 Poll 및 CoroutineState를 개선하는 방법을 살펴봅니다.
비동기 Rust에서 Future::poll 메서드는 퓨처의 상태를 진행시키기 위해 호출되며, Poll 열거형을 통해 진행 상황을 보고합니다. 이 열거형에는 두 가지 변형이 있습니다:
poll을 다시 호출해야 함을 뜻합니다.T를 산출할 준비가 되었음을 뜻합니다.퓨처가 그런 이름으로 불리는 이유는 언젠가, 미래에 값으로 해소되기 때문입니다. 그러나 이는 복수의 값이 아니라 단 하나의 값입니다. 표준 라이브러리 문서는 이 보장을 명확히 하지 않으며(그래야 하지만), 생태계도 언제나 이를 올바르게 구현하지는 않습니다(역시 그래야 하지만). 하지만 의도는 분명합니다. 하나의 퓨처는 오직 하나의 값만 산출해야 합니다.
그렇다면 이미 값을 산출한 뒤 Future::poll을 다시 호출하려 하면 어떻게 되어야 할까요? Future 트레이트 문서에는 다음과 같이 적혀 있습니다:
퓨처가 완료된 뒤(
poll에서Ready를 반환한 뒤) 해당poll메서드를 다시 호출하면 패닉이 발생하거나, 영원히 차단되거나, 다른 종류의 문제가 발생할 수 있습니다.Future트레이트는 그러한 호출의 효과에 아무런 요구 사항도 부과하지 않습니다. 다만poll메서드는unsafe로 표시되어 있지 않으므로 Rust의 일반적인 규칙이 적용됩니다 […].
문서는 퓨처가 Poll::Ready를 산출한 뒤 Future::poll을 다시 호출하는 일은 지원되지 않으며, 퓨처의 동작에 큰 제약이 없다고 말합니다. 여기서 가장 흔한 동작은 panic!이며, 모든 async 블록과 함수가 그렇게 동작합니다. 이는 아쉽습니다. 이전에 Ready를 산출한 뒤에도 Future::poll을 다시 호출할 수 있는 능력은 실제로 매우 유용하고, 제가 자주 필요하다고 느끼는 기능이기 때문입니다.
Rust를 작성하는 모든 사람이 비동기 Rust를 작성하는 것은 아니며, 비동기 Rust를 작성하는 모든 사람이 비동기 동시성 기본 요소를 작성하는 것도 아닙니다. 하지만 그런 작업을 하는 사람이라면 MaybeDone 타입을 잘 알 것입니다. 이는 어떤 Future를 감싸는 단순한 래퍼로, Future를 보관하거나 완료되었다면 그 Future::Output을 보관하는 일만 합니다. 단순하지는 않지만, 비동기 Rust에 충분히 익숙하다면 따라가기 어렵지 않을 것입니다:
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::Ready(())를 반환한 뒤 Future::poll을 다시 호출하려 하면, 계속해서 항상 Poll::Ready(())를 반환합니다. 그리고 이전에 Some(Future::Output)을 받은 뒤 take 메서드를 다시 호출하면, 그 이후로는 영원히 None을 반환합니다.
퓨처 또는 그 출력 값을 보관하는 슬롯을 만드는 비교적 간단한 방식에는 흥미로운 부수 효과가 있습니다. 한 번 값을 산출한 뒤에도 퓨처의 상태를 실제로 전달할 수 있다는 점입니다. 호출자가 Poll::Ready(())와 None을 관찰한다면, 이는 퓨처가 끝났으며 다시는 값을 산출하지 않을 것임을 의미합니다.
MaybeDone만이 futures 크레이트에서 퓨처의 완료 상태를 추적하는 API는 아닙니다. FusedFuture 트레이트는 is_terminated 메서드를 제공하고, 그 Fuse 어댑터는 내부 퓨처가 Poll::Ready를 반환한 뒤에도 계속 Poll::Pending을 반환합니다. 하지만 이는 안타깝게도 다음과 같이 명시된 Poll::Pending을 오용합니다:
함수가
Pending을 반환할 때, 해당 함수는 진행이 가능해지면 현재 작업이 깨어나도록 예약되게끔 반드시 보장해야 합니다.
이 정의는 Fuse가 Pending을 사용하는 방식과 호환되지 않으므로, 이를 표준 라이브러리에 도입하려면 Pending을 “보류 중”과 “완료됨” 둘 다를 뜻하도록 재정의해야 합니다. 먼저 is_terminated를 호출하는 일을 잊으면 퓨처가 영원히 멈추기 때문에 이는 좋지 않습니다. 대신 적용할 수 있는 다른 임시방편도 있지만, 이를 올바르게 고치는 방법은 Done 상태를 Poll 자체의 일부로 만드는 것입니다:
pub enum Poll<T> {
Ready(T),
Pending,
Done, // ← 새로 추가
}
안타깝게도 Poll에 #[non_exhaustive] 표시가 없으므로 이 변경을 할 수 없습니다. 패닉을 일으키거나 멈추는 대신, 퓨처가 더 이상 보고할 내용이 없다는 사실을 전달할 기회를 얻을 수 있었을 텐데 아쉽습니다. 이렇게 하면 그 정보를 어떻게 처리할지에 대한 책임이 피호출자에서 호출자로 옮겨갑니다. 호출자는 여전히 패닉을 선택할 수 있지만, Done 상태를 전달할 수도 있고, join 같은 동시성 연산에서는 이제 MaybeDone 없이도 어떤 퓨처가 완료되었는지 추적하는 데 사용할 수 있습니다.
이는 비동기 효과가 패닉 효과에 암묵적으로 의존하는 문제도 제거합니다. 컴파일러는 .await 호출이 Ready를 받은 뒤에는 절대 폴링하지 않음을 보장할 수 있으므로 Done 상태를 처리할 필요가 없습니다. 이렇게 되면 async 함수와 블록은 더 이상 모든 경로에 언와인딩 지원을 주입할 필요가 없어지고, 코드 생성 크기가 줄어 특히 임베디드 대상에 이롭습니다. 또한 crates.io의 no_panic 크레이트나, 장차 도입될 함수/블록 수준의 패닉 금지 어노테이션과도 잘 맞습니다.
CoroutineState 열거형도 최종 Done 상태를 표현할 수 없다는 점에서 Poll 열거형과 같은 문제가 있습니다. 하지만 Poll과 달리 Coroutine 트레이트와 CoroutineState 열거형은 안정화되지 않았으므로, 호환성 문제 없이 고칠 수 있습니다.
Coroutine은 컴파일러의 디슈거링 내부에서 사용되므로, 이러한 변경 역시 쉽지 않을 수 있습니다. 그러나 적어도 반복에서도 비동기에서 저지른 것과 같은 실수를 되풀이할 필요는 없습니다.
CoroutineState의 문제를 더 잘 보여 주기 위해, 오늘날의 정의는 다음과 같습니다:
pub enum CoroutineState<Y, R> {
Yielded(Y),
Complete(R),
}
문서는 Complete 변형에 대해 다음과 같이 말합니다:
코루틴이
Complete를 반환한 뒤에는resume을 다시 호출하는 것을 프로그래머 오류로 간주합니다.
여기서 resume은 Iterator::next 및 Future::poll과 비슷하게 코루틴의 진행을 구동하는 Coroutine::resume을 가리킵니다. 하지만 퓨처와 마찬가지로(그리고 이터레이터와는 달리1), 코루틴은 “완료됨” 상태를 표현할 수 없습니다.
이는 FusedIterator에서처럼 일반적인 FusedCoroutine 트레이트를 정의할 수 없다는 뜻입니다. Complete(R)는 제네릭 매개변수를 받으므로 물론 Complete(Option<R>)를 요구할 수는 있지만, 그러면 다시 문제를 피해 가는 것에 불과합니다. 이는 우리가 알고 있는 문제이며, 해결 방법도 알고 있으니 해결해야 합니다:
pub enum CoroutineState<Y, R> {
Yielded(Y),
Complete(R),
Done, // ← 새로 추가
}
다만 이 경우 다소 혼란스러운 이름이 남습니다. 여기서 Complete는 Done과 같지 않습니다. 그러므로 ControlFlow에서 그랬듯이(Break|Continue), 변형의 이름을 그것이 나타내는 연산에 따라 지을 것을 제안합니다:
pub enum CoroutineState<Y, R> {
Yield(Y), // ← 이름 변경
Return(R), // ← 이름 변경
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 {}은 아직 불안정하므로 변경하기가 훨씬 쉽습니다.
마지막으로, Never Snooze a Future에서 Jack O’Connor는 유효하지 않아야 하는 퓨처 상태인 “Snoozed”를 설명합니다. 퓨처가 진행할 준비가 되었지만 폴링되지 않을 때 퓨처는 잠든 상태입니다. 저는 이를 “bbbs” 시나리오2를 활성 속성으로 번역하는 방법이라고 생각합니다.
퓨처는 자연스럽게 트리를 이룹니다. “잠든” 상태는 어떤 자식 퓨처의 웨이커가 호출되었고 부모는 폴링되었지만 자식은 폴링되지 않을 때 적용됩니다. 이는 모든 퓨처에 재귀적으로 적용할 수 있는 규칙으로 이어집니다. “퓨처가 폴링될 때는 웨이커가 호출된 모든 자식 퓨처도 반드시 폴링해야 한다.” 모든 퓨처가 이 규칙을 따르면 어떤 퓨처도 잠든 상태가 되지 않습니다.
퓨처가 완료되었음을 알릴 수 없다는 사실은 문제이며, 좋은 우회책도 없습니다. MaybeDone 같은 타입은 사용 측면에서 이 제한을 우회할 수 있지만, 중복된 장부 관리를 도입하는 방식입니다.
퓨처의 완료 상태 추적은 거의 모든 비동기 동시성 연산을 구현하는 데 필요합니다. 하지만 async {} 블록은 완료 후 다시 폴링되면 패닉을 일으키는 퓨처로 디슈거링됩니다. 이는 코드 크기가 중요한 임베디드 및 웹 같은 제약된 환경에서 문제를 만듭니다.
기존 Future::poll 메서드를 폐기하지 않고는 Future 트레이트를 고칠 수 없습니다. 이 글은 Future 트레이트의 문제를 분석하는 제 연재의 네 번째 글이며, 그렇다 하더라도 아직은 현재 가진 것을 대체할 만큼 충분하다고 생각하지는 않습니다. 그러나 우리가 할 수 있는 일은 Future에서 저지른 실수를 되풀이하지 않는 것입니다. 예를 들어 Coroutine 트레이트와 gen 키워드를 고치는 방식으로 말입니다.