《Go 语言高级编程》10.2 结构化并发模型

把结构化并发当模型来讨论:子任务生命周期受父约束的三条契约、取消传播的三个层次、生命周期所有权与「何时不该结构化」的判断标准,并用 goleak 实测非结构化 scatter 泄漏两个 goroutine、无主后台循环泄漏一个,结构化版本零泄漏,最后给出落地检查清单。

本节要回答:结构化并发到底是一套什么契约,为什么 Go 没有把它做进语言,用 errgroup/context 手工实现时要在哪些地方守住边界。与卷二 9.1 的分工:卷二讲 TaskHub 里怎么用 errgroup,本节把「子任务必须活在父任务的作用域里」当作一条可检验的模型来推演。
适用版本:Go 1.27(实测 go1.27.0),golang.org/x/sync v0.24.0、go.uber.org/goleak v1.3.0。

10.2 结构化并发模型

上一节讲的是 errgroup、semaphore 这些 API 的语义。这一节退一步:把这些 API 背后的模型说清楚。结构化并发(structured concurrency)不是一个库,而是一条纪律——它约束「谁能创建 goroutine、谁负责等它结束」。理解这条纪律,比记住 errgroup 的方法名更重要,因为它决定了你排障时该往哪里找泄漏。

10.2.1 三条契约

结构化并发的全部内容可以压成三条契约:

  1. 生命周期包含:父任务返回前,它派生的所有子任务必须已经结束(正常结束或被取消)。
  2. 取消下传:父任务被取消时,取消信号必须能到达每一个子任务。
  3. 错误上抛:子任务的失败必须能到达父任务,而不是被静默吞掉。

把它类比成函数里的局部变量:变量在作用域内创建,作用域结束就该销毁,不会泄漏到外面。goroutine 也应该如此——goroutine 不是「发射后不管」的线程,而是有归属的、会被回收的资源。

sync.WaitGroup 只实现了第 1 条的一半(等它结束),但不等「必须结束」,也不管 2、3。errgroup 把三条补齐,这就是它存在的理由。

10.2.2 反例实测:非结构化 scatter 泄漏

「拿到第一个结果就返回」是最常见的非结构化写法。下面这个函数起了 N 个 goroutine 抢答,谁先返回就返回谁:

func leakyScatter(ctx context.Context, n int) int {
	ch := make(chan int)
	for i := 0; i < n; i++ {
		i := i
		go func() {
			time.Sleep(time.Duration(50*(n-i)) * time.Millisecond)
			ch <- i
		}()
	}
	return <-ch // 拿到第一个就返回,其余 goroutine 仍阻塞在 ch<-
}

ch 是无缓冲的,return <-ch 只取走一个值,其余 goroutine 会永远阻塞在 ch <- i 上——它们既没有 ctx 可以监听,也没有人再读 ch。用 goleak 把它钉在测试里:

func TestLeaky(t *testing.T) {
	defer goleak.VerifyNone(t, goleak.IgnoreTopFunction("testing.tRunner"))
	_ = leakyScatter(context.Background(), 3)
}
$ GOTOOLCHAIN=go1.27.0 go test ./ch10/structured/ -run TestLeaky -v
=== RUN   TestLeaky
leaky 返回
    structured_test.go:50: found unexpected goroutines:
        [Goroutine 36 in state chan send, with probe/ch10/structured.leakyScatter.func1 on top of the stack:
        ...
         Goroutine 37 in state chan send, with probe/ch10/structured.leakyScatter.func1 on top of the stack:
        ...]
--- FAIL: TestLeaky (0.49s)

3 个 goroutine 泄漏了 2 个,状态都是 chan send——卡在发送上。这就是结构化并发缺失的典型症状:goroutine 创建出去了,但没有任何机制保证它们结束。泄漏在单元测试里跑 3 个任务才漏 2 个,在生产里每秒被调用上千次,几分钟就能积累几万个 goroutine,最终 OOM。

10.2.3 正例实测:结构化版本零泄漏

把同一个逻辑改成结构化的:用带缓冲的 channel 保证发送不阻塞,用 errgroup + ctx 保证父函数返回前所有子任务结束:

func structuredScatter(ctx context.Context, n int) int {
	g, ctx := errgroup.WithContext(ctx)
	ch := make(chan int, n) // 缓冲 n,发送不会阻塞
	for i := 0; i < n; i++ {
		i := i
		g.Go(func() error {
			select {
			case <-time.After(time.Duration(50*(n-i)) * time.Millisecond):
				ch <- i
			case <-ctx.Done():
			}
			return nil
		})
	}
	_ = g.Wait() // 等所有子任务结束
	close(ch)
	return <-ch
}
$ GOTOOLCHAIN=go1.27.0 go test ./ch10/structured/ -run TestStructured -v
=== RUN   TestStructured
structured 返回
--- PASS: TestStructured (0.15s)
PASS

