异步 Rust 如何工作?


Rust 有一个新兴的异步系统。如果你的应用程序 IO 很重,你应该简单地“使用异步”,一切都会高效地工作。

您可以使用async fn,.await,让它在后台处理,而 CPU 会做一些有用的事情。
然后你学习添加 Tokio让它做任何事情,事情可能看起来很神奇。

幸运的是,计算机还不能神奇地工作,所以我们可以尝试简化事情并获得更好的理解。

Rust 中的异步函数只是常规函数的语法糖,但它不是直接返回值,而是为您提供一个实现Future trait的复杂状态机。

// 下面语法糖
async fn foo() -> i32 {
   
// …
}

// 实际上,解糖后为
fn foo() -> impl Future<Output = i32> {
   
// …
}

编译器然后会生成适当的类型和实现。这很有帮助,但它仍然过于复杂。

我将重点介绍我认为最重要的两个组件,即 Futuretraitexecutor,最后展示可用于运行异步代码的最小执行器实现。

让我们先从trait 开始。

Future trait
这个Future特性虽然是异步 Rust 工作原理的基础,但却是一个相当简单的特性。很简单,这里是完整的:

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Output关联类型表示Future最终会返回什么类型,poll方法将检查异步进程是否完成,如果完成了,将返回Poll::Ready(Self::Output),如果没有完成,则返回Poll::Pending。那个Pin<&mut Self>的存在有复杂的原因,在本文中我们将基本忽略。

最重要的是,一个Future本身什么都不做。它应该迅速返回一个Poll::Pending或Poll::Ready,这样程序就可以继续检查其他的Future,或者回去睡觉。Future的实际工作应该发生在其他地方。

关于poll方法的唯一其他有趣的事情是它需要一个Context方法。在写这篇文章的时候,上下文的一个目的是:为你提供(参考)一个Waker,以后可以唤醒Executor。我们接下来会讨论这个问题。

因此,异步函数被转化为植入式的Future类型,但有些东西最后必须是异步的。

为了说明这一点,让我们看看一个简单的future,它不返回任何东西,但会产生一个线程,并在它产生的线程完成后完成。


use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

struct ThreadedFuture {
    started: bool,
    completed: Arc<AtomicBool>
};

impl std::future::Future for ThreadedFuture {
    type Output = ();

    fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // 1. Check if the completion was already done
        if !self.completed.load(Ordering::Acquire) {
            if !self.started {
               
// 2. 2.如果我们没有完成,也没有启动线程,就启动一个后台线程。

                self.started = true;

                let completer = Arc::clone(&self.completed);
                let waker = cx.waker().clone();

                thread::spawn(move || {
                   
// 3. First mark the future as completed
                    completer.store(true, Ordering::Release);
                   
// 4. Then wake up the executor
                    waker.wake();
                });
            }

           
// 5. 现在我们已经设置了异步完成,但是还没有完成,所以返回Pending。
            Poll::Pending
        } else {
           
// 6. Already completed, so return.
            Poll::Ready(())
        }
    }
}

这是一大模板,但基本原理相对简单:

  • 检查我们是否已经完成了future。如果没有,检查我们是否已经开始了异步计算
  • 如果没有,在后台生成一个最终将完成计算的线程
  • 在该线程中,将自己标记为已完成
  • 并唤醒
  • 然后返回一个挂起的状态
  • 最后,如果我们最终完成了,则返回Ready状态。

Executor
异步拼图的另一块是执行器Executor。执行器主要需要轮询期货,在无事可做时进入睡眠状态,并提供一个合适的唤醒器来唤醒。许多实现,如tokio,提供了大量的附加功能,但从根本上说,它需要做的只是完成以下步骤:

当用户向执行器提供一个Future时,执行器将开始轮询,直到它最终返回一个值。这个轮询不是一个繁忙的循环;相反,执行器将等待,直到它收到一个唤醒信号。
这个信号可以来自任何线程,甚至是一个简单的C风格的信号处理程序,并且通常是特定于支撑操作的异步过程。

或者,以流程图的形式:

对于执行器的实施,信号来自哪里并不重要。只要它引起了唤醒,就应该再次轮询future。有了这些知识,我们可以构建一个基本的执行器。只是我们不必这样做;在标准库的文档中,你可以找到一个故意的、有点小毛病的执行器实现2,它就是这样实现的。

我可以写一个正确的执行器样本,就像我写一个future的例子一样,但碰巧我已经写了,并把它作为一个crate发布了。现在让我们来看看这个。

Beul
写这篇文章的主要原因是Beul的 1.0.0 版本。Beul 是一个基于上述想法的安全、简约的期货执行者。它非常简约,只有 84 行(注释)代码,其整个公共 API 如下所示:

pub fn execute<T>(f: impl Future<Output = T>) -> T


除了功能齐全的异步框架之外,人们会用它来做什么?主要原因是编写与执行程序无关的异步代码的测试,以及在大部分同步代码中使用异步库。

可以嵌套调用beul::execute,您可以在异步代码中调用一些同步代码,而异步代码又使用 Beul 调用异步代码。在许多情况下,不这样做对性能更好,并确保.await尽可能进行任何异步函数调用。

其他:

  • Pollster提供了与这个 crate 大致相同的实现,只是使用了一个小的不安全代码,可以从 Rust 1.68 中删除。它使用扩展特性来提供其阻塞功能。main()它最近还获得了一组(可选的)过程宏,允许您以可以正常执行的方式注释异步或测试。Beul 稍微更小一些,并使用动态调度来减少代码大小。
  • futures-executor提供了几个执行器和实用程序来简化 futures 的工作。它的block_on执行器类似于 Beul 的 API,尽管对它的调用可以有意不嵌套。这也是一个相当大的板条箱。
  • extreme具有与 Beul 相同的 API,但早于该Wake特征,因此必须使用不安全的 Rust 来实现其执行程序。extreme还根据 GNU 公共许可证获得许可,这可能使其不适用于许多应用程序。版本控制方案虽然很好,但有些特殊。
  • Yet Another Async Runtime(或yaar)和safina-executor都提供了更多的通用执行器框架,而不仅仅是执行期货的东西。如果您需要更多,它们可能是有意义的,但对于简单的未来执行,Beul 应该足够了。

综上所述
异步 Rust 由重复轮询 futures 直到完成的Executor执行者组成。
当 future 仍然悬而未决时,executor 会在再次轮询 future 之前去做其他事情,通常是睡觉。重复直到完成。

有了这个,我希望我已经提供了一个关于引擎盖下发生的事情的直觉。