4-并发编程

并发编程

译文 · 基于 Asynchronous Programming in Rust

并发编程

原文链接: https://rust-lang.github.io/async-book/part-guide/concurrency.html

本章旨在让你对异步并发如何工作、以及它与基于线程的并发有何不同,有一个高层次的理解。我认为在深入实践之前,先建立良好的心智模型很重要;但如果你属于那种想先看些真实代码的人,不妨先读下一两章,再回来看本章。

我们会先从动机讲起,然后介绍顺序编程、基于线程或进程的编程,以及异步编程。本章最后有一节关于并发与并行的内容。

用户希望计算机能同时做多件事。有时用户希望这些事情同时进行(例如一边听音乐应用,一边在编辑器里打字)。有时同时做多件事更高效(例如在下载大文件的同时,在编辑器里继续工作)。有时多个用户希望同时使用同一台计算机(例如多个客户端连接到服务器)。

举个更底层的例子:音乐程序可能需要在用户与界面(UI)交互的同时继续播放音乐。要「继续播放音乐」,它可能需要从服务器流式传输音乐数据,将数据从一种格式处理成另一种格式,并通过操作系统(OS)把处理后的数据发送到计算机的音频系统。对用户而言,它可能需要根据用户指令向服务器发送和接收数据或命令,可能需要向播放音乐的子系统发送信号(例如用户换曲或暂停时),可能需要更新图形显示(例如高亮按钮或更改曲名),并且在做以上所有事情的同时,必须保持鼠标光标或文本输入的响应性。

同时(或看起来同时)做多件事称为并发。程序(连同 OS)必须管理其并发,方式有很多。本章会描述其中一些,但我们会从纯顺序代码开始,即完全没有并发。

顺序执行

大多数编程语言(包括 Rust)的默认执行模式是顺序执行。

do_a_thing();
println!("hello!");
do_another_thing();

每条语句在下一条开始之前完成1。这些语句之间不会发生任何事2。这听起来可能很琐碎,但对推理代码来说是非常有用的性质。然而,这也意味着我们浪费了大量时间。在上面的例子中,等待 println!("hello!") 完成时,本可以执行 do_another_thing()。或许我们甚至可以同时执行全部三条语句。

每当发生 IO3(用 println! 打印就是 IO——它通过调用 OS 向控制台输出文本),程序会等待 IO 完成4后再执行下一条语句。等待 IO 完成才继续执行会阻塞程序,使其无法取得其他进展。阻塞式 IO 是最容易使用、实现和推理的 IO 类型,但效率也最低——在顺序世界里,程序在等待 IO 完成时什么也做不了。

进程与线程

进程和线程是 OS 提供的用于实现并发的概念。每个可执行文件对应一个进程,因此支持多进程意味着计算机可以并发运行多个程序5;每个进程可以有多个线程,这意味着进程内部也可以有并发。

进程和线程的处理方式有许多细微差别。最重要的区别是:线程之间共享内存,进程之间不共享6。这意味着进程间通信通过某种消息传递进行,类似于在不同计算机上运行的程序之间的通信。从程序角度看,单个进程就是它们的整个世界;创建新进程意味着运行新程序。而创建新线程只是程序常规执行的一部分。

由于进程与线程的这些区别,程序员感受上它们非常不同。但从 OS 角度看它们非常相似,我们会把它们当作单一概念来讨论其性质。我们讨论线程,但除非另有说明,你应理解为「线程或进程」。

OS 负责调度线程,即决定线程何时运行、运行多久。大多数现代计算机有多个核心,因此可以字面意义上同时运行多个线程。然而,线程数通常远多于核心数,因此 OS 会让每个线程运行一小段时间,然后暂停它,再运行另一个线程一段时间7。多个线程以这种方式在单个核心上运行,称为交错或时间片。由于 OS 选择何时暂停线程的执行,这称为抢占式多任务(multitasking 这里仅指同时运行多个线程);OS 抢占线程的执行(更冗长地说,OS 抢占式地暂停执行。之所以是抢占式,是因为 OS 在第一个线程本不会暂停时就暂停它,以便为另一个线程腾出时间,确保第二个线程能在无法执行成为问题之前得以执行)。

再看 IO。当线程阻塞等待 IO 时会发生什么?在有线程的系统中,OS 会暂停该线程(反正它只是在等待),并在 IO 完成时再次唤醒它8。根据调度算法,IO 完成后可能要过一段时间 OS 才唤醒等待 IO 的线程,因为 OS 可能要让其他线程先完成一些工作。因此效率大大提高:一个线程等待 IO 时,另一个线程(更可能是由于多任务而有多个线程)可以取得进展。但从执行 IO 的线程角度看,事情仍是顺序的——它等待 IO 完成后再开始下一项操作。

