背景与动机
Go 经常强调“通过通信来共享内存”,但这并不代表所有并发问题都应该用 channel。只是在保护一个共享 map、计数器、缓存状态时,sync.Mutex 往往比 channel 更直接。
sync 包解决的是另一类问题:
- 等一组 goroutine 结束:
WaitGroup - 保护共享可变状态:
Mutex/RWMutex - 只执行一次初始化:
Once
这一篇只整理 sync 包中最常见的同步原语,不展开 channel 和 select 的通信模型。
核心原理拆解
1. WaitGroup:只负责等待,不负责取消和错误
WaitGroup 是计数器:
Add(n):增加待等待任务数量。Done():任务完成,计数减一。Wait():阻塞直到计数归零。
典型写法是先 Add,再启动 goroutine:
var wg sync.WaitGroup
wg.Add(1)go func() { defer wg.Done() doWork()}()
wg.Wait()WaitGroup 只负责“等完”。它不负责:
- 收集错误。
- 通知 goroutine 取消。
- 设置超时。
- 限制并发数量。
如果任务需要失败传播或取消,通常要配合 context.Context 和错误 channel,或者使用更高层的并发编排。
版本说明:WaitGroup.Go 是 Go 1.25 新增 API。为了兼容 Go 1.22+ 的基础笔记,这里仍以 Add/Done/Wait 写法为主。
2. Mutex:保护共享可变状态
Mutex 的作用是保证同一时刻只有一个 goroutine 进入临界区:
var mu sync.Mutexvar count int
mu.Lock()count++mu.Unlock()实际代码里通常用 defer 保证释放锁:
mu.Lock()defer mu.Unlock()
count++Mutex 不只是互斥,还提供内存可见性保证。一次 Unlock 先于后续成功的 Lock,所以后一个 goroutine 能看到前一个 goroutine 在临界区里完成的写入。这个语义来自 Go Memory Model 和 sync 包文档。
【并发编程 - WaitGroup、Mutex、Once 职责边界】

3. RWMutex:读多写少时才值得用
RWMutex 把锁分成读锁和写锁:
- 多个 goroutine 可以同时持有读锁。
- 写锁和读锁互斥。
- 写锁和写锁也互斥。
var mu sync.RWMutexvar cache = map[string]string{}
func get(key string) string { mu.RLock() defer mu.RUnlock() return cache[key]}
func set(key, value string) { mu.Lock() defer mu.Unlock() cache[key] = value}RWMutex 不是 Mutex 的无脑升级版。只有读操作明显多于写操作、临界区足够稳定时,它才可能带来收益。否则更复杂的锁语义可能让代码更难维护。
4. Once:并发安全地只执行一次
Once 用来保证某段初始化逻辑只执行一次:
var once sync.Oncevar config Config
func loadConfig() Config { once.Do(func() { config = readConfig() }) return config}多个 goroutine 同时调用 Do 时,只有一个会执行函数,其他调用者会等它执行完成。函数返回后,后续调用直接跳过。
需要注意:如果 Do 里的函数 panic,Once 仍然视为已经执行过,后续不会自动重试。如果初始化可能失败并且需要重试,就不能简单把整段逻辑塞进 Once.Do。
5. sync 类型首次使用后不应复制
Mutex、RWMutex、WaitGroup、Once 这类类型内部都带状态。首次使用后复制它们,等于复制内部状态,行为会变得不可预期。
常见风险是把包含锁的结构体按值传递:
type Counter struct { mu sync.Mutex n int}
func bad(c Counter) { c.mu.Lock() defer c.mu.Unlock() c.n++}更稳妥的方式是传指针:
func good(c *Counter) { c.mu.Lock() defer c.mu.Unlock() c.n++}最小可运行代码示例
下面这个例子把 WaitGroup、Mutex、Once 放在一起,但各自职责保持清楚:
package main
import ( "fmt" "sync")
type Counter struct { mu sync.Mutex n int}
func (c *Counter) Add(v int) { c.mu.Lock() defer c.mu.Unlock() c.n += v}
func (c *Counter) Value() int { c.mu.Lock() defer c.mu.Unlock() return c.n}
var once sync.Once
func initResource() { fmt.Println("init resource")}
func main() { var wg sync.WaitGroup counter := &Counter{}
for i := 1; i <= 3; i++ { wg.Add(1) go func(v int) { defer wg.Done() once.Do(initResource) counter.Add(v) }(i) }
wg.Wait() fmt.Println("total:", counter.Value())}这个例子里:
WaitGroup等待三个 goroutine 结束。Mutex保护Counter.n这个共享状态。Once保证资源初始化只发生一次。
常见陷阱与错误示例
1. 在 goroutine 内部调用 wg.Add(1)
错误写法:
for i := 0; i < 3; i++ { go func() { wg.Add(1) defer wg.Done() doWork() }()}
wg.Wait()Wait 可能先看到计数为 0 并返回,导致主流程误判任务已经结束。正确写法是先 Add,再 go。
2. 忘记 Unlock
mu.Lock()if failed { return // 锁没有释放}mu.Unlock()临界区里有多个返回路径时,优先用 defer mu.Unlock(),避免漏解锁。
3. 持锁做慢操作
mu.Lock()defer mu.Unlock()
resp, err := http.Get(url)_ = resp_ = err网络请求、磁盘 I/O、复杂计算不应该随手放在锁里。锁保护的范围越大,其他 goroutine 等待越久,也越容易形成复杂阻塞。
4. 复制包含锁的结构体
c2 := c1 // 如果 c1 已经使用过内部锁,这样复制很危险包含 sync 原语的结构体通常应该通过指针传递。需要复制业务数据时,应明确避开锁状态。
5. 以为 Once 失败后会自动重试
once.Do(func() { panic("init failed")})这次 panic 后,once 仍然认为函数已经执行过。后续 Do 不会再次运行这个函数。
性能影响或设计取舍
Mutex 适合保护小块共享状态。临界区越短、锁竞争越低,成本越可控。
RWMutex 适合读多写少。读写比例不明显时,它未必比 Mutex 更快,因为锁状态更复杂,写锁还要等待已有读锁释放。
WaitGroup 很适合“等一批任务结束”,但它不表达任务结果。需要结果就另建结果通道或受锁保护的数据结构;需要取消就配合 context.Context。
Once 适合不可变资源初始化,例如只加载一次配置、只初始化一次客户端。它不适合需要失败重试、动态刷新或按参数多实例初始化的场景。
channel 和 sync 的选择可以简单记成:
- 有数据流转、生产消费、生命周期通知:优先想 channel。
- 只是保护共享状态:优先想
Mutex。 - 只是等待任务结束:优先想
WaitGroup。 - 只初始化一次:优先想
Once。
面试高频问题
-
WaitGroup为什么要先Add再启动 goroutine? 因为Wait可能在Add执行前观察到计数为 0 并提前返回。 -
Mutex只是互斥吗? 不只是。Unlock先于后续成功的Lock,因此也建立了内存可见性关系。 -
RWMutex一定比Mutex快吗? 不一定。它只在读多写少、临界区合适的场景下可能更有收益。 -
Once中的函数 panic 后会怎样? 这次Do会 panic,并且Once仍视为已经执行过,后续不会自动重试。 -
为什么
sync类型首次使用后不能复制? 因为内部状态会被复制,多个副本之间不再代表同一个同步状态,行为不可预期。
一句话总结
sync 包解决的是“共享状态如何安全落地”和“任务完成如何等待”,它不是 channel 的替代品,而是并发编程里另一组更直接的同步工具。