简述异步 Rust 编程

计算机的运行速度可谓一日千里。加快程序运行速度的一种方法是并行或并发运行。这两个词之间有细微的区别。并行执行是指我们在两个不同的 CPU 上同时执行两个不同的任务。并发执行是指一个 CPU 通过交错执行多个任务,同时在多个任务上取得进展。

Rust 标准库为底层操作系统提供了绑定和抽象。这包括线程,一种并行运行代码的方式。并行由操作系统管理,你可以拥有与 CPU 内核一样多的线程,但也可以有更多,操作系统决定何时执行哪些任务。这可能会带来很大的工作量和开销。

因此,我们只能选择两种方式:要么顺序运行所有程序,要么使用操作系统线程并行执行,但这都会造成开销。对于某些领域(如网络或网络应用程序)来说,这两种方法都不是最佳解决方案。

Async 试图解决这些问题。Async 是一种按顺序编写代码的方法,但可以并发执行,无需管理任何线程或执行。其原理是将现有代码分割成任务,然后执行部分代码,并让异步运行时选择下一个需要执行的任务。运行时将决定何时执行哪些任务,并以非常高效的方式执行。

它还利用了这样一个事实,即 CPU 大部分时间都在等待某些事情的发生,比如网络请求或文件读取。请看下面一行代码:

let mut socket = net::TcpStream::connect((host, port)).unwrap();

我们要做的就是建立一个 TCP 连接。但这需要时间。对你来说不一定很明显,但对电脑来说,这意味着什么都不做,只是等待连接的建立。我们可以更好地利用这段时间。

异步基础

并发执行在编程世界中并不新鲜。而且,异步编程已经存在了一段时间,你可能在 JavaScript 或 C# 中见过类似的东西。但在 Rust 中,乍一看可能很相似,但仔细观察就会发现有所不同。

一个最大的不同是,Rust 没有异步运行时。我们需要一个异步运行时来管理任务的正确执行,但参与其中的 Rust 团队认为不存在 “放之四海而皆准 “的异步运行时,开发人员应该有权选择适合自己需要的运行时。从概念上讲,这与 Go 不同,后者只有一种并发模型:goroutines。而开发人员只能使用这种模式。

而在 Rust 中,我们可以决定使用哪一种并发模型。尽管如此,Rust 还是为我们提供了一种为异步执行器预处理任务的方法。这可以通过使用 Future trait 的抽象来实现。Future trait 是 Rust 异步编程的核心。它是一个表示尚未可用但将在未来某个时间可用的值的特质。这与 JavaScript 中的 Promise 非常相似。

实现 Future 的一切都可以在异步运行时中执行。Future trait的定义如下:

