幕 IV · 深水区CH 20进度 20/25

结构化并发:正在统一三门语言的一个思想

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 considered harmfulnursery黑箱规则Trio三语收敛

先回顾一下 goto 的罪状

goto 的年代,你看到一个函数,你无法知道它做完之后控制流会去哪 —— 它可能跳到任何地方。

结构化编程(if / while / 函数调用)修好了这一点,靠的是一个简单的约定:

◆ 定论 · 「黑箱规则」(The Black Box Rule)

每一个控制结构,都有一个入口和一个出口。

    │                    你可以把整个 if/while/函数
    ▼                    当成一个【黑箱】来推理:
┌───────┐
│  if   │                  控制流从上面进去,
│ while │  ← 一个黑箱        从下面出来。
│ f(x)  │
└───────┘                  你【不需要】知道里面发生了什么。
    │
    ▼

这就是为什么你能读懂代码。你可以在任何一层停止思考,把下面的细节当成黑箱。

而这正是 go f() 破坏的东西。

go f() 是并发时代的 goto

func doStuff() {
    go backgroundWork()      // ← 控制流从这里【分叉了】
    return                   // ← 函数返回了。但 backgroundWork 还在跑。
}                            //    它什么时候结束?没人知道。
                             //    它出错了会怎样?没人知道。
                             //    谁能取消它?没人。
黑箱规则被打破了控制流跳出去,再也不回来
调用方 调 doStuff() 它返回了 继续往下…(以为一切都结束了)
那个 goroutine 还在跑。还在用内存。还可能 panic。还可能写数据库。
后果 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 缺的不是工具,是「强制」

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同上。

下一章:结构对了,任务不泄漏了。但如果活儿来得比你干得快呢?—— 异步让你可以「先接下来再说」,于是你接下了永远处理不完的活。