《Go 语言编程入门》11.2 WaitGroup/Once/sync.Map

上一节用锁保护了 store,但「谁等谁结束」「配置只加载一次」「只增不减的缓存」还缺趁手工具。本节讲透 WaitGroup 的 Add/Wait 时序与 Go 1.25 新增的 wg.Go、Once 与 OnceValue 的差别,并用实测数据回答 sync.Map 到底该不该用。

11.2 WaitGroup/Once/sync.Map

11.1 节解决了「多个 goroutine 同时读写同一份数据」的问题,但还有三类需求没被覆盖:

  • 等待一组 goroutine 全部结束。10.1 节用了一个 done channel 做握手,但那只适用于「等一个」。等 N 个要怎么写?
  • 只做一次初始化。TaskAPI 启动时要加载配置、打开数据库,这些操作既耗时又不能重复做,多个 goroutine 同时触发怎么办?
  • 并发安全的只增不减缓存。用 RWMutex 包一个 map 当然可以,但有没有更合适的结构?

这三个需求分别对应 sync.WaitGroup、sync.Once、sync.Map。

本节把 TaskAPI 推进到:用 WaitGroup 收口批量任务、用 Once 保证配置只加载一次、用 sync.Map 缓存任务查询结果。

11.2.1 WaitGroup:等待一组 goroutine

sync.WaitGroup 是一个计数器:

  • Add(n) 把计数加 n;
  • Done() 把计数减 1(等价于 Add(-1));
  • Wait() 阻塞,直到计数归零。

典型用法是「Add 在启动 goroutine 之前,Done 在 goroutine 里 defer」:

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		time.Sleep(20 * time.Millisecond)
		fmt.Println("worker 完成:", id)
	}(i)
}
wg.Wait()
fmt.Println("全部完成")

WaitGroup 的零值可用,不需要初始化,也不能复制(和 Mutex 一样)。

Go 1.25 起多了一个更方便的方法 wg.Go,它把「Add + 起 goroutine + defer Done」三件事合成一步:

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
	wg.Go(func() {
		time.Sleep(20 * time.Millisecond)
		fmt.Println("worker 完成")
	})
}
wg.Wait()

实测两种写法输出等价:

worker 完成: 2
worker 完成: 1
worker 完成: 3
全部完成

wg.Go 的官方文档明确写了两条约束:

  1. 传给它的函数不能 panic。因为 wg.Go 内部用 defer 保证计数递减,panic 会沿着它自己的栈传播,语义与你手写的 defer wg.Done() 略有差别——不要依赖这个差别。
  2. 如果 WaitGroup 当前是空的,Go 必须发生在 Wait 之前。这正是 11.2.2 要讲的时序问题。

11.2.2 最容易写错的时序

WaitGroup 有一个经典的误用:在 Wait() 之后才 Add。

var wg sync.WaitGroup
go func() {
	wg.Add(1) // 危险:这个 Add 可能与 Wait 同时发生
	// ...
	wg.Done()
}()
wg.Wait() // 可能在 Add 之前就返回了

Wait 看到计数为 0,直接放行,然后那个 goroutine 才开始跑——你以为等到了,其实什么都没等到。更糟的情况是 Add 恰好落在 Wait 返回的瞬间,触发 WaitGroup is reused before previous Wait has returned 的 panic。

规则很硬:Add(或 wg.Go)必须发生在 Wait 之前,而且要在「同一个 goroutine 里、有明确先后顺序」的位置调用。换句话说,别把 Add 藏进被等待的 goroutine 自己里面。

同样地,如果 WaitGroup 被复用来等多批任务,上一批的 Wait 返回之后才能开始下一批的 Add。官方文档的表述是:If a WaitGroup is reused to wait for several independent sets of tasks, new Go calls must happen after all previous Wait calls have returned.

11.2.3 Once:一次性初始化

TaskAPI 启动时要加载配置。这个动作有几个特点:耗时、只能做一次、可能被多个 goroutine 同时触发。

sync.Once 保证「无论多少 goroutine 调用,函数体只执行一次」,而且其他 goroutine 会阻塞到这一次执行完成——这一点比「用原子标志位自己实现」要强得多:

var (
	cfgOnce sync.Once
	cfg     *Config
)

func LoadConfig() *Config {
	cfgOnce.Do(func() {
		fmt.Println("[init] 真正加载配置(只应出现一次)")
		cfg = &Config{DSN: "file:taskapi.db"}
	})
	return cfg
}

起 5 个 goroutine 同时调用,实测输出:

[init] 真正加载配置(只应出现一次)

只有一行,说明函数体确实只跑了一次。三个关键性质:

  1. 阻塞语义:第一个进入 Do 的 goroutine 执行函数,其他 goroutine 会等它执行完才返回。所以 LoadConfig() 返回时 cfg 一定已经初始化好了。
  2. 失败也是一次:如果 Do 里的函数 panic 了,Once 会认为「已经执行过」,后续调用不再重试,直接返回(cfg 可能还是 nil)。所以初始化逻辑里的错误要显式处理,不要靠 panic 表达。
  3. Once 不可复制,同样只能放进指针接收者的结构体或包级变量。

Once 最常见的落点就是包级变量的懒初始化。不要在 init() 里做耗时操作——init() 无法处理错误,也无法延迟到真正需要时。

11.2.4 OnceValue / OnceFunc

Go 1.21 起,标准库提供了 Once 的函数式封装,写起来更紧凑:

函数返回
sync.OnceFunc(f func())一个只会执行 f 一次的 func()
sync.OnceValue[T](f func() T)一个只会执行 f 一次并返回其结果的 func() T
sync.OnceValues[T1,T2](f func() (T1,T2))同上,返回两个值(适合「值 + error」)

用 OnceValue 重写上面的配置加载:

var ConfigValue = sync.OnceValue(func() *Config {
	fmt.Println("[OnceValue] 加载配置")
	return &Config{DSN: "file:taskapi.db"}
})

// 任意 goroutine 调用都安全
c := ConfigValue()

它把「Once + 结果变量」的两段式压成了一行,而且结果变量不再需要暴露在包级作用域,封装性更好。缺点是不能像 Do 那样在调用点控制「什么时候初始化」——它是在第一次调用返回函数时决定的。

OnceValues 则特别适合「初始化可能失败」的场景:

var openDB = sync.OnceValues(func() (*sql.DB, error) {
	return sql.Open("sqlite", "file:taskapi.db")
})

db, err := openDB() // 无论调用多少次,sql.Open 只执行一次

(sql.Open 需要先 import _ "modernc.org/sqlite" 之类的方式注册驱动,第 14 章会展开。)

注意:即便第一次返回了 error,后续调用也不会重试,会把同一个 error 再返回一次。这通常正是你要的行为——初始化失败就是失败,不要在每次请求时重试。

11.2.5 sync.Map:为特定场景优化的并发 map

sync.Map 是标准库提供的并发安全 map,用法与普通 map 不同:

var cache sync.Map

cache.Store(key, value)          // 写
v, ok := cache.Load(key)         // 读
cache.Delete(key)                // 删
v, loaded := cache.LoadOrStore(key, value) // 有就读、没有就写
cache.Range(func(k, v any) bool { return true }) // 遍历

它不是泛型的,key 和 value 都是 any,所以取值时要自己做类型断言,这是它的主要缺点。

官方文档明确说明它针对两种场景优化:

  1. 键只写一次、读很多次,比如只增不减的缓存;
  2. 多个 goroutine 读写互不相交的键集合。

在这两种场景下,sync.Map 通过「读路径无锁」的设计大幅减少锁竞争。

用 sync.Map 给 TaskAPI 做一个查询缓存:

var cache sync.Map

func cacheTask(t Task) {
	cache.Store(t.ID, t.Title)
}

func lookupTitle(id int64) (string, bool) {
	v, ok := cache.Load(id)
	if !ok {
		return "", false
	}
	return v.(string), true // 需要类型断言
}

实测一段并发写入 + 读取:

缓存命中 #7 -> 任务7
删除后命中: false
缓存条目数: 49

这里 Range 遍历出 49 条,因为存了 50 条又删了 1 条——Range 的语义和普通 map 的 for range 一致,且不保证一致性快照:遍历过程中其他 goroutine 的增删可能反映在结果里。

11.2.6 实测:sync.Map 比 RWMutex 快多少

「读多写少就用 RWMutex,sync.Map 更优」这个说法流传很广,但它到底差多少?我写了一个基准测试:1000 个预计算好的 key,用 RunParallel 模拟多 goroutine 并发读,benchtime=300ms。

GOMAXPROCSsync.MapRWMutex + map
119.00 ns/op14.49 ns/op
210.03 ns/op50.83 ns/op
44.80 ns/op84.97 ns/op
82.75 ns/op116.5 ns/op

(Apple M1 Pro,go1.27.0)

