errgroup 的六个坑:Wait 之后 ctx 已取消、SetLimit 嵌套死锁,以及另外四个

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 锁定的版本为准。

相关推荐
IT_陈寒1 小时前
JavaScript的this指向问题又让我加了个班
前端·人工智能·后端
美好世界1 小时前
Codex 源码导读:第一部分——工程分层
后端
行百里er1 小时前
Redis 核心数据结构(四)——Set 与 Sorted Set,去重与排名神器
redis·后端
lizhongxuan1 小时前
Firecracker 与 KVM
后端
PC2005_cloud1 小时前
Nginx 学习笔记:Server 块配置详解,域名路由与多站点部署实战
前端·后端
YIAN1 小时前
LangChain.js 对话记忆体系(一):内存存储与文件持久化,让 AI 拥有对话记忆
前端·后端·langchain
flash俊杰1 小时前
pgvector 实战:把向量检索"塞"进关系数据库,一条 SQL 搞定联合查询
后端
IT_陈寒1 小时前
React状态管理这个坑,我是怎么翻车的
前端·人工智能·后端
mldong1 小时前
聚合边界:为什么 ProcessTask 没有自己的 Repository
后端·架构