文章目录

Go 等待组:sync.WaitGroup 与同步

发布于 2026-04-03 21:07:18 · 浏览 3 次 · 评论 0 条

Go 等待组:sync.WaitGroup 与同步

在 Go 语言中,并发是核心特性之一。你经常需要启动多个 goroutine(轻量级线程)来并行处理任务,但主程序不能提前退出——否则后台 goroutine 会被强制终止。sync.WaitGroup 就是用来解决这个问题的标准工具:它让你能“等待”一组 goroutine 全部完成后再继续执行。


理解 WaitGroup 的基本原理

sync.WaitGroup 内部维护一个计数器。你通过以下三个方法操作它:

  • Add(delta int):增加(或减少)计数器的值。
  • Done():将计数器减 1(等价于 Add(-1))。
  • Wait():阻塞当前 goroutine,直到计数器归零。

关键规则:必须在启动 goroutine 之前调用 Add(),否则可能因竞态条件导致程序崩溃或逻辑错误。


基础使用步骤

  1. 导入 sync 包:

    import "sync"
  2. 声明一个 WaitGroup 变量:

    var wg sync.WaitGroup
  3. 在启动每个 goroutine 前,调用 wg.Add(1)

    wg.Add(1)
    go func() {
        defer wg.Done() // 确保函数退出时计数器减 1
        // 执行实际任务
    }()
  4. 在所有 goroutine 启动后,调用 wg.Wait() 阻塞主流程

    wg.Wait()

完整示例:并发打印

下面代码启动 3 个 goroutine,每个打印一个数字,主程序等待它们全部完成后才结束:

package main

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

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1) // 每次循环前增加计数
        go func(id int) {
            defer wg.Done()
            time.Sleep(time.Millisecond * 100) // 模拟耗时操作
            fmt.Printf("Goroutine %d finished\n", id)
        }(i)
    }

    wg.Wait() // 等待所有 goroutine 完成
    fmt.Println("All done!")
}

运行结果(顺序可能不同):

Goroutine 2 finished
Goroutine 1 finished
Goroutine 3 finished
All done!

常见错误与避坑指南

错误 1:在 goroutine 内部调用 Add()

// ❌ 危险!可能导致 Wait() 提前返回
go func() {
    wg.Add(1) // 错误位置
    defer wg.Done()
    // ...
}()
wg.Wait()

后果:如果 Wait()Add(1) 执行前被调用,计数器为 0,Wait() 立即返回,主程序退出,goroutine 被杀死。

正确做法:始终在启动 goroutine 之前调用 Add()


错误 2:忘记调用 Done()

// ❌ 计数器永远不会归零,程序永久阻塞
wg.Add(1)
go func() {
    // 忘记 wg.Done()
}()
wg.Wait() // 死锁!

解决方案:使用 defer wg.Done() 确保无论函数如何退出(包括 panic),计数器都会减少。


错误 3:负计数器

// ❌ panic: sync: negative WaitGroup counter
wg.Add(-1)

原因Add() 的参数不能使计数器变为负数。即使传入负值,也必须保证总和非负。

建议:除非你明确知道自己在做什么,否则只对 Add() 传正整数。


高级技巧:动态任务数量

有时你不知道要启动多少 goroutine(例如从队列消费任务)。此时可结合通道(channel)与 WaitGroup:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    tasks := []string{"A", "B", "C"}

    taskChan := make(chan string, len(tasks))
    for _, t := range tasks {
        taskChan <- t
    }
    close(taskChan)

    // 启动固定数量的工作 goroutine
    numWorkers := 2
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for task := range taskChan {
                fmt.Printf("Processing %s\n", task)
            }
        }()
    }

    wg.Wait()
    fmt.Println("All tasks processed")
}

此模式适用于“生产者-消费者”场景,WaitGroup 确保所有工作 goroutine 处理完通道中的任务后才退出。


WaitGroup 与其他同步机制对比

机制 适用场景 是否阻塞 是否可重用
sync.WaitGroup 等待固定/动态数量的 goroutine 完成 是(Wait() 阻塞) 否(计数器归零后不可再用)
channel goroutine 间通信、信号传递 可选(带缓冲/无缓冲)
sync.Once 确保某段代码仅执行一次 是(但只触发一次)

选择建议

  • 如果只是“等所有任务结束”,用 WaitGroup 最简单直接。
  • 如果需要传递数据或复杂协调,优先考虑 channel。

性能与注意事项

  • WaitGroup 的操作是原子的,线程安全,无需额外加锁。
  • Wait() 可被多个 goroutine 同时调用,它们都会在计数器归零时被唤醒。
  • 不要复用已归零的 WaitGroup。若需重复使用,应创建新的实例。
// ❌ 不要这样做
var wg sync.WaitGroup
wg.Add(1)
go func() { wg.Done() }()
wg.Wait()

wg.Add(1) // 这里可能 panic 或行为未定义
// ...

// ✅ 正确做法:重新声明或赋值
wg = sync.WaitGroup{}
wg.Add(1)
// ...

实战:并发下载文件

假设你要并发下载多个 URL,并确保全部下载完成后再继续:

package main

import (
    "fmt"
    "net/http"
    "sync"
)

func download(url string, wg *sync.WaitGroup) {
    defer wg.Done()
    resp, err := http.Get(url)
    if err != nil {
        fmt.Printf("Error downloading %s: %v\n", url, err)
        return
    }
    defer resp.Body.Close()
    fmt.Printf("Downloaded %s, status: %s\n", url, resp.Status)
}

func main() {
    urls := []string{
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
    }

    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go download(url, &wg)
    }

    wg.Wait()
    fmt.Println("All downloads completed")
}

注意:这里将 *sync.WaitGroup 作为参数传给 goroutine 函数,避免闭包捕获问题。


调试技巧:检测 WaitGroup 泄漏

如果程序卡在 Wait() 不动,很可能是某个 goroutine 没有调用 Done()。可通过以下方式排查:

  1. 添加日志:在每个 goroutine 开头和 Done() 前打印标识。
  2. 设置超时:结合 contexttime.After 避免永久阻塞。
  3. 使用 -race 标志编译:检测潜在的数据竞争。

例如,带超时的等待:

done := make(chan struct{})
go func() {
    wg.Wait()
    close(done)
}()

select {
case <-done:
    // 正常完成
case <-time.After(5 * time.Second):
    fmt.Println("Timeout! Some goroutines may be stuck.")
}

最佳实践总结

  1. 总是在启动 goroutine 调用 wg.Add(1)
  2. 总是在 goroutine 内部使用 defer wg.Done()
  3. 不要对同一个 WaitGroup 实例重复使用(归零后重建)。
  4. 优先传递指针*sync.WaitGroup)给函数,避免值拷贝导致计数器不一致。
  5. 结合 channel 或 context 处理超时和取消逻辑,避免死锁。
// ✅ 推荐写法模板
var wg sync.WaitGroup
for item := range items {
    wg.Add(1)
    go func(x Item) {
        defer wg.Done()
        process(x)
    }(item)
}
wg.Wait()

评论 (0)

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

扫一扫,手机查看

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