键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

综合运用:Future、任务与线程

正如我们在第 16 章中所见,线程提供了一种并发方式。在本章中,我们看到了另一种方式:使用带有 future 和 stream 的 async。如果你想知道何时选择哪种方式,答案是:视情况而定!而且在很多情况下,选择不是线程或者 async,而是线程加上 async。

几十年来,许多操作系统都提供了基于线程的并发模型,因此许多编程语言也支持它们。然而,这些模型并非没有代价。在许多操作系统上,每个线程都会占用相当多的内存。线程也只有在你的操作系统和硬件支持的情况下才可用。与主流台式机和移动计算机不同,一些嵌入式系统根本没有操作系统,因此也没有线程。

async 模型提供了一组不同的——最终是互补的——权衡。在 async 模型中,并发操作不需要自己的线程。相反,它们可以运行在任务上,就像我们在 stream 一节中使用 trpl::spawn_task 从同步函数中启动工作那样。任务类似于线程,但它不是由操作系统管理,而是由库级代码管理的:即运行时(runtime)。

生成线程和生成任务的 API 如此相似是有原因的。线程充当一组同步操作的边界;并发存在于线程之间。任务充当一组异步操作的边界;并发既存在于任务之间,也存在于任务内部,因为任务可以在其主体中的 future 之间切换。最后,future 是 Rust 最细粒度的并发单元,每个 future 可能代表一棵由其他 future 组成的树。运行时——具体来说是它的执行器(executor)——管理任务,任务管理 future。从这个角度来看,任务类似于轻量级的、由运行时管理的线程,并且具有由运行时而非操作系统管理所带来的额外能力。

这并不意味着 async 任务总是比线程更好(反之亦然)。在某些方面,使用线程的并发比使用 async 的并发是更简单的编程模型。这可能是优点,也可能是缺点。线程有点“发射后不管”的意味;它们没有与 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}");
        }
    });
}
示例 17-25:在线程中使用阻塞代码发送消息,并在 async 块中等待这些消息

我们首先创建一个异步通道,然后生成一个线程,该线程使用 move 关键字获取通道发送端的所有权。在线程内,我们发送数字 1 到 10,每发送一个就休眠一秒。最后,我们运行一个由 async 块创建的 future,并将它传递给 trpl::block_on,就像我们在本章中所做的那样。在那个 future 中,我们等待这些消息,就像在我们见过的其他消息传递示例中一样。

回到本章开头的场景,想象一下使用专用线程运行一组视频编码任务(因为视频编码是计算密集型的),但用异步通道通知 UI 这些操作已完成。在实际使用场景中,这类组合的例子数不胜数。

总结

这并不是你在本书中最后一次看到并发。第 21 章中的项目将在比这里讨论的简单示例更现实的情境中应用这些概念,并更直接地比较使用线程与使用任务和 future 来解决问题的不同。

无论你选择哪种方式,Rust 都为你提供了编写安全、快速、并发代码所需的工具——无论是用于高吞吐量的 Web 服务器,还是嵌入式操作系统。

接下来,随着你的 Rust 程序规模变大,我们将讨论对问题建模和构建解决方案的惯用方法。此外,我们还将讨论 Rust 的惯用法与你可能熟悉的面向对象编程惯用法之间的关系。