Go 用 errgroup 管理并发子任务:错误收敛、取消传播与限流
并发拉一批数据,你大概率写过这样的代码:开五个 goroutine 各自请求一个接口,用 sync.WaitGroup 等它们全部结束。跑起来没问题,直到有一天某个接口挂了------其他四个还在傻等,错误也没人收,日志里只留下一条超时。更糟的是,第一个失败之后剩下的请求已经没意义了,却还在白白消耗连接和 CPU。
golang.org/x/sync/errgroup 就是为解决这类问题而生的:它在 WaitGroup 的基础上,帮你收敛第一个错误、并在出错时自动取消其他子任务。这篇把它的三个实战用法讲透:错误收敛、取消传播、并发限流。
先看朴素写法的问题
用原生 WaitGroup 并发请求,想拿到「任意一个失败就返回错误」并不容易:
go
func fetchAll(urls []string) error {
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
if err := fetch(u); err != nil {
mu.Lock()
if firstErr == nil { // 只记第一个错误,得手动加锁
firstErr = err
}
mu.Unlock()
}
}(u)
}
wg.Wait()
return firstErr
}
问题一眼看不完:要手动加锁保护 firstErr、要手动 Add/Done、而且某个请求失败后其余请求不会停,continue 跑到底。这些样板代码每写一次都容易漏一个 wg.Done() 导致死锁。
用 errgroup 收敛错误
同样的逻辑,errgroup 一把梭:
go
import "golang.org/x/sync/errgroup"
func fetchAll(urls []string) error {
var g errgroup.Group
for _, u := range urls {
u := u // 捕获循环变量(Go 1.22 前必须,之后可省)
g.Go(func() error {
return fetch(u) // 直接返回 error,g 帮你收第一个
})
}
return g.Wait() // 任意一个非 nil,Wait 就返回它
}
g.Go 接收一个 func() error,内部帮你管理 WaitGroup;g.Wait() 会阻塞到所有子任务结束,并返回第一个非 nil 的错误。锁、计数器全省了。
注意语义:Wait 只返回第一个错误,后面的错误会被丢弃。如果你需要所有错误,得自己在闭包里收集(比如塞进一个带锁的 slice),errgroup 不替你做这件事。
关键升级:出错就取消其他任务
上面的版本虽然收了错,但失败后其余请求仍会跑完。真正的价值在 errgroup.WithContext:它派生一个 context,任意子任务返回非 nil 错误时,自动 cancel 这个 context,其他子任务只要监听了 ctx 就能及时退出。
go
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
g, ctx := errgroup.WithContext(ctx) // 派生可取消的 ctx
results := make([]string, len(urls))
for i, u := range urls {
i, u := i, u
g.Go(func() error {
// 把 ctx 透传给下游,失败时这里会随 ctx 一起被取消
body, err := fetchWithCtx(ctx, u)
if err != nil {
return err // 触发 ctx cancel,其他 goroutine 收到 Done
}
results[i] = body // 各写各的下标,无需加锁
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
func fetchWithCtx(ctx context.Context, url string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req) // ctx 被 cancel 时这里立刻返回 error
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return string(b), err
}
这里有个容易忽略的细节:results[i] = body 每个 goroutine 写不同下标,读写不重叠,所以不需要加锁。这是并发写 slice 安全的少数场景之一------前提是长度预分配好、各 goroutine 下标互不相同。
要让取消真正生效,子任务里的阻塞操作必须监听 ctx。HTTP 用 NewRequestWithContext,数据库用 QueryContext,自己的循环用 select { case <-ctx.Done(): return ctx.Err() ... }。如果下游不吃 ctx,cancel 就是一纸空文,任务照样跑到底。
用 SetLimit 限流,别一次开几千个 goroutine
如果 urls 有几千个,上面的写法会瞬间开几千个 goroutine 和连接,把下游打挂。Go 1.20 起 errgroup 内置了 SetLimit,限制同时运行的子任务数:
go
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // 最多 10 个 goroutine 并发
for _, u := range urls {
u := u
g.Go(func() error { // 超过 10 个时,g.Go 会阻塞直到有空位
return fetchWithCtx2(ctx, u)
})
}
return g.Wait()
}
SetLimit(n) 之后,g.Go 在并发数达到上限时会阻塞 ,直到有子任务结束腾出名额。这等价于自己维护一个容量为 n 的信号量 channel,但代码干净得多。注意 SetLimit 必须在任何 g.Go 之前调用,中途改会 panic。
如果你不想让 g.Go 阻塞,而是希望「没名额就跳过」,可以用 TryGo,它返回 bool 表示是否成功启动。
一个真实的坑:漏传 ctx 导致取消失效
最常见的翻车是这样:用了 WithContext 拿到新 ctx,却在子任务里图省事用了外层的旧 ctx 或 context.Background()。
go
g, ctx := errgroup.WithContext(parentCtx)
g.Go(func() error {
// 错误:用了 parentCtx,errgroup 的 cancel 传不进来
return fetchWithCtx(parentCtx, u)
})
errgroup.WithContext 返回的新 ctx 才是挂了 cancel 钩子的那个。子任务必须用这个新 ctx,取消才会传播。记住口诀:g, ctx := errgroup.WithContext(...) 之后,组内一律用左边这个新 ctx。
小结
- 并发子任务要「收敛第一个错误」,别再手写 WaitGroup + 锁,用
errgroup.Group。 - 需要「一个失败就停掉其他」时用
errgroup.WithContext,并把返回的新 ctx 透传给每个子任务的阻塞调用。 - 任务数很大时用
SetLimit(n)限流,避免 goroutine 与连接爆炸;要非阻塞就用TryGo。 - 各 goroutine 写不同 slice 下标可以免锁,但共享变量仍要自己保护。
- 一句话记忆点:errgroup = WaitGroup + 第一个错误 + 自动取消 + 限流,但取消能不能生效,全看你有没有把新 ctx 传下去。