Go 1.25 的 WaitGroup.Go 省了两行代码,也补不上这三个并发边界

0 阅读1分钟

Go 1.25 给 sync.WaitGroup 增加了 Go 方法。常见的 goroutine 启动代码可以从手工 Addgodefer Done,缩成一次调用:

并发任务生命周期关系图

var wg sync.WaitGroup
for _, job := range jobs {
    job := job
    wg.Go(func() {
        process(job)
    })
}
wg.Wait()

这个 API 首先修正的是启动顺序。过去有人把 wg.Add(1) 写进 goroutine:

go func() {
    wg.Add(1)
    defer wg.Done()
    process(job)
}()
wg.Wait()

Wait 可能在 Add 执行前看到计数为零,提前返回。Go 1.25 的 go vet 新增 waitgroup 检查,会报告这类误放的 AddWaitGroup.Go 把计数增加与 goroutine 创建放进同一个标准方法,降低了写错顺序的机会。

它没有提供错误传播。f func() 没有返回值,业务函数失败后仍要自己决定如何记录、汇总或取消其他任务。若一组任务要求“任意一个失败就停止”,errgroup.Group 或自建带 context 的协调层仍更合适。不能为了使用新 API,把错误吞进日志后继续执行。

第二个边界是 panic。WaitGroup.Go 的文档要求传入函数不能 panic。即使运行时能够执行计数清理,业务也不能把 panic 当普通错误流。需要隔离不可信插件或任务时,应在明确的边界恢复 panic,附带堆栈并转成系统能够处理的失败状态,而不是假定 WaitGroup 会替你定义语义。

第三个边界是并发数量。下面代码会为每个 job 立即创建 goroutine:

for _, job := range jobs {
    job := job
    wg.Go(func() { process(job) })
}

任务有几十个时很自然,输入可能达到几十万时,goroutine 栈、参数、下游连接和队列都会形成压力。WaitGroup 负责“等它们结束”,不负责限流。可以在外层使用带容量的 channel 作为信号量,或创建固定数量 worker 从任务 channel 读取。

limit := make(chan struct{}, 32)
for _, job := range jobs {
    job := job
    limit <- struct{}{}
    wg.Go(func() {
        defer func() { <-limit }()
        process(job)
    })
}
wg.Wait()

这段代码仍要考虑取消。如果调用方 context 已结束,向 limit 发送可能继续阻塞。生产代码可以用 select 同时等待令牌与 ctx.Done(),并让 process 接收同一个 context。并发控制、取消与等待是三件事,API 简化了一件,不会自动组合其余两件。

嵌套调用也是新方法的一个实用点。只要 WaitGroup 非空,正在执行的 Go 启动的函数可以继续调用同一 WaitGroup 的 Go。但这并不意味着任意生命周期都安全。第一次 Go 必须发生在空 WaitGroup 的 Wait 之前;一轮任务结束后复用 WaitGroup,也要等上一轮 Wait 返回再启动下一轮。

迁移不必全仓替换。先运行 Go 1.25 的 go vet ./...,修复错误的 Add 位置。对单纯启动且不需要返回错误的任务,改用 WaitGroup.Go 能减少样板代码。已有 errgroup、worker pool 或专门任务执行器的地方,保留原结构更清楚。API 新不等于抽象层级更高。

测试方面,别只断言最终结果数量。加入取消、任务阻塞、输入为空、并发上限和内部启动子任务的用例。配合 race detector 检查共享数据,而不是把 WaitGroup 当作互斥锁。它只协调任务完成,不保护 map、slice 或计数器。

我会采用这条迁移规则:纯 fire-and-wait 任务用 WaitGroup.Go;需要错误与联动取消时用 errgroup;需要资源上限时加显式并发控制;共享状态另设同步。两行样板消失是好事,但并发程序真正难的部分从来不是那两行。

还有一个容易被代码审查漏掉的点:循环变量捕获。较新的 Go 版本已经调整了常见 for 循环变量语义,但项目的 go.mod 版本、旧代码写法和自定义变量仍要看清。显式写 job := job 虽然有时不再必要,却能让读者马上知道闭包使用的是本轮任务。迁移时不要顺手删除所有这类赋值。

基准测试也要区分 API 开销和业务吞吐。WaitGroup.Go 的目标是减少错误与样板,不是承诺比手工写法更快。若基准出现明显差异,先检查任务粒度、逃逸和测试噪声。对大多数网络服务,数据库与网络等待远大于这几行协调代码。

最后检查可观测性。批量启动任务时给每轮操作一个 trace 或批次标识,记录启动数、完成数、失败数和取消数。Wait 返回只能证明计数归零,不能证明每个任务达成业务结果。把完成协调与结果统计分开,事故时才能回答究竟丢在哪一步。