同样的 goleak 检查,结构化版本零泄漏通过。 三个改动各对应一条契约:缓冲 channel 消除「发送阻塞」、select ctx.Done() 让取消能下传、g.Wait() 保证父任务返回前子任务全部结束。

注意结构化版本总耗时是 g.Wait() 等最慢的那个,而不是 return <-ch 的最快那个。实测两者的延迟差距:

$ GOTOOLCHAIN=go1.27.0 go run ./ch10/timing
非结构化(拿到第一个即返回): 52ms
结构化(等全部结束): 151ms

52ms 对 151ms,结构化版本慢了近 3 倍——这是结构化并发的代价:为了确定性,你放弃了「最快那个先返回」的收益。注意结构化版本拿到的结果值仍然是最快的那个(缓冲 channel 里最先写入的就是最先完成的任务),它只是不再「拿到就走」,而是等所有落败者收尾后才把结果交出去。如果业务确实不能等,可以保留非结构化的延迟特性,但必须给落败者一个缓冲 channel 或 ctx 来收尾,否则就是 10.2.2 的泄漏。

10.2.4 取消传播的三个层次

取消不是「一个开关」,而是要在三个层次都打通,缺一层就断链:

层次机制断链后果
语法层ctx 作为函数首参一路传递下游拿不到 ctx,取消进不去
阻塞点层每个阻塞操作 select { case <-ctx.Done(): ... }阻塞点不响应取消,任务跑完才退
资源层defer 释放(连接、锁、信号量)取消后资源不释放,二次泄漏

三层里最容易漏的是阻塞点层:ctx 传下去了,但下游代码里是 time.Sleep(d) 或裸 ch <- v,没有 select,取消信号到了也没人听。errgroup 的 ctx 只是把信号送到门口,进不进得去取决于每个阻塞点。这就是 10.2.2 里 ch <- i 泄漏的根本原因——它根本不看 ctx。

一个实用的自查法:列出函数里所有的阻塞操作(channel 收发、锁、IO、Sleep),逐个问「取消发生时它会不会退出」。有一个答「不会」,那里就是潜在的泄漏点。

10.2.5 生命周期所有权

结构化并发背后是一个「所有权」问题:谁创建 goroutine,谁负责等它结束。违反所有权的两种典型:

  • 越权等待:A 创建了 goroutine,却让 B 去 Wait。B 不知道 A 还起过哪些 goroutine,等漏了就是泄漏。
  • 无主 goroutine:在 init()、HTTP handler 里 go func(){...}() 起一个后台循环,没人持有它的取消函数——这是最隐蔽的泄漏,进程不退出它就永远在跑。

正确的所有权模型是:goroutine 的创建点必须和它的等待点/取消点在同一个函数的作用域里。errgroup 之所以好用,就是因为它把创建(g.Go)和等待(g.Wait)绑在同一个 Group 上,而这个 Group 通常就是某个函数里的局部变量——作用域一结束,Wait 就把所有子任务收了尾。

后台常驻任务(如「每秒刷一次缓存」)属于例外,它本来就该活得比函数久。这类任务的处理方式是把它显式交给一个长生命周期的主控对象(如 fx.Lifecycle,见 11.2),而不是用裸 go 偷偷起——让「长生命周期」变成显式契约,而不是意外。

10.2.6 反例实测:无主 goroutine

「无主 goroutine」是所有权缺失里最隐蔽的一种。下面这个函数起了一个后台轮询循环,但没有任何人持有它的取消函数:

func startBackgroundPoller() {
	go func() {
		for {
			time.Sleep(time.Hour) // 永不退出
		}
	}()
}

调用它的人以为「起了个后台任务就完事」,goleak 会立刻拆穿:

$ GOTOOLCHAIN=go1.27.0 go test ./ch10/orphan/ -run TestOrphan -v
        [Goroutine 20 in state sleep, with time.Sleep on top of the stack:
        time.Sleep(0x34630b8a000)
        	.../src/runtime/time.go:368 +0x150
        probe/ch10/orphan.startBackgroundPoller.func1()
        	/tmp/gbadv4/ch10/orphan/orphan_test.go:14 +0x28
        created by probe/ch10/orphan.startBackgroundPoller in goroutine 19
        	/tmp/gbadv4/ch10/orphan/orphan_test.go:12 +0x24
        ]
--- FAIL: TestOrphan (0.45s)

栈顶是 time.Sleep,创建者是 startBackgroundPoller——goleak 直接指出了「谁起的、卡在哪」。修复方式有两种:给循环加 ctx 并在 select 里监听 ctx.Done(),或者(更规范地)把这个后台任务交给主控对象管理其生命周期。关键不是「能不能起后台任务」,而是「起了之后谁负责让它停」。

10.2.7 何时不该结构化

