我用 WaitGroup 等一批 goroutine 全部干完,却把 wg.Add 写进了 goroutine 内部,结果主协程的 Wait 在 Add 之前就跑完直接返回,程序以为活儿都干完了其实大半还没开始:一次 WaitGroup 时序用错、把登记放在了等待之后的深度复盘
那个"任务明明没做完,程序却说做完了"的诡异 bug,让我对 sync.WaitGroup 的用法彻底较了真。我有个场景:启动一批 goroutine 并发处理任务,然后用 WaitGroup 等它们全部干完,再汇总结果。我写成了这样:在循环里 go func(){ wg.Add(1); defer wg.Done(); doWork() }()——把 wg.Add(1) 放在了启动的 goroutine 内部。然后主协程 wg.Wait() 等待。线上现象诡异:程序没报错,但汇总出来的结果经常是不完整的——明明启动了 100 个任务,汇总时却只有零星几个的结果,像是大部分 goroutine 还没干完,主协程就往下走了。复盘 WaitGroup 的机制,我才彻底搞懂,后背发凉:问题出在我把 wg.Add(1) 放在了 goroutine 内部,造成了"登记"和"等待"的时序错乱。WaitGroup 的原理是维护一个计数器:Add(n) 把计数加 n(登记"有 n 件事要等"),Done() 减 1(报告"一件事干完了"),Wait() 阻塞直到计数器归零;关键在于:主协程的 Wait() 和 goroutine 内部的 Add() 是并发执行的,谁先谁后没有保证;而我的循环 go func(){...}() 启动 goroutine 是异步的——主协程很可能在那些 goroutine 还没来得及执行到内部的 wg.Add(1) 时,就已经跑到了 wg.Wait();此时计数器还是 0(没几个 Add 执行),Wait() 一看"计数器是 0",立刻就返回了——主协程以为"所有任务都干完了",其实大部分 goroutine 还没开始干。根本原因是:wg.Add 必须在启动 goroutine 之前(主协程里)调用,以确保 Wait 执行时计数器已正确登记;我把 Add 放进了 goroutine 内部,使得"登记"与"等待"产生竞态,Wait 在 Add 之前执行、看到计数器为 0 就提前返回。问题的根,是 WaitGroup 的 Add 时序用错——把登记(Add)放在了被等待的 goroutine 内部,与主协程的 Wait 形成竞态,导致 Wait 在登记完成前就看到计数 0 而提前返回。这篇就把这次"WaitGroup 时序"的坑,从头到尾复盘一遍。
故障现场:Add 在 goroutine 里,Wait 提前返回
问题在于 Add 与 Wait 的时序竞态:
// 我的错误写法: wg.Add(1) 放在 goroutine 内部
func process(tasks []Task) []Result {
var wg sync.WaitGroup
results := make([]Result, 0)
var mu sync.Mutex
for _, task := range tasks {
go func(t Task) {
wg.Add(1) // ✗ Add 在 goroutine 内部! 与主协程的 Wait 竞态
defer wg.Done()
r := doWork(t)
mu.Lock(); results = append(results, r); mu.Unlock()
}(task)
}
wg.Wait() // 主协程在这里等
return results // ✗ 可能在大部分 goroutine 还没 Add 时就返回了!
}
/*
为什么结果不完整(时序竞态):
- WaitGroup 维护一个计数器: Add(n)加n、Done()减1、Wait()阻塞到计数器归零;
- 我的 for 循环用 go 启动 goroutine 是"异步"的: go 语句立即返回, goroutine 何时真正
开始执行(执行到内部的 wg.Add(1))是不确定的、由调度器决定;
- 主协程 for 循环跑完(只是"发起"了100个go), 立刻执行 wg.Wait();
- 此时那100个 goroutine 可能一个都还没执行到 wg.Add(1) → 计数器还是 0;
- wg.Wait() 一看计数器=0 → "没有要等的" → 立即返回!
- 主协程返回 results(此刻几乎是空的), 而那些 goroutine 还在后台慢慢跑、慢慢 Add/Done;
→ 表现: 没报错, 但结果不完整(主协程没等真正的任务完成就走了)。
(有时还会更糟: 若Wait归零返回后又有goroutine Add, 可能触发 panic: WaitGroup misuse)
★ WaitGroup 的铁律(官方文档明确):
Add 必须在 "会导致 Wait 的那个 goroutine" 之前调用, 即:
Add 要在启动 goroutine 的 go 语句【之前】, 在主协程里调用——
这样 Wait 执行时, 计数器已经被正确登记好了, 不会出现"还没登记就开始等"的竞态。
*/
看着那个"没报错但结果残缺"的输出,我又困惑又恍然:"我以为 Add 写在 goroutine 里、紧挨着 Done,逻辑更紧凑、更清晰……谁知道主协程的 Wait 根本不等这些 Add 执行,看见计数器是 0 就走了!原来 Add 必须在 go 之前登记好。"这个坑最隐蔽的地方在于:它不一定每次都错——有时调度器让几个 goroutine 抢先执行了 Add,Wait 就会等(碰巧对);有时主协程跑得快,Wait 提前返回(错)。这种时序相关的竞态,表现飘忽不定、难以复现,在测试里(任务少、跑得快)可能恰好没暴露,一上线(任务多、负载高)就出问题。下面就来拆解,WaitGroup 到底该怎么用。
第一件事:搞懂 WaitGroup 的计数器与 Add 时序
我顺着这次事故,把 WaitGroup 的机制和正确用法彻底理清了。
WaitGroup 怎么工作? 为什么 Add 必须在 go 之前?
【核心: WaitGroup是计数器(Add加/Done减/Wait阻塞到0); Add必须在启动goroutine的go语句之前(主协程里)
调用, 否则Wait可能在Add之前执行、看到计数0就提前返回; defer wg.Done()放协程开头, Add数量要匹配】
1. WaitGroup 的三个操作:
- Add(n): 计数器 += n(登记"有n件事要等");
- Done(): 计数器 -= 1(报告"一件事完成了", 等价于 Add(-1));
- Wait(): 阻塞当前协程, 直到计数器变为 0。
2. 为什么 Add 必须在 go 之前(时序是关键):
- go func(){...}() 是异步的: go语句立即返回, 协程体何时执行由调度器定, 不确定;
- 若 Add 在协程体内 → 主协程可能在协程还没执行Add时就到了Wait → 计数器仍是0 → Wait立即返回;
- 把 Add(1) 放在 go 之前(主协程里) → Wait执行时计数器已经是正确的总数 → 才会真正等待。
- 官方文档原话: "the Add call should happen before the statement creating the goroutine"。
3. 正确的固定写法(背下来):
for _, t := range tasks {
wg.Add(1) // ① Add 在 go 之前, 主协程里
go func(t Task) {
defer wg.Done() // ② Done 用 defer 放协程开头, 保证一定执行(即使panic)
doWork(t)
}(t) // ③ 循环变量用参数传入(同544篇, 避免闭包捕获问题)
}
wg.Wait() // ④ 等全部完成
或一次性 Add: wg.Add(len(tasks)) 再循环 go(数量确定时)。
4. 其他常见 WaitGroup 误用:
- 忘了 Done / Done次数不够 → 计数器永不归零 → Wait 永久阻塞(死锁, fatal error);
- Done 多调了 → 计数器变负 → panic: negative WaitGroup counter;
- Add 的数量和实际 goroutine 数不匹配 → 少等或多等;
- 复制 WaitGroup(值传递)→ 副本和原来的不是同一个计数器(WaitGroup不能复制, 要传指针);
- Wait 返回后又 Add(复用同一个wg做下一轮)→ 可能 panic, 要确保上一轮Wait后再Add。
5. 本质: WaitGroup 是"登记-完成-等待"的协调机制
- "登记(Add)"必须先于"等待(Wait)", 否则等待方不知道要等多少;
- 这是个时序约束: 先把"要等几件事"登记清楚, 再开始等。
一句话: WaitGroup是计数器, Add必须在启动goroutine的go语句之前(主协程)调用, 否则Wait与Add竞态、
会提前返回; defer wg.Done()放协程开头、Add数量匹配、别复制wg(传指针)、Wait后再复用要谨慎。
这套认知,是整个坑的根。WaitGroup 的三个操作:Add(n) 登记、Done() 完成减一、Wait() 阻塞到计数器归零。为什么 Add 必须在 go 之前:go 是异步的、协程体何时执行不确定;Add 在协程体内则主协程可能在 Add 前就到 Wait、计数器仍是 0、Wait 立即返回;Add 放 go 之前则 Wait 时计数器已是正确总数。正确写法:循环里 wg.Add(1) 在 go 之前、defer wg.Done() 放协程开头、循环变量用参数传入(或数量确定时一次性 Add(len))。其他误用:忘 Done 永久阻塞死锁、Done 多调 panic 负计数、数量不匹配、复制 wg、Wait 后复用。本质:"登记(Add)"必须先于"等待(Wait)",否则等待方不知道要等多少。一句话:WaitGroup 是计数器,Add 必须在启动 goroutine 的 go 语句之前(主协程)调用,否则 Wait 与 Add 竞态、会提前返回;defer wg.Done() 放协程开头、Add 数量匹配、别复制 wg(传指针)、Wait 后再复用要谨慎。
第二件事:正解——Add 在 go 之前、defer Done、数量匹配
知道了时序的关键,正解就清楚了:把 Add 提到 go 语句之前,Done 用 defer 兜住。
// 正解1: Add 在 go 之前(主协程里), defer Done 在协程开头(本次该做的)
func process(tasks []Task) []Result {
var wg sync.WaitGroup
results := make([]Result, len(tasks)) // 预分配, 按索引写, 避免append加锁
for i, t := range tasks {
wg.Add(1) // ✓ Add 在 go 之前! 主协程里, Wait时计数已正确
go func(idx int, task Task) {
defer wg.Done() // ✓ defer 放协程开头, 保证一定执行(哪怕doWork panic)
results[idx] = doWork(task) // 各写各的索引, 无需锁
}(i, t) // ✓ 循环变量用参数传入(同544篇)
}
wg.Wait() // 此时计数器=len(tasks), 真正等所有完成
return results // ✓ 结果完整
}
// 正解2: 数量确定时, 一次性 Add(更清晰)
wg.Add(len(tasks)) // 一次登记总数
for i, t := range tasks {
go func(idx int, task Task) {
defer wg.Done()
results[idx] = doWork(task)
}(i, t)
}
wg.Wait()
// 正解3: 传指针, 别复制 WaitGroup
func worker(wg *sync.WaitGroup) { // ✓ 传指针; 传值会复制出另一个计数器, 失效
defer wg.Done()
...
}
// 正解4: 配合 errgroup 处理"并发 + 错误收集"(更现代)
import "golang.org/x/sync/errgroup"
func processV2(tasks []Task) error {
var g errgroup.Group
for _, t := range tasks {
t := t // 或Go1.22+循环变量已每轮独立
g.Go(func() error { // 不用手动Add/Done, 内部管理; 还能收集第一个错误
return doWork(t)
})
}
return g.Wait() // 等全部完成, 返回第一个非nil错误
}
// 核心: Add 在 go 语句之前(主协程)、defer wg.Done() 放协程开头、循环变量传参、传指针不复制;
// 并发+错误处理优先用 errgroup, 省去手动 Add/Done 的时序陷阱。
这套正解的关键,是让"登记(Add)"在"等待(Wait)"之前确定完成,不留竞态。Add 在 go 之前:在主协程里、启动 goroutine 之前 wg.Add(1),保证 Wait 时计数器已是正确总数——这正是本次我该做的。defer Done 放协程开头:保证每个 goroutine 无论正常结束还是 panic 都会 Done,计数器能归零。数量确定时一次性 Add(len):更清晰。传指针不复制:WaitGroup 不能按值复制。用 errgroup:并发 + 错误收集场景优先用 errgroup,它内部管理计数、还能收集错误,省去手动 Add/Done 的时序陷阱。
第三件事:其他几个"并发时序/协调"的坑
顺着这次 WaitGroup,我把 Go 并发里"时序与协调"相关的几个坑也一并理了:
几个Go并发时序/协调的坑:
坑1: WaitGroup 忘了 Done → Wait 永久阻塞(死锁):
fatal error: all goroutines are asleep - deadlock!
正解: 用 defer wg.Done() 放协程开头, 保证一定执行。
坑2: 主协程不等 goroutine 直接退出 → goroutine 还没跑完, main 结束了:
main里 go func(){...}() 然后main返回 → 程序退出, goroutine被强杀;
正解: 用 WaitGroup/channel 等它们; main别裸跑goroutine就退。
坑3: 用 channel 同步但没人收 → 发送方阻塞:
无缓冲channel发送, 没有接收方 → 发送方永久阻塞(同580篇相关);
正解: 确保收发配对, 或用带缓冲channel, 或select+default。
坑4: 启动了goroutine却没等它初始化完就用它的结果(同上WaitGroup本质):
"发起"不等于"完成"; 异步发起后, 必须有同步点(Wait/channel)确认完成才能用结果。
坑5: select 没有 default 且所有 case 都阻塞 → 卡死; 或有 default 但空转忙等;
正解: 想清要不要阻塞; 配合 ctx.Done() 做超时/取消。
坑6: 多个goroutine写同一变量没同步(数据竞争)→ 结果错乱(用 -race 检测);
正解: 加锁、用channel传递、或各写各的(如按索引写不同位置)。
共同的根: 并发里, "发起一个异步动作" 和 "这个动作真正完成/生效" 之间有时间差;
任何"依赖它已完成/已登记"的后续动作, 都必须有一个明确的"同步点"来保证时序——
不能假设"我发起了/我写了这行, 它就已经按我期望的顺序生效了"。
这些坑看似不同,根却是同一个:并发里,"发起一个异步动作"和"这个动作真正完成/生效"之间有时间差、有不确定的时序;任何依赖"它已完成/已登记"的后续动作,都必须有一个明确的"同步点"来保证时序,不能想当然假设"我写在前面它就先执行"。认清这个根("发起≠生效,跨协程的时序要靠同步点保证"),才能避开各种并发竞态。
第四件事:WaitGroup 正误对照 / 误用后果——两张表
我把 WaitGroup 的正确与错误写法、以及各种误用的后果,整理成对照表,贴在了团队的 Go 规范里:
| 写法 | 对/错 | 结果 |
|---|---|---|
| wg.Add(1) 在 go 之前(主协程) | ✓ 对 | Wait 正确等待全部 |
| wg.Add(1) 在 goroutine 内部 | ✗ 错 | Wait 竞态,可能提前返回 |
| defer wg.Done() 在协程开头 | ✓ 对 | 保证一定 Done |
| wg.Done() 写在末尾(可能被 panic 跳过) | △ 欠妥 | panic 时漏 Done,死锁 |
| wg.Add(len(tasks)) 一次性 | ✓ 对 | 数量确定时清晰 |
| 传 wg(值) 给函数 | ✗ 错 | 复制了计数器,失效 |
| 传 *wg(指针) | ✓ 对 | 同一个计数器 |
| 误用 | 后果 |
|---|---|
| Add 在 goroutine 内 | Wait 提前返回,结果不完整 |
| 忘了 Done | Wait 永久阻塞,deadlock |
| Done 多调(超过 Add) | panic: negative WaitGroup counter |
| Add 数 < goroutine 数 | 少等,部分没等到就返回 |
| Add 数 > goroutine 数 | 多等,Wait 永久阻塞 |
| 复制 WaitGroup | 副本独立计数,协调失效 |
| Wait 后立即 Add 复用 | 可能 panic(WaitGroup reused) |
这两张表的核心,第一张是记住黄金写法:Add 在 go 之前、defer Done 在协程开头、传指针;第二张是各种误用都通向"少等(结果残缺)、多等(死锁)、或 panic"——而我栽的"Add 在 goroutine 内"导致的"提前返回结果残缺",恰恰是最隐蔽的(不报错)。记住一条:WaitGroup 用错往往不报错,只是悄悄"没等够"——所以更要严守"Add 先于 Wait"的时序。
第五件事:关于 WaitGroup 的几组容易想当然的认知
这次事故也让我厘清了几组关于 WaitGroup 的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| Add 写哪都行,反正都会执行 | 必须在 go 之前,否则 Wait 竞态提前返回 |
| Add 紧挨 Done 写在协程里更清晰 | 清晰但错,Wait 不等这些 Add |
| Wait 返回就说明任务都干完了 | 若 Add 时序错,可能压根没等 |
| 没报错就说明 WaitGroup 用对了 | Add 在协程内常不报错,只是悄悄少等 |
| WaitGroup 可以随便传值 | 不能复制,值传递会失效,要传指针 |
| 测试通过就没并发时序问题 | 竞态飘忽,测试(任务少)可能恰好没暴露 |
| go 写在前面就会先执行 | go 是异步发起,执行时机由调度器定 |
这张表里,我栽的是第一行和第二行:觉得"Add 和 Done 写在一起更清晰",把 Add 放进了 goroutine,没意识到主协程的 Wait 根本不等这些还没执行的 Add。厘清这些,核心是一个意识:WaitGroup 是个有严格时序要求的协调工具——"登记要等几件事(Add)"必须在"开始等待(Wait)"之前完成;而 goroutine 是异步的,写在前面不代表先执行,所以 Add 必须放在同步的主协程里、go 语句之前。
第六件事:用 WaitGroup / 并发协调时,我现在的自检习惯
现在每当我用 WaitGroup 协调一批 goroutine,我都会先按这张图问自己:
这张图的精髓,是"Add 在 go 之前、defer Done 在开头、传指针、有错误用 errgroup"。先看要不要收集错误(要就 errgroup)、再保证Add 在 go 之前、defer Done 在协程开头、传指针不复制。这套习惯,让我从"Add 随手写协程里"变成了"Add 必须先于 Wait 登记好"——核心始终是:WaitGroup 是计数器,Add 必须在启动 goroutine 的 go 语句之前调用,否则 Wait 与 Add 竞态提前返回;defer Done 放协程开头、数量匹配、传指针;并发+错误优先用 errgroup。
我立下的几条规矩
这场"Wait 提前返回、结果残缺"的事故,换来了我写 Go 并发时,刻进骨子里的几条铁律:
- WaitGroup 是计数器:Add 加、Done 减、Wait 阻塞到归零。
- wg.Add 必须在启动 goroutine 的 go 语句之前(主协程里)调用,否则 Wait 与 Add 竞态、会提前返回。
- defer wg.Done() 放在 goroutine 开头,保证哪怕 panic 也会 Done,不留死锁。
- Add 的数量要和 goroutine 数严格匹配:少则漏等、多则死锁。
- WaitGroup 不能复制(值传递失效),要传指针 *sync.WaitGroup。
- WaitGroup 用错常不报错、只是悄悄少等,更要严守 Add 先于 Wait 的时序。
- 并发 + 收集错误,优先用 errgroup,省去手动 Add/Done 的时序陷阱。
附:用 -race 和封装防住 WaitGroup 时序坑
借这次的坑,我总结了两条落地手段:一是用 race 检测器跑测试,二是把"并发执行一批任务并等待"封装成一个不会用错的工具函数。
// 1. 用 -race 跑测试, 能发现很多并发时序/数据竞争问题
// go test -race ./...
// go run -race main.go
// (它能检出数据竞争; 配合压力测试能放大WaitGroup这类时序问题的暴露概率)
// 2. 封装一个"并发执行并等待全部完成"的工具, 把正确时序固化进去
func RunAll[T any, R any](items []T, fn func(T) R) []R {
var wg sync.WaitGroup
results := make([]R, len(items))
for i, item := range items {
wg.Add(1) // ✓ Add 固定在 go 之前, 调用方无从写错
go func(idx int, it T) {
defer wg.Done() // ✓ defer Done 固定在开头
results[idx] = fn(it) // 各写各的索引, 无需锁
}(i, item)
}
wg.Wait()
return results
}
// 调用方只管业务逻辑, 不碰 Add/Done/Wait, 也就不会把时序写错:
results := RunAll(tasks, func(t Task) Result {
return doWork(t)
})
// 原则: 把"容易写错时序"的并发原语(WaitGroup的Add/Done/Wait)封装进一个正确的工具,
// 让调用方只写业务、碰不到时序细节——比依赖每个人都记得"Add在go之前"可靠得多;
// 再用 -race 做自动化检测兜底。
这两条手段一个"防"一个"查":封装把"Add 在 go 之前、defer Done"的正确时序固化进工具,让调用方根本碰不到、也就写不错;-race 检测器则在测试时主动放大并发问题、把潜伏的时序竞态揪出来。并发的坑往往飘忽难复现,所以"把正确做法封装成默认"加"用工具自动检测",远比"靠人记住规则"可靠。
写在最后
回头看,这场由"把 wg.Add 放在了 goroutine 内部"引发的、Wait 提前返回的事故,真正教给我的,远不止"Add 要写在 go 之前"这一个技巧。它让我对"在并发、异步的世界里, '代码写在前面' 完全不等于 '它就先执行'; 而很多协调机制能正确工作, 恰恰依赖一个严格的'先后时序'——'登记我要做什么' 必须先于 '检查是不是都做完了', 一旦这个时序被打乱, 协调就失效了",有了一次刻骨的体会。我栽跟头,是因为我把同步世界的直觉(写在前面的先执行)带进了异步并发的世界——我以为"Add 写在 Done 旁边, 反正都在任务里, 顺理成章";可我忽略了: go 语句一发出, goroutine 的执行就脱离了我代码的书写顺序, 交给了调度器;于是"主协程的 Wait" 和 "子协程里的 Add" 成了一场赛跑, 而 Wait 常常跑赢——它在"该等的事还没登记"时, 就看着空计数器宣布"都做完了";问题的本质, 是我没保证"登记(Add)"切实发生在"等待(Wait)"之前。这让我领悟到一个关于"时序与协调"的深刻认知:任何"协调多方"的机制(等待、汇合、计数、确认),其正确性都建立在一个隐含的时序前提上:"建立协调关系的动作(登记、注册、占位、声明)" 必须切实发生在 "依赖这个关系的动作(等待、检查、汇总)" 之前;一旦你把"登记"放到了"等待"之后(或与之竞态), 等待方就会基于一个"还不完整的登记"做出判断, 得出"虚假的完成";这在并发编程里叫"happens-before"(先行发生)——而它远不止于代码: 开会前要先确认到齐几人、统计前要先登记完名单、验收前要先明确验收项; 凡是"等所有相关方就位再行动"的事, 都要先确保"就位的登记"完成于"行动的判断"之前。这给了我一种处理"协调与等待"时的清醒:每当我要"等一组事情都完成再继续"时,要确保"我要等哪些事(登记)"是在"开始等待之前"就确定、登记完毕的,而不是边等边登记——尤其在异步并发下, 别假设"写在前面的登记就先于等待执行", 要用明确的同步点(把 Add 放在 go 之前)来保证这个时序;"让'建立协调关系'切实先行于'依赖该关系的判断'",是一切等待/汇合/计数类协调能正确工作的根基。认清异步下代码顺序不等于执行顺序、协调机制依赖严格的先后时序、登记必须先于等待——这,是我用一次 WaitGroup 时序用错的事故,换来的、关于 Go 并发、也关于如何保证协调之时序的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写 WaitGroup 时,把那行 wg.Add(1) 稳稳地放在 go 语句的前面,那我对着那个"没报错却结果残缺"的输出排查的这段时间,就值了。
—— 别看了 · 2026