《Go 语言高级编程》10.1 errgroup 与 semaphore(x/sync)

卷二 9 章讲过 errgroup 的工程用法,本节只讲模型与语义:WithContext 的取消契约、context.Cause 如何保留首个错误、Wait 的返回语义、SetLimit 的阻塞背压,以及 semaphore.Weighted 的加权配额、TryAcquire 与 FIFO 公平性的本机实测。

本节要回答:errgroup 的取消为什么这样设计、Wait 到底返回什么、semaphore 与 SetLimit 的边界在哪。与卷二 9.1 的分工:卷二讲「工程上怎么把并发写对」(TaskHub 的用法),本节讲「这些 API 的语义契约与边界」,不重复用法。
适用版本:Go 1.27(实测 go1.27.0),golang.org/x/sync v0.24.0。

10.1 errgroup 与 semaphore(x/sync)

卷二 9.1 已经用 errgroup 把 TaskHub 的并发收进了结构化边界,那一节解决的是「怎么写」。本节换个角度:errgroup 的取消是怎么实现的、Wait 的返回值语义有哪些反直觉的地方、SetLimit 与 semaphore 到底管的是不是同一件事。这些是选型与排障时真正决定成败的细节。

10.1.1 取消契约:WithCancelCause 而不是 WithCancel

errgroup.WithContext 返回的派生 ctx 会在首个任务返回错误或 Wait 返回时被取消。它内部用的不是 context.WithCancel,而是 context.WithCancelCause:

// golang.org/x/sync/errgroup 源码(v0.24.0)
func WithContext(ctx context.Context) (*Group, context.Context) {
	ctx, cancel := context.WithCancelCause(ctx)
	return &Group{cancel: cancel}, ctx
}

这个选择有实际后果:普通 ctx.Err() 只能告诉你「被取消了」,而 context.Cause(ctx) 能拿回那个导致取消的原始错误。实测:

$ GOTOOLCHAIN=go1.27.0 go run ch10/cause.go
ctx.Err()      = context canceled
context.Cause  = boom
errors.Is(Cause, sentinel) = true

ctx.Err() 是笼统的 context canceled,而 context.Cause 精确地给出了 boom,并且 errors.Is 能匹配到原始哨兵错误。这意味着下游任务在 <-ctx.Done() 醒来后,可以用 context.Cause(ctx) 判断「是不是我的兄弟任务失败了、失败原因是什么」,从而决定是重试还是放弃。

context.WithCancelCause 与 context.Cause 都是 Go 1.20 引入的(证据 A):

grep -rh "WithCancelCause" /usr/local/go/api/go1.*.txt
# -> pkg context, func WithCancelCause(Context) (Context, CancelCauseFunc) #51365
#    命中 go1.20.txt

10.1.2 实测:首个错误取消其余

5 个任务,第 2 个立刻返回错误,其余 4 个正在 select 上等待 200ms——它们会被派生 ctx 立刻唤醒:

g, ctx := errgroup.WithContext(context.Background())
for i := 1; i <= 5; i++ {
	i := i
	g.Go(func() error {
		if i == 2 {
			return fmt.Errorf("task %d failed", i)
		}
		select {
		case <-time.After(200 * time.Millisecond):
			return nil
		case <-ctx.Done():
			return ctx.Err()
		}
	})
}
err := g.Wait()
$ GOTOOLCHAIN=go1.27.0 go run ./ch10
Wait 返回: task 2 failed

整个调用没有等满 200ms,因为第 2 个任务一失败,g.cancel(err) 就被调用,其余任务在 ctx.Done() 上立刻返回。这就是结构化并发的取消下传:父任务一失败,子任务不必跑完。

10.1.3 Wait 的返回语义(三个反直觉点)

Wait 的语义比多数人以为的窄,有三个点必须记牢:

问题答案原因
多个任务都失败,Wait 返回哪个?第一个非 nil 错误内部用 sync.Once 记录,后续错误被丢弃
所有任务都成功,但外部取消了父 ctx,Wait 返回什么?nil没有任何任务返回错误
任务返回错误后,派生 ctx 还会被取消吗?会,且在 Wait 返回时再取消一次Wait 内部也调用 g.cancel(g.err)

第二点最容易踩坑:Wait 返回 nil 不等于派生 ctx 没被取消。实测这个反直觉场景——外部取消父 ctx,子任务醒来后故意吞掉取消错误返回 nil:

ctx, cancel := context.WithCancel(context.Background())
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
	<-gctx.Done() // 等取消
	return nil    // 故意吞掉取消错误
})
cancel() // 外部取消父 ctx
err := g.Wait()
$ GOTOOLCHAIN=go1.27.0 go run ch10/waitsem.go
所有任务返回 nil, Wait 返回: <nil>
派生 ctx.Err(): context canceled

Wait 返回 <nil>,但 gctx.Err() 是 context canceled——两者不矛盾,因为 Wait 只汇总任务的返回值,不汇总 ctx 的状态。所以判断整体是否成功,不能只看 Wait,还要确认任务是否把 ctx.Err() 上抛。若子任务遵守约定返回 ctx.Err(),Wait 就会返回 context canceled;若像上面这样吞掉,Wait 就返回 nil,调用方会误以为成功。

第三点是设计上的对称:Wait 无论成功失败都会取消派生 ctx,避免 Wait 返回后仍有人拿着 ctx 继续起任务——这是「生命周期受父约束」的强制实现。

10.1.4 x/sync 三个包的 API 面与版本

本章用到的 golang.org/x/sync 子包,导出面都很小,值得一次看清(go doc 实测于 v0.24.0):

包导出符号用途
errgroupGroup、WithContext、Go、TryGo、SetLimit、Wait带错误传播与取消的 WaitGroup
semaphoreWeighted、NewWeighted、Acquire、TryAcquire、Release加权信号量
singleflightGroup、Do、DoChan、Forget同 key 请求合并(去重)
GOTOOLCHAIN=go1.27.0 go doc golang.org/x/sync/errgroup.Group
# func WithContext(ctx context.Context) (*Group, context.Context)
# func (g *Group) Go(f func() error)
# func (g *Group) SetLimit(n int)
# func (g *Group) TryGo(f func() error) bool
# func (g *Group) Wait() error

Group 的零值可用(var g errgroup.Group),此时无并发上限、且不取消——只有 WithContext 返回的 Group 才带取消能力。这是一个容易忽略的语义分叉:new(errgroup.Group) 不会取消任何东西。

singleflight 的语义值得单独说一句:它把「同一时刻、同一 key 的多次调用」合并成一次真实调用,其余调用者共享同一个结果。这解决的是惊群问题(缓存击穿时 N 个请求同时回源),与 semaphore 限并发是正交的两个维度。

10.1.5 SetLimit 的语义:活跃 goroutine 上限,不是速率限制

SetLimit(n) 限制的是本组同时活跃的 goroutine 数,不是每秒调用次数。达到上限时 g.Go 阻塞,形成背压:生产速度自动降到消费速度。实测峰值并发:

g := new(errgroup.Group)
g.SetLimit(3)
for i := 0; i < 20; i++ {
	g.Go(func() error { /* 记录并发峰值 */ return nil })
}
_ = g.Wait()
$ GOTOOLCHAIN=go1.27.0 go run ./ch10
SetLimit(3) 实测峰值并发 = 3

20 个任务、上限 3,实测峰值正好 3。注意 SetLimit 的约束是每个 Group 独立的:两个 Group 各 SetLimit(3),对同一个下游的并发合计是 6。要限制「对某个资源的全局并发」,得让多个 Group 共享一个 semaphore。

TryGo 是 Go 的非阻塞版本:有空槽返回 true 并启动,满了立即返回 false 不启动。它的语义是「宁可拒绝也不排队」,适合过载保护。

10.1.6 semaphore.Weighted:带权重的信号量

semaphore.NewWeighted(n) 创建一个容量为 n 的加权信号量。与 SetLimit 的「按个数」不同,semaphore 按权重计费:

sem := semaphore.NewWeighted(3)
err := sem.Acquire(ctx, 1) // 申请 1 个配额,不够则阻塞直到 ctx 取消
defer sem.Release(1)       // 归还 1 个配额

权重让「轻量调用」和「重量调用」可以共用一份预算:一次小查询申请 1,一次大导出申请 3,容量 10 的信号量能同时容纳不同的组合。这是 SetLimit 做不到的——SetLimit 里每个 goroutine 都算 1。

TryAcquire(n) 是非阻塞版:够就返回 true,不够返回 false,不阻塞、不等待。

10.1.7 实测:TryAcquire 与加权

容量 3 的信号量,连续 TryAcquire 观察剩余配额:

sem := semaphore.NewWeighted(3)
sem.TryAcquire(2) // true, 剩 1
sem.TryAcquire(2) // false, 剩 1 不够
sem.TryAcquire(1) // true, 占满
sem.TryAcquire(1) // false
$ GOTOOLCHAIN=go1.27.0 go run ./ch10
初始: TryAcquire(2)=true
已占2: TryAcquire(2)=false (剩1, 不够)
已占2: TryAcquire(1)=true
占满3: TryAcquire(1)=false
全部释放后: TryAcquire(3)=true
Acquire(5) 阻塞中(容量只有3,权重超限永不满足)

最后一行是关键边界:申请权重超过总容量时,Acquire 会永久阻塞(直到 ctx 取消),因为配额永远不可能凑够。这类 bug 在压测里表现为「挂死」,根源是把权重写成了大于容量的常量。写代码时用 Acquire 的权重必须 ≤ NewWeighted 的容量,否则就是死锁。

10.1.8 公平性:FIFO 等待队列

semaphore.Weighted 内部维护一个 FIFO 等待队列:先 Acquire 的调用者先被唤醒。实测——容量 1,先占满,再让 4 个 goroutine 按 10ms/20ms/30ms/40ms 的间隔依次排队,然后释放:

sem := semaphore.NewWeighted(1)
_ = sem.Acquire(context.Background(), 1) // 占满
for i := 1; i <= 4; i++ { /* 第 i 个在 i*10ms 后排队 Acquire */ }
$ GOTOOLCHAIN=go1.27.0 go run ./ch10
排队唤醒顺序: [1 2 3 4]

唤醒顺序与排队顺序一致(1→2→3→4),没有出现后来者插队。这很重要:如果 semaphore 是「唤醒任意一个」,高并发下低优先级请求可能被无限饿死。FIFO 保证了「先到先得」,代价是无法实现优先级调度——要做优先级得自己套一层队列。

10.1.9 SetLimit 与 semaphore 的选择

两者都能限制并发,但管的「东西」不同:

维度errgroup.SetLimitsemaphore.Weighted
计量单位goroutine 个数(每个算 1)权重(可自定义)
作用域单个 Group可跨 Group 共享
超额行为Go 阻塞 / TryGo 拒绝Acquire 阻塞 / TryAcquire 拒绝
公平性无明确保证FIFO 队列
适用本批任务别起太多 goroutine对某下游/资源的全局并发预算

经验法则:只关心「本批任务起多少个 goroutine」用 SetLimit;要限制「对某个共享资源的全局并发」用 semaphore。TaskHub 对同一个下游服务的调用统一走一个全局 semaphore,多个入口(HTTP、定时任务、消息消费)共享同一份配额,这样下游看到的并发上限才是可控的。

10.1.10 常见坑

  • Acquire 的权重 > 容量:永久阻塞,是死锁而非背压。
  • Release 多于 Acquire:semaphore 会 panic(配额溢出)。
  • 忘记 defer sem.Release:配额泄漏,最终所有 Acquire 都挂死。
  • 以为 Wait 返回所有错误:只返回第一个,全集要自己收集(卷二 9.1 有写法)。
  • SetLimit 在有活跃 goroutine 时调用:文档禁止,必须在启动前设好。
  • TryGo 在未 SetLimit 时用:不调用 SetLimit 时无上限,TryGo 恒成功,起不到保护作用(SetLimit(0) 反而会禁止再启动新 goroutine)。
  • WithContext 的 ctx 没传给下游:取消信号进不去,任务照跑,取消形同虚设。
  • 共享 semaphore 却各自 NewWeighted:配额被放大成 N 倍,全局上限失效。

小结

  • errgroup.WithContext 基于 context.WithCancelCause,context.Cause 能拿回首个错误(Go 1.20 引入,已用 api 清单核实)。
  • Wait 只返回第一个错误;返回 nil 不代表派生 ctx 没被取消。
  • SetLimit 管「本组 goroutine 数」并阻塞背压;semaphore 管「带权重的全局配额」,权重超容量会永久阻塞。
  • semaphore 是 FIFO 公平的(实测唤醒顺序 1→2→3→4);SetLimit 无公平性保证。
  • 全局并发预算用共享 semaphore,本批 goroutine 上限用 SetLimit。

errgroup 与 semaphore 提供了取消与配额的机制,但「子任务的生命周期为什么必须受父约束」这个更根本的契约,值得单独一节展开——下一节把结构化并发当模型来讨论。

阅读导航:上一节:9.3 自研代码生成器与 go/ast · 下一节:10.2 结构化并发模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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