线程也可以通过调用 sleep 函数(通常带超时)主动暂停自己。此时 OS 应线程自身请求暂停该线程。与因抢占或 IO 而暂停类似,OS 会在稍后(超时后)再次唤醒线程以继续执行。

当 OS 因任何原因暂停一个线程并启动另一个时,称为上下文切换。被切换的上下文包括寄存器、OS 记录以及许多缓存的内容。这是不小的工作量。再加上与 OS 之间控制权的转移以及处理陈旧缓存的成本,上下文切换是昂贵的操作。

最后注意,某些硬件或 OS 不支持进程或线程,这在嵌入式领域更常见。

异步编程

异步编程是一种并发形式,与基于线程的并发有相同的高层目标(同时做多件事),但实现不同。异步并发与基于线程的并发之间两大区别是:异步并发完全在程序内部管理,不依赖 OS 帮助9;多任务是协作式而非抢占式的10(我们马上解释)。异步并发模型有很多,本指南后面会对比,但现在我们只关注 Rust 的模型。

为与线程区分,我们把异步并发中的一段执行序列称为任务(它们也叫绿色线程,但有时带有抢占式调度和每任务一个栈等实现细节的意味)。任务的执行、调度和内存表示方式与线程非常不同,但在高层直觉上,把任务想成类似线程、但完全在程序内部管理而非由 OS 管理,是有用的。

在异步系统中仍有调度器决定下一个运行哪个任务(它是程序的一部分,不是 OS 的一部分)。然而,调度器不能抢占任务。任务必须自愿交出控制权,让另一个任务被调度。因为任务必须协作(通过交出控制权),这称为协作式多任务。

使用协作式而非抢占式多任务有许多影响:

  • 在可能让出控制权的点之间,你可以保证代码会顺序执行——你不会被意外暂停,
  • 如果任务在让出点之间耗时很长(例如做阻塞 IO 或长时间计算),其他任务将无法取得进展,
  • 实现调度器简单得多,调度(和上下文切换)开销更小。

异步并发比基于线程的并发高效得多。内存开销低得多,上下文切换也便宜得多——不需要把控制权交给 OS 再交回程序,需要切换的数据也少得多。但仍可能有缓存效应——虽然 OS 的缓存(如 TLB)不必更改,任务很可能操作内存的不同部分,因此新调度任务所需的数据可能不在内存缓存中。

异步 IO 是阻塞 IO 的替代(有时称为非阻塞 IO)。异步 IO 与异步并发没有直接绑定,但两者常一起使用。在异步 IO 中,程序用一次系统调用发起 IO,然后可以检查或在 IO 完成时被通知。这意味着程序在 IO 进行时可以自由做其他工作。在 Rust 中,异步 IO 的机制由异步运行时处理(调度器也是运行时的一部分,本书后面会更详述运行时,但本质上运行时只是一个负责部分基础异步工作的库)。

从整个系统角度看,基于线程的并发系统中的阻塞 IO,与异步并发系统中的非阻塞 IO 是相似的。两种情况下 IO 都需要时间,而在 IO 进行时其他工作得以完成:

  • 使用线程时,执行 IO 的线程向 OS 请求 IO,OS 暂停该线程,其他线程完成工作,IO 完成后 OS 唤醒该线程,使其能以 IO 结果继续执行。
  • 使用异步时,执行 IO 的任务向运行时请求 IO,运行时向 OS 请求 IO,但 OS 将控制权返回给运行时。运行时暂停 IO 任务并调度其他任务完成工作。IO 完成后,运行时唤醒 IO 任务,使其能以 IO 结果继续执行。

使用异步 IO 的优势是开销低得多,系统可以支持的 task 数量比线程多几个数量级。这使异步并发特别适合有大量用户、且大量时间花在等待 IO 上的任务(若不怎么等待而是做大量 CPU 密集型工作,低开销的优势就不那么明显,因为瓶颈会是 CPU 和内存资源)。

线程与异步并不互斥:许多程序两者都用。有些程序的部分更适合用线程实现,部分更适合用异步实现。例如,数据库服务器可能用异步技术管理与客户端的网络通信,但用 OS 线程处理数据计算。或者,程序可能只使用异步并发编写,但运行时会在多个线程上执行任务。这对程序利用多个 CPU 核心是必需的。本书后面会在多处讨论线程与异步任务的交集。

