文章目录

Go语言sync.WaitGroup的Add和Done不匹配导致死锁

发布于 2026-05-06 00:19:44 · 浏览 13 次 · 评论 0 条

Go语言sync.WaitGroup的Add和Done不匹配导致死锁

Go 语言中的 sync.WaitGroup 是用于等待一组 Goroutine 完成执行的同步原语。死锁通常发生在 Add 增加的计数器与 Done 减少的计数器数量不一致,或者 Wait 被调用时计数器非零的情况下。解决此问题的关键在于严格维护计数器的平衡,并正确管理 Goroutine 的生命周期。

1. 理解 WaitGroup 的工作原理

WaitGroup 内部维护着一个整数计数器。其核心逻辑遵循以下规则:

  1. 计数器初始值为 0。
  2. 调用 Add(delta int) 将计数器增加 delta
  3. 调用 Done() 将计数器减 1(等同于 Add(-1))。
  4. 调用 Wait() 会阻塞执行,直到计数器变为 0。

如果 Wait 检测到计数器大于 0,它将无限期等待,导致程序死锁。反之,如果计数器变为负数,程序会直接 panic。

2. 识别死锁场景:计数不匹配

最常见的问题场景是“申请了资源但未释放”或“申请的资源数量与释放的数量不对等”。以下代码演示了一个典型的死锁案例:我们告诉 WaitGroup 有 2 个任务要执行,但实际上只启动了 1 个 Goroutine。

编写如下测试代码以复现问题:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup

    // 设置计数器为 2,表示需要等待 2 个任务
    wg.Add(2)

    go func() {
        // 任务 1 执行完毕
        fmt.Println("Task 1 running")
        time.Sleep(1 * time.Second)
        wg.Done()
    }()

    // 模拟忘记启动第二个任务,或者第二个任务逻辑出错没有执行到 Done
    // 这里为了演示,仅启动了一个 goroutine

    fmt.Println("Waiting for tasks to finish...")

    // 此时计数器剩余 1,Wait 将永远阻塞
    wg.Wait()
    fmt.Println("All tasks completed") // 这行代码永远不会被执行
}

运行上述代码,程序将挂起,最终会被 Go 运行时检测到死锁并报错:fatal error: all goroutines are asleep - deadlock!


3. 分析执行流程与阻塞点

为了更直观地理解死锁是如何发生的,我们可以通过以下流程图观察计数器与阻塞状态的变化逻辑。

graph TD A[Start] --> B[Initialize WaitGroup Counter=0] B --> C[Call Add delta=2] C --> D[Counter becomes 2] D --> E[Start Goroutine 1] E --> F[Call Done] F --> G[Counter becomes 1] G --> H[Call Wait Check Counter] H --> I{Is Counter == 0 ?} I -- No --> J[Block Main Goroutine Deadlock] I -- Yes --> K[Unblock and Continue] style J fill:#ffcccc,stroke:#333,stroke-width:2px style K fill:#ccffcc,stroke:#333,stroke-width:2px

在上图中,当执行流到达 H 节点时,计数器为 1,不满足释放条件,因此主 Goroutine 进入 J 节点的死锁状态。


4. 修复死锁的标准化步骤

要修复并预防此类死锁,必须确保 AddDone 严格配对,并遵循最佳实践。

步骤 1:在创建 Goroutine 之前调用 Add

绝不在 Goroutine 内部调用 Add。这会导致竞态条件,可能使 WaitAdd 执行前就触发,从而立即返回或导致错误。执行以下操作:

// 错误做法
go func() {
    wg.Add(1) // 竞态风险
    defer wg.Done()
}()

// 正确做法
wg.Add(1) // 在启动 goroutine 之前增加计数
go func() {
    defer wg.Done()
    // 业务逻辑
}()

步骤 2:使用 defer 确保资源释放

无论 Goroutine 内部发生 panic 还是提前 return,defer wg.Done() 都能确保计数器被正确减少。

修改之前有问题的代码:

func main() {
    var wg sync.WaitGroup

    // 1. 明确指定任务数
    wg.Add(2)

    go func() {
        defer wg.Done() // 使用 defer 确保最后执行
        fmt.Println("Task 1 running")
        time.Sleep(1 * time.Second)
    }()

    // 2. 补充第二个任务
    go func() {
        defer wg.Done() // 使用 defer 确保最后执行
        fmt.Println("Task 2 running")
        time.Sleep(1 * time.Second)
    }()

    fmt.Println("Waiting for tasks to finish...")
    wg.Wait()
    fmt.Println("All tasks completed")
}

5. 常见错误模式与修正对照表

下表总结了开发中容易导致 Add/Done 不匹配的错误模式及其修正方案。

错误模式 错误代码示例 后果 修正方案
在循环内错误 Add for i:=0; i<5; i++ { wg.Add(1); go worker() } (逻辑正确,但若 Add 在 loop 外) 计数器不足 wg.Add(1) 移至循环内部,或 wg.Add(N) 移至循环外部
忘记 Done wg.Add(1); go func() { fmt.Println("work") }() 永久死锁 添加 defer wg.Done()
重复 Done wg.Done(); wg.Done() (对应 Add(1)) Panic: "negative WaitGroup counter" 确保 Done 调用次数 <= Add 增加的次数
逻辑跳过导致 Done 未执行 if err != nil { return }; defer wg.Done() 若 err 非 nil,直接 return,跳过 defer,死锁 defer wg.Done() 放在函数开头最前面
并发调用 Add 多个 Goroutine 同时无锁调用 wg.Add 计数器混乱,可能导致死锁或 Panic 确保对 Add 的调用是串行的,或者在循环外统一调用

6. 预防性检查清单

在提交代码前,对照以下清单进行检查:

  1. 计数 Add 的总和是否等于 Done 的总和?
  2. 是否所有 Done 都被 defer 包裹,且位于函数体的最顶部?
  3. 是否存在 ifreturn 语句可能在 defer 执行前退出 Goroutine?
  4. 是否在 wg.Add() 调用之后立即 go func() 启动 Goroutine?

通过严格遵循上述步骤和规范,可以完全避免 sync.WaitGroup 因计数不匹配而导致的死锁问题。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文