为什么 WaitGroup.Go 在 panic 时不会调用 Done
Go 并发中的一个细节:为什么 WaitGroup.Go 在 panic 时不会调用 Done?
在阅读 Go 标准库源码时,可以看到 sync.WaitGroup 的一个实现细节(Go 新版本提供的 WaitGroup.Go 方法):
1 | func (wg *WaitGroup) Go(f func()) { |
其中有一个非常值得注意的设计:
如果
f()发生panic,代码 **不会调用wg.Done(),而是重新panic**。
为什么要这样设计?
理解这一点需要先了解 Go 中 panic、defer、goroutine 和 WaitGroup 的行为。
一、panic 发生时 defer 会执行吗?
在 Go 中,发生 panic 时:
- 当前函数立即停止执行后续代码
- 开始进行 栈展开(stack unwinding)
- 按 LIFO(后进先出) 顺序执行
defer - panic 继续向调用栈上传播
示例:
1 | func test() { |
输出:
1 | defer2 |
说明:
- panic 时
defer仍然会执行 - 执行顺序是 后注册先执行
二、goroutine 中 panic 的特殊之处
如果 panic 发生在 goroutine 中,并且 没有被 recover 捕获:
程序会直接崩溃。
示例:
1 | func main() { |
输出:
1 | panic: error |
原因是:
未捕获的 panic 会终止整个程序,而不仅仅是当前 goroutine。
三、WaitGroup 的常见使用方式
最常见的 WaitGroup 写法:
1 | var wg sync.WaitGroup |
执行流程:
1 | Add(1) |
这在大多数情况下都没有问题。
但如果 work() 发生 panic 呢?
四、如果 panic 时调用 Done 会发生什么?
假设代码如下:
1 | go func() { |
执行流程:
1 | panic |
可能出现的情况:
1 | main goroutine 已经结束 |
也就是说:
Wait()可能在 panic 完成之前返回。
这会产生一个 竞态条件(race condition)。
五、WaitGroup.Go 的解决方案
Go 标准库采用的方案是:
1 | defer func() { |
逻辑如下:
| 情况 | 行为 |
|---|---|
| f 正常返回 | 调用 Done |
| f 调用 runtime.Goexit | 调用 Done |
| f panic | 重新 panic,不调用 Done |
这样可以保证:
1 | panic 一定优先终止程序 |
而不会出现:
1 | Wait 提前结束 |
六、为什么 Go 文档说 “f must not panic”
在文档中有一句:
1 | The function f must not panic. |
原因是:
如果 f panic:
1 | wg.Done() 不会执行 |
不过由于 panic 会导致程序直接崩溃,这种情况通常不会持续运行。
七、与 Go 内存模型的关系
源码中还有一句说明:
1 | the return from f synchronizes before the return of Wait |
意思是:
1 | f return |
这保证:
1 | goroutine 中的写操作 |
这是 Go 并发模型中的 happens-before 关系保证。
八、总结
WaitGroup.Go 的设计体现了 Go 并发库的一个重要原则:
不要让同步原语掩盖程序错误。
因此在 panic 场景下:
| 情况 | 行为 |
|---|---|
| 正常执行 | Done |
| Goexit | Done |
| panic | 重新 panic |
这样可以保证:
- panic 不会被吞掉
- 程序不会进入不一致状态
- 并发语义保持正确