18-结构化并发

结构化并发

译文 · 基于 Asynchronous Programming in Rust

结构化并发

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

作者注(TODO):我们可能希望在本章之前更早讨论其中某些部分,尤其是作为设计原则(首次介绍在 guide/intro 中)。不过,为了更好地理解该主题并先写出一版内容,我从独立一章开始。内容也仍有些粗糙。

(注:前几节讨论的是结构化并发的抽象概念,并不特定于 Rust 或 async 编程(cf. 使用线程的同步并发编程)。我用「任务」指代任何线程、async 任务或其他类似的并发原语。)

结构化并发是一种设计并发程序的哲学。要让程序完全遵循结构化并发的原则,需要特定的语言特性和库,但即便没有这些特性,遵循该哲学也能获得许多好处。结构化并发与语言及并发原语(线程 vs async 等)无关。许多人在使用 async Rust 编程时发现结构化并发的思想很有用。

结构化并发的核心思想是:任务被组织成一棵树。子任务在父任务之后启动,并总是在父任务之前结束。这使得结果和错误总能传回父任务,并要求父任务的取消总是传播到子任务。主要而言,时间作用域跟随词法作用域,即任务不应比创建它的函数或代码块存活更久。不过,只要更长寿的任务在程序中以某种方式被具象化(通常通过使用对象来表示子任务在其父任务中的时间作用域),这就不是结构化并发的硬性要求。

TODO 图示

结构化并发这一名称类比于结构化编程——控制流应使用函数、循环等结构,而非任意跳转(goto)。

在考虑结构化并发之前,先反思一下常见并发设计在何种意义上是「非结构化」的,会很有帮助。典型模式是:用某种 spawn 语句启动一个任务,该任务随后与系统中的其他任务(包括 spawn 它的任务)并发运行直至完成。哪个任务先结束没有约束。程序本质上只是一袋彼此独立、可能随时终止的任务。任务之间的通信或同步是临时凑合的,程序员不能假定任何其他任务仍在运行。

非结构化并发的实际弊端在于:从任务返回结果必须以语言之外的方式完成,语言层面没有对何时或如何发生提供保证。错误可能未被捕获,因为语言的错误处理机制无法应用于非结构化并发中不受约束的控制流。我们也无法保证任务之间的相对状态——任何任务可能正在运行、成功终止、出错终止,或被外部取消,与任何其他任务的状态无关1。这一切使得并发程序难以理解和维护。缺乏结构是并发编程被认为比顺序编程难一个量级的原因之一。

值得注意的是,结构化并发是一种对你的程序施加限制的编程纪律。就像函数和循环比 goto 灵活性更低一样,结构化并发也比随意 spawn 任务灵活性更低。然而,与结构化编程一样,结构化并发在灵活性上的代价,会被可预测性上的收益所抵消。

结构化并发的原则

结构化并发的关键思想是:所有任务(或线程或其他什么)被组织成一棵树。也就是说,每个任务(除作为根的主任务外)有且仅有一个父任务,且不存在父任务的环。子任务由其父任务启动2,并且必须总是在其父任务之前完成执行。兄弟任务之间没有约束。任务的父任务不能改变。

在推理实现了结构化并发的程序时,关键的新事实是:如果一个任务是存活的,那么它的所有祖先任务也必须是存活的。这并不保证它们处于良好状态——它们可能正在关闭或处理错误的过程中——但它们必须以某种形式在运行。这意味着对任何任务(根任务除外),总有一个存活任务可以接收结果或错误。事实上,理想做法是将语言的错误处理扩展为错误总是传播到父任务。在 Rust 中,这应同时适用于返回 Result::Err 和 panic。

此外,子任务的生存期可以在父任务中表示。在常见情况下,任务的生存期(其时间作用域)与启动它的词法作用域绑定。例如,在函数内启动的所有任务应在函数返回之前完成。这是极其强大的推理工具。当然,这对所有情况都过于严格,因此任务的时间作用域可以通过程序中的对象(常称为「scope」或「nursery」)延伸到词法作用域之外。这样的对象可以被传递或存储,从而拥有任意生存期。我们仍有一个重要的推理工具:与该对象绑定的任务不能比它活得更久(在 Rust 中,这一性质让我们能将任务与生存期系统集成)。

上述内容带来结构化并发的另一项好处:它让我们能推理跨多个任务的资源管理。清理代码在资源不再被使用时调用(例如关闭文件句柄)。在顺序代码中,何时调用清理代码的问题通过确保对象离开作用域时调用析构函数来解决。然而在并发代码中,对象可能仍被另一任务使用,因此何时清理并不明确(引用计数或垃圾收集在许多情况下是解决方案,但会使对象生存期的推理变难并可能导致错误,也有运行时开销)。

