结合起来看:Future、任务与线程
ch17-06-futures-tasks-threads.md
正如我们在第十六章中看到的那样,线程提供了一种实现并发的方式。而在本章里,我们又看到了另一种方式:使用基于 future 和 stream 的 async。如果你想知道应该在什么时候选哪一种,答案是:这取决于具体情况!而且在很多情况下,真正的选择并不是线程或 async,而是线程和 async 一起用。
许多操作系统几十年来一直都提供基于线程的并发模型,因此许多编程语言也都支持它们。但这些模型并非没有代价。在许多操作系统中,每个线程都要占用相当可观的内存。线程也只有在你的操作系统和硬件支持它们时才可用。与主流桌面和移动设备不同,一些嵌入式系统甚至根本没有操作系统,因此也就根本没有线程。
async 模型提供了一组不同的,而且归根结底是互补的权衡。在 async 模型中,并发操作不需要各自独占一个线程。相反,它们可以运行在任务上,就像我们在 stream 那一节里使用 trpl::spawn_task 从同步函数中启动工作时那样。任务和线程有点像,但它不是由操作系统管理,而是由库层面的代码,也就是运行时来管理。
线程 API 和任务 API 长得这么像并不是偶然。线程是“一组同步操作”的边界;并发发生在线程之间。任务则是“一组异步操作”的边界;并发既可能发生在任务之间,也可能发生在任务内部,因为任务的函数体里可以在多个 future 之间切换。最后,future 是 Rust 中最细粒度的并发单位,而每个 future 又可能代表一棵由其他 future 组成的树。运行时,准确地说是它的 executor,管理任务;任务再去管理 future。从这个角度说,任务有点像“轻量级的、由运行时管理的线程”,只不过由于它们是由运行时而不是操作系统管理,所以又拥有了一些额外能力。
这并不意味着 async 任务就一定总比线程更好,反过来也一样。在线程基础上的并发,在某些方面其实是比 async 并发更简单的编程模型。这一点既可能是优点,也可能是缺点。线程有点像 “射后不理”(“fire and forget”):它们没有原生对应 future 的机制,因此通常只是一路运行到结束,除非被操作系统本身打断。
而线程和任务常常又能很好地协同工作,因为任务(至少在某些运行时里)可以在线程之间来回移动。事实上,我们这一章一直使用的运行时,在底层默认就是多线程的,包括 spawn_blocking 和 spawn_task 在内。许多运行时会采用一种叫作 work stealing(工作窃取)的方法,根据各个线程当前的利用情况,把任务透明地在线程之间迁移,以提高系统整体性能。而这种方法本身就同时需要线程、任务,也就同时需要 future。
在思考什么时候该用哪种方式时,可以先记住这些经验法则:
- 如果工作是非常适合并行化的,也就是典型的 CPU 密集型任务,比如有一大批数据而且每一部分都能单独处理,那么线程通常是更好的选择。
- 如果工作是高度并发的,也就是典型的 I/O 密集型任务,比如要同时处理来自很多不同来源、且到达间隔和频率都各不相同的消息,那么 async 通常是更好的选择。
如果你同时既需要并行,又需要并发,那也完全不必在“线程”和“async”之间二选一。你可以自由地把它们组合起来,让每一种都去做自己最擅长的那一部分。比如,示例 17-25 展示的就是一种在现实 Rust 代码里相当常见的组合方式。
文件名:src/main.rs
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 这些数字,并在每次发送之间睡眠 1 秒。最后,我们像本章前面一直做的那样,把一个由 async 代码块构造出来的 future 交给 trpl::block_on 去执行。在这个 future 里,我们等待那些消息,就像前面那些消息传递示例里做的一样。
回到本章一开始举过的场景:想象一下,你用一个专门的线程来执行一组视频编码任务,因为视频编码是计算密集型工作;但当这些操作完成时,再通过一个异步信道通知 UI。现实世界里这类组合的例子多得数不过来。
总结
这不会是你在本书里最后一次见到并发。第二十一章中的项目,会把这些概念放到一个比这里那些小例子更真实的场景里来运用,并且更直接地比较“线程”和“任务 / future”这两种解决问题的方式。
无论你最终选择哪一种方法,Rust 都为你提供了编写安全、快速并发代码所需的工具,不管你的目标是高吞吐的 Web 服务器,还是嵌入式操作系统。
接下来,我们将讨论:随着 Rust 程序不断变大,应该如何用符合 Rust 惯例的方式来建模问题并组织解决方案。此外,我们也会谈谈 Rust 的这些惯用法,和你可能已经熟悉的面向对象编程风格之间是什么关系。