整合所有内容:Futures、任务和线程
正如我们在第 16 章中看到的,线程提供了一种实现并发的方法。在本章中,我们看到了另一种方法:使用 async 与 futures 和 streams。如果你想知道何时选择哪种方法,答案是:视情况而定!在许多情况下,选择不是线程或 async,而是线程和 async。
许多操作系统数十年来一直在提供基于线程的并发模型,许多编程语言也因此支持它们。然而,这些模型并非没有权衡。在许多操作系统上,每个线程都会占用相当多的内存,并且启动和关闭它们会带来一些开销。线程也只有在你的操作系统和硬件支持它们时才是一种选择。与主流桌面和移动计算机不同,一些嵌入式系统根本没有操作系统,因此它们也没有线程。
async 模型提供了一组不同且最终互补的权衡。在 async 模型中,并发操作不需要它们自己的线程。相反,它们可以在任务上运行,就像我们在 streams 部分中使用 trpl::spawn_task
从同步函数启动工作时一样。任务类似于线程,但它不是由操作系统管理的,而是由库级别的代码(运行时)管理的。
在上一节中,我们看到我们可以通过使用 async channel 并生成一个可以从同步代码调用的 async 任务来构建 stream。我们可以使用线程做完全相同的事情。在 Listing 17-40 中,我们使用了 trpl::spawn_task
和 trpl::sleep
。在 Listing 17-41 中,我们将这些替换为标准库中的 thread::spawn
和 thread::sleep
API,用于 get_intervals
函数。
get_intervals
函数使用 std::thread
API 而不是 async trpl
API如果你运行这段代码,输出结果与 Listing 17-40 的输出结果相同。并请注意,从调用代码的角度来看,这里几乎没有变化。更重要的是,即使我们的一个函数在运行时上生成了一个 async 任务,而另一个函数生成了一个操作系统线程,但结果 stream 并没有受到这些差异的影响。
尽管这两种方法非常相似,但它们的行为却截然不同,尽管我们可能很难在这个非常简单的示例中衡量出来。我们可以在任何现代个人计算机上生成数百万个 async 任务。如果我们尝试用线程来做这件事,我们真的会耗尽内存!
然而,这些 API 如此相似是有原因的。线程充当同步操作集的边界;并发在线程之间是可能的。任务充当异步操作集的边界;并发既可以在任务之间,也可以在任务内部发生,因为任务可以在其主体中的 futures 之间切换。最后,futures 是 Rust 最细粒度的并发单元,每个 future 可能代表其他 futures 的树。运行时(特别是其执行器)管理任务,而任务管理 futures。在这方面,任务类似于轻量级的、运行时管理的线程,它们具有因运行时而不是操作系统管理而带来的附加功能。
这并不意味着 async 任务总是比线程更好(反之亦然)。使用线程的并发在某些方面比使用 async
的并发更简单的编程模型。这可能是一个优点,也可能是一个缺点。线程在某种程度上是“发射后不管”的;它们没有类似于 future 的原生等价物,因此它们只是运行到完成,除非操作系统本身中断它们。也就是说,它们没有内置的对 futures 那样的任务内并发的支持。Rust 中的线程也没有取消机制——我们没有在本章中明确涵盖这个主题,但每当我们结束一个 future 时,它的状态都会被正确清理,这暗示了取消机制的存在。
这些限制也使得线程比 futures 更难组合。例如,使用线程构建像我们本章前面构建的 timeout
和 throttle
方法这样的助手要困难得多。futures 是更丰富的数据结构这一事实意味着它们可以更自然地组合在一起,正如我们所见。
然后,任务为我们提供了对 futures 的额外控制,允许我们选择在何处以及如何对它们进行分组。事实证明,线程和任务通常可以很好地协同工作,因为任务可以(至少在某些运行时中)在线程之间移动。实际上,在底层,我们一直在使用的运行时(包括 spawn_blocking
和 spawn_task
函数)默认是多线程的!许多运行时使用一种称为工作窃取的方法来透明地在线程之间移动任务,这取决于线程当前的利用率,以提高系统的整体性能。这种方法实际上需要线程和任务,因此也需要 futures。
在考虑何时使用哪种方法时,请考虑以下经验法则
- 如果工作是非常可并行化的,例如处理大量数据,其中每个部分可以单独处理,那么线程是更好的选择。
- 如果工作是非常并发的,例如处理来自大量不同来源的消息,这些消息可能以不同的间隔或不同的速率到达,那么 async 是更好的选择。
如果你既需要并行性又需要并发性,你不必在线程和 async 之间做出选择。你可以自由地将它们结合使用,让每个都发挥其最擅长的作用。例如,Listing 17-42 展示了一个在真实世界的 Rust 代码中相当常见的这种混合示例。
我们首先创建一个 async channel,然后生成一个线程,该线程获取 channel 发送端的 ownership。在线程内部,我们发送数字 1 到 10,每个数字之间休眠一秒钟。最后,我们运行一个使用传递给 trpl::run
的 async 代码块创建的 future,就像我们在整章中所做的那样。在该 future 中,我们等待这些消息,就像我们在我们见过的其他消息传递示例中一样。
回到我们在本章开头提出的场景,想象一下使用专用线程(因为视频编码是计算密集型的)运行一组视频编码任务,但使用 async channel 通知 UI 这些操作已完成。在真实世界的用例中,有无数个这种组合的例子。
总结
这不会是你在本书中最后一次看到并发。 第 21 章中的项目将在比此处讨论的更简单的示例更真实的情况下应用这些概念,并更直接地比较使用线程与任务解决问题。
无论你选择哪种方法,Rust 都会为你提供编写安全、快速、并发代码所需的工具——无论是 для 高吞吐量的 Web 服务器还是嵌入式操作系统。
接下来,我们将讨论在你的 Rust 程序变得更大时,对问题进行建模和构建解决方案的惯用方法。此外,我们将讨论 Rust 的惯用方法与你可能熟悉的面向对象编程中的惯用方法之间的关系。