结构化并发:正在统一三门语言的一个思想
1968 年,Dijkstra 写了一封信,题目是《Go To Statement Considered Harmful》。他的论点不是「goto 会出 bug」,而是更深刻的一个东西:goto 破坏了「代码的文本结构」和「程序的执行结构」之间的对应关系 —— 你没法再靠读代码来推断它在干什么。五十年后,Nathaniel J. Smith 写了一篇《Notes on structured concurrency, or: Go statement considered harmful》,指出 go f() 犯的是同一个错误。这篇文章的影响力,在过去几年里悄悄重塑了 Kotlin、Python、Java、Swift 的并发 API。而 Go 本身,是唯一还没有跟进的那个。
先回顾一下 goto 的罪状
在 goto 的年代,你看到一个函数,你无法知道它做完之后控制流会去哪 —— 它可能跳到任何地方。
结构化编程(if / while / 函数调用)修好了这一点,靠的是一个简单的约定:
每一个控制结构,都有一个入口和一个出口。
│ 你可以把整个 if/while/函数
▼ 当成一个【黑箱】来推理:
┌───────┐
│ if │ 控制流从上面进去,
│ while │ ← 一个黑箱 从下面出来。
│ f(x) │
└───────┘ 你【不需要】知道里面发生了什么。
│
▼
这就是为什么你能读懂代码。你可以在任何一层停止思考,把下面的细节当成黑箱。
而这正是 go f() 破坏的东西。
go f() 是并发时代的 goto
func doStuff() {
go backgroundWork() // ← 控制流从这里【分叉了】
return // ← 函数返回了。但 backgroundWork 还在跑。
} // 它什么时候结束?没人知道。
// 它出错了会怎样?没人知道。
// 谁能取消它?没人。
doStuff() 不再是一个黑箱了。它「返回」了,但它没有真的结束。于是你在调用它的地方,无法再停止思考 —— 你必须知道它内部启动了什么、那些东西什么时候结束、它们会不会失败。
抽象泄漏了。而这正是 goto 的罪状。
结构化并发:把黑箱规则搬回来
一个并发作用域,在它内部启动的所有任务全部结束之前,不允许退出。
│
▼
┌─────────────────────┐
│ coroutineScope { │
│ ┌──┐ ┌──┐ ┌──┐ │ 里面可以有任意多的并发任务,
│ │T1│ │T2│ │T3│ │ 它们可以并行、可以互相通信。
│ └──┘ └──┘ └──┘ │
│ } │ 但【出口只有一个】:
└─────────────────────┘ 必须等 T1、T2、T3 全部结束(或全被取消)。
│
▼
← 到这一行时,保证:没有任何东西还在后台跑。
就这一条。它带来的连锁反应是巨大的:
- ① 不可能有泄漏。任务跑不到 scope 外面去。
- ② 错误一定会被处理。子任务抛的异常,会在 scope 的出口重新抛出。没有「静静消失的异常」。
- ③ 取消是自动传播的。父被取消 → 所有子被取消。不需要手动传一个 ctx。
- ④
try/finally又能用了。因为 scope 有明确的出口,你可以在那里做清理。 - ⑤ 你可以重新停止思考。调用一个函数,它返回了,那就是真的完了。黑箱规则回来了。
四门语言,同一个思想
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch(url1))
tg.create_task(fetch(url2))
# ← 到这一行时,两个 task 保证都结束了。
coroutineScope {
launch { fetch(url1) }
launch { fetch(url2) }
}
// ← 到这一行时,两个协程保证都结束了。
// JEP 505,JDK 25 里是第 5 次预览
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> fetchUser(id));
var orders = scope.fork(() -> fetchOrders(id));
scope.join(); // 等全部结束
return new Page(user.get(), orders.get());
} // ← try-with-resources 的 close() 保证:所有 fork 出去的都结束了
// Swift 从第一天(2021)就把结构化并发做进了语言 async let user = fetchUser(id) async let orders = fetchOrders(id) return await Page(user, orders) // ← 作用域退出时,自动等待/取消
Trio(Nathaniel J. Smith 的第三方 Python 异步库,2017)提出了 nursery(育婴室)这个概念 —— 「你不能凭空启动一个任务,你必须把它交给一个 nursery,而 nursery 会一直等到所有孩子都长大」。
2018 年,他写了那篇《Go statement considered harmful》,把这个想法上升到了理论高度。
然后:
- Kotlin 在 1.3(2018)的协程正式版里,把它做成了语言的默认行为(
coroutineScope、Job 树)。Kotlin 是第一个把它做成主流语言核心特性的。 - Swift(2021)从第一天就内建了它。
- Python(3.11,2022)把 Trio 的 nursery 抄进了标准库,改名叫
TaskGroup。 - Java(JDK 21+,仍在预览)正在做
StructuredTaskScope。
五年,四门主流语言,从「一个第三方库里的怪想法」变成了「大家的标配」。
这是软件工程史上很少见的一次快速收敛。而收敛这么快的原因很简单:它真的解决了一个所有人都在痛的问题。
Go 呢?
Go 是这个故事里唯一的缺席者,而且这个缺席是结构性的。
| Go 有什么 | 差在哪 | |
|---|---|---|
sync.WaitGroup |
能等。 (Go 1.25 加了 wg.Go(func(){}),省掉 Add/Done 样板。) |
❌ 不管错误 ❌ 不管取消 ❌ 不强制 —— 你可以在旁边写个裸 go f() |
errgroup |
能等 + 能传错误 + 能传取消。已经很接近了。 | ❌ 它是第三方库(golang.org/x/sync)❌ 不强制 —— 编译器不会阻止你在 g.Go() 旁边写 go f() |
context |
能传取消信号 | ❌ 纯靠纪律 —— 你必须手动把 ctx 一路传下去,而且被调方必须自己检查它 ❌ 它不能让你「等」一个 goroutine |
Go 的工具箱其实是齐的:errgroup + context 加起来,功能上约等于 coroutineScope。
但它们是「可选的」。
而结构化并发的全部价值,恰恰来自「不可选」:
- 在 Kotlin 里,你不可能在
coroutineScope里启动一个逃逸的协程 —— 语言不让。 - 在 Go 里,任何人、在任何一行,都可以写一个
go f()。而它看起来完全正常。
「一个五十人的团队、一个跑了五年的代码库」—— 在这个尺度上,「纪律」和「保证」的差别是决定性的。纪律会漏。而漏掉的那一次,就是一次凌晨的 OOM。
这是 Go 为「简洁」付出的最大一笔代价 —— 也是我认为 Go 在并发上唯一一个真正的设计缺陷。
Go 社区在做什么
提案有过(比如给 go 语句加作用域),但都没有被接受。原因也很 Go:加一个新关键字/新语义的成本太高,而 errgroup 已经「够用了」。
务实的做法(如果你写 Go):
// 把「结构化」变成团队的强制规范:
// 1. 【禁止】裸 go 语句出现在业务代码里。
// 所有并发都必须通过 errgroup 或一个内部的 Group 抽象。
// 用 linter 强制:golangci-lint 的自定义规则,或者 forbidigo。
// 2. 每个 Group 必须绑定一个 context。
// 3. CI 里跑 goleak —— 泄漏了就红。
// 4. 自己包一个薄封装,让「正确的做法」成为最省事的做法:
type Group struct { g *errgroup.Group; ctx context.Context }
func NewGroup(ctx context.Context, limit int) (*Group, context.Context) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
return &Group{g, ctx}, ctx
}
// 然后在项目里,只允许用 NewGroup。
一个更深的问题:为什么这个思想这么晚才出现
结构化编程(干掉 goto)是 1968 年的事。为什么结构化并发等到 2018 年?
因为在那之前,并发的「代价」不够明显:
- 线程很贵 → 你不会开几万个 → 泄漏几个线程也就那样(而且线程池会复用它们)。
- 并发单元少 → 你能手工追踪它们的生命周期。
协程改变了这个经济学。当「启动一个并发任务」变得像调用一个函数一样便宜(第 7 章:几百纳秒 + 几十字节)—— 人们就会真的随手启动几万个。
而这时候,「谁来管它们的一生」就从一个可以手工处理的小问题,变成了一个必须由语言解决的结构性问题。
这是一个普遍的规律,不只在并发里:
- 手工 malloc/free 的年代,内存很宝贵,你会小心翼翼地管理它 → 后来有了 GC。
- goto 的年代,跳转很便宜,人们跳得到处都是 → 后来有了结构化编程。
- 协程的年代,并发单元很便宜,人们随手开几万个 → 于是需要结构化并发。
每一次「让某件事变便宜」的抽象,都会创造一个「管理它的泛滥」的新问题。
(顺便:微服务让「拆一个服务」变便宜了,于是有了服务网格和分布式追踪。Serverless 让「开一个函数」变便宜了,于是……你懂的。)
1. 读那篇原文:Nathaniel J. Smith 的《Notes on structured concurrency, or: Go statement considered harmful》(2018)。它是过去十年并发领域最有影响力的一篇文章,而且写得极其清楚。
2. 如果你写 Go:在你的 linter 里禁掉裸 go 语句。然后看看会红多少处。那些就是你的技术债。
3. 如果你写 Kotlin:全局搜 GlobalScope。那是 Kotlin 里的 go f()。
4. 如果你写 Python:全局搜裸的 create_task。同上。
下一章:结构对了,任务不泄漏了。但如果活儿来得比你干得快呢?—— 异步让你可以「先接下来再说」,于是你接下了永远处理不完的活。