本节要回答:
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):
| 包 | 导出符号 | 用途 |
|---|---|---|
errgroup | Group、WithContext、Go、TryGo、SetLimit、Wait | 带错误传播与取消的 WaitGroup |
semaphore | Weighted、NewWeighted、Acquire、TryAcquire、Release | 加权信号量 |
singleflight | Group、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.SetLimit | semaphore.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 结构化并发模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。