结构化并发不是万能药,有两类场景它反而是错的:

场景是否结构化理由
请求内的并行子任务是请求结束就该全部结束,泄漏即 bug
请求内的「火并」抢答是(但要注意缓冲)抢答的落败者也必须被取消,见 10.2.3
进程级后台 worker(轮询、心跳)否,但要显式托管生命周期比函数长,用主控对象持有取消函数
一次性的 best-effort 上报通常否失败不影响主流程,但要有超时兜底,不能无限阻塞
事件总线/回调里 go否(危险)典型的无主 goroutine,应由订阅者统一托管

判断标准一句话:「这个 goroutine 应该活得比调用它的函数更久吗?」 答「否」,就该结构化;答「是」,就必须把它显式交给一个更长的生命周期持有者,而不是让它无主。

10.2.8 Go 为什么没有内建结构化并发

Java 的虚拟线程(Project Loom)在 JDK 21 把 StructuredTaskScope 做进了标准库,Kotlin 有 coroutineScope,Python 有 asyncio.TaskGroup。Go 至今没有语言级的结构化并发,只有 errgroup 这样的库约定。原因值得理解,因为它决定了你日常要自己做多少约束:

  • goroutine 的设计目标就是「廉价、可随意创建」:Go 从第一天起鼓励「需要并发就 go」,语言层面没有 async/await 的传染性,也就没有一个天然的「并发作用域」语法。
  • 没有生命周期语法:coroutineScope { } 这种块级结构在 Go 里没有对应物,只能靠库的 Wait 模拟。
  • context 是约定不是强制:ctx 传不传、阻塞点听不听,编译器不管。

所以 Go 里的结构化并发是**「库 + 纪律」**,不是「语言保证」。errgroup 提供了机制,但守不守纪律在你。这也解释了为什么 goleak 在 Go 生态里地位特殊——语言不保证的事,只能靠测试来兜(10.3 展开)。

10.2.9 与 WaitGroup、context 的关系

把三者摆在一起看,职责边界很清楚:

原语提供不提供
sync.WaitGroup等待一组 goroutine 结束错误传播、取消、生命周期包含
context.Context取消信号与值的传递通道等待、错误汇总、创建/结束的绑定
errgroup.Group等待 + 首个错误 + 取消下传全部错误的收集、优先级、公平性

errgroup 本质上是 WaitGroup + context 的组合封装:WithContext 派生一个 ctx,Go 内部 Add(1) 并在结束时 Done(),Wait 等所有结束并把首个错误返回。理解了这层「就是这两样的组合」,你就能在需要时自己拼出变体(比如卷二 9.1 里收集全部错误的写法,就是关掉 errgroup 的取消能力、自己用 mutex 聚合)。

10.2.10 落地检查清单

把模型落成可执行的检查项:

  • 每个 go 关键字,都能指出「谁等它结束」。
  • 每个阻塞操作,都能指出「取消发生时它怎么退出」。
  • errgroup/WaitGroup 的 Wait 一定被调用,且返回值被检查。
  • 长生命周期任务显式交给主控对象,不用裸 go。
  • channel 的缓冲容量与「最多几个发送者可能同时阻塞」匹配。
  • 所有 errgroup.WithContext 的派生 ctx 一路传到最底层。
  • 测试里挂 goleak.VerifyTestMain,把泄漏变成构建失败。
  • 抢答(scatter-gather)场景里,落败的 goroutine 有缓冲 channel 或 ctx 收尾。
  • context.WithCancelCause 的 Cause 在需要区分「谁取消的」时被读取,而不是只看 Err()。

小结

  • 结构化并发是三条契约:生命周期包含、取消下传、错误上抛;WaitGroup 只给了其中半条。
  • 非结构化 scatter 实测泄漏 2 个 goroutine(状态 chan send);结构化版本用缓冲 channel + select ctx.Done() + g.Wait() 零泄漏,代价是耗时从 50ms 变成最慢任务的 150ms。
  • 取消要在语法层、阻塞点层、资源层三层都打通,最容易漏的是阻塞点层。
  • Go 没有语言级结构化并发,它是「库 + 纪律」,所以要用 goleak 在测试里兜底。
  • 所有权原则:创建 goroutine 的地方,必须同时是等待/取消它的地方。
  • 无主后台循环实测泄漏 1 个 goroutine(栈顶 time.Sleep);「该不该结构化」的判断标准是「这个 goroutine 该不该活得比调用它的函数久」。

模型讲完了,但「纪律靠人守」终究不可靠——人可以忘、review 可以漏,只有自动检查不会。下一节看 goleak 如何用 runtime.Stack 把「有没有泄漏」变成一条可自动判定的断言。

阅读导航:上一节:10.1 errgroup 与 semaphore(x/sync) · 下一节:10.3 goleak 与泄漏检测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练