likes
comments
collection
share

Rust异步编程简介

作者站长头像
站长
· 阅读数 3

Rust异步编程简介

计算机已经尽可能快了。加快程序速度的一种方法是并行或并发执行操作。这两个术语之间有细微的区别。并行执行意味着我们同时在两个不同的 CPU 上执行两个不同的任务。并发执行意味着单个 CPU 通过交错执行这些任务,同时在多个任务上取得进展。

Rust异步编程简介

Rust 标准库为底层操作系统提供了绑定和抽象。这包括线程,一种并行运行代码的方式。并行性由操作系统管理,您可以拥有与CPU核心一样多的线程,但也可以有更多,并且操作系统决定何时执行什么。这可能非常繁重并且有大量开销。

因此,我们陷入了两种方法:要么按顺序运行所有内容,要么使用操作系统线程并行执行,这可能会导致开销。对于某些领域(例如 Web 或网络应用程序 ,即IO密集型)来说,它们都可能不是最佳解决方案。

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

它还利用了这样一个事实:大多数时候,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 异步编程的核心。它是一种trait,代表一种尚不可用但在未来某个时候可用的值。这与 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 是告诉运行时再次轮询此任务所必需的。

好吧好吧,那是什么?Polling,waking?让我们深入挖掘一下。

执行

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

公平地说,详细来说,这取决于所使用的异步运行时,但一些基本概念对于所有这些都是相同的。

尼克·卡梅伦 (Nick Cameron) 撰写了有关此主题的概述,总结如下:

异步运行时有一个执行器。执行器通常有两个关键 API: spawnblock_onblock_on 用于 阻塞等待当前线程上的任务完成。 spawn 用于在执行器上启动一个新任务,但非阻塞。它立即返回。返回值取决于 Future 。是否有异步发生?然后轮询 Future 将立即返回 Poll::Pending ,同时还为执行器设置规则,以便在任务准备好时唤醒任务。这可以是操作系统上的一些 IO 事件,例如已建立的 TCP 连接。如果没有任何异步发生, Future 将返回 Poll::Ready 及其返回值。

一旦事件发生,waker 指示执行器再次轮询相同的future,可能已经有结果了。

语法糖: async and 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>;

您也不需要自己进行poll。您可以使用 await 关键字等待 Future 的结果。

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 。这就是我想写的。

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 内部的处理方式有关:它们不执行代码,它们只是将执行代码的机会传递给另一个运行时环境,即我们之前提到的执行器!异步函数创建这样的 future,并且它们需要保留对 输入参数的所有引用。根据 Rust 的所有权规则,所有这些引用都需要与future本身一样长久。为了确保此信息可用,我们需要向 Future trait添加一个生命周期参数

这就产生了一个称为 通用关联类型的功能。 ChatSink 特征的等效版本如下所示:

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 ,则该功能是必需的。更不用说,如果您想决定异步代码只能在单线程或多线程运行时上工作,那么添加 SendSync 标记特征就是您想要的自行定义(更多关于这些标记特征的信息请参见here)。

为什么我需要知道所有这些?

公平地说,这是很多信息,并且深入了解了异步 Rust 的本质细节。但这是有原因的。就像 Rust 中的一切一样,事情一开始看起来简单明了,但一旦你深入挖掘,你会发现有很多复杂性和很多需要考虑的事情

Rust 中的异步编程也会发生同样的情况。您肯定已经完成了定义异步方法。毕竟,您正在阅读 Shuttle 博客,并且异步为 Rust 中的 Web 开发提供了动力。起初,它们很简单,但突然间您可能会看到无法掌握的错误消息

您在异步函数中定义一个资源,将其包装在 std::sync::Mutex 中,并在锁定它后获取它的 MutexGuard 。突然您决定调用异步 API 并传递 .await 点。编译器会对你抱怨,因为 MutexGuard 没有实现 Send 特征,并且你不能将它传递给另一个线程。但为什么需要将它传递给另一个线程呢?您所做的只是调用异步函数?这就是运行时的用武之地。您的运行时配置可能是多线程工作的,并且您永远不知道哪个工作线程执行当前任务。由于您需要为自动 Future 实现准备好所有资源,因此所有这些引用和资源都需要是线程安全的。还有更多的陷阱,但这是另一次的事情了。

进一步阅读

如果您已经了解了这么多,您可能会对以下资源感兴趣:


Async Rust in a Nutshell