事情是这样的。
我用了快四年 Go 了,WaitGroup 这个东西吧,几乎每周都在写。Add、Done、Wait,肌肉记忆了都。
但前几天 Code Review 的时候,同事写了一个很奇怪的 WaitGroup 用法,我盯着看了半分钟,脑子里只蹦出两个字,尼玛。
那个瞬间我突然意识到一件事,我根本不知道 WaitGroup 里面到底长什么样。我知道怎么用它,但我不知道它为什么是安全的。就这三行代码,Go 的并发安全到底是怎么保证的?
于是我干了一件每个程序员都会干的事。
我点进了 sync 包的源码。
然后我发现,这玩意的设计,比我想象的精妙太多了。一个 64 位整数同时管着三个状态,Wait 方法全程没加锁却不会出错,信号量直接怼到 runtime 层去阻塞唤醒 goroutine。
这篇文章,就是我读源码过程中发现的一些让我觉得「卧槽」的东西。
说明一下,以下源码基于 Go 1.21+ 版本,这个版本把 state 字段从 uint64 换成了 atomic.Uint64,如果你还在用老版本,细节上会有些出入。
先聊聊 WaitGroup 的结构体。
Go 1.21 之后的 WaitGroup,核心长这样,
typeWaitGroupstruct { noCopy noCopy state atomic.Uint64 sema uint32}就三个字段。但每个字段背后都是精心的设计决策。
noCopy 这个东西,你写代码的时候根本感觉不到它的存在,它是一个空结构体。它的价值不在运行时,在编译期。go vet 扫描到包含 noCopy 的结构体被值拷贝的时候,会直接报 warning。
为什么不能拷贝?因为 WaitGroup 的同步依赖 state 和 sema 的内存地址,值拷贝之后新对象有自己的地址,原对象的 goroutine 在旧地址上永远等不到信号。不是「可能」出 bug,是「一定」出 bug。
第二个字段 state,是 WaitGroup 最骚的设计。
一个 64 位的 atomic.Uint64,被切成了三段用,
低 32 位存 counter,记录还有多少个 goroutine 没完成。
第 33 位是一个 flag,只在 Go 内部的 synctest 测试框架里用,正常代码里永远碰不到。
高 31 位存 wait count,记录有多少个 goroutine 在 Wait 那里阻塞着。
把三个变量打包进一个原子变量,这个操作我第一次看到的时候愣了几秒。然后越想越觉得,太合理了。
你想象一下,counter 和 wait count 如果分成两个 atomic 变量会怎样?当 counter 归零的那一刻,wait count 的值还没来得及读出来,就会有一个中间状态——counter 已经是 0 了但还不知道有没有人在等。这种中间状态是并发 bug 的温床。
把三个相关的状态塞进同一个 64 位整数里,一个原子操作就能保证它们的一致性。不需要锁,不需要额外的同步,靠的就是「你要么全部读到,要么全部读不到」这个原子性。
这就是 Go 并发编程的设计哲学,不是让你少犯错,是让你没法犯错。
第三个字段 sema 是一个信号量。WaitGroup 不直接操作它,而是通过 runtime 的两个内部函数来用,runtime_Semacquire 负责阻塞当前 goroutine,runtime_Semrelease 负责唤醒等待的 goroutine。
这里有一个很有意思的细节。WaitGroup 没有用 channel 来做阻塞唤醒,而是直接怼到了 runtime 层的信号量。channel 本身也是一个结构体,有锁、有缓冲区、有排队逻辑,用 channel 来实现 WaitGroup 等于用一个大而全的工具去做一个小而精的活。不如直接对接 runtime 调度器,开销更小,语义更清晰。
好,聊完了结构体,我们来看看 Add 方法。
Add 是整个 WaitGroup 的核心入口,Done 其实就是 Add(-1)。Add 的职责不只是改 counter,它要在 counter 归零的时候判断有没有人在等、如果有就把它们全唤醒。
先看原子修改 counter 这一步,
state := wg.state.Add(uint64(delta) << 32)你看这行。delta 左移 32 位,刚好推到 counter 的位置。这里没有 mutex,不是因为 Go 的开发者不喜欢锁,而是因为 atomic.Uint64.Add 本身就是原子的——多个 goroutine 同时调 Add 或者同时调 Done,state 的更新不会出现竞争。
然后拆出两个值,
v := int32(state >> 32) // counter, 右移取高 32 位w := uint32(state & 0x7fffffff) // wait count, 低 31 位v 是 counter,右移 32 位取高 32 位。w 是 wait count,用 0x7fffffff 按位与,刚好取到低 31 位,顺便排掉了 bit[32] 的 flag 位。
接下来的两道校验让我印象很深。
第一道,counter 不能为负。Done 调多了?那对不起了,直接 panic。
if v < 0 { panic("sync: negative WaitGroup counter")}第二道,Add 不能和 Wait 并发。如果你已经有人在等了(w != 0),又往里面加任务(delta > 0 且这是第一次加),直接 panic。
if w != 0 && delta > 0 && v == int32(delta) { panic("sync: WaitGroup misuse: " + "Add called concurrently with Wait")}Go 的哲学在这里非常明确,宁可程序崩溃,也不允许静默的并发错误。panic 不是 bug,是设计。带着损坏的状态继续运行比崩溃可怕一万倍。
然后是最关键的一段,counter 归零且有等待者的时候,
for ; w != 0; w-- { runtime_Semrelease(&wg.sema, false, 0)}遍历等待计数,逐个释放信号量唤醒等待的 goroutine。唤醒之前,它还做了一次二次校验——把当前 state 再 Load 出来跟刚才操作的 state 比对一下,如果不一致,说明有人在 counter 归零的瞬间并发修改了 state,直接 panic。
这种「先检查、再操作、操作后再检查」的模式,在 sync 包里到处可见。它不是花活,是用最朴素的方式保证正确性。
唤醒完成之后,state 被重置为 0,WaitGroup 可以安全复用。
顺着上面的,再聊聊 Wait 方法。
Wait 的核心逻辑在一个 for 无限循环里完成。这是一个无锁自旋加 CAS 加信号量阻塞的模式。
先看 fast path,
v := int32(state >> 32)if v == 0 { return// counter 已归零,直接返回}如果 counter 已经是 0,所有 Done 都调过了,Wait 直接返回,不阻塞。这是最常见的「任务已经在线程池里跑完了,不需要等」的场景。
然后是一个 CAS 自旋。这一段,是我觉得整个 WaitGroup 最牛逼的地方,
if wg.state.CompareAndSwap(state, state+1) { runtime_SemacquireWaitGroup(&wg.sema, ...)}你仔细看第二行那个表达式,
state+1。
就这一个加 1 操作,让我沉默了好几秒。
因为 counter 在高 32 位,wait count 在低 31 位。对 64 位的 state 整体加 1,刚好等价于给 wait count 加 1——进位不会影响到 counter。这个操作在语义上是对 wait count 原子地加 1,但实现上你不需要手动去拆位、加值、拼回去,一个 +1 就搞定了。
这些写标准库的人,脑子里装的什么。
CAS 失败会怎样?有另一个 goroutine 跟你同时调了 Wait,或者 Add 改了 state。失败的 goroutine 回到 for 循环重新 Load state,重新尝试。这就是无锁并发的核心——不是让竞争不发生,而是让竞争发生的时候能正确重试。
runtime_SemacquireWaitGroup 返回之后,表示 Add 那边 counter 归零了,释放了信号量把 Wait 唤醒。这时候有一个防复用检查,
if wg.state.Load() != 0 { panic("sync: WaitGroup is reused before " + "previous Wait has returned")}被唤醒的时候 state 理论上应该被 Add 清零了。如果 state 不为 0,说明有人在 Wait 还没返回的时候就调了 Add 复用 WaitGroup。又一个直接 panic 的场景。
好,源码拆完了,回到我们日常写代码的场景。
WaitGroup 它有六条规则,我知道很多人把它们当「要记住的规矩」背的。但你看完源码之后,这六条规则不是背的,是你自己能推出来的。
第一条,Add 必须在 Wait 之前调用。根因就是 Add 源码里的那个 panic 检查——w != 0 且 delta > 0 且 v == int32(delta) 直接崩。正确的模式就一种,Add(n),启动 n 个 goroutine,Wait(),三者严格串行。
第二条,Done 次数必须等于 Add 的正数次数。Done 就是 Add(-1),Done 调多了 counter 变负,panic。
第三条,禁止值拷贝。noCopy 字段让 go vet 在编译期给你兜底,但你自己用的时候也得注意,我见过有人在 for 循环里把 WaitGroup 当成值传给闭包的,那个 bug 找到凌晨两点。
第四条,Wait 没返回的时候不能复用。根因是 Wait 被唤醒后会检查 state 是不是 0,不是就 panic。
第五条,Done 必须被调到。这个不是 WaitGroup 的问题,是 goroutine 的问题,如果你 goroutine 里提前 return 了、panic 了或者分支漏掉了,Done 没调,counter 永远归不了零,Wait 永久阻塞,goroutine 泄露。标准做法就是把 deferwg.Done() 放在 goroutine 第一行。
第六条,零值可用。varwgsync.WaitGroup 不用初始化,三个字段零值就是合法的。
写着写着,我突然想到一个事。
WaitGroup 为什么能这么简洁?三个字段,一百多行核心逻辑,搞定了一个几乎所有 Go 程序都在用的并发原语。
因为它做了几个非常关键的选择。
第一个选择是位分段打包状态。当你需要原子地修改两个相关变量的时候,最好的方案就是把它们放进同一个原子变量里。用锁虽然也能保证一致性,但在高并发的短临界区场景下,锁的开销比原子操作大一个量级。
第二个选择是 CAS 代替锁。竞争不是问题,不能正确处理竞争才是问题。CAS 自旋把一个「加锁→修改→解锁」三步操作变成了一次「尝试→失败→重试」的轻量循环,在临界区够短的时候,这比锁快得多。
第三个选择是 fail-fast。Go 对 WaitGroup 的使用错误是零容忍的,直接 panic,不给你静默损坏状态的机会。很多人觉得 panic 是 bug,但其实恰恰相反,一个确定的、崩溃得足够早的 bug,比一个在你生产环境跑了三个月才炸的竞态条件好一万倍。
我突然想起了大刘在《三体》里写的那句话,无知不是生存的障碍,傲慢才是。
WaitGroup 的设计,就是 Go 团队在跟并发编程说一句话,别傲慢。你以为你能处理好竞争条件?不,你不能。所以我把容易出错的地方全锁死了,你只能走我设计好的安全路径。
我觉得这就是 Go 这个语言最打动我的地方。它不是给你一堆高级的并发原语让你自己选,而是给你几个最简单的、但是绝对安全的工具,然后把复杂的事情替你藏起来了。
就像 WaitGroup。它把 counter 和 wait count 塞进一个 64 位整数,把阻塞和唤醒扔给 runtime 信号量,把值拷贝的错误交给 go vet 在编译期拦截。你只看到三个方法,Add、Done、Wait。你以为很简单。
简单,是因为有人在你看不到的地方替你承担了所有复杂度。
— END —
夜雨聆风