首页 / Go 语言入门教程 / sync.WaitGroup 与 Once

Go 语言入门教程

sync.WaitGroup 与 Once

本教程共 80 篇 · 第 63 篇 · 更新于 2026-07-27 · 约 8 分钟阅读

GoGo 入门教程syncWaitGroupOnceCond并发同步

63. sync.WaitGroup 与 Once

本节目标:学会用 WaitGroup 等待一组 goroutine 完成,用 Once 保证初始化只执行一次,了解 Cond 条件变量。

sync.WaitGroup

前面我们用 time.Sleep 等子 goroutine,那是碰运气。正式做法是用 sync.WaitGroup。它像一个计数器:Add 加数,Done 减数,Wait 等到归零。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 5; i++ {
		wg.Add(1) // 计数 +1
		go func(n int) {
			defer wg.Done() // 计数 -1
			fmt.Println("任务", n)
		}(i)
	}

	wg.Wait() // 阻塞直到计数归零
	fmt.Println("全部完成")
}

三个方法:

  • Add(n int):计数器加 n,通常传 1
  • Done():等价于 Add(-1)
  • Wait():阻塞直到计数归零

Add 的位置很重要

Add 一定要在启动 goroutine 之前调用,不能放 goroutine 里面:

// 错误:Add 放 goroutine 里
for i := 0; i < 5; i++ {
	go func() {
		wg.Add(1)    // 可能主 goroutine 已经 Wait 了
		defer wg.Done()
		fmt.Println(i)
	}()
}
wg.Wait()

主 goroutine 可能先跑到 Wait,这时计数还是 0,直接放行,子 goroutine 后面才 Add,就漏了。

// 正确:Add 在外面
for i := 0; i < 5; i++ {
	wg.Add(1)
	go func(n int) {
		defer wg.Done()
		fmt.Println(n)
	}(i)
}
Warning

计数器不能变负数,否则 panic。确保 AddDone 的数量配对。

WaitGroup 不能复制

WaitGroup 内部有状态,复制后是另一个独立的计数器。传给函数必须用指针:

// 错误:传值
func work(wg sync.WaitGroup) {
	defer wg.Done() // 减的是副本的计数,原 wg 永远不归零
}

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

如果 WaitGroup 是结构体字段,也要确保结构体本身用指针传递,或者把 WaitGroup 声明为指针字段。

复用 WaitGroup

WaitGroup 可以复用,但必须等上一轮 Wait 返回后才能再次 Add。并发 AddWait 会有数据竞争。

var wg sync.WaitGroup

// 第一轮
wg.Add(2)
go func() { defer wg.Done(); work() }()
go func() { defer wg.Done(); work() }()
wg.Wait()

// 第二轮,复用
wg.Add(1)
go func() { defer wg.Done(); moreWork() }()
wg.Wait()

如果不想操心复用,直接每次新建一个也行。

sync.Once

sync.Once 保证某个操作在并发下只执行一次,常用于懒加载初始化:

var (
	once   sync.Once
	instance *Database
)

func getDB() *Database {
	once.Do(func() {
		fmt.Println("初始化数据库")
		instance = &Database{host: "localhost"}
	})
	return instance
}

无论多少个 goroutine 同时调 getDB,初始化只执行一次,所有调用拿到同一个 instance

Once 只有一个方法 Do(f func())

  • 第一次调用:执行 f
  • 后续调用:直接跳过
func main() {
	var once sync.Once
	var wg sync.WaitGroup

	wg.Add(3)
	for i := 0; i < 3; i++ {
		go func() {
			defer wg.Done()
			once.Do(func() {
				fmt.Println("只执行一次")
			})
		}()
	}
	wg.Wait()
}

输出只有一行「只执行一次」。

Note

sync.Once 内部用了原子操作和锁实现(Go 1.21 起优化为快速路径原子检查)。它比「自己加锁 + bool 标志」更安全,因为 Once 保证:即使 Do 里的 f 还没执行完,后续调用的 goroutine 也会阻塞等待,直到第一次执行完成。

Go 1.21 的新工具

如果你只是想懒加载一个值,Go 1.21 提供了更顺手的函数:

// 返回一个函数,首次调用执行 f,之后返回缓存结果
loadConfig := sync.OnceValue(func() *Config {
	return loadConfigFromDisk()
})

cfg := loadConfig() // 第一次:加载
cfg2 := loadConfig() // 后续:返回缓存
  • sync.OnceValue[T]:返回单个值
  • sync.OnceValues[T1, T2]:返回两个值(比如值和 error)
loadConfig := sync.OnceValues(func() (*Config, error) {
	return loadConfigFromDisk()
})

cfg, err := loadConfig()

比手动 Once + 变量更简洁。

sync.Cond 条件变量

sync.Cond 用于「等待某个条件满足」。它配合锁使用,提供三个方法:

  • Wait():释放锁并阻塞,被唤醒后重新加锁
  • Signal():唤醒一个等待者
  • Broadcast():唤醒所有等待者
var (
	mu   sync.Mutex
	cond = sync.NewCond(&mu)
	queue []int
)

// 消费者:等队列有数据
func consume() {
	mu.Lock()
	for len(queue) == 0 {
		cond.Wait() // 释放锁,等通知
	}
	v := queue[0]
	queue = queue[1:]
	mu.Unlock()
	fmt.Println("消费:", v)
}

// 生产者:加数据后通知
func produce(v int) {
	mu.Lock()
	queue = append(queue, v)
	cond.Signal() // 通知一个等待者
	mu.Unlock()
}
Warning

Wait 必须在 for 循环里,不能是 if。因为被唤醒时条件不一定真的满足了(可能有别的消费者抢先处理了),要重新检查:

for len(queue) == 0 {
	cond.Wait()
}
Tip

实际项目中 Cond 用得不多,因为 channel 通常能更优雅地解决同样的问题。了解它主要是为了读懂标准库或老代码。能用 channel 就别用 Cond

小结

  • WaitGroupAdd 加数、Done 减数、Wait 等归零
  • Add 必须在 goroutine 外调用,Donedefer
  • WaitGroup 不能复制,传指针
  • Once.Do:并发下只执行一次,用于初始化
  • Go 1.21 的 OnceValue/OnceValues 懒加载更方便
  • Cond 条件变量:Wait 必须在 for 里,但通常用 channel 替代

下一节讲 sync.Mapatomic 包。