这张表有两个反直觉的地方:

  1. 单核时 RWMutex 反而更快(14.49 vs 19.00)。没有竞争时,RLock 只是一次原子加,而 sync.Map 要走的读路径更长。
  2. 多核时 RWMutex 越并发越慢(14.49 → 116.5)。因为所有 goroutine 都在读写同一个读者计数,这个缓存行在核之间来回弹跳(cache line ping-pong),并发越高,代价越大。而 sync.Map 的读路径几乎不写共享状态,所以并发越高越快。

这组数据说明:选择依据不是「读多写少」这个模糊描述,而是「有没有跨核竞争」。

11.2.7 什么时候不该用 sync.Map

sync.Map 的代价也很明显:

维度普通 map + RWMutexsync.Map
类型安全有(泛型 map)无(key/value 是 any,要断言)
遍历for range,可随时 breakRange 回调,需返回 bool 控制
长度len(m) O(1)没有 Len 方法,只能 Range 数
复合操作可以在同一把锁里做多步只有单键的原子操作
单核低竞争更快稍慢

所以下面这些情况不要用 sync.Map:

  • 需要知道「有多少条」(没有 Len(),只能 Range 遍历,代价 O(n))。
  • 需要「读一个键、根据结果改另一个键」这类复合操作——sync.Map 只保证单键原子,跨键一致性还得自己加锁。
  • 键值类型固定、可以用泛型 map 表达——用 map[K]V + RWMutex 类型更安全。
  • 写操作很频繁且键集不断变化——这正是 sync.Map 不擅长的场景。

一句话:sync.Map 是一个专用工具,不是「并发版 map」的通用替代品。默认用 map + RWMutex,只有当你的访问模式确实命中官方说的那两种场景、且压测证明锁是瓶颈时,再换成 sync.Map。

11.2.8 组装进 TaskAPI

把本节的三个原语放进 TaskAPI 的启动流程:

type Config struct {
	DSN     string
	Workers int
}

type App struct {
	store *MemStore
	cache sync.Map
	once  sync.Once
	cfg   *Config
}

// 配置懒加载:多个组件同时触发也只加载一次
func (a *App) Config() *Config {
	a.once.Do(func() {
		a.cfg = &Config{DSN: "file:taskapi.db", Workers: 4}
	})
	return a.cfg
}

// 并发安全的标题缓存
func (a *App) CacheTitle(id int64, title string) {
	a.cache.Store(id, title)
}

// 批量处理:用 WaitGroup 收口
func (a *App) ProcessBatch(tasks []Task) {
	var wg sync.WaitGroup
	for _, t := range tasks {
		wg.Go(func() {
			a.store.Save(t)
			a.CacheTitle(t.ID, t.Title)
		})
	}
	wg.Wait() // 返回时所有任务都已处理完
}

三个要点:

  • Config() 用 once 保证「无论多少个 goroutine 先调用,配置只加载一次」,且调用方拿到的一定是加载完成后的值。
  • ProcessBatch 用 wg.Go + wg.Wait,语义是「这个方法返回时,所有任务都已落库」——调用方不需要关心内部并发了多少个 goroutine。
  • cache 和 store 各用各的同步手段:cache 是只增不减的查询缓存(命中 sync.Map 的场景),store 是需要复合操作的任务表(用 RWMutex)。

11.2.9 小结与练习

  1. WaitGroup 的 Add 必须早于 Wait;wg.Go(Go 1.25+)是更简洁的写法。
  2. Once.Do 是阻塞式的「只执行一次」,失败不重试;OnceValue / OnceValues 是它的函数式封装。
  3. sync.Map 为「键只写一次、读很多次」和「键集不相交」两类场景优化,不是通用替代品。
  4. 实测数据表明:低竞争时 RWMutex 更快,高竞争时 sync.Map 更快;判据是竞争强度而不是「读多写少」这个说法。

练习:

  • 用 sync.OnceValues 把 TaskAPI 的「打开数据库」封装成一个只会执行一次的初始化函数。
  • 把 11.2.6 的基准测试抄下来跑一遍,把 -cpu 换成你自己机器的核数,看曲线形状是否一致。
  • 用 WaitGroup 实现一个「并发抓取 10 个 URL,全部完成后打印耗时」的小程序,比较串行与并发的差距。

下一节我们把 -race 当成正式工具用起来:如何解读竞态报告、如何在 CI 里持续跑、以及它的盲区在哪里。

阅读导航:上一节:11.1 Mutex/RWMutex 与 atomic · 下一节:11.3 用 -race 发现竞态 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练