文章目录

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

发布于 2026-05-06 00:19:44 · 浏览 134 次 · 评论 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. 修复死锁的标准化步骤

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

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

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

// 错误做法
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. 是否存在 if 或 return 语句可能在 defer 执行前退出 Goroutine?
  4. 是否在 wg.Add() 调用之后立即 go func() 启动 Goroutine?

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

评论 (0)

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

扫一扫,手机查看

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