5.4 同步原语:Mutex、WaitGroup与原子操作


5.4 同步原语:Mutex、WaitGroup 与原子操作

本节摘要:channel 解决"传话",sync 包解决"守物"。本节从数据竞争的定义出发,覆盖互斥锁 Mutex 与读写锁 RWMutex 的用法与拷贝陷阱、WaitGroup 的计数器语义与 Add/Done/Wait 纪律、Once 的一次性保证、Cond 条件变量与原子操作的轻量路线,最后给出"channel 还是锁"的选型准则。

上手前先明确

阅读完本节,你应当能够:

  1. 定义数据竞争并说明其危害与检测手段;
  2. 正确使用 Mutex 保护临界区,避免锁拷贝与死锁;
  3. 用 RWMutex 优化读多写少场景;
  4. 用 WaitGroup 等待一组 goroutine 并遵守 Add 先行纪律;
  5. 用 Once 实现单次初始化;
  6. 在锁与 channel 之间做架构选型。

一、数据竞争:一切同步的靶心

数据竞争的充要条件:两个及以上执行流访问同一内存位置,至少一个是写,且没有同步关系约束顺序。后果不是"算错一次"那么温和——未同步的并发写是未定义行为,可能撕裂读、可能读到半新半旧。Go 内置竞态检测器,测试或运行时加一个标志即可开启:它会用动态 instrumentation 在运行中抓现行并打印两边的栈。任何并发代码合入前跑一遍,是最低门槛的卫生习惯。

counter := 0 for i := 0; i < 1000; i++ { go func() { counter++ }() // 数据竞争:千次并发写无保护 }

这段程序不加同步时结果随缘(很少是 1000)。修复手段就是本章接下来的全部内容。

二、Mutex:临界区的门锁

var ( counter int mu sync.Mutex ) func increment() { mu.Lock() counter++ mu.Unlock() }

Lock 与 Unlock 夹住的部分叫临界区——同一时刻只有一个执行流在里面。工程上更稳的写法是 defer Unlock,保证任何 return 路径都放锁:

func increment() { mu.Lock() defer mu.Unlock() counter++ }

两条铁律。锁不可拷贝:Mutex 的内部状态含"是否被持有"标志,拷贝一份等于两把锁各管各的,保护即刻失效——所以含锁的结构体要按指针传递,内建的静态检查工具会抓这个错误。临界区要小:锁住的是"数据的一致性窗口",不是"整个业务",IO、网络调用、大计算挪出锁外,吞吐立刻回来。

RWMutex 是读多写少场景的升级门:RLock 可多人同时持有,Lock 独占且与读互斥。代价是锁本身更重,写极频繁或临界区极小时反而不如普通锁,动手前用基准测试说话(第 6 章测试工具)。典型客户是"配置缓存":读请求洪峰、写分钟级更新。

三、WaitGroup:等大伙儿干完

三个方法一个模型:Add 加计数、Done 减一(等价 Add 负一)、Wait 阻塞到归零。纪律只有一条:Add 必须在 go 语句之前调用——放进 goroutine 里再 Add,主流程可能先 Wait,计数还是零直接放行,等了个寂寞:

var wg sync.WaitGroup for i := 1; i <= 5; i++ { wg.Add(1) // 先记账再开工 go func(id int) { defer wg.Done() // 收工销账 work(id) }(i) } wg.Wait() // 计数归零放行 fmt.Println("全部完成")

Done 用 defer 挂是最稳的,panic 也能销账。WaitGroup 是"一次性"的:归零后想再用得重新来一轮,别复用旧实例。

四、Once 与原子操作

Once 保证某函数全局只执行一次,无论多少 goroutine 同时抢:

var once sync.Once var conn *DB func getConn() *DB { once.Do(func() { conn = connect() // 并发调用也只连一次 }) return conn }

单例、配置加载、连接建立的标配,比"自己加锁加布尔标志"简洁且无竞态。

原子操作是计数器类场景的轻量路线,对整数与指针提供原子的加减、读、写、比较交换:

var requests int64 atomic.AddInt64(&requests, 1) // 原子加,无锁开销 n := atomic.LoadInt64(&requests)

一把 Mutex 的加解锁几十纳秒,简单数值统计用原子操作更省;但原子只护住"单个变量的一次操作",护不住"多变量的不变量",后者必须上锁。

五、Cond:了解即可

条件变量用于"等待某条件成立再继续"的场景:Wait 挂起并放锁、被唤醒后抢回锁再检查条件。它能把"轮询检查"换成"精确唤醒",但极易写错(虚假唤醒、错过信号),现代 Go 代码里绝大多数此类需求被 channel 与 select 取代。看懂老代码即可,新代码优先 channel。

六、选型:channel 还是锁

| 维度 | 倾向 channel | 倾向 sync 锁 |
| --- | --- |
| 通信内容 | 数据流、任务、结果 | 共享状态 |
| 结构 | 生产者消费者、管道 | 缓存、计数器、连接池 |
| 语义 | 所有权转移 | 就地保护 |
| 复杂度 | 拓扑清晰但通道变多 | 直接但易临界区膨胀 |

我的经验法则:数据在 goroutine 间流动、所有权清晰 → channel;数据待在原地、多方读写 → 锁。两者混用完全正常——一个带 Mutex 的缓存挂在 channel 管道中间,谁也不抢谁的戏。

💡 关键直觉:锁是卫生间的门(占用期间别人等着),channel 是快递柜(东西放进去人就走)。排队上厕所用门,传送包裹用柜,别用门传包裹。

七、同步工具选择图

问答补遗。锁的粒度怎么拿捏? 保守起步一粗锁,剖析证明竞争激烈再细化;先细后粗比重构容易,但过早细化会把不变量撕碎到多个锁里,死锁风险上升。死锁怎么排查? 运行时能检测"全阻塞"的死锁并打印所有 goroutine 栈;部分死锁(有人在空转)要靠超时与监控发现。trylock 有吗? 标准库近年补了尝试加锁的接口,但"拿不到就跳过"的语义极易藏 bug,用之前想清楚拿不到锁时业务到底该怎么办。

要点串联

  • 竞争三要件:并发访问、至少一写、无同步序;竞态检测器是必备卫生习惯。
  • Mutex 纪律:defer 放锁、锁不拷贝、临界区最小化。
  • RWMutex 适配读多写少,写频繁时未必划算。
  • WaitGroup 铁律:Add 先于 go,Done 用 defer,一次性使用。
  • Once 做单次初始化,原子操作护单变量计数。
  • Cond 认识即可,新代码优先 channel。
  • 选型口诀:流动用 channel,守物用锁。

下一节把取消与超时串成全链路:Context。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U