pub trait Future {
    type Output;

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

这很简单。它有一个关联类型 Output,代表未来值。它有一个名为 poll 的方法,该方法带有一个 Context 并返回一个 Poll

Poll 是一个枚举,有两种状态。或者是 Pending,这意味着我们在等待一个值。或者 Ready,表示值已经可用。Ready 变体拥有 Output 类型的输出。

pub enum Poll<T> {
    Ready(T),
    Pending,
}

目前,Context 仅用于提供对 Waker 对象的访问。要让运行时再次轮询这项任务,就必须使用 Waker

好吧,好吧,那是什么?轮询、唤醒?让我们深入探讨一下。

执行

如前所述,Future trait用于抽象可在异步运行时执行的任务。但这是如何实现的呢?

说白了,这取决于所使用的异步运行时,但有几个基本概念是相同的。

尼克-卡梅隆(Nick Cameron)撰写了一篇关于此主题的综述:

异步运行时有一个执行器。block_on 用于等待当前线程上的任务完成。它会立即返回。返回值取决于 Future。是否有异步事件发生?那么轮询 Future 会立即返回 Poll::Pending,但同时也会为执行器设置规则,以便在任务准备就绪时唤醒任务。这可能是操作系统上的某个 IO 事件,如已建立的 TCP 连接。如果没有异步事件发生,Future 将返回 Poll::Ready 返回值。

事件发生后,waker 会指示执行器再次轮询同一个 Future,因为可能已经有了结果。

语法糖:async 和 await

好了,只要有实现 Future 的函数或结构体就可以了。对吗?对不对?其实没那么简单。实现 Future trait可能会让人望而生畏,而且也不太符合人体工程学。

这就是 Rust 引入 asyncawait 关键字的原因。要实现异步,需要返回一个 Future。因此,如果你想要一个 read_to_string 方法,这是同步版本:

fn read_to_string(&mut self, buf: &mut String) -> Result<usize>;

异步版本看起来像这样:

fn read_to_string(&mut self, buf: &mut String) -> impl Future<Output = Result<usize>>;

有一些语法糖可以写成这样。您可以将其声明为 async,而不是返回一个 Future

async fn read_to_string(&mut self, buf: &mut String) -> Result<usize>;

您也不需要自己轮询。您可以使用 await 关键字来等待 Future 的结果。

let result = fileread_to_string(&mut buf).await;

在底层,Rust 编译器会为你创建Future 。为此,编译器会将代码分割成多个任务,每个等待都是分隔任务的断点。然后,编译器会为你创建一个状态机,并为你实现 Future trait 。每一次 await 都会对状态机进行轮询,并有可能进入下一个状态。Tokio 团队在本教程中精彩展示了编译器如何实现或创建这些Future 。

说到 TokioTokio 是目前最流行的运行时之一,专为异步执行网络应用程序而设计。它也是异步早期的平台,性能稳定,用于生产,很可能也是您正在使用的任何网络框架的基础。它不仅为操作系统事件提供了必要的抽象,还提供了一个功能丰富的运行时,具有不同的模式以及标准库 IO 和网络功能的异步表示。如果你想从 Rust 的 Async 开始,Tokio 是一个不错的选择。

Traits中的异步方法

所有这一切都引出了 async Rust 中最想要却又最期待的功能之一:在 traits 中定义异步方法。这一特性最近才在 Rust 中出现,但仍有一些局限性。下面的问题是,作为开发人员,我们希望使用漂亮的 async / await 语法编写代码,但编译器需要为自动生成的状态机准备 Future trait 实现,这可能会变得非常复杂。

让我们来看一个例子,它希望为一个名为 ChatSink 的聊天应用程序定义一个编写接口。我想这样写:

pub trait ChatSink {
  type Item: Clone;
  async fn send_msg(&mut self, msg: Self::Item) -> Result<(), ChatCommErr>;
}

一旦我们想把它移植到使用 Future 实现的东西中,事情就变得有点棘手了。我们需要定义一个 Future 返回类型,而不是 async 方法,但我们不知道它将是哪个 Future!这将由trait 的实现者在稍后阶段定义。所以我们能做的就是说,无论将来会出现什么,它都将实现 Future trait 。这可以通过使用 impl 关键字来实现。

pub trait ChatSink {
  type Item: Clone;
  fn send_msg(&mut self, msg: Self::Item) -> impl Future<Output = Result<(), ChatCommErr>>;
}

有趣的是,impl Trait 也只是关联类型的语法糖。在现实中,会生成类似这样的内容:

pub trait ChatSink {
  type Item: Clone;
  type $: Future<Output = Result<(), ChatCommErr>>;
  fn send_msg(&mut self, msg: Self::Item) -> Self::$;
}

但这还不是全部,我们还遗漏了一个非常重要的细节。与其他

但这还不是全部,我们还遗漏了一个非常重要的细节。与其他impl Trait相比,Future 需要添加一个生命周期参数。这与未来的内部处理方式有关:它们不执行代码,只是将执行代码的机会传递给另一个运行时环境,也就是我们之前提到的执行器!异步函数就是这样创建Future 的,它们需要保留对输入参数的所有引用。根据 Rust 的所有权规则,所有这些引用需要与未来本身一样长的时间。为了确保这些信息可用,我们需要为 Future trait 添加一个 lifetime 参数。

这就产生了一种叫做泛型关联类型的特性(generic associated types.)。ChatSink trait的等效版本如下:

pub trait ChatSink {
  type Item: Clone;
  type $<'m>: Future<Output = Result<(), ChatCommErr>> + 'm;
  fn send_msg(&mut self, msg: Self::Item) -> Self::$<'_>;
}

但在 Rust 1.75 之前,这一切都无法实现。impl Trait 目前不允许添加用户定义的trait 边界,而如果开发人员想从库中实现trait ,这一功能是必不可少的。更不用说如果你想决定你的异步代码只能在单线程或多线程运行时工作,那么添加 SendSync marker  traits就是你想自己定义的事情了(关于这些标记marker  traits 的更多信息,请点击此处)。

我为什么需要知道这些?

平心而论,这些信息量很大,涉及到 async Rust 的方方面面。但这是有原因的。就像 Rust 中的所有内容一样,起初看起来简单明了,但一旦深入研究,你就会发现其中有很多复杂的东西需要考虑。

Rust 中的异步编程也是如此。定义异步方法是你肯定做过的事情。毕竟,你正在阅读 Shuttle 博客,而 async 为 Rust 中的网络开发提供了强大的动力。起初,它们很简单,但突然间,你可能会看到无法掌握的错误信息。

你在你的 async 函数中定义了一个资源,将其封装在一个 std::sync::Mutex 中,并在锁定后获得了它的 MutexGuard。突然,你决定调用 async API 并传递一个 .await 点。编译器会对你大喊大叫,因为 MutexGuard 没有实现 Send 特性,你不能将它传递给另一个线程。但为什么要把它传递给另一个线程呢?你只不过是调用了一个异步函数而已?这就是运行时的问题所在。您的运行时配置可能是多线程的,您永远不知道当前任务是由哪个工作线程执行的。由于您需要为自动实现 Future 准备好所有资源,因此所有这些引用和资源都必须是线程安全的。还有更多隐患,但这是下次再讨论的内容。

本文文字及图片出自 Async Rust in a Nutshell

阅读余下内容
 

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号