并发与并行

到目前为止我们讨论的是并发(同时做或看起来同时做多件事),并暗示了并行(多个 CPU 核心的存在使字面意义上同时做多件事成为可能)。这两个术语有时混用,但它们是不同概念。本节我们尝试精确定义这些术语及其区别。我会用简单的伪代码说明。

想象一个任务被拆成许多子任务:

task1 {
  subTask1-1()
  subTask1-2()
  ...
  subTask1-100()
}

假设我们是执行此类伪代码的处理器。显然的做法是先做 subTask1-1,再做 subTask1-2,依此类推直到完成所有子任务。这是顺序执行。

现在考虑多个任务。我们如何执行它们?可以启动一个任务,做完所有子任务直到整个任务完成,再开始下一个。两个任务是顺序执行的(每个任务内的子任务也是顺序执行的)。只看子任务,执行顺序如下:

subTask1-1()
subTask1-2()
...
subTask1-100()
subTask2-1()
subTask2-2()
...
subTask2-100()

或者,可以做 subTask1,然后把 task1 放到一边(记住进度),拿起下一个任务做它的第一个子任务,再回到 task1 做一个子任务。两个任务会交错执行,我们称之为两个任务的并发执行。可能像这样:

subTask1-1()
subTask2-1()
subTask1-2()
subTask2-2()
...
subTask1-100()
subTask2-100()

除非一个任务能观察到另一个任务的结果或副作用,否则从任务角度看,子任务仍是顺序执行的。

我们不必限于两个任务,可以交错任意数量,以任意顺序。

注意,无论加多少并发,整个工作完成所需总时间相同(实际上因上下文切换开销,并发更多可能更慢)。但对某个子任务,我们可能比纯顺序执行更早完成(对用户来说可能感觉更响应)。

现在想象不只是你一个人在处理任务,还有处理器朋友帮忙。你们可以同时处理任务,更快完成工作!这是并行执行(也是并发的)。子任务可能这样执行:

Processor 1           Processor 2
==============        ==============
subTask1-1()          subTask2-1()
subTask1-2()          subTask2-2()
...                   ...
subTask1-100()        subTask2-100()

若有超过两个处理器,可以并行处理更多任务。也可以在每个处理器上对任务做交错,或在处理器间共享任务。

在真实代码中事情更复杂。有些子任务(如 IO)不需要处理器主动参与,只需启动,稍后收集结果。有些子任务可能需要另一个任务中某子任务的结果(或副作用)才能继续(同步)。这两种情况都限制了任务可有效并发执行的方式,再加上要保证某种公平性,这就是调度重要的原因。

够了这些简单例子,来正确定义一下

并发关乎计算的顺序,并行关乎执行模式。

给定两个计算,若可观察到其中一个在另一个之前发生,则称它们是顺序的(即非并发);若无法观察到(或 alternatively,顺序无关紧要)其中一个在另一个之前发生,则称它们是并发的。

两个计算并行发生,是指它们字面意义上同时发生。可以把并行看作一种资源:可用并行越多,在固定时间内可发生的计算越多(假设计算速度相同)。在不增加并行的情况下增加系统并发,永远无法使其更快(虽然可以使系统更响应,且可能使原本不现实的优化变得可行)。

重申:两个计算可能一个接一个发生(既非并发也非并行),可能在单个 CPU 核心上交错执行(并发但不并行),或在两个核心上同时执行(既并发又并行)11。

另一个有用的框架12是:并发是组织代码的方式,并行是一种资源。这是有力的表述!并发关乎组织代码而非执行代码很重要,因为从处理器角度看,没有并行的并发根本不存在。这对异步并发尤其相关,因为它完全在用户态代码中实现——它不仅「只是」组织代码,你读源码就能轻易证明。并行是资源也有用,因为它提醒我们:论并行与性能,重要的是处理器核心数量,而非代码如何按并发组织(例如有多少线程)。

基于线程和异步的系统都可以提供并发和并行。两种情况下,并发由代码控制(生成线程或任务),并行由调度器控制——对线程是 OS 的一部分(通过 OS API 配置),对异步是运行时库的一部分(通过运行时选择、实现方式以及运行时提供给客户端代码的选项配置)。然而,由于惯例和常见默认值,存在实际差异。在线程系统中,每个并发线程默认尽可能并行执行。在异步系统中没有强默认值:系统可能所有任务在单线程运行,可能把多个任务分给单线程并锁定到核心(因此任务组并行执行,但组内每个任务并发执行、组内任务间永不并行),或任务可有限制或无限地并行运行。本指南前半部分使用 Tokio 运行时,主要支持最后一种模型,即并行方面的行为与基于线程的并发类似。此外,我们会看到 async Rust 中有明确支持并发但不支持并行的特性,与运行时无关。