父任务比其子任务活得久的原则,对取消有重要含义:如果一个任务被取消,则其所有子任务必须被取消,且它们的取消必须在父任务的取消完成之前完成。这反过来对如何在结构化并发系统中实现取消有影响。

如果任务因错误提前完成(在 Rust 中,这可能意味着 panic,以及提前 return),则在返回之前该任务必须等待其所有子任务完成。实践中,提前 return 必须触发子任务的取消。这类比于 Rust 中的 panic:panic 会在沿栈向上行走、在每个作用域调用析构函数直至程序终止或 panic 被捕获之前,先触发当前作用域中的析构函数。在结构化并发下,提前 return 必须触发子任务的取消(从而清理那些任务中的对象),并沿任务树向下取消所有(传递的)子任务。

有些设计在结构化并发下非常自然(例如完成单一工作的 worker 任务),而另一些则不太契合。通常这些模式的特点是不绑定到特定任务是一种特性,例如 worker 池或后台线程。即便使用这些模式,任务通常也不应比整个程序活得更久,因此总有一个任务可以作为父任务。

实现结构化并发

结构化并发的典范实现是 Python 的 Trio 库。Trio 是围绕结构化并发概念设计的、用于 async 编程和 IO 的通用库。Trio 程序使用 async with 构造为 spawn 任务定义词法作用域。被 spawn 的任务与一个 nursery 对象关联(有些类似 Rust 中的 Scope)。任务的生存期与其 nursery 的动态时间作用域绑定,在常见情况下,与 async with 块的词法作用域绑定。这强制执行任务之间的父子关系,从而强制执行结构化并发的树不变量。

错误处理使用 Python 异常,异常会自动传播到父任务。

部分结构化并发

与许多编程技术一样,结构化并发的全部好处来自仅使用它。如果所有并发都是结构化的,推理整个程序的行为会容易得多。然而,这对语言有不易满足的要求;例如在 Rust 中很容易做非结构化并发。不过,即便有选择地应用结构化并发的原则,或以结构化并发的术语思考,也可能有用。

可以将结构化并发作为一种设计纪律。在设计程序时,始终考虑并记录任务之间的父子关系,确保子任务在其父任务之前终止。这在正常执行下通常相当容易,但在面对取消和 panic 时可能很困难。

结构化并发中较容易采纳的另一要素是始终将错误传播到父任务。就像常规错误处理一样,最好的办法可能是忽略错误,但这应在父任务的代码中显式体现。

从结构化并发中学到的另一编程纪律是:在取消父任务时取消所有子任务。这使结构化并发的保证更可靠,也使取消总体上更容易推理。

在 async Rust 中实践结构化并发

Rust 中的并发(无论 async 还是使用线程)本质上是非结构化的。任务可以被任意 spawn,其他任务上的错误和 panic 可以被忽略,取消通常是瞬时的且不会传播到其他任务(见下文为何这些问题不易解决)。不过,有几种方式可以在程序中获得结构化并发的部分好处:

  • 在高层设计上让程序符合结构化并发。
  • 尽可能坚持结构化并发惯用法(并避免非结构化惯用法)。
  • 使用 crate 使结构化并发更易用、更可靠。

在 Rust 中使用结构化并发最棘手的问题之一,是将取消传播到子 future/任务。如果你在使用 future 并并发组合它们,这在自然发生的同时也有些突兀(drop 一个 future 会 drop 它拥有的任何 future,从而取消它们)。然而,当一个任务被 drop 时,没有机会向它已 spawn 的任务发送信号(至少用 Tokio 不行3)。

其含义是:你只能假定比「真正的」结构化并发更弱的不变量:与其能假定父任务总是存活的,你只能假定父任务总是存活的,除非它已被取消或已 panic。虽然这并非最优,但仍能简化编程,因为在正常执行下你永远不必处理没有父任务来处理某个结果的情况。

TODO

  • 所有权/生存期自然导向结构化并发
  • 推理资源

将结构化并发应用于 async 程序设计

在设计程序方面,应用结构化并发有几项含义:

  • 以树形结构组织程序的并发,即以父子任务的术语思考。
  • 时间作用域应尽可能跟随词法作用域,具体而言,函数不应在函数内启动的任何任务完成之前返回(包括提前 return 和 panic)。
  • 数据通常从子任务流向父任务。当然,一些数据会从父流向子或以其他方式流动,但主要而言,任务将其工作结果传给父任务以进一步处理。这包括错误,因此父任务应处理子任务的错误。

如果你在写库并希望使用结构化并发(或希望库可用于结构化并发的程序),重要的是库组件的封装应包含时间封装。也就是说,它不应启动在 API 函数返回后仍继续运行的任务。

