Go WaitGroup与Once——并发同步的基石

1. 引言

在 Go 并发编程中,sync.WaitGroup 和 sync.Once 是两个最基础也最常用的同步原语。WaitGroup 用于等待一组 goroutine 完成任务,而 Once 则确保某个函数只执行一次。理解它们的原理与易错点,是写出健壮并发代码的前提。

2. 核心知识点

2.1 sync.WaitGroup 三方法

WaitGroup 提供三个方法:

  • Add(delta int):增加或减少等待的计数器
  • Done():等价于 Add(-1),表示一个任务完成
  • Wait():阻塞直到计数器归零
go 复制代码
var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    // 执行任务
}()
wg.Wait()

2.2 Add 必须在 Wait 前调用

Add 必须在 Wait 之前调用,否则可能导致 panic 或 Wait 提前返回。这是因为 Wait 在计数器为 0 时会立即返回,如果在 goroutine 内部才调用 Add,Wait 可能已经返回了。

2.3 WaitGroup 不可复制

WaitGroup 内部包含计数器状态,复制后行为异常。因此,WaitGroup 应当通过指针传递,绝不能复制。

go 复制代码
// 错误:复制 WaitGroup
func process(wg sync.WaitGroup) { // 危险!
    defer wg.Done()
}

// 正确:传递指针
func process(wg *sync.WaitGroup) {
    defer wg.Done()
}

2.4 sync.Once:Do(f) 确保 f 只执行一次

sync.Once 的 Do(f) 方法确保函数 f 只执行一次,即使被多个 goroutine 并发调用。

go 复制代码
var once sync.Once
var config *Config

func getConfig() *Config {
    once.Do(func() {
        config = loadConfig() // 只执行一次
    })
    return config
}

2.5 Once 的实现:atomic + Mutex 双重检查

sync.Once 内部使用 atomic 和 Mutex 实现双重检查,确保高效且线程安全:

go 复制代码
type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

2.6 sync.OnceFunc / sync.OnceValue(Go 1.21+)

Go 1.21 引入了更便捷的封装:

  • sync.OnceFunc(f):返回一个只执行一次的包装函数
  • sync.OnceValue(f):返回一个只执行一次并缓存返回值的函数
go 复制代码
getConfig := sync.OnceValue(loadConfig)
cfg := getConfig()  // 首次调用执行 loadConfig
cfg2 := getConfig() // 返回缓存

3. WaitGroup 使用模式

3.1 主 goroutine Add,子 goroutine Done

最标准的模式:主 goroutine 负责 Add,子 goroutine 负责 Done。

go 复制代码
// 正确:主 goroutine Add
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        process(n)
    }(i)
}
wg.Wait()

3.2 子 goroutine 内 Add 需谨慎

在 goroutine 内部调用 Add 是危险的,可能导致 Wait 提前返回:

go 复制代码
// 错误:goroutine 内 Add
for i := 0; i < 10; i++ {
    go func(n int) {
        wg.Add(1) // 危险!Wait 可能已返回
        defer wg.Done()
        process(n)
    }(i)
}
wg.Wait()

3.3 配合 channel 实现优雅关闭

WaitGroup 常与 channel 配合,实现优雅关闭:

go 复制代码
var wg sync.WaitGroup
jobs := make(chan int, 10)

// 消费者
for i := 0; i < 3; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for job := range jobs {
            process(job)
        }
    }()
}

// 生产者
for i := 0; i < 10; i++ {
    jobs <- i
}
close(jobs)

wg.Wait() // 等待所有消费者完成

4. 易错点与常见误解

4.1 在 goroutine 内调用 Add

如上所述,这可能导致 Wait 提前返回,因为 Wait 可能在 Add 执行前就检查到计数器为 0。

4.2 复制 WaitGroup

复制 WaitGroup 会复制其内部状态,导致计数器不一致,行为异常。务必使用指针传递。

4.3 Done 调用次数多于 Add → panic

如果 Done 的调用次数超过 Add 的总数,计数器会变为负数,触发 panic:

go 复制代码
var wg sync.WaitGroup
wg.Add(1)
go func() {
    wg.Done()
    wg.Done() // panic: negative WaitGroup counter
}()
wg.Wait()

4.4 Once.Do 中的函数 panic 后,Once 仍视为已执行

如果 Once.Do 中的函数 panic,Once 仍会标记为已执行,不会重试:

go 复制代码
var once sync.Once
once.Do(func() {
    panic("boom") // panic 后 Once 仍标记为已执行
})
once.Do(func() {
    fmt.Println("不会执行")
})

5. 完整示例

5.1 WaitGroup 批量任务

go 复制代码
package main

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

func process(n int) {
    time.Sleep(time.Millisecond * 100)
    fmt.Printf("处理任务 %d\n", n)
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            process(n)
        }(i)
    }
    wg.Wait()
    fmt.Println("所有任务完成")
}

5.2 Once 幂等初始化

go 复制代码
package main

import (
    "fmt"
    "sync"
)

var (
    once   sync.Once
    config map[string]string
)

func loadConfig() map[string]string {
    fmt.Println("加载配置...")
    return map[string]string{"host": "localhost", "port": "8080"}
}

func getConfig() map[string]string {
    once.Do(func() {
        config = loadConfig()
    })
    return config
}

func main() {
    // 并发调用,但 loadConfig 只执行一次
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(getConfig())
        }()
    }
    wg.Wait()
}

5.3 OnceValue 简化写法(Go 1.21+)

go 复制代码
package main

import (
    "fmt"
    "sync"
)

func loadConfig() map[string]string {
    fmt.Println("加载配置...")
    return map[string]string{"host": "localhost", "port": "8080"}
}

func main() {
    getConfig := sync.OnceValue(loadConfig)

    cfg1 := getConfig() // 首次调用,执行 loadConfig
    cfg2 := getConfig() // 返回缓存

    fmt.Println(cfg1)
    fmt.Println(cfg2)
}

6. 总结

  • WaitGroup 用于等待一组 goroutine 完成,核心是 Add、Done、Wait 三方法
  • Add 必须在 Wait 前调用,且应在主 goroutine 中调用
  • WaitGroup 不可复制,应通过指针传递
  • Once 确保函数只执行一次,内部使用 atomic + Mutex 双重检查
  • Once.Do 中的函数 panic 后不会重试
  • Go 1.21+ 提供了 OnceFunc 和 OnceValue 便捷封装

掌握这些同步原语,能帮助你写出更健壮、更高效的并发代码。

相关推荐
ITOM运维行者1 小时前
PHP性能监控怎么做?从响应时间到慢函数的6个关键指标
前端·javascript·后端
程序员老陆1 小时前
通过std::unique_ptr初始化std::shared_ptr
开发语言·c++
liangshanbo12151 小时前
前端面试题:AI 对话中超长消息导致内存溢出,怎么解决?
java·开发语言·前端
RobinDevNotes1 小时前
亲手量化大模型,Mac实测和NVIDIA指南
人工智能·后端
Bazingga2 小时前
从0到1搭一个Agent:Spring AI显式ReAct循环完整实战
后端
LEE2 小时前
前端转型全栈 05:SQL 与迁移,AI 写的 SQL 怎么安全上线
前端·后端·ai编程
站大爷IP2 小时前
Python的列表删除把我坑惨了,原来remove和pop的区别这么大
后端
JWASX2 小时前
Java 转 go 学习 - GRPC(1)
学习·golang
montEvergreen2 小时前
RTMP 王国的“信笺百科全书”
后端·go