Rust异步编程简介
计算机已经尽可能快了。加快程序速度的一种方法是并行或并发执行操作。这两个术语之间有细微的区别。并行执行意味着我们同时在两个不同的 CPU 上执行两个不同的任务。并发执行意味着单个 CPU 通过交错执行这些任务,同时在多个任务上取得进展。
Rust 标准库为底层操作系统提供了绑定和抽象。这包括线程,一种并行运行代码的方式。并行性由操作系统管理,您可以拥有与CPU核心一样多的线程,但也可以有更多,并且操作系统决定何时执行什么。这可能非常繁重并且有大量开销。
因此,我们陷入了两种方法:要么按顺序运行所有内容,要么使用操作系统线程并行执行,这可能会导致开销。对于某些领域(例如 Web 或网络应用程序 ,即IO密集型)来说,它们都可能不是最佳解决方案。
异步试图解决这些问题。异步是一种顺序编写代码但同时并发执行代码的方法,无需管理任何线程或执行。这个想法是 将现有代码分割成任务,然后执行一部分代码,并让异步运行时选择下一个需要执行的任务。然后,运行时决定何时执行什么,并且可以以非常有效的方式执行。
它还利用了这样一个事实:大多数时候,CPU 正在等待某些事情发生,例如网络请求或要读取的文件。看下面的代码行。
rust
let mut socket = net::TcpStream::connect((host, port)).unwrap();
我们所做的就是建立一个 TCP 连接。但这需要时间。对于您来说不一定引人注目,但对于计算机来说,这意味着什么都不做,只是等待建立连接。 其实 我们可以更好地利用这段时间。
异步原语
并发执行在编程领域并不是什么新鲜事。此外,异步编程已经存在了一段时间,您可能在 JavaScript 或 C# 中看到过类似的东西。但在 Rust 中,乍一看事情可能很相似,但如果我们仔细观察就会有所不同。
一个很大的区别是 Rust 没有异步运行时。我们需要一个异步运行时来管理任务的正确执行,但参与的 Rust 团队认为不存在"一刀切"的异步运行时,开发人员应该有权选择适合自己需求的运行时。从概念上讲,这不同于例如Go,它只有一种并发模型:goroutines。开发人员却陷入困境。
在 Rust 中,我们可以决定使用哪一种。尽管如此,Rust 为我们提供了一种为异步执行器准备任务的方法。这是通过使用 Future
trait 的抽象来完成的。 Future
trait是 Rust 异步编程的核心。它是一种trait,代表一种尚不可用但在未来某个时候可用的值。这与 JavaScript 中的 Promise
非常相似。
实现 Future
的所有内容都可以在异步运行时中执行。 Future
trait定义如下:
rust
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
类型的输出。
rust
pub enum Poll<T> {
Ready(T),
Pending,
}
Context
目前仅用于提供对 Waker
对象的访问。 Waker
是告诉运行时再次轮询此任务所必需的。
好吧好吧,那是什么?Polling,waking?让我们深入挖掘一下。
执行
如前所述, Future
trait用于抽象可以在异步运行时中执行的任务。但这是如何运作的呢?
公平地说,详细来说,这取决于所使用的异步运行时,但一些基本概念对于所有这些都是相同的。
尼克·卡梅伦 (Nick Cameron) 撰写了有关此主题的概述,总结如下:
异步运行时有一个执行器。执行器通常有两个关键 API: spawn
和 block_on
。 block_on
用于 阻塞等待当前线程上的任务完成。 spawn
用于在执行器上启动一个新任务,但非阻塞。它立即返回。返回值取决于 Future
。是否有异步发生?然后轮询 Future
将立即返回 Poll::Pending
,同时还为执行器设置规则,以便在任务准备好时唤醒任务。这可以是操作系统上的一些 IO 事件,例如已建立的 TCP 连接。如果没有任何异步发生, Future
将返回 Poll::Ready
及其返回值。
一旦事件发生,waker 指示执行器再次轮询相同的future,可能已经有结果了。
语法糖: async
and await
好的,所以您需要的只是实现 Future
的函数或结构,然后您就完成了。这可行吗?嗯,从字面上看,这并不那么容易。实现 Future
trait可能非常艰巨,而且不太符合工效学。
这就是 Rust 引入 async
和 await
关键字的原因。要使某些内容异步,它需要返回 Future
。因此,如果您想要一个方法 read_to_string
,这是同步版本:
rust
fn read_to_string(&mut self, buf: &mut String) -> Result<usize>;
异步版本如下所示:
rust
fn read_to_string(&mut self, buf: &mut String) -> impl Future<Output = Result<usize>>;
这儿有个有语法糖。您可以将其声明为 async
,而不是返回 Future
。
rust
async fn read_to_string(&mut self, buf: &mut String) -> Result<usize>;
您也不需要自己进行poll 。您可以使用 await
关键字等待 Future
的结果。
rust
let result = fileread_to_string(&mut buf).await;
在幕后,Rust 编译器 为您创建了Future。它通过 将代码拆分为多个任务来实现这一点 ,每个 await
都是分隔任务的分割点 。然后,编译器会为您创建一个状态机,并为您实现 Future
trait 。对于每个 await
,状态机都会被轮询并可能移动到下一个状态。 Tokio 团队在本教程中精彩地展示了how those Futures can be implemented or created by the compiler in this tutorial。
Tokio 是最流行的运行时之一,专为网络应用程序的异步执行而设计。它也是早期异步的playground,并且很稳定,可用于生产,而且很可能也是您正在使用的任何 Web 框架的基础。它不仅提供了操作系统事件的必要抽象,还提供了具有不同模式的功能丰富的运行时,以及标准库 IO 和网络功能的异步表示。如果你想开始使用 Rust 中的异步,Tokio 是一个不错的选择。
Traits 中的async 方法
所有这些都导致了异步Rust中最需要的,也是最期待的特性之一:在trait中定义async 方法。该功能最近已在 Rust 中引入,但仍然存在一些限制 。下面的问题是,我们作为开发人员希望使用漂亮的 async
/ await
语法进行编写,但编译器需要为自动生成的代码准备 Future
trait实现状态机,这可能会变得非常复杂。
让我们看一个例子,它想要为聊天应用程序定义一个编写接口,名为 ChatSink
。这就是我想写的。
rust
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
关键字来完成的。
rust
pub trait ChatSink {
type Item: Clone;
fn send_msg(&mut self, msg: Self::Item) -> impl Future<Output = Result<(), ChatCommErr>>;
}
但有趣的是 impl Trait
也只是关联类型的语法糖。事实上,会产生类似这样的东西。
rust
pub trait ChatSink {
type Item: Clone;
type $: Future<Output = Result<(), ChatCommErr>>;
fn send_msg(&mut self, msg: Self::Item) -> Self::$;
}
但这还不是全部,我们遗漏了一个非常重要的细节。与其他 impl Trait
解决方法相比, Future
需要添加生命周期参数。这与 future 内部的处理方式有关:它们不执行代码,它们只是将执行代码的机会传递给另一个运行时环境,即我们之前提到的执行器!异步函数创建这样的 future,并且它们需要保留对 输入参数的所有引用。根据 Rust 的所有权规则,所有这些引用都需要与future本身一样长久。为了确保此信息可用,我们需要向 Future
trait添加一个生命周期参数。
这就产生了一个称为 通用关联类型的功能。 ChatSink
特征的等效版本如下所示:
rust
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 bounds),如果您作为开发人员想要实现库中的trait ,则该功能是必需的。更不用说,如果您想决定异步代码只能在单线程或多线程运行时上工作,那么添加 Send
和 Sync
标记特征就是您想要的自行定义(更多关于这些标记特征的信息请参见here)。
为什么我需要知道所有这些?
公平地说,这是很多信息,并且深入了解了异步 Rust 的本质细节。但这是有原因的。就像 Rust 中的一切一样,事情一开始看起来简单明了,但一旦你深入挖掘,你会发现有很多复杂性和很多需要考虑的事情。
Rust 中的异步编程也会发生同样的情况。您肯定已经完成了定义异步方法。毕竟,您正在阅读 Shuttle 博客,并且异步为 Rust 中的 Web 开发提供了动力。起初,它们很简单,但突然间您可能会看到无法掌握的错误消息。
您在异步函数中定义一个资源,将其包装在 std::sync::Mutex
中,并在锁定它后获取它的 MutexGuard
。突然您决定调用异步 API 并传递 .await
点。编译器会对你抱怨,因为 MutexGuard
没有实现 Send
特征 ,并且你不能将它传递给另一个线程。但为什么需要将它传递给另一个线程呢?您所做的只是调用异步函数?这就是运行时的用武之地。您的运行时配置可能是多线程工作的,并且您永远不知道哪个工作线程执行当前任务 。由于您需要为自动 Future
实现准备好所有资源,因此所有这些引用和资源都需要是线程安全的。还有更多的陷阱,但这是另一次的事情了。
进一步阅读
如果您已经了解了这么多,您可能会对以下资源感兴趣:
- Nick Cameron writes a lot about Async Rust, you might want to check it out.
- So does Without Boats, who gives a lot of insights into the design and development of async Rust.
- The Tokio Tutorial on Async in Depth is nothing but excellent.
- So is the Async chapter in "Programming Rust" by Jim Blandy, Jason Orendorff, and Leonora Tindall.
- Also check out my talk at the first Shuttle Labs.