为什么 WaitGroup.Go 在 panic 时不会调用 Done

Go 并发中的一个细节:为什么 WaitGroup.Go 在 panic 时不会调用 Done?

在阅读 Go 标准库源码时,可以看到 sync.WaitGroup 的一个实现细节(Go 新版本提供的 WaitGroup.Go 方法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (wg *WaitGroup) Go(f func()) {
wg.Add(1)
go func() {
defer func() {
if x := recover(); x != nil {
// f panicked
panic(x)
}

wg.Done()
}()
f()
}()
}

其中有一个非常值得注意的设计:

如果 f() 发生 panic,代码 **不会调用 wg.Done(),而是重新 panic**。

为什么要这样设计?
理解这一点需要先了解 Go 中 panic、defer、goroutine 和 WaitGroup 的行为


一、panic 发生时 defer 会执行吗?

在 Go 中,发生 panic 时:

  1. 当前函数立即停止执行后续代码
  2. 开始进行 栈展开(stack unwinding)
  3. LIFO(后进先出) 顺序执行 defer
  4. panic 继续向调用栈上传播

示例:

1
2
3
4
5
6
7
8
9
10
func test() {
defer fmt.Println("defer1")
defer fmt.Println("defer2")

panic("boom")
}

func main() {
test()
}

输出:

1
2
3
defer2
defer1
panic: boom

说明:

  • panic 时 defer 仍然会执行
  • 执行顺序是 后注册先执行

二、goroutine 中 panic 的特殊之处

如果 panic 发生在 goroutine 中,并且 没有被 recover 捕获

程序会直接崩溃。

示例:

1
2
3
4
5
func main() {
go func() {
panic("error")
}()
}

输出:

1
panic: error

原因是:

未捕获的 panic 会终止整个程序,而不仅仅是当前 goroutine。


三、WaitGroup 的常见使用方式

最常见的 WaitGroup 写法:

1
2
3
4
5
6
7
8
9
10
var wg sync.WaitGroup

wg.Add(1)

go func() {
defer wg.Done()
work()
}()

wg.Wait()

执行流程:

1
2
3
4
5
6
7
Add(1)

goroutine 执行

Done()

Wait() 返回

这在大多数情况下都没有问题。

但如果 work() 发生 panic 呢?


四、如果 panic 时调用 Done 会发生什么?

假设代码如下:

1
2
3
4
5
go func() {
defer wg.Done()

panic("boom")
}()

执行流程:

1
2
3
4
5
6
7
8
9
10
11
panic

defer 执行

wg.Done()

Wait() 返回

main goroutine 继续执行

panic 仍在传播

可能出现的情况:

1
2
3
main goroutine 已经结束
程序提前退出
panic 信息被截断

也就是说:

Wait() 可能在 panic 完成之前返回。

这会产生一个 竞态条件(race condition)


五、WaitGroup.Go 的解决方案

Go 标准库采用的方案是:

1
2
3
4
5
6
7
defer func() {
if x := recover(); x != nil {
panic(x)
}

wg.Done()
}()

逻辑如下:

情况 行为
f 正常返回 调用 Done
f 调用 runtime.Goexit 调用 Done
f panic 重新 panic,不调用 Done

这样可以保证:

1
panic 一定优先终止程序

而不会出现:

1
2
3
Wait 提前结束
主 goroutine 继续运行
panic 被打断

六、为什么 Go 文档说 “f must not panic”

在文档中有一句:

1
The function f must not panic.

原因是:

如果 f panic:

1
2
3
wg.Done() 不会执行
WaitGroup 计数不会减少
Wait() 永远不会返回

不过由于 panic 会导致程序直接崩溃,这种情况通常不会持续运行。


七、与 Go 内存模型的关系

源码中还有一句说明:

1
the return from f synchronizes before the return of Wait

意思是:

1
2
3
f return
happens-before
Wait return

这保证:

1
2
goroutine 中的写操作
在 Wait 返回后一定可见

这是 Go 并发模型中的 happens-before 关系保证


八、总结

WaitGroup.Go 的设计体现了 Go 并发库的一个重要原则:

不要让同步原语掩盖程序错误。

因此在 panic 场景下:

情况 行为
正常执行 Done
Goexit Done
panic 重新 panic

这样可以保证:

  • panic 不会被吞掉
  • 程序不会进入不一致状态
  • 并发语义保持正确