11.2 WaitGroup/Once/sync.Map
11.1 节解决了「多个 goroutine 同时读写同一份数据」的问题,但还有三类需求没被覆盖:
- 等待一组 goroutine 全部结束。10.1 节用了一个
donechannel 做握手,但那只适用于「等一个」。等 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 的官方文档明确写了两条约束:
- 传给它的函数不能 panic。因为
wg.Go内部用 defer 保证计数递减,panic 会沿着它自己的栈传播,语义与你手写的defer wg.Done()略有差别——不要依赖这个差别。 - 如果 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] 真正加载配置(只应出现一次)
只有一行,说明函数体确实只跑了一次。三个关键性质:
- 阻塞语义:第一个进入
Do的 goroutine 执行函数,其他 goroutine 会等它执行完才返回。所以LoadConfig()返回时cfg一定已经初始化好了。 - 失败也是一次:如果
Do里的函数 panic 了,Once会认为「已经执行过」,后续调用不再重试,直接返回(cfg可能还是 nil)。所以初始化逻辑里的错误要显式处理,不要靠 panic 表达。 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,所以取值时要自己做类型断言,这是它的主要缺点。
官方文档明确说明它针对两种场景优化:
- 键只写一次、读很多次,比如只增不减的缓存;
- 多个 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。
| GOMAXPROCS | sync.Map | RWMutex + map |
|---|---|---|
| 1 | 19.00 ns/op | 14.49 ns/op |
| 2 | 10.03 ns/op | 50.83 ns/op |
| 4 | 4.80 ns/op | 84.97 ns/op |
| 8 | 2.75 ns/op | 116.5 ns/op |
(Apple M1 Pro,go1.27.0)
这张表有两个反直觉的地方:
- 单核时 RWMutex 反而更快(14.49 vs 19.00)。没有竞争时,
RLock只是一次原子加,而sync.Map要走的读路径更长。 - 多核时 RWMutex 越并发越慢(14.49 → 116.5)。因为所有 goroutine 都在读写同一个读者计数,这个缓存行在核之间来回弹跳(cache line ping-pong),并发越高,代价越大。而
sync.Map的读路径几乎不写共享状态,所以并发越高越快。
这组数据说明:选择依据不是「读多写少」这个模糊描述,而是「有没有跨核竞争」。
11.2.7 什么时候不该用 sync.Map
sync.Map 的代价也很明显:
| 维度 | 普通 map + RWMutex | sync.Map |
|---|---|---|
| 类型安全 | 有(泛型 map) | 无(key/value 是 any,要断言) |
| 遍历 | for range,可随时 break | Range 回调,需返回 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 小结与练习
WaitGroup的Add必须早于Wait;wg.Go(Go 1.25+)是更简洁的写法。Once.Do是阻塞式的「只执行一次」,失败不重试;OnceValue/OnceValues是它的函数式封装。sync.Map为「键只写一次、读很多次」和「键集不相交」两类场景优化,不是通用替代品。- 实测数据表明:低竞争时
RWMutex更快,高竞争时sync.Map更快;判据是竞争强度而不是「读多写少」这个说法。
练习:
- 用
sync.OnceValues把 TaskAPI 的「打开数据库」封装成一个只会执行一次的初始化函数。 - 把 11.2.6 的基准测试抄下来跑一遍,把
-cpu换成你自己机器的核数,看曲线形状是否一致。 - 用
WaitGroup实现一个「并发抓取 10 个 URL,全部完成后打印耗时」的小程序,比较串行与并发的差距。
下一节我们把 -race 当成正式工具用起来:如何解读竞态报告、如何在 CI 里持续跑、以及它的盲区在哪里。
阅读导航:上一节:11.1 Mutex/RWMutex 与 atomic · 下一节:11.3 用 -race 发现竞态 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。