wait不会死锁但可能饿死,因其采用自旋+休眠混合策略:先忙等约30次原子读,再调用runtime_semacquire阻塞;若done未被调用则永久阻塞,但死锁检测器不触发。
wait 方法为什么不会死锁但可能饿死协程
wait 本质是自旋 + 休眠的混合等待:它先忙等(循环读取 state 字段),直到计数器归零;若发现计数器长期不为零,会退避并调用 runtime_semacquire 进入系统级阻塞。这不是纯用户态自旋,但也不是一上来就挂起——go 运行时做了精细的退避策略(如指数退避、最大自旋次数限制)。
常见误用是把 Wait 放在没有对应 Done 调用的 goroutine 里,导致它永远卡在 runtime_semacquire,看起来像“死锁”,实则是“永久阻塞”。注意:Go 的死锁检测器(fatal error: all goroutines are asleep - deadlock)**不会触发**,因为至少有一个 goroutine 在系统调用中休眠,不算“asleep”。
- 自旋阶段最多约 30 次原子读(具体取决于 CPU 数量和运行时版本)
- 一旦进入
semacquire,该 goroutine 就交由调度器管理,不再消耗 CPU - 如果
Done被遗忘或调用次数不足,Wait永远不会返回
state 字段如何同时存计数器和 waiter 数
sync.WaitGroup 内部只用一个 uint64 字段 state,通过位运算复用:低 32 位存当前计数器(counter),高 32 位存等待者数量(waiters)。这是典型的无锁编程技巧,避免额外字段和锁开销。
例如:atomic.AddUint64(&wg.state, uint64(1) 表示增加一个 waiter;<code>atomic.AddUint64(&wg.state, ^uint64(0)>>32) 表示 Done 减 1(即对低 32 位减 1)。
- 计数器溢出(> 2³²−1)会导致未定义行为,所以不要让
Add(n)的n过大 -
waiters字段仅用于唤醒逻辑,不参与业务语义,你无法直接读取它 - 所有修改都通过
atomic操作完成,不依赖互斥锁
为什么不能在 Wait 之后调用 Add
Wait 返回意味着内部 state 的计数器已为 0,且所有 waiter 已被清理。此时若再调用 Add,会直接操作一个可能已被重置或正在被并发修改的 state,极大概率触发 panic: sync: negative WaitGroup counter —— 因为 Add 的原子加法会先检查低 32 位是否为负,而旧 waiter 链可能尚未完全清除,状态处于中间态。
- 典型错误模式:
go func() { wg.Wait(); wg.Add(1) }() -
Add必须在Wait开始前完成,且所有Add应在任何Wait调用之前确定好总量 - 想动态增减?说明设计不适合
WaitGroup,应换用chan struct{}或sync.Cond
Wait 和 Done 的内存序到底怎么保证
Wait 中的自旋读使用 atomic.LoadUint64(即 LOAD-acquire),而 Done 中的计数器减法使用 atomic.AddUint64(即 STORE-release)。这构成一对 acquire-release 语义对,确保 Done 之前的所有写操作(比如对共享数据的修改)对 Wait 返回后的代码可见。
但注意:这个保证**仅针对同一个 WaitGroup 实例上的配对操作**。如果你在 Done 里改了某变量 x,又在 Wait 返回后读 x,那是安全的;但如果你在另一个 goroutine 里并发读 x,且没其他同步手段,仍可能看到旧值。
- 别依赖
WaitGroup做跨 goroutine 的通用内存同步,它不是内存栅栏替代品 - Go 1.22+ 对
WaitGroup做了进一步优化,将部分路径转为更轻量的atomic.CompareAndSwap,但 acquire-release 语义不变 - 实际调试时,
go tool trace可以看到Wait的阻塞/唤醒事件,但看不到原子操作的内存序细节
真正容易被忽略的是:WaitGroup 不是引用类型的安全封装,它的零值可用,但一旦复制(比如作为结构体字段值拷贝、传参时不取地址),就会产生独立副本,导致 Wait 永远不返回。这点连很多资深 Go 程序员都会栽跟头。











