缓存时间:
2026/10/29 05:37
# Future Trait 缺少一种状态 来源:https://blog.yoshuawuyts.com/the-future-trait-has-a-missing-state ## 引言 在异步 Rust 中,调用`Future::poll`方法来推进 future 的状态,future 则通过`Poll`枚举报告其进度。这个枚举包含两个变体:- **Poll::Pending**:表示 future 尚未准备好产生一个值,需要稍后再次调用`poll`。- **Poll::Ready\(T\)**:表示 future 已准备好产出某个值`T`。Future 之所以叫这个名字,是因为它们最终会在*未来*解析为一个值。但这里说的是单数的一个值,而不是复数的多个值。标准库文档没有明确作出这一保证(尽管它应该如此),生态系统中的实现也并非总能正确遵循这一点(尽管它们也应该如此)。但意图很明确:一个 future 应当始终只产出一个值。那么,当你在 future 已经产出一个值之后,再次调用`Future::poll`时,应该发生什么?`Future`trait 的文档是这样说的:> 一旦 future 已完成(从`poll`返回了`Ready`),再次调用其`poll`方法可能会 panic、永久阻塞,或导致其他类型的问题;`Future`trait 对这种调用的效果不作任何要求。不过,由于`poll`方法没有标记为`unsafe`,Rust 通常的规则仍然适用 \[...\]。文档在这里指出,在 future 已产出`Poll::Ready`之后再次调用`Future::poll`是不受支持的,而且对 future 的行为几乎没有限制。这里最常见的行为是`panic!`,所有`async`块和函数都是如此。这很可惜,因为在 future 已产出`Ready`之后还能再次调用`Future::poll`,实际上非常有用,也是我经常希望能做到的事情。## MaybeDone 并非所有 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 { Future(#[pin] F), Done(Option), } impl MaybeDone { pub fn take(self: Pin<&mut Self>) -> Option { match self.project() { DoneState::Done(output) => output.take(), DoneState::Future(_) => None, } } } impl Future for MaybeDone { 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(()), } } } `` 本质上,它提供了一个槽位,用于存放 future 或其最终解析出的值。在此过程中,它还做了另一件事:将 future 的输出替换为单元类型,并添加一个新的`take`方法,用来实际取出 future 的值。在它曾经返回过一次`Poll::Ready\(إ\)`之后,再次调用`Future::poll`,它就会始终继续返回`Poll::Ready\(إ\)`。而如果你在已经得到过`Some\(Future::Output\)`之后再次调用`take`方法,此后它就会一直返回`None`。这种创建槽位以存放 future 或其输出值的相对直接的方式,带来了一个有趣的副作用:即使 future 已经产出过一次值,它仍然能够传达自身的状态。如果调用方观察到`Poll::Ready\(إ\)`+`None`,就意味着 future 已经结束,不会再产出值。## 修正 Poll `MaybeDone`并不是`futures`crate 中唯一跟踪 future 结束状态的 API。`FusedFuture`trait 提供了一个`is\_terminated`方法,而它的`Fuse`适配器则会在内部 future 返回`Poll::Ready`之后持续返回`Poll::Pending`。但遗憾的是,这滥用了`Poll::Pending`,其文档指出:> 当一个函数返回`Pending`时,该函数也*必须*确保在能够取得进展时,当前任务会被调度唤醒。这个定义与`Fuse`使用`Pending`的方式不兼容,因此要将它纳入标准库,就需要重新定义`Pending`,让它同时表示“等待中”和“已结束”。这让人感觉不太好,因为如果你忘了先调用`is\_terminated`,future 就会永远挂起。我们也可以采用其他取巧的方案,但正确的修复方式应该是让`Done`状态成为`Poll`本身的一部分:`` pub enum Poll { Ready(T), Pending, Done, // ← 新增 } `` 遗憾的是,我们无法进行这项修改,因为`Poll`没有标记为`#[non_exhaustive]`。这很可惜,因为 future 本可以有机会表明自己已经没有更多内容可报告,而不是 panic 或挂起。这会把如何处理这一信息的责任从被调用方转移到调用方。调用方仍然可以选择 panic,但也可以转发`Done`状态,或者在`join`这样的并发操作中,用它来跟踪哪些 future 已经完成,从而无需`MaybeDone`。这样还有一个好处,就是消除了异步效应对 panic 效应的隐式依赖。编译器可以保证,调用`\.await`在得到`Ready`之后绝不会再次轮询,因此它无需处理`Done`状态。这样一来,`async`函数和块就不再需要在每条路径上注入栈展开支持,从而缩减生成代码的体积,这对嵌入式目标平台尤其有益。这也能很好地配合 crates.io 上的`no_panic`crate,或者未来可能出现的函数/块级禁止 panic 注解。## 修正 CoroutineState `CoroutineState`枚举与`Poll`枚举存在相同的问题:它无法表达最终的`Done`状态。但与`Poll`不同,`Coroutine`trait 和`CoroutineState`枚举尚未稳定,因此可以在不破坏兼容性的情况下修正。由于编译器内部的语法糖展开会使用`Coroutine`,这些改动可能仍然不容易。但至少我们不必在迭代上重蹈异步的覆辙。为了更好地说明`CoroutineState`的问题,下面是它目前的定义:`` pub enum CoroutineState { Yielded(Y), Complete(R), } `` 文档 (https://doc.rust-lang.org/std/ops/enum.CoroutineState.html) 对`Complete`变体是这样说的:> 一旦协程返回了`Complete`,再次调用`resume`就会被视为程序员错误。这里的`resume`指的是`Coroutine::resume`,它会推进协程的执行,类似于`Iterator::next`和`Future::poll`。但与 future 一样(而与迭代器不同¹ (https://blog.yoshuawuyts.com/the-future-trait-has-a-missing-state#unlike)),协程无法表达“已结束”状态。这意味着我们无法像定义`FusedIterator` (https://doc.rust-lang.org/std/iter/trait.FusedIterator.html) 那样定义一个通用的`FusedCoroutine`trait。由于`Complete\(R\)`接受一个泛型参数,我们当然可以要求使用`Complete\(Option\)`,但这又是在绕开问题。这是一个我们知道存在、也知道如何解决的问题,因此我们就应该解决它:`` pub enum CoroutineState { Yielded(Y), Complete(R), Done, // ← 新增 } `` 不过,这会留下一些令人困惑的名称。这里的`Complete`并不等同于`Done`。因此,我建议根据各变体所代表的操作来命名,就像我们对`ControlFlow`(`Break|Continue`)所做的那样:`` pub enum CoroutineState { Yield(Y), // ← 重命名 Return(R), // ← 重命名 Done, } `` ## 更多状态 到目前为止,我们已经讨论了 future 的三种状态:`Ready`、`Pending`和`Done`。这三种状态是我们向*外部*传达的状态。但在内部,还有一种状态:`Start`或`Init`。这是 future 在*首次*被轮询之前所处的状态。但由于我们从不产出这一状态,它仅在内部存在。这里还有一种我们尚未讨论的状态,即 future 或协程被初始化*之前*的状态。在异步 Rust 中,`\.await`会在轮询 future 之前调用`IntoFuture::into_future`方法,就像迭代时`for..in`语法会在调用`Iterator::next`之前调用`IntoIterator::into_iter`一样。我不确定该如何称呼这个前置状态,但它的作用是在无需实例化局部变量的情况下捕获输入。遗憾的是,`async {}`目前返回的是`impl Future`,而非`impl IntoFuture`。在《gen 的自动 trait 问题》(https://blog.yoshuawuyts.com/gen-auto-trait-problem) 一文中,我解释了这种改变如何帮助我们创建更精确的约束,并解决若干`Send`/`Sync`交互问题。虽然更改`async {}`的语法糖展开方式很棘手,但`gen {}`仍未稳定,修改起来要容易得多。最后,Jack O’Connor 在《永远不要让 Future 打盹》(https://jacko.io/snooze.html) 中描述了一种本应无效的 future 状态:“打盹”(Snoozed)。当一个 future 已准备好取得进展,却没有被轮询时,它就处于打盹状态。我认为,这是将“bbbs”场景 (https://rust-lang.github.io/wg-async/vision/submitted_stories/status_quo/barbara_battles_buffered_streams.html)¹ (https://blog.yoshuawuyts.com/the-future-trait-has-a-missing-state#bbbs) 转化为活性属性的一种方式。Future 天然构成树形结构 (https://blog.yoshuawuyts.com/tree-structured-concurrency/index)。当某个子 future 的 waker 被调用,而其父 future 被轮询、该子 future 却没有被轮询时,就出现了“打盹”状态。这引出了一条可以递归应用到每个 future 的规则:*“当一个 future 被轮询时,它也必须轮询所有 waker 已被调用的子 future。”*如果每个 future 都遵守这条规则,就不会有任何 future 打盹。## 结论 Future 无法发出自身已结束的信号,这是一个问题,而且没有好的变通办法。像`MaybeDone`这样的类型可以在使用时绕过这一限制,但代价是引入冗余的状态记录。实现几乎所有异步并发操作,都需要跟踪 future 的完成状态。而`async {}`块却被展开为在完成后再次被轮询就会 panic 的 future。这会在嵌入式和 Web 等重视代码体积的受限环境中造成问题。若不弃用现有的`Future::poll`方法,我们就无法修正`Future`trait。本文是我分析`Future`trait 问题系列中的第四篇,但即便如此,我仍不认为这些问题已足以成为替换现有机制的理由。不过,我们可以确保不再重复在`Future`中犯过的错误,例如修正`Coroutine`trait 和`gen`关键字。