小结

  • 执行模型有很多。我们描述了顺序执行、线程与进程,以及异步编程。
    • 线程是 OS 提供(并调度)的抽象。通常涉及抢占式多任务,默认并行,管理和上下文切换开销较高。
    • 异步编程由用户态运行时管理。多任务是协作式的。开销比线程低,但编程感受与线程不同,因为使用不同的编程原语(async 和 await、future,而非一等线程)。
  • 并发与并行是不同但密切相关的概念。
    • 并发关乎计算的顺序(若无法观察到执行顺序,则操作是并发的)。
    • 并行关乎在多个处理器上计算(若字面意义上同时发生,则操作是并行的)。
  • OS 线程和异步编程都提供并发和并行;异步编程还可提供灵活或细粒度并发的构造,而大多数 OS 线程 API 并不包含。

  1. 这并不完全正确:现代编译器和 CPU 会重排你的代码,按它们喜欢的任意顺序运行。顺序语句很可能以多种方式重叠。然而,这对程序本身或其用户来说永远不应是可观察到的。 ↩︎

  2. 这也不完全正确:即使一个程序是纯顺序的,其他程序也可能同时运行;下一节会详述。 ↩︎

  3. IO 是 input/output(输入/输出)的缩写。指程序与程序外部世界之间的任何通信。可能是读写磁盘或网络、写入终端、从键盘或鼠标获取用户输入,或与 OS 或系统中运行的其他程序通信。在并发语境下 IO 很有趣,因为它比程序内部几乎任何任务都要慢几个数量级。这通常意味着大量等待,而等待时间是做其他工作的机会。 ↩︎

  4. 从程序角度看,IO 何时完成其实相当复杂。对程序而言,单次 IO 调用在从 OS 返回控制权时即告完成。这通常表示数据已发送到某硬件或其他程序,但不一定意味着数据已真正写入磁盘或显示给用户等。硬件可能还需进一步工作,或需要定期刷新缓存,或需要其他程序读取数据。多数情况下我们不必为此担心,但有所了解是好的。 ↩︎

  5. 从用户角度看,单个程序可能包含多个进程,但从 OS 角度看每个进程是独立的程序。 ↩︎

  6. 某些 OS 支持进程间共享内存,但使用它需要特殊处理,且大多数内存并不共享。 ↩︎

  7. OS 究竟选择哪个线程运行、运行多久(以及在哪个核心上),是调度的关键部分。选项很多,既有高层策略,也有配置这些策略的选项。在此做出好选择对性能至关重要,但很复杂,此处不深入。 ↩︎

  8. 还有另一种选择:线程可以忙等,即循环自旋直到 IO 完成。这效率不高,因为其他线程无法运行,在现代大多数系统中不常见。你可能在锁的实现或非常简单的嵌入式系统中遇到它。 ↩︎

  9. 我们的解释先假设程序只有一个线程,后面会扩展。系统上可能还有其他进程在运行,但它们并不真正影响异步并发如何工作。 ↩︎

  10. 有些编程语言(甚至库)有在程序内部管理(不依赖 OS)、但使用抢占式调度器而非依赖线程间协作的并发。Go 是著名例子。这些系统不需要 async 和 await 记号,但有其他缺点,包括与其他语言或 OS 互操作困难得多,以及重量级运行时。Rust 非常早期的版本有过这样的系统,但 1.0 时已无痕迹。 ↩︎

  11. 计算能否并行但不并发?某种程度上可以,但不太算。想象两个任务(a 和 b),各有一个子任务(1 属于 a,2 属于 b)。通过同步,子任务 1 完成前不能启动子任务 2,任务 a 必须等子任务 2 完成才算完成。现在 a 和 b 在不同处理器上运行。若把任务当黑盒,可以说它们在并行运行,但某种意义上它们不并发,因为顺序完全确定。然而若看子任务,它们既非并行也非并发。 ↩︎

  12. 我认为这归功于 Aaron Turon,并反映在 Rust 标准库的一些设计中,例如 available_parallelism 函数。 ↩︎

最后修改 August 23, 2026: 更新 (499855b16)