由于 Rust 无法强制执行结构化并发的规则,重要的是要清楚并记录程序(或组件)在哪些方面是结构化的,以及在哪些地方违反了结构化并发纪律。

一种有用的折中模式是:仅在最高抽象层允许非结构化并发,且仅允许从主任务的最外层函数 spawn 任务(理想情况下仅从 main 函数,但程序常有若干设置或配置代码,使得程序的逻辑「顶层」实际上在若干层函数深处)。在此模式下,从 main spawn 一批任务,通常职责分明且彼此交互有限。这些任务可能被重启、被任何其他任务启动新任务,或与客户端等绑定的有限生存期——即它们是非结构化并发的。在每个这样的任务内部,则严格应用结构化并发。

TODO 为何有用?

TODO 若能有一个案例研究会很好。

结构化与非结构化惯用法

本节涵盖一批与结构化并发方法配合良好的惯用法,以及若干使结构化并发更困难的惯用法。

遵循结构化并发最简单的方式是使用 future 和并发组合,而非任务和 spawn。如果你需要任务来实现并行,则需要使用 JoinHandle 或 JoinSet。你必须注意:如果父任务 panic 或被取消,子任务能否正确清理。必须检查 handle 上的错误,以确保子任务中的错误被正确处理。

绕过缺乏取消传播的一种做法是:避免突兀地取消(drop)可能有子任务的任务。改用信号(例如 cancellation token),让任务在终止前取消其子女。不幸的是这与 select 不兼容。

要处理程序(或组件)的关闭,使用显式的 shutdown 方法而非 drop 组件,以便 shutdown 函数能等待子任务终止或取消它们(因为 drop 不能是 async)。

有几种惯用法与结构化并发不太合拍:

  • spawn 任务却不通过 join handle 等待其完成,或 drop 那些 join handle。
  • Select 或 race 宏/函数。它们本身并非非结构化,但由于会突兀地取消 future,是常见的非结构化取消来源。
  • Worker 任务或池。对 async 任务而言,启动/关闭任务的开销如此之低,使用任务池而非「数据」池(例如连接池)的收益可能很小。
  • 没有清晰所有权结构的数据——这未必与结构化并发矛盾,但常导致设计问题。

用于结构化并发的 crate

TODO

相关主题

要了解如何在 async Rust 中使用结构化并发,本节并非必读,但作为好奇者的背景知识很有用。

作用域线程(scoped threads)

Rust 线程上的结构化并发效果相当不错。虽然无法阻止 spawn 无作用域生存期的线程,但这很容易避免。相反,限制自己只使用作用域线程,参见 scope 函数文档。使用作用域线程限制子线程生存期,并自动将 panic 传播回父线程。父线程必须检查子线程的结果以处理错误。你甚至可以像 Trio nursery 一样传递 Scope 对象。取消对 Rust 线程通常不是问题,但如果你确实使用线程取消,需要手动将其与作用域线程集成。

Rust 特有的是,作用域线程允许子线程从父线程借用数据,这在并发非结构化的线程中不可能。这非常有用,展示了结构化并发与 Rust 所有权式资源管理能如何良好配合。

Async drop 与作用域任务

在 Rust 中,析构函数(drop)用于确保对象生存期结束时资源被清理。由于 future 只是对象,其析构函数本应是确保子 future 被取消的明显位置。然而在 async 程序中,清理操作常常是异步的往往非常可取(不这样做可能阻塞其他任务)。不幸的是 Rust 目前不支持异步析构函数(async drop)。支持它们的工作正在进行,但由于多种原因很困难,包括:带 async 析构函数的对象可能从非 async 上下文被 drop,以及由于调用 drop 是隐式的,没有地方写显式的 await。

鉴于作用域线程多么有用(无论一般意义上还是对结构化并发),另一个好问题是:为何没有类似的 async 编程构造(「作用域任务」)?TODO 回答这个问题

参考资料

如果你感兴趣,以下是一些供进一步阅读的好博文:


  1. 使用 join handle 能在一定程度上缓解这些弊端,但它是一种临时凑合的机制,没有可靠保证。要获得结构化并发的全部好处,你必须一丝不苟地始终使用它们,并正确处理取消和错误。没有语言或库的支持,这很难做到;下文会再讨论一些。 ↩︎

  2. 这实际上不是结构化并发的硬性要求。如果任务的时间作用域可以在程序中表示并在任务之间传递,则子任务可以由一个任务启动,但以另一个任务作为其父任务。 ↩︎

  3. Tokio 的 JoinHandle 的语义是:如果 handle 被 drop,则底层任务被「释放」(cf. 被 drop),即子任务的结果不由任何其他任务处理。 ↩︎

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