Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

融会贯通:Future、任务与线程

正如我们在第十六章中所见,线程是实现并发的一种方式。在本章中我们又见识了另一种方式:使用 async 配合 future 和流。如果你在犹豫何时该选择哪种方式,答案是:视情况而定!而且在很多场景下,选择并非线程 async 二选一,而是线程 async 兼而用之。

许多操作系统提供基于线程的并发模型已有数十年之久,许多编程语言也因此支持线程。然而,线程模型并非没有代价。在很多操作系统上,每个线程都会占用相当多的内存。而且线程只有在操作系统和硬件支持的情况下才可用。与主流的桌面和移动计算机不同,某些嵌入式系统根本没有操作系统,因此也就没有线程可用。

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

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

这并不意味着 async 任务总是优于线程(反之亦然)。使用线程实现并发在某些方面比使用 async 实现并发的编程模型更简单,这既可以是优势也可以是劣势。线程在某种程度上是“即发即忘“的;它们没有与 future 对等的原生概念,因此它们只是一直运行到完成,除非被操作系统本身中断。

事实上,线程和任务往往能很好地协同工作,因为任务(至少在某些运行时中)可以在线程之间迁移。实际上,我们一直在使用的运行时——包括 spawn_blockingspawn_task 函数——默认就是多线程的!许多运行时使用一种称为工作窃取(work stealing)的策略,根据线程当前的利用情况,在线程之间透明地迁移任务,以提升系统的整体性能。这种策略实际上同时需要线程任务,因此也需要 future。

在考虑使用哪种方式时,可以参考以下经验法则:

  • 如果工作是高度可并行化的(即 CPU 密集型),例如处理一大批数据且每个部分可以独立处理,那么线程是更好的选择。
  • 如果工作是高度并发的(即 I/O 密集型),例如处理来自许多不同来源的消息,这些消息可能以不同的间隔或不同的速率到达,那么 async 是更好的选择。

如果你同时需要并行性和并发性,不必在线程和 async 之间二选一。你可以自由地将它们结合使用,让各自发挥所长。例如,示例 17-25 展示了在实际 Rust 代码中这种混合使用的一个常见例子。

Filename: 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}");
        }
    });
}
Listing 17-25: 在线程中使用阻塞代码发送消息,并在 async 块中等待接收消息

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

回到本章开头提到的场景:假设你要使用专用线程运行一组视频编码任务(因为视频编码是计算密集型的),然后通过 async 通道通知 UI 这些操作已完成。在实际应用中,这类组合使用的例子不胜枚举。

总结

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

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

接下来,我们将讨论随着 Rust 程序规模增长,如何以惯用的方式对问题建模和组织解决方案。此外,我们还将讨论 Rust 的惯用模式与你可能熟悉的面向对象编程之间的关系。