用 errgroup 控制并发任务的首错取消与信号传播

写 Go 并发的时候,最常踩的坑不是「不会起 goroutine」,而是「起了收不回来」。典型场景:一批 RPC 要并发发起,只要有一个失败就立刻停掉其余请求并返回第一个错误;或者一批 worker 在跑,用户在终端按下 Ctrl+C,需要让所有 worker 收到信号、优雅收尾。手写 sync.WaitGroup + context.WithCancel

写 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")
}

几个必须记住的行为细节:

  1. errgroup.WithContext 返回的 ctx 会在 Wait 返回时被取消,同时也会在第一个 Go 函数返回非 nil error 时被取消。
  2. 必须把 ctx 传进每个任务内部,并让内部所有阻塞操作(HTTP、DB、channel 收发)都感知它,否则取消只是一句空话。
  3. g.Wait() 返回的是第一个非 nil 错误,而不是最后一个,也不是错误聚合。后面我们会用 errors.Join 补上聚合能力。
  4. 循环变量捕获问题在 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")
}

这段代码里有三个容易被忽略但很要紧的点:

  1. signal.NotifyContext 返回的 stop 必须被 defer 调用,否则信号处理不会被解除,长驻进程可能泄漏。
  2. 在 ctx.Done() 后再调用一次 stop(),可以让用户按第二次 Ctrl+C 时走默认行为(立即终止),这是 CLI 工具的标准做法。
  3. 把 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。把这四点做实,并发代码的调试成本会显著下降。