融会贯通:Future、任务与线程
正如我们在第 16 章中看到的,线程提供了一种实现并发的方法。我们在本章中看到了另一种方法:使用 async 配合 future 和 stream。如果你想知道何时选择一种方法而不是另一种,答案是:视情况而定!而且在很多情况下,选择并非线程或 async,而是线程和 async。
许多操作系统几十年来一直提供基于线程的并发模型,许多编程语言也因此支持它们。然而,这些模型并非没有权衡。在许多操作系统上,它们为每个线程使用相当多的内存。线程也仅在操作系统和硬件支持它们时才是一种选择。与主流桌面和移动计算机不同,一些嵌入式系统根本没有操作系统,因此也不会有线程。
异步模型提供了一种不同的——最终是互补的——权衡集合。在异步模型中,并发操作不需要自己的线程。相反,它们可以运行在任务上,就像我们在 stream 部分中使用 trpl::spawn_task 从同步函数启动工作时那样。任务类似于线程,但它不是由操作系统管理的,而是由库级别的代码——即运行时——来管理的。
生成线程和生成任务的 API 如此相似是有原因的。线程充当了一组同步操作的边界;并发可以在线程之间实现。任务充当了一组异步操作的边界;并发可以同时在任务之间和任务内部实现,因为任务可以在其函数体中的 future 之间切换。最后,future 是 Rust 中最细粒度的并发单元,每个 future 可以表示一个由其他 future 组成的树。运行时——具体地说,其执行器——管理任务,而任务管理 future。在这方面,任务类似于轻量级的、由运行时管理的线程,并且由于由运行时而不是操作系统管理而具有额外的能力。
这并不意味着异步任务总是比线程更好(反之亦然)。在某些方面,使用线程的并发比使用 async 的并发是一种更简单的编程模型。这可能是优点,也可能是缺点。线程在某种程度上是“发射后不管“的;它们没有与 future 对等的原生概念,所以它们只是运行到完成,除了被操作系统本身中断之外不会被中断。
而且事实证明,线程和任务通常配合得非常好,因为任务可以(至少在某些运行时中)在线程之间移动。事实上,在底层,我们一直在使用的运行时——包括 spawn_blocking 和 spawn_task 函数——默认是多线程的!许多运行时使用一种称为工作窃取(work stealing)的方法,根据线程当前的使用情况,透明地将任务在线程之间移动,以提高系统的整体性能。这种方法实际上需要线程和任务,因此也需要 future。
当考虑何时使用哪种方法时,可以参考以下经验法则:
- 如果工作是高度可并行化的(即 CPU 密集型),例如处理大量数据,其中每个部分可以单独处理,那么线程是更好的选择。
- 如果工作是高度并发的(即 I/O 密集型),例如处理来自多个不同来源的消息,这些消息可能以不同的间隔或不同的速率到达,那么 async 是更好的选择。
如果你既需要并行又需要并发,你不必在线程和 async 之间做选择。你可以自由地将它们一起使用,让每个发挥其最擅长的部分。例如,示例 17-25 展示了现实世界 Rust 代码中这种混合的一个相当常见的例子。
extern crate trpl; // for mdbook test
use std::{thread, time::Duration};
fn main() {
let (tx, mut rx) = trpl::channel();
thread::spawn(move || {
for i in 1..11 {
tx.send(i).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
trpl::block_on(async {
while let Some(message) = rx.recv().await {
println!("{message}");
}
});
}
我们首先创建一个异步通道,然后生成一个线程,使用 move 关键字获取通道发送端的所有权。在线程内,我们发送数字 1 到 10,每次发送之间休眠一秒。最后,我们运行一个由传入 trpl::block_on 的 async 代码块创建的 future,就像我们在本章中一直做的那样。在那个 future 中,我们等待这些消息,就像我们在其他消息传递示例中看到的那样。
回到我们在本章开头提出的场景,想象使用专用线程运行一组视频编码任务(因为视频编码是计算密集型的),但使用异步通道通知 UI 这些操作已完成。在实际用例中有无数这种组合的例子。
总结
这不是你在本书中最后一次看到并发。第 21 章第 21 章 中的项目将在比这里讨论的更简单的示例更现实的情况下应用这些概念,并更直接地比较使用线程与使用任务和 future 解决问题的方式。
无论你选择哪种方法,Rust 都为你提供了编写安全、快速、并发代码所需的工具——无论是用于高吞吐量的 Web 服务器还是嵌入式操作系统。
接下来,我们将讨论随着你的 Rust 程序变得更大,建模问题和构建解决方案的惯用方式。此外,我们还将讨论 Rust 的习语(idiom)与你可能从面向对象编程中熟悉的习语之间的关系。