写 Go 并发的时候,最常踩的坑不是「不会起 goroutine」,而是「起了收不回来」。典型场景:一批 RPC 要并发发起,只要有一个失败就立刻停掉其余请求并返回第一个错误;或者一批 worker 在跑,用户在终端按下 Ctrl+C,需要让所有 worker 收到信号、优雅收尾。手写 sync.WaitGroup + context.WithCancel + 一个带锁的错误变量当然能做,但代码会被样板逻辑淹没,而且极易写漏——忘了 cancel、忘记把 ctx 传下去、第一个错误被后到的错误覆盖、上游取消被误判成业务失败。本文用 golang.org/x/sync/errgroup 把这套模式收敛干净,并讲清楚它内部到底做了什么、哪些地方会翻车。
为什么不用裸 WaitGroup:三个真实会出的事故
先看一段「看起来没问题」的代码:
func fetchAll(urls []string) error {
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var firstErr error
var mu sync.Mutex
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
if err := fetch(ctx, u); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
cancel()
}
}(u)
}
wg.Wait()
return firstErr
}
这段代码能跑,但有三处隐患:
- 错误不可归并:只能拿到一个 error,多个任务同时失败时其余错误被直接丢弃,排查线上问题时信息缺失。
- 取消语义靠手工维护:只要有人漏写一处理
cancel(),或者后续新增一条失败分支忘了调用,goroutine 就会挂到 ctx 超时才退出。 - 取消与业务错误混淆:当一个任务因为 ctx 被取消而返回
context.Canceled时,它可能抢在真正失败的任务之前把firstErr覆盖成取消错误,最终你看到的是「context canceled」,而根因被埋了。
errgroup 的设计目标正是这三条:并发执行 + 首个错误即取消 + 保持第一个错误。在 golang.org/x/sync v0.7.0 之后它新增了 WithContextCause、TryGo、SetLimit,能力已经比较完整。
WithContext:首错取消的最小可用形态
核心 API 只有两个:
package main
import (
"context"
"fmt"
"net/http"
"time"
"golang.org/x/sync/errgroup"
)
func check(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("head %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("head %s: unexpected status %d", url, resp.StatusCode)
}
return nil
}
func main() {
urls := []string{
"https://example.com",
"https://example.org",
"https://httpbin.org/status/500",
}
g, ctx := errgroup.WithContext(context.Background())
for _, u := range urls {
u := u
g.Go(func() error {
return check(ctx, u)
})
}
if err := g.Wait(); err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println("all ok")
}
几个必须记住的行为细节:
errgroup.WithContext返回的ctx会在 Wait 返回时被取消,同时也会在第一个 Go 函数返回非 nil error 时被取消。- 必须把
ctx传进每个任务内部,并让内部所有阻塞操作(HTTP、DB、channel 收发)都感知它,否则取消只是一句空话。 g.Wait()返回的是第一个非 nil 错误,而不是最后一个,也不是错误聚合。后面我们会用errors.Join补上聚合能力。- 循环变量捕获问题在 Go 1.22 起已修复(每次迭代新建变量),但如果你在用 1.21 及以下,
u := u这行必须保留,否则所有任务都会拿到最后一个 URL。
常见误用:
g.Go内部只写go func(){...}(),让 errgroup 只负责计数。这样做会丢掉首错取消能力,而且外层Wait可能在内部 goroutine 还没结束时返回,属于「看起来用了 errgroup 其实没用」。
错误传播与取消传播:两条链路别搞混
errgroup 内部实际做的是两件事,很多人把它们混为一谈:
| 链路 | 方向 | 触发条件 | 如何感知 |
|---|---|---|---|
| 错误传播 | worker → 调用方 | 任一 Go 函数返回非 nil | g.Wait() 的返回值(仅保留第一个) |
| 取消传播 | errgroup → 所有 worker | 首个错误出现,或 Wait 返回 |
共享 ctx 的 Done() / Err() |
这意味着一个任务失败后,其他任务不是「被杀死」,而是「收到取消信号,自行决定何时退出」。这是 Go 并发的一贯原则:不强制终止 goroutine,只通知。所以如果你的 worker 里有一段纯 CPU 的循环而没有检查 ctx.Err(),它依然会跑完。
下面是一个能直观看到取消传播过程的例子:
package main
import (
"context"
"errors"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
var errBoom = errors.New("boom")
func worker(ctx context.Context, id int, fail bool) error {
for i := 0; ; i++ {
select {
case <-ctx.Done():
fmt.Printf("worker %d exit: %v\n", id, ctx.Err())
return ctx.Err()
case <-time.After(100 * time.Millisecond):
fmt.Printf("worker %d tick %d\n", id, i)
if fail {
return fmt.Errorf("worker %d: %w", id, errBoom)
}
}
}
}
func main() {
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return worker(ctx, 1, true) })
g.Go(func() error { return worker(ctx, 2, false) })
g.Go(func() error { return worker(ctx, 3, false) })
err := g.Wait()
fmt.Printf("Wait returned: %v\n", err)
fmt.Println("is errBoom:", errors.Is(err, errBoom))
}
输出大致是:worker 1 在一次 tick 后返回错误,worker 2、3 立刻在 ctx.Done() 上醒来并返回 context.Canceled,但 Wait 返回的仍然是 worker 1: boom。这是因为 errgroup 只在第一个错误到来时取消 ctx,后续任务的取消错误不会覆盖它——这正是「首错保留」的价值。
注意 errors.Is(err, errBoom) 输出 true 的原因:worker 1 用 %w 包装了错误。如果你的包装链路断了(用了 %v),errors.Is 就会失败,这也是线上「明明日志里有 boom,代码里 Is 判断不中」的经典原因。
用 WithContextCause 保留根因
golang.org/x/sync v0.7.0 起提供 errgroup.WithContextCause(ctx, cause),可以在取消时携带一个 cause,worker 通过 context.Cause(ctx) 取出。这比 ctx.Err() 信息量更大:
g, ctx := errgroup.WithContextCause(context.Background(), nil)
g.Go(func() error { return errors.New("db timeout") })
g.Go(func() error {
<-ctx.Done()
fmt.Println("cause:", context.Cause(ctx)) // db timeout
return context.Cause(ctx)
})
err := g.Wait()
fmt.Println("wait:", err)
内部实现上,errgroup 会把首个错误作为 cause 写入 ctx,因此下游 context.Cause(ctx) 拿到的就是「到底为什么被取消」,不必再靠猜。
限制并发度:SetLimit 与 TryGo
无脑 g.Go 一个 for 循环里几万个任务,会把下游数据库连接池打穿,或者触发对端的限流。errgroup 在 v0.4.0 起提供 SetLimit(n),语义是「同时最多 n 个 Go 函数处于运行中,多余的 Go 调用会阻塞到有空位」:
func scanAll(ctx context.Context, hosts []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, h := range hosts {
h := h
g.Go(func() error {
return probe(ctx, h)
})
}
return g.Wait()
}
SetLimit 的两个细节必须知道:
- 它限制的是正在运行的 Go 函数数量,不是启动的 goroutine 数量。超过上限时
Go会在信号量上阻塞,所以调用g.Go的 goroutine 本身也会被卡住,不要在持有锁的时候调用它。 SetLimit应在任何Go之前调用;虽然并发调用是安全的,但运行中改上限没有意义。
如果不想阻塞,用 TryGo:拿不到名额时返回 false,由调用方决定是等待、丢弃还是报错。
g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(4)
skipped := 0
for _, job := range jobs {
job := job
if !g.TryGo(func() error { return handle(ctx, job) }) {
skipped++
}
}
if err := g.Wait(); err != nil {
return err
}
fmt.Println("skipped:", skipped)
一个真实报错:
errgroup: Go called after Wait。这是 errgroup 的运行时保护——Wait返回之后再调用Go会 panic(v0.3.0 起)。常见触发方式是在Wait之后又补了任务,或者在一个被延迟执行的回调里继续g.Go。解决办法是把所有任务提交收集齐再统一Wait,或者改用semaphore.Weighted自己控制。
错误聚合:首错取消之后还要知道「全貌」
首错保留适合「快速失败」,但有些场景是「收集所有失败再统一报告」,比如批量校验、批量导入。这时不要用 errgroup 的默认语义硬扛,有两种干净写法。
写法一:限流执行 + 手工聚合。用 errgroup 只做并发控制和等待,任务内部把错误收集到带锁的切片里,最后统一返回。
func validateAll(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(16)
var mu sync.Mutex
var errs []error
for _, it := range items {
it := it
g.Go(func() error {
if err := validate(ctx, it); err != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("item %s: %w", it.ID, err))
mu.Unlock()
}
return nil // 关键:永远返回 nil,否则会触发首错取消
})
}
if err := g.Wait(); err != nil {
return err
}
return errors.Join(errs...)
}
注意这里的取舍:返回 nil 换取「不取消」。如果某个 item 校验失败导致后续没有必要继续,那就应该正常返回错误,让 errgroup 去取消。
写法二:用 WithContextCause 做分级。业务失败返回 error 触发取消,而取消产生的 context.Canceled 在收尾时过滤掉,避免污染错误汇总:
func aggregate(cnt int) error {
g, ctx := errgroup.WithContextCause(context.Background(), nil)
for i := 0; i < cnt; i++ {
i := i
g.Go(func() error {
if err := step(ctx, i); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
return nil
})
}
return g.Wait()
}
过滤 context.Canceled 这一步非常关键。否则你会看到日志里刷满 context canceled,而真正的错误被淹没,或者上游调用方把「取消」当成超时去重试,白白放大流量。
信号传播实战:把 Ctrl+C 接到 errgroup 上
这才是「信号传播」最容易出错的地方。目标:所有并发任务共享一个由 signal.NotifyContext 派生的 ctx,收到 SIGINT/SIGTERM 时全体退出,进程在限定时间内收尾。
package main
import (
"context"
"errors"
"fmt"
"log"
"os/signal"
"syscall"
"time"
"golang.org/x/sync/errgroup"
)
func poll(ctx context.Context, name string) error {
t := time.NewTicker(200 * time.Millisecond)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
log.Println("polling", name)
}
}
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
g, ctx := errgroup.WithContext(ctx)
for _, n := range []string{"orders", "payments", "shipments"} {
n := n
g.Go(func() error { return poll(ctx, n) })
}
// 任务内部需要一个能区分「上游取消」的入口:
// 这里演示收尾超时的写法
go func() {
<-ctx.Done()
stop() // 恢复默认信号处理,第二次 Ctrl+C 直接强制退出
}()
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
log.Fatalf("exit with error: %v", err)
}
log.Println("graceful shutdown done")
}
这段代码里有三个容易被忽略但很要紧的点:
signal.NotifyContext返回的stop必须被 defer 调用,否则信号处理不会被解除,长驻进程可能泄漏。- 在
ctx.Done()后再调用一次stop(),可以让用户按第二次 Ctrl+C 时走默认行为(立即终止),这是 CLI 工具的标准做法。 - 把
context.Canceled当成正常退出,而不是错误。否则用户正常中断也会打印exit with error: context canceled,运维会误判。
如果你需要「超时收尾」,也就是取消后给任务留 N 秒做清理,标准做法是用 context.WithTimeout 再包一层并在主逻辑外面 select:
done := make(chan error, 1)
go func() { done <- g.Wait() }()
select {
case err := <-done:
if err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}
case <-time.After(10 * time.Second):
log.Println("shutdown timeout, forcing exit")
}
数据竞态:把结果写回去也要保护
并发任务写共享结果时,errgroup 不会帮你加锁。最典型的是向 map 写入导致 fatal error: concurrent map writes——这个错误不可 recover,进程直接崩溃。
func fetchMap(ctx context.Context, keys []string) (map[string]string, error) {
g, ctx := errgroup.WithContext(ctx)
out := make(map[string]string, len(keys))
var mu sync.Mutex
for _, k := range keys {
k := k
g.Go(func() error {
v, err := load(ctx, k)
if err != nil {
return fmt.Errorf("load %s: %w", k, err)
}
mu.Lock()
out[k] = v
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}
如果键的集合已知,更简单的做法是预分配切片 + 索引写入,避免锁:results := make([]string, len(keys)),每个 goroutine 只写 results[i],不同元素之间没有竞争。
FAQ
errgroup 能替代 WaitGroup 吗?
在「需要错误返回值或取消传播」的场合可以,而且应该。纯粹只想等待一组 goroutine 结束、不关心错误时,sync.WaitGroup 更轻;errgroup 多了一层 mutex 与 ctx 管理,没必要为此付代价。
为什么 Wait 返回的不是「所有错误」?
这是设计选择:首错取消的语义下,其余任务多半是被取消掉而返回 context.Canceled,聚合这些错误意义不大。需要全量错误就按上文「错误聚合」那一节自己收集,用 errors.Join 合并。
Go 函数里 panic 了会怎样?
errgroup 不会 recover。panic 会按照正常的 goroutine 语义传播,导致整个进程崩溃。g.Wait() 也就永远返回不了。任务内可能 panic 时,在 Go 函数最前面加自己的 defer recover(),把它转成 error 返回。
报错 errgroup: Go called after Wait 怎么修?
说明有代码在 Wait 之后又调用了 Go。检查是否存在延迟回调、定时器或者嵌套的 errgroup 误用了外层的 g。把任务提交与 Wait 严格分成两段:先全部 Go,再 Wait。
SetLimit 之后任务顺序还会保持吗?
不会,errgroup 从不保证执行顺序;SetLimit 只是限制同时在跑的数量。需要保序输出就自己在结果切片里带索引,最后统一排序或按索引读取。
任务返回了 error,但 goroutine 好像没退出?
几乎总是因为任务内部没有检查 ctx:用了不带 ctx 的 http.Get、没有 SetDeadline 的数据库查询、在无缓冲 channel 上阻塞发送。检查方式是把 ctx 的取消点打印出来,或临时在 ctx.Done() 分支加日志确认是否被唤醒。
小结
errgroup 的价值不在「少写几行」,而在于把三个易错点固化成不变量:首个错误被保留、失败即取消、Wait 前不能再提交任务。使用时要配套三件事:把 ctx 贯穿到每个阻塞调用、区分业务错误与 context.Canceled、共享写入自己加锁或用索引隔离。需要全量错误就退回 errors.Join 手工聚合,需要限流就用 SetLimit / TryGo,需要根因就用 WithContextCause + context.Cause。把这四点做实,并发代码的调试成本会显著下降。