errgroup 大概是 Go 里被用得最多的并发工具之一。十行代码就能把"并发跑一批任务、有一个失败就整体失败"写完,比手搓 sync.WaitGroup + 错误 channel 干净得多。
但它的 API 太小了,小到很容易让人以为没什么可踩的。实际上我在 code review 里见过的 errgroup 相关 bug,比 WaitGroup 的还多。下面六个坑,每一个都附了能直接跑的代码,结果是我在 Go 1.25 + golang.org/x/sync v0.22.0 下实际跑出来的。
坑一:Wait 之后,ctx 已经是取消状态了
go
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return nil })
_ = g.Wait()
fmt.Println("after Wait:", ctx.Err())
输出:
scss
after Wait: context canceled
所有任务都成功了,ctx 照样被取消。WithContext 返回的 ctx,文档里写得很清楚:第一个任务返回非 nil 错误时,或者 Wait 返回时,它就会被取消,以先发生的为准。
这个坑的典型现场是这样的:
go
func handle(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx) // 注意:这里遮蔽了外层的 ctx
g.Go(func() error { return loadA(ctx) })
g.Go(func() error { return loadB(ctx) })
if err := g.Wait(); err != nil {
return err
}
return save(ctx) // 必然失败:context canceled
}
:= 把外层 ctx 遮蔽了,Wait 之后再拿它去做后续的数据库写入,每次都是 context canceled。而且这个错误只在"后面还有操作"的代码路径上出现,单测很可能覆盖不到。
改法很简单,别遮蔽:
go
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return loadA(gctx) })
g.Go(func() error { return loadB(gctx) })
if err := g.Wait(); err != nil {
return err
}
return save(ctx) // 用外层的 ctx
我现在的习惯是 errgroup 的 ctx 一律叫 gctx,一眼就能看出它的生命周期只在 group 内部。
坑二:一个失败了,别的任务并不会停
很多人以为 errgroup 是"一个失败,其他全部中止"。不是的。errgroup 只负责取消 ctx,停不停是任务自己的事。
go
func fetch(ctx context.Context, id int) (string, error) {
select {
case <-time.After(time.Duration(id*50) * time.Millisecond):
if id == 3 {
return "", fmt.Errorf("item %d: upstream 502", id)
}
return fmt.Sprintf("item-%d", id), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
十个任务,第 3 个在 150ms 时失败,第 10 个本来要 500ms。因为 fetch 监听了 ctx.Done(),第 3 个一失败,其余的立刻返回,整个 Wait 在 200ms 之内结束。
如果把 case <-ctx.Done() 那个分支删掉,Wait 就会老老实实等满 500ms。在真实业务里,这就是"一个下游超时了,但接口还是等所有下游都跑完才返回"。
所以用 errgroup 的时候要检查一件事:任务里面的每一个阻塞调用,有没有把 ctx 传进去 。HTTP 请求要用 http.NewRequestWithContext,数据库要用 QueryContext,自己写的循环要定期检查 ctx.Err()。有一个没传,取消就在那里断掉。
坑三:Wait 只返回第一个错误
go
g, _ := errgroup.WithContext(context.Background())
for i := 1; i <= 3; i++ {
g.Go(func() error { return fmt.Errorf("task %d failed", i) })
}
fmt.Println("Wait:", g.Wait())
三个任务全失败了,Wait 只给你一个(而且是哪一个不确定,取决于谁先返回,我这次跑出来是 task 3 failed)。
大部分场景这是对的:一个失败就整体失败,知道一个原因就够了。但有些场景你需要全部错误,比如批量校验一批数据,用户希望一次看到所有不合格的行,而不是改一个报一个。
这种场景就别让任务返回错误了,自己收集:
go
var g errgroup.Group
errs := make([]error, 5)
for i := range 5 {
g.Go(func() error {
if i%2 == 0 {
errs[i] = fmt.Errorf("task %d failed", i)
}
return nil
})
}
_ = g.Wait()
fmt.Println(errors.Join(errs...))
task 0 failed
task 2 failed
task 4 failed
每个任务写自己下标的那一格,不需要加锁。errors.Join 会跳过 nil,全成功时返回的也是 nil。
注意这里用的是零值 errgroup.Group 而不是 WithContext:既然不想"一个失败就取消其他",就不需要那个 ctx。
坑四:SetLimit 之后,在任务里再 Go 会死锁
SetLimit 是后来加进 x/sync 的,用来限制并发数,非常好用。但它的语义是:并发数满了,Go 会阻塞调用方,直到有空位。
那如果调用方本身就是 group 里的一个任务呢?
go
var g errgroup.Group
g.SetLimit(1)
g.Go(func() error {
g.Go(func() error { return nil }) // 等空位,但空位被自己占着
return nil
})
fmt.Println(g.Wait())
sql
fatal error: all goroutines are asleep - deadlock!
外层任务占了唯一的名额,然后在里面等名额释放,而名额要等外层任务返回才释放。经典的自己等自己。
这个例子里 limit 是 1,一眼能看出来。真实代码里通常是 limit 设成 10,递归地遍历一棵树,每个节点里再对子节点 g.Go。树浅的时候一切正常,某天来了一棵很深的树,十个名额全被中间节点占满,全部卡在等名额上,服务 hang 住,但没有任何报错(只要进程里还有别的 goroutine 活着,runtime 就检测不到死锁)。
两个办法:
- 递归场景不要共用一个带 limit 的 group,改成先把所有节点收集成一个平铺的列表,再用一个 group 并发处理;
- 或者用
TryGo,拿不到名额就在当前 goroutine 里同步执行:
go
if !g.TryGo(task) {
if err := task(); err != nil {
return err
}
}
顺便一提,SetLimit 在有任务运行时修改会直接 panic(errgroup: modify limit while N goroutines in the group are still active),所以它只能在 Go 之前调用一次。
坑五:panic 不会变成 error
go
g.Go(func() error {
var m map[string]int
m["x"] = 1 // panic
return nil
})
这个 panic 不会被 errgroup 捕获,也不会变成 Wait 的返回值,它会直接把整个进程带走。
有人会觉得 errgroup 应该帮忙 recover。x/sync 的源码里专门写了一段注释解释为什么不这么做:它会让 panic 被延迟、让 panic 的调用栈变成一个普通的值、还可能在程序已经处于损坏状态时引发死锁把 panic 本身藏起来。我觉得这个取舍是对的。
但在 HTTP 服务里,你大概不希望某个请求的某个并发子任务 panic 了,整个服务挂掉。框架的 recover 中间件只能兜住处理请求的那个 goroutine,兜不住 errgroup 起的新 goroutine。所以如果任务代码不完全可控(比如调用了第三方库),需要自己包一层:
go
func safeGo(g *errgroup.Group, f func() error) {
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
return f()
})
}
注意 debug.Stack() 要在 defer 里、recover 之后立即取,这时候栈还在,日志里才能看到是哪一行炸的。
坑六:Go 1.22 之前的循环变量
go
for _, id := range ids {
g.Go(func() error { return process(id) })
}
Go 1.22 之前,所有 goroutine 共享同一个 id 变量,大概率全部拿到最后一个值。1.22 起每次迭代是新变量,这个问题消失了。
之所以还要提,是因为这个行为取决于 go.mod 里的 go 版本 ,不是你本地编译器的版本。一个老项目 go.mod 写着 go 1.20,你用 1.25 的编译器编译,循环变量语义仍然是旧的。从新项目里复制代码到老项目时要注意这一点,要么在老项目里显式写 id := id,要么把 go.mod 的版本升上去。
总结一张表
| 坑 | 表现 | 解决 |
|---|---|---|
| Wait 后 ctx 已取消 | 后续操作报 context canceled | group 的 ctx 起名 gctx,不遮蔽外层 |
| 失败后其他任务不停 | 接口还是等最慢的下游 | 每个阻塞调用都传 ctx |
| 只返回第一个错误 | 批量校验只报一个 | 按下标收集 + errors.Join |
| SetLimit + 嵌套 Go | 死锁或 hang 住 | 平铺任务,或 TryGo 降级为同步 |
| panic 不被捕获 | 整个进程挂掉 | 自己包 recover |
| 循环变量 | 全部处理同一个元素 | 看 go.mod 版本 |
这篇里的例子,是我在做 forxi.cn 后端时一类很常见的场景抽出来的:一个请求要同时查好几个数据源,拼好结果再返回。errgroup 用来做这个非常顺手,但它的 API 越小,要自己记住的约定就越多。上面这六条,我现在 review 代码时基本会逐条对一遍。
局限说明 :本文的行为都是基于 golang.org/x/sync v0.22.0。errgroup 不在标准库里,不同版本之间有过变化(比如 SetLimit 和 TryGo 是后来才加的),老项目用的版本如果比较旧,部分 API 可能不存在,行为以你项目里 go.sum 锁定的版本为准。