09 sync 全家桶:Mutex 自旋与饥饿、Once、WaitGroup、atomic 内存序
开篇提问
先看一个几乎人人都写过的模式:
var mu sync.Mutex
var counter int
func inc() {
mu.Lock()
counter++
mu.Unlock()
}问题:当几十个 goroutine 同时抢这一把 mu.Lock() 时,没抢到的那些 goroutine,在干什么?
A. 空转(自旋),忙等 CPU B. 立刻睡过去,等唤醒 C. 先自旋一小会儿,抢不到再睡
答案是 C。但这里藏着一个更深的问题:为什么是”自旋一小会儿再睡”,而不是直接睡?自旋多久算合适?如果抢锁的 goroutine 太多、大家都不让,会发生什么?
这一章,我们拆开 Go 的 sync 包,从最底层的 Mutex,讲到 Once、WaitGroup、atomic,最后落到一个关键概念——内存序。
子主题一:Mutex 的三层策略——自旋、信号量、饥饿
Go 的 sync.Mutex 不是一把简单的”加锁/解锁”,它的内部是一个精心设计的状态机,有三种策略协作:
策略一:自旋(spin)。 当一个 goroutine 抢锁失败时,它不会立刻睡下,而是空转一会儿(反复检查锁是否释放)。为什么?因为”睡下再唤醒”要走操作系统调度,代价很高(微秒级);如果锁很快就被释放(临界区很短),空转几纳秒就等到了,比”睡下再醒来”便宜得多。
但自旋不能太久。 自旋在烧 CPU,如果锁迟迟不释放,空转就是浪费。所以 Go 的自旋有严格限制:只在”多核、且自旋次数有限(几十次)、且锁确实处于竞争态”时才自旋。
策略二:信号量(semaphore)。 自旋抢不到,goroutine 就”认怂”,把自己挂到操作系统的信号量上睡过去,让出 CPU。等锁释放时,再唤醒一个等待者。这对应”没抢到就睡”的阶段。
策略三:饥饿模式(starvation mode)。 这是 Go Mutex 最体现设计功力的一处。想象一个场景:一个 goroutine 睡了很久才被唤醒,它慢悠悠地醒来、准备抢锁;此时一个新来的 goroutine 正好在自旋,它”手快”,一把抢走了锁。这个睡醒的老实人,可能永远抢不过那些”新鲜、正在自旋”的新 goroutine——它被饿死了。
Go 1.9 引入了饥饿模式来解决这个问题:如果一个等待者等得太久(超过 1ms),Mutex 就切换到饥饿模式,把锁直接”递”给等待最久的那个 goroutine(FIFO),不再让新来的自旋者插队。等这个老实人拿到锁,再视情况退出饥饿模式。
这背后是一个经典的调度哲学:正常模式下,宁可牺牲公平换吞吐(让自旋者快速接手,减少切换);但当”不公平”导致某个 goroutine 饿死时,就要切回公平模式(FIFO)。 公平和吞吐,是一对永恒的张力,Go 的 Mutex 用”两段式”策略动态平衡。
子主题二:sync.Once——只执行一次的并发安全
sync.Once 解决的是”某个初始化只执行一次”的问题:
var once sync.Once
once.Do(func() { /* 初始化 */ })不管多少个 goroutine 同时调 once.Do,传入的函数只会执行一次,其他 goroutine 会阻塞等它执行完。
它的实现精妙在哪?在于 “fast path + slow path” 的经典结构:
- fast path:用一个 atomic 变量(
done标记)判断”是否已经执行过”。如果done == 1,直接返回,无锁。 - slow path:如果
done == 0,才加锁,再次检查(double-check),没执行过就执行,然后 atomic 置done = 1。
这里有两个关键点:
第一,double-checked locking。 加锁后再检查一次 done,防止”两个 goroutine 都通过了第一次检查、都去加锁,结果执行了两次”。
第二,内存序保证。 done 用 atomic 写,且 once.Do 保证了”函数执行的所有副作用,对 Do 返回后的所有 goroutine 可见”。也就是说,任何 goroutine 看到 done==1 时,初始化函数的副作用一定已经完成——这是 Once 的 happens-before 保证,你不用担心”看到 done 了但初始化还没做完”。
Once 是”懒加载 + 并发安全”的教科书实现,它内部的 fast path(atomic 读)让”已经初始化后”的调用几乎零成本,这是它比”手动加锁”高明的地方。
子主题三:sync.WaitGroup——等待一组 goroutine 完成
WaitGroup 解决的是”等待一组 goroutine 全部完成”:
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 干活
}()
}
wg.Wait() // 等 10 个 goroutine 都 Done它内部就是一个计数器(atomic)+ 信号量(等待者睡在信号量上,计数归零时唤醒)。
两个最常见的坑,务必记住:
坑一:Add 必须在 Wait 之前调用完(且不能在 goroutine 里并发 Add)。 正确用法是”先 Add 再 go”,或者至少保证”Add 发生在 Wait 观察到计数归零之前”。如果你在 goroutine 里 Add(1),可能 Wait 已经看到计数为 0 就返回了,新 Add 的 goroutine 没人等——这是数据竞争。
坑二:Done() 只能 Add 负数一次,不能把计数减到负数。 Add(-1) 使计数变负会 panic。所以 Add(1) 和 Done() 要严格配对。
子主题四:atomic 与内存序——真正的底层
sync/atomic 提供的是无锁的原子操作:atomic.AddInt64、atomic.LoadInt64、atomic.StoreInt64、atomic.CompareAndSwapInt64(CAS)等。
它的意义在两点:
第一,原子性。 一个 64 位变量的读写在 32 位平台上,可能被拆成两次 32 位读写,导致读到”撕裂”的值。atomic 保证读/写/加是不可分割的。
第二,内存序(memory ordering)。 这是更深的一层。现代 CPU 和编译器为了性能,会重排指令:你写的代码顺序,不等于实际执行顺序。atomic 操作提供了内存屏障,保证”某个 atomic 写之前的操作,不会被重排到它之后”(反之亦然)。
这里必须引入一个关键概念:Go 的 atomic 操作默认是”顺序一致(sequentially consistent)“的——它比 C++ 的 memory_order_relaxed 更严格。这意味着 Go 的 atomic 用起来更简单、更不易错,但代价是比”放松内存序”的 C++ atomic 略慢。Go 选择”默认强保证”,又是一次”用一点点性能换安全简单”的取舍。
什么时候用 atomic 而不是 Mutex? 经验法则:单个计数器的加减、单个标志位的读写、单个指针的 CAS,用 atomic;多个变量的组合操作、需要保护一段临界区,用 Mutex。 atomic 快(无锁、不阻塞),但只能保护”单个变量”;Mutex 慢一点,但能保护”一段逻辑”。选择的关键是”你要保护的是一个值,还是一段过程”。
子主题五:锁的本质——为什么锁也是”通信”
学完这些,回到一个哲学层面:锁和 channel,本质上是同一件事的两种表达——同步。
- 锁:通过”互斥”来同步——大家约定”同一时刻只能一个人进临界区”。
- channel:通过”交接”来同步——数据所有权随消息转移。
它们都要解决同一个底层问题:多个执行流之间,怎么保证”我对内存的修改,能被别人正确看到”(可见性)和”不会互相踩”(互斥)。
理解了这层,你就能跳出”锁 vs channel”的伪对立:它们都是”同步原语”,选哪个取决于你要表达的是”保护一块共享状态”(锁)还是”传递数据所有权”(channel)。 Go 同时给你两套工具,且都做得很扎实,这才是它并发能力强的原因——不是”channel 天下第一”,而是”该用什么用什么,且每个都足够好用”。
子主题六:Mutex 状态机——normal 与 starvation 到底怎么切
上一节我们说 Mutex 有”自旋→睡眠→饥饿”三层。现在把它剖开。先别急着看代码,先回答一个问题:
如果让你设计一把锁,你会怎么表达它的”当前状态”?至少需要几样信息?
- 锁现在有没有人拿着?(locked)
- 有没有人在排队?排了几个?(waiter 数量)
- 现在是不是在处理”有人被饿死”的紧急状态?(starvation 标志)
就这三样。Go 的实现把这三样信息塞进同一个整型状态字里,用一次原子操作就能同时读改——这就是为什么 Mutex 是 8 字节、为什么 sync.Mutex 零值可用(零值 = 未加锁、无等待者、非饥饿,正好是”干净的新锁”)。
注意:我特意不去列举这个状态字里哪一位是 locked、哪几位是 waiter 计数。因为那是内部实现、随时可能变,你依赖它就是给自己埋雷。要记住的是语义,不是位布局。
除了这三个,实现上还需要一两样辅助信息,其中最重要的一样是:
“是否已经有等待者被唤醒、正在抢锁”(woken 语义)。 为什么需要它?想象解锁的瞬间:如果队首的等待者已经被唤醒、正在 CAS 抢锁,那解锁方还去唤醒第二个等待者干什么?两个醒着的人抢一把锁,只会让第二个白醒一次(一次徒劳的调度 + 一次失败的 CAS)。这个标志就是用来避免重复唤醒的。
好,状态清楚了,来看两套模式。
normal 模式:吞吐优先,允许”插队”
normal 模式下的规则是:新来的 goroutine 可以和队首的等待者竞争,而且新来的赢面更大。
凭什么新来的赢?这不是偏心,是物理规律决定的:
- 新来的 goroutine 正在 CPU 上跑(它可能刚自旋完,热乎的;或者根本没自旋,一次 CAS 直接命中);
- 队首的等待者还睡在信号量上,它要先被唤醒、被调度器放到某个 P 上、恢复上下文,然后才执行 CAS。
一个在跑,一个还在穿衣服。 谁先摸到锁,用脚趾头都想得出来。
这个”不公平”是有意为之的。因为让”正跑着的人”直接接手锁,可以省掉一次完整的线程/goroutine 切换——这一次切换的成本是微秒级,而整个临界区可能只有几百纳秒。如果每次都严格 FIFO,那么”锁传递”的开销会比”执行临界区”还贵,吞吐直接腰斩。
所以 normal 模式的哲学是:牺牲公平换吞吐。
切换条件:等太久 → 拉响警报
那”公平”就完全不要了吗?不要。
Go 的做法是给每个等待者计时。一个等待者被唤醒后,会算一下”我从开始等(或从上次被唤醒)到现在,多久了”。如果这个值超过了一个阈值(源码里是一个常量,约 1 毫秒),它就把状态字里的 starvation 标志打开。
一旦进入 starvation 模式,规则彻底反转:
- 新来的 goroutine 不许自旋、不许抢。管你手多快,一进门发现是饥饿模式,直接去队尾排队睡觉。
- 解锁方不再”释放锁让别人抢”,而是”把锁直接递给队首”(handoff)。实现上就是:解锁时不清除 locked 位,而是直接信号量唤醒一个等待者,那个等待者醒来就知道”这把锁现在是你的了”,连 CAS 都不用做。
这就是 FIFO。锁的所有权像接力棒一样被传递,不存在竞争窗口。
类比一下:normal 模式是地铁早高峰挤门——谁力气大谁先上,整体通过率最高;starvation 模式是银行叫号——你拿的是 37 号,前面还有 12 个人,你就老老实实坐着,不许插队。地铁模式在人流可控时效率最高,但真有人被挤到永远上不去时,就必须换成叫号。
退出条件:两个,满足其一
饥饿模式不能一直待着——它是”急救措施”,代价是没有并发、没有吞吐(锁只能一个人一个人地传,无法被”正在跑的人”接手)。所以有两个退出条件:
- 等待队列空了。 当前这个被递锁的等待者,发现自己是最后一个(waiter 计数为 0 了),那还 FIFO 个什么劲?退回 normal。
- 队首等待者的等待时间已经小于阈值(约 1ms)。 说明”积压”已经被消化完了,队伍不再饥渴,退回 normal 换吞吐。
于是整条状态机就是:
normal(抢,吞吐优先)
│ 等待者等待时间 > 约 1ms
▼
starvation(递,FIFO 公平优先)
│ 队列空 或 队首等待时间 < 约 1ms
▼
normal
注意这条曲线是个”滞回”结构:进入的门槛和退出的门槛不是同一个瞬间,中间有一段”粘性”。这是所有自适应系统的通用手法——如果进出的判定条件完全相同,状态就会在临界点上疯狂抖动,反而更糟。TCP 的拥塞控制、JVM 的 GC 启发式、你的空调温控,全都是这个套路。
waiter 计数的三重作用
现在回头看”等待者数量”这个字段,它至少干三件事:
- 决定解锁时是否要走”唤醒”这条慢路。 解锁的 fast path 是:一次原子操作清掉 locked 位,然后检查有没有等待者——没有,直接返回。这意味着无竞争场景下的 Unlock 根本不碰信号量、不做系统调用,成本低到只有几十纳秒。反之,如果有人等着,才走信号量唤醒。
- 决定能否退出饥饿模式。 队列空不空,就靠这个计数。
- 饥饿模式下判断”还有没有人等着被递锁”。 递锁是一个个来的,递完了就回归自由竞争。
一个反直觉的结论由此而来:读多写少、几乎不竞争的锁,加解锁接近免费的。 你真正要害怕的从来不是”加锁”,而是”抢锁”。
自旋的完整前置条件
补充一下自旋的准入条件(只说条件,不谈具体次数——那个数是内部调优参数,会变):
- 必须多核(P 的数量 > 1)。 单核自旋是纯粹的自残:你占着唯一的 CPU 空转,持锁的人根本没机会运行去释放锁,等于白烧掉一个调度周期。
- 锁确实处于竞争态,且持有者正在运行。 如果持锁的 goroutine 自己都睡了,你转晕了它也醒不来。
- 同时自旋的线程数量有限。 runtime 会限制全局自旋的线程数(大致与 P 的数量挂钩),否则几十个线程一起空转,CPU 直接被锁的等待者吃光。
- 自旋循环里会插入 CPU 的 PAUSE 一类指令,让出流水线、降低功耗,同时减少对锁变量所在 cache line 的无谓争抢(不然自旋者自己就成了持锁者释放锁的阻碍)。
- 次数有限。 转几次发现没戏,立刻认怂去睡。
三件”文档里写了但你八成没读”的事
- Mutex 不是可重入锁。 同一个 goroutine 连续
Lock()两次,第二次会把自己锁死。需要重入?说明你的抽象分层有问题,重新设计。 - Mutex 不绑定 goroutine。 文档明确写了:允许 A goroutine 加锁、B goroutine 解锁。技术上可行,但这是一次显式的所有权交接,只有当你非常清楚自己在做什么时才用(比如某些异步完成语义)。默认当它不存在。
- 解锁一把没持有的锁会 panic。
Unlock一个未加锁或被别人持有的 Mutex,直接sync: unlock of unlocked mutex。
TryLock:一把你多半不该用的钥匙
Go 1.18 给 Mutex 和 RWMutex 加了 TryLock——抢得到就返回 true,抢不到立刻返回 false,绝不阻塞。
它解决的是什么?是**“我本来就不打算等”**的场景:比如后台的定时清理任务路过看一眼,锁被占着我就下次再来,绝不能因为我让主流程卡住。
它的文档里有一句很克制但很重要的提醒:别把 TryLock 当成主同步手段,也别用它写”随机退避重试”的业务逻辑。 为什么?因为 TryLock 失败不代表锁被持有——它可能只是在饥饿模式下排了队,或者锁此刻恰好空闲但被 handoff 出去了。用 TryLock 写”我以为我没拿到锁所以我跳过这个任务”的逻辑,迟早漏数据。
一句话:TryLock 是逃生舱,不是正门。
子主题七:RWMutex——读共享写独占,以及它埋的四个坑
假设你在写一个服务,内存里缓存了一张配置表:一万个 goroutine 在读,运维一天改两次。
用 Mutex?那一万个读者也要排成一队,一个一个进。荒谬吗?太荒谬了——读操作根本不修改数据,读者之间没有任何冲突,凭什么互斥?
问题来了:能不能让所有读者同时进,只有写者独占? 这就是 sync.RWMutex 的命题。
它的语义很直白:
- 读锁(RLock/RUnlock):可以被任意多个 goroutine 同时持有,读者之间不互斥。
- 写锁(Lock/Unlock):完全独占,和所有读锁、所有其他写锁都互斥。
内部大致靠三样东西支撑:一个读者计数(当前有几个读者在里面)、一个写者互斥/信号量、以及一个”有写者在等”的信号。
听起来很美。但坑,也全在这三样东西上。
坑一:写者到来,会阻塞新来的读者
这是 RWMutex 最反直觉、也最重要的行为:一旦有写者在排队等待,后续所有的 RLock 都会被阻塞,直到这个写者拿到锁、做完事、释放锁为止。
官方文档原话:如果一个 goroutine 持有读锁,另一个 goroutine 调用 Lock,那么后续到来的 RLock 会被阻塞,直到先前的读锁释放。
为什么要这么设计? 反过来想:如果不阻止新读者会怎样?
想象一条无限流的读者长龙,每个人读 10 微秒,但每微秒都有新读者加入。写者站在门口等”所有读者走光”——这个时刻永远不会到来。写者被饿死了,配置和现实的偏差无限扩大。
生活类比:公共厕所要换纸。管理员站在门口举着牌子**“暂停进入”**——已经在里面的人可以出来,但新人别进了。等里面清空,管理员进去三秒钟换完纸,再放行。如果不拦新人,换纸这件事永远发生不了。
所以:RWMutex 是”写者优先”的(writer preference)。这是防止写饥饿的必要代价。
代价是什么?写者会制造一次”读停顿尖刺”。在写者等待期间,所有新读者都被卡住。如果你的系统对读延迟的 P99 很敏感、而写操作又比较频繁,RWMutex 会把延迟曲线撕出锯齿。
坑二:递归读锁会把自己锁死
这是 Go 面试的常青题,也是线上真会发生的 bug:
var mu sync.RWMutex
func load(key string) (string, bool) { // 内部会 RLock
mu.RLock()
defer mu.RUnlock()
v, ok := cache[key]
return v, ok
}
func loadWithDefault(key string) string {
mu.RLock()
defer mu.RUnlock()
if v, ok := load(key); ok { // ← 又 RLock 一次!
return v
}
return "default"
}看起来人畜无害:读锁不是可共享的吗?嵌套两遍能有什么事?
出事的时机是:中间恰好插进来一个写者。
loadWithDefault拿到读锁(读者数 = 1);- 某个写者调用
Lock(),开始等待,并且点亮”有写者在等”的信号; loadWithDefault内部调用load,再次RLock()——因为有写者在等,这次 RLock 被阻塞;- 写者要等”读者数归零”才进得去,而
loadWithDefault的第一次读锁要等load返回才释放……
读者等写者,写者等读者。死锁。
而且它只在有写者恰好插入时才触发——所以它在你的测试环境里跑一万次都好好的,上线两周后在凌晨三点的流量低谷(恰好是配置刷新时段)崩一次。这种 bug 能让你怀疑人生。
怎么修?
- 最根本的:锁的持有范围不要跨函数。锁应该在”知道全貌的那一层”加,被调函数写成”不加锁的裸实现”(通常命名为
xxxLocked),由调用方负责加锁。这是标准做法,看 Go 标准库源码里满是fooLocked()就是这个道理。 - 如果确实需要分层,就用
RLocker()把读锁传给下层,或者干脆重构掉嵌套。
顺带一提:LoadWithDefault 这种”先读、读不到再写”的模式,本身就是锁升级(upgrade)的诱惑——而 RWMutex 不支持从读锁升级成写锁。你不能在持有读锁时去 Lock(),那必然死锁。正确做法是:先 RUnlock,再 Lock,进去之后重新检查一遍条件(因为中间可能已经被别人改了)。这个模式叫 double-check,和 sync.Once 里那个是同一个思想。
坑三:RWMutex 不总是比 Mutex 快
这大概是最大的误解。很多人看到”读多写少”四个字,条件反射就把 Mutex 换成 RWMutex。别。
算笔账:
- RLock 不是免费的。 它要原子地给”读者计数”加一,RUnlock 再减一。这个计数器是所有读者共享的一个 cache line。一百个核同时去加同一个数字,这就是教科书级的 cache line 争用(false sharing 的真共享版)——每一次加一都要独占那个缓存行,代价和一次 CAS 差不多。
- Lock(写锁)比 Mutex 贵得多。 它要标记”有写者在等”、等所有读者退出、操作信号量、醒来再清理标记。RWMutex 的写路径明显长于 Mutex。
- 如果临界区极短(比如一次 map 查找 + 返回),RWMutex 的额外簿记开销可能完全吃掉”读者并发”带来的收益,净结果是更慢。
粗略判据(别当圣旨,最终以 benchmark 为准):
- 读写比高到 9:1 甚至更高,且读临界区不是几纳秒级别的操作(比如要遍历、要序列化、要拷贝),RWMutex 才划得来;
- 临界区极短 → 优先 Mutex,甚至考虑 atomic;
- 读写比没那么夸张 → Mutex 更简单、更可预测。
记住这条元规则:任何”我觉得 X 更快”都必须翻译成”我 benchmark 过 X 更快”。 写两个 Benchmark,一个 Mutex 一个 RWMutex,用 -cpu=1,4,8,16 各跑一遍。你会发现答案完全取决于核数和临界区长度,根本没有通用结论。
坑四:和 Mutex 共享的那几条铁律
- RWMutex 同样不可复制,复制后语义全乱(
go vet的copylocks会抓)。 RUnlock一个没持有的读锁、Unlock一个没持有的写锁 → panic。- 写锁持有期间不要调用用户回调、不要做 IO、不要递归。
RLocker()返回一个sync.Locker接口(只有 Lock/Unlock),方便你把读锁传给”只需要加解锁语义”的通用代码。
那 RWMutex 的正确打开方式是什么?
一句话:保护的粒度是”一份数据结构”,而读操作占比压倒性地高,且读操作本身有一定长度。 典型场景:内存配置中心的快照、路由表、元数据缓存。
而且——最有效的优化往往不是换锁,而是”不共享”。下一节讲 atomic,再下一节讲性能,你会看到:配置热更新用 atomic.Value 或 atomic.Pointer[T] 存一个不可变快照,读路径连锁都不用,这才是最优解。RWMutex 是”必须共享可变结构”时的次优选择。
子主题八:atomic 操作族的完整图谱——Add / Load / Store / Swap / CAS
sync/atomic 一共就那么几个操作,但绝大多数人只用过 AddInt64 和 LoadInt64,另外三个压根不知道存在的意义。这一节把这五个全讲透。
先建立一个心智模型:atomic 是一个”一次只能做一个动作、但绝不会被打断”的最小同步单元。
对照生活:Mutex 是”锁上门的车间”,你在里面可以干一整套活;atomic 是”一把只能拧一颗螺丝的电动起子”,一按下去,一颗螺丝到位,但你没法用它组装一台机器。
一、Add:计数,以及一个反直觉的用法
var total atomic.Int64
total.Add(1) // 返回新值
total.Add(-1)Add 的语义:原子地做 new = old + delta,并返回 new。
用途显然:计数器、累加器、统计指标(QPS、错误数、字节数)。
反直觉用法:无符号数的减法。 atomic.Uint64 没有 Sub,但你可以加一个”补码”:
var inflight atomic.Uint64
inflight.Add(1) // 加一
inflight.Add(^uint64(0)) // 减一:加一个"全 1",在无符号算术里等价于减 1
inflight.Add(^uint64(n - 1)) // 减 n因为无符号整数是模 2^64 的环,加 2^64 - n 就等于减 n。这不是 hack,这是标准做法,Go 标准库内部就这么写。
Add 的一个陷阱: 它返回的是新值,不是旧值。想拿旧值?用 Swap(见下)或者 Add 完再 Add 回去(别,用 Swap)。
二、Load 与 Store:为什么”普通读写”不行
这是最需要讲清楚的一对。
var ready atomic.Bool
// goroutine A
ready.Store(true)
// goroutine B
if ready.Load() { /* ... */ }反问:我直接用 var ready bool 然后 ready = true / if ready 不行吗?
三个理由,一个比一个致命:
理由 1:撕裂(torn read)。 在 32 位平台上,一个 int64 的读写要拆成两次 32 位操作。写的一半、读的一半,中间被另一个 goroutine 插进来,你读到的就是一个从来没有存在过的值(高 32 位是新的,低 32 位是旧的)。atomic 保证整个 64 位是原子的。
理由 2:编译器优化。 这个更隐蔽,而且在 64 位平台上照样中招。看这段代码:
var stop bool
func worker() {
for !stop { // 编译器:stop 在循环里没被改过?那我把它提到寄存器里,只读一次
doWork()
}
}编译器完全有权利认为”这个循环里没人改 stop”,于是把它优化成”读一次,然后无限循环”。你在另一个 goroutine 里 stop = true,worker 永远看不到。 这不是理论——这是每个写过多线程代码的人都踩过的坑。
Go 编译器不会随便做这种激进优化(Go 的数据竞争是 undefined-ish,但 Go 团队刻意保持编译器的保守),可是一旦优化器变聪明、或者内联之后寄存器分配刚好这么做,你的程序就从”能跑”变成”偶尔卡死”。 依赖”编译器现在还比较笨”来写正确的并发代码,是把脑袋别在裤腰带上。
理由 3:内存序。 即使读写都是原子的、也没被优化掉,你还需要保证**“写这个标志之前做的所有事,对读到这个标志的人可见”。普通读写不提供任何顺序保证。atomic 操作提供——而且是顺序一致(seq_cst)**的强保证。
所以 Load/Store 的用途是:
- 标志位:优雅退出开关、特性开关(feature flag)、熔断器状态;
- 发布(publishing):把一个初始化好的对象”发布”出去,让其他 goroutine 安全地看到它;
- 读取共享计数器:统计打点、监控采样。
记住这个组合: Store 用来发布,Load 用来订阅。发布方先把数据准备好,最后一步 Store(指针);订阅方 Load(指针),一旦拿到非空指针,数据必定完整可见。这就是一个最小可用的”发布-订阅”,连锁都不需要。
三、Swap:被严重低估的一个操作
old := ptr.Swap(newVal) // 原子地:把新值写进去,把旧值交还给你Swap = 一次原子完成 “读旧值 + 写新值”,中间不给你任何插队的缝隙。
它有什么用?凡是**“我要把某个槽位里的东西整个取走,换上新的”**的场景,Swap 都是最优解:
// 场景:抢占式地取走当前缓冲区,换一个新的空缓冲
buf := curBuf.Swap(make([]byte, 0, 4096))
// 现在 buf 是"我独占的旧缓冲",别人再来 Swap 拿到的就是新缓冲了
flush(buf)这就是无锁日志/tracing 缓冲区的常见写法。用 Mutex 也能做,但 Swap 只要一条指令。
其他典型用途:
- 标记”已处理”并拿回旧状态:
if oldState.Swap(Closed) == Open { /* 只有第一个切换者能进来 */ }—— 这是一个一次性状态转换的原子实现,比 CAS 写起来还顺手; - 实现自旋锁 / 简单的无锁结构;
- 把值”摘下来”:比如无锁栈的 pop 之后清理。
Swap 的心智模型:一手交钱、一手交货,而且是在同一个瞬间。
四、CAS:乐观并发的发动机
ok := val.CompareAndSwap(old, new) // 只有"当前值 == old"时才写成 newCAS 是所有无锁算法的原子积木。它的语义是:“我以为现在是 A,如果是,就改成 B;如果不是,说明有人动过,我认栽。”
它和锁的哲学差异在哪?
- 锁(悲观):“我怕你改,所以先把你挡在外面,改完再放你进来。”
- CAS(乐观):“我猜你不会改,我先把活干完,提交的时候检查一下——没被改过就成交,被改过就重做。”
在低竞争下 CAS 完胜(不用进内核、不用上下文切换);在高竞争下 CAS 惨败(所有人都在重试,白烧 CPU,这就是”活锁”的雏形)。所以”无锁”不等于”更快”,无锁只是”在高竞争下不会有人被挂起”,代价是大家一起烧 CPU。
CAS 循环的正确骨架——请背下来:
for {
old := counter.Load()
newVal := compute(old) // 关键:基于 old 计算,不能基于"我以为的值"
if counter.CompareAndSwap(old, newVal) {
break // 成功
}
// 失败:回到循环顶部,重新 Load。禁止用上一次的 old!
}三个要点:
- 失败后必须重新
Load。 不要用上一次的old重试——那已经是一个过期的值,你只会再次失败(或者更糟:在你没注意的时候它碰巧又变回 old 了,你以为成功,其实语义错了)。 compute必须无副作用、可重入。 因为它可能被执行很多次。- 循环可能永远不结束(理论上)。实践中如果不收敛,说明你的竞争太激烈,应该改用锁或分片。
五、CAS 的阿喀琉斯之踵:ABA 问题
考虑一个无锁栈:栈顶是 A。
- 你想 pop:读到
old = A,算出new = A.next = B; - 在你 CAS 之前,别的 goroutine 把 A 弹走,又 push 了 C,又 push 了一个新的 A(可能是从对象池里复用的同一块内存!);
- 现在栈顶还是 A。你的 CAS 检查”当前是不是 A”——是! 于是成功,栈顶被设成 B。
- 但 B 早就不是”现在的栈顶的下一个”了,栈被你破坏了。
值从 A → B → A,CAS 看到”A 没变”,但世界已经变了。这就是 ABA。
怎么解?
- 版本号(tagged pointer):把”指针”和”版本计数”打包进一个字,每次修改版本 +1。CAS 比较的是”指针+版本”的组合体。经典解法,但有 128 位 CAS 的对齐要求,实现起来很讲究。
- 不复用内存 / 用 GC 兜底:在 Go 里,因为有 GC,只要你还持有那个对象的引用,它就不会被回收并复用成”新的 A”。Go 的 GC 天然缓解了很大一部分 ABA 问题(C/C++ 里那个经典的”我 free 了又被 malloc 回来”的场景,在有 GC 的语言里不成立)。这是 Go 写无锁代码比 C++ 轻松的一个隐性优势。
- 最实用的答案:别写无锁数据结构。 用
atomic.Value/atomic.Pointer[T]存不可变对象,整个替换,不做原地修改。不可变 = 没有中间态 = 没有 ABA。 99% 的业务场景,这才是正解。
六、Go 1.19 的类型化 atomic:一次”防呆设计”
老写法:
var n int64
atomic.AddInt64(&n, 1)
atomic.LoadInt64(&n)问题在哪?n 本身是个普通的 int64,编译器不会阻止你写 n++ 或 fmt.Println(n)。 一旦有人手滑写了一句普通读,数据竞争就悄悄溜进来了,而且这种模式极难 review。
Go 1.19 引入了类型化的原子类型:
var n atomic.Int64
var p atomic.Pointer[Config]
var b atomic.Bool
var u atomic.Uint64好处有三:
- 防呆:类型本身没有普通读写方法,你只能调用
Load/Store/Add/Swap/CAS。想写出数据竞争?写不出来。 - 自动对齐:32 位平台上,64 位原子操作要求 8 字节对齐。老写法里这是个著名陷阱——你必须把
int64放在结构体的第一个字段,否则在 32 位 ARM 上直接 panic(“unaligned 64-bit atomic operation”)。类型化 atomic 自己保证对齐,坑被埋掉了。 - 可读性:
n.Load()明显比atomic.LoadInt64(&n)干净。
结论:新代码一律用类型化 atomic,别再用老的 atomic.LoadXxx(&v) 函数族。 那套 API 还在,但已经不推荐了。
七、atomic.Value:任意值的原子容器
var config atomic.Value // 存 *Config
config.Store(&Config{Timeout: 3 * time.Second}) // 发布
c := config.Load().(*Config) // 订阅,零成本规则只有两条:
- 类型必须一致。 先
Store一个*Config,再Store一个*Rules→ panic。空的atomic.Value首次Store时的类型被”锁定”。 - 不能存 nil(
Store(nil)会 panic)。
用途:配置热更新。这是它的杀手级场景。读路径只有一次原子加载,不加锁、不阻塞、不 syscall,然后你拿到的是一个不可变的配置快照——读的过程中配置被改了?没关系,你手上这个旧快照依然完整自洽,用完就扔。
对比 RWMutex 方案: RWMutex 保护一个可变 config 结构,读的时候加锁、读完解锁,写的时候独占。atomic.Value 方案是写时整体替换、读时无锁快照。后者读性能高一到两个数量级,而且读者永远不会阻塞写者——写者只是替换指针,读者拿着旧对象继续用,GC 负责回收没人引用的旧快照。
这就是”用不可变性换并发安全”的经典套路。 函数式编程讲了几十年的东西,在这里变成了实打实的性能。
八、atomic 的边界:它保护不了一致性
最后一个反问,很重要:
var hits atomic.Int64
var total atomic.Int64
func rate() float64 {
return float64(hits.Load()) / float64(total.Load())
}两个操作各自都是原子的。这个 rate() 是对的吗?
不对。 你在 t1 时刻读了 hits,在 t2 时刻读了 total,中间两个值都可能变了。你算出来的比率是一个根本不存在的时刻的快照。如果要求”同一瞬间的 hits 和 total”,atomic 做不到——因为 atomic 的原子性单位是”一个变量的一次操作”,不是”一组变量的一个快照”。
那怎么办?
- 如果允许近似(绝大多数监控场景都允许)→ 无所谓,用它;
- 如果必须严格一致 → 用 Mutex 保护一个结构体里的两个字段,一次加锁同时读两个;
- 或者更妙:把 hits 和 total 打包进一个不可变对象,用
atomic.Pointer整体替换(写入方构造新对象,读方一次 Load 拿到自洽快照)。
这就是”atomic vs Mutex”的真正分界线:不是”快不快”,而是**“你要保护的,是一个值,还是一组值之间的不变量(invariant)”**。
子主题九:WaitGroup 与 Once 的实现细节补全
上一章讲过它们的基本用法,这里补三个层面的东西:内部是怎么搭的、fast path/slow path 在哪、以及那些文档里写了但你没注意的规则。
WaitGroup:一个 64 位字里塞两件事
WaitGroup 的内部结构极其精简,核心就两样:
- 一个 64 位的状态字,里面打包了两个计数:任务计数器(还剩几个任务没 Done)和等待者计数(有几个 goroutine 正卡在 Wait 上)。标准做法是把 64 位劈成两半各用一个(高 32 位 + 低 32 位,具体布局是实现细节,别依赖)。
- 一个信号量,给等待者睡觉用。
为什么要把两个计数塞进同一个字?因为对它们的修改必须是原子的、而且是同时的。 如果你用两个变量,就会出现”计数器归零了、但等待者计数还没加进去”的中间态,等待者就此错过唤醒信号、永远睡死。打包成一个字,就能用一次 CAS 同时更新两者,中间态不存在。
这个技巧值得记住:当你需要原子地更新一组相关的状态时,要么加锁,要么把它们打包进一个字用 CAS。 这是无锁编程的基本手法。
三个方法的 fast/slow path:
| 方法 | fast path | slow path |
|---|---|---|
Add(delta) | 一次原子加,把 delta 加到计数器上 | 计数器归零时,唤醒所有等待者(走信号量) |
Done() | 就是 Add(-1) | 同上 |
Wait() | 原子读状态字,若计数器已为 0,直接返回(不睡!) | 增加等待者计数,睡到信号量上,等被唤醒 |
注意 Wait() 的 fast path:如果所有任务已经 Done 了,Wait 根本不进内核,就是一次原子读。这是”先跑完再等”这种场景几乎零成本的原因。
三条规则,以及它们为什么是规则
规则一:Add 的正 delta 调用,如果发生在计数器为 0 时,必须先于 Wait。
这不是洁癖,是硬性的正确性要求。回到 fast path:如果 Wait 看到计数器是 0,它立刻返回。此时另一个 goroutine 慢悠悠地 Add(1) 并启动了一个任务——没人等它了。等它跑完调用 Done(),计数器变成 -1,panic:sync: negative WaitGroup counter。
所以”在 goroutine 里 Add”是绝对禁止的:
// ❌ 错误:Add 在 goroutine 内部
for i := 0; i < 10; i++ {
go func() {
wg.Add(1) // 主 goroutine 的 Wait 可能已经返回了!
defer wg.Done()
work()
}()
}
wg.Wait()
// ✅ 正确:先 Add 再 go
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()
// ✅ 也正确:一次性 Add,代码更简洁、也更快(少 N 次原子操作)
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()顺带说一句:最后一个写法是最好的,一次原子加 vs 十次原子加,还顺便杜绝了”循环体内 panic 导致少 Add”的问题。
规则二:计数不能减到负数。 Add(-1) 让计数器变负 → 直接 panic。Add 和 Done 必须严格配对,用 defer wg.Done() 是唯一可靠的姿势(panic 路径也能扣减)。
规则三:复用 WaitGroup 是允许的,但新一轮的 Add 必须在上一轮 Wait 全部返回之后。 理由和规则一同源:Wait 的 fast path 是靠”读一次状态”来判断的,如果旧的 Wait 还没真正醒来、新的 Add 就已经把计数改了,状态就混了。实践中别复用,直接 var wg sync.WaitGroup 新建一个——这东西才 12 字节,不值得你为省一次分配去冒这个险。
WaitGroup 的两个常见误用
误用一:把 wg 当参数值传进函数。
func run(wg sync.WaitGroup) { /* ... */ } // ❌ 复制了!go vet 会报警WaitGroup 内部含 noCopy,go vet 的 copylocks 检查会直接报 “passes lock by value”。复制一个 WaitGroup 意味着”两个独立的计数器”,Done 作用在副本上,主 goroutine 永远等不到。永远传 *sync.WaitGroup。
误用二:闭包捕获循环变量。 (Go 1.22 之后循环变量语义改了,这个坑基本消失;但如果你维护的是老代码、或者 GOEXPERIMENT 没跟上,还是要留意。)
WaitGroup 到底”等”的是什么?
一个容易混淆的点:WaitGroup 等的是”计数器归零”这个事件,不是一个”计数器的值”。 你不能用它实现”等到计数降到 5 以下”、“等到某个条件成立”——那是 sync.Cond 的活。
而且:WaitGroup 不传递错误、不传递结果、不支持超时、不支持取消。 一旦你需要这些,就该上 errgroup(golang.org/x/sync/errgroup,它会传播第一个错误、支持 WithContext 做取消)——用 channel + WaitGroup 手搓错误收集,是每个 Go 程序员都干过、然后都后悔的事。
Once 的实现补全
Once 的结构简单到让人失望:一个 done uint32 + 一把 Mutex。
流程:
Do(f):
fast path: atomic.Load(done) == 1 ? → 直接 return(无锁,零阻塞)
slow path:
加锁
if done == 0: ← double-check
f() ← 在持锁期间执行
置 done = 1(atomic 写)
解锁
四个必须讲清楚的点:
1. 为什么 fast path 能无锁? 因为 done 只有”0 → 1”这一个方向,永远不会从 1 变回 0。单向的状态转移 + 原子读 = 无锁安全。 这是并发设计里一个极其好用的模式:如果你的状态是单调的(只增不减、只前置不回退),读取方往往可以完全无锁。
2. 为什么需要 double-check? 因为”检查”和”加锁”之间有一道缝:两个 goroutine 可能同时读到 done == 0,然后排队进锁。第一个进去执行完、置了 done、解锁;第二个进去——如果不检查,就会再执行一次 f。所以必须加锁后再看一眼。这就是 double-checked locking。
3. done 为什么要在解锁之前用 atomic 写? 顺序很讲究:f() 的所有副作用 → atomic.Store(done, 1) → 解锁。如果在解锁之后才写 done,那么排在锁外面的 goroutine 拿到锁、进入 slow path 时可能看到 done 还是 0(因为写还没发生),于是它也要执行一遍 f ——而且此时没人拦得住它。先置 done 再放锁,保证了”任何后续进入者(无论走 fast path 还是 slow path)看到的 done 都是 1”。
配合 atomic 的顺序一致(seq_cst)语义,就有了 Once 的核心保证:任何 goroutine 看到 done == 1 时,f() 的所有副作用对它一定可见。 你永远不用担心”看到 done 了但初始化还没做完”。
4. 如果 f panic 了会怎样? 文档写得很明确:Do 认为 f 已经返回了,done 依然会被置为 1,后续调用不会重试 f。
这是个必须知道的行为——如果你在 Once 里做”初始化连接池”,第一次失败了(panic 或 error),Once 不会给你第二次机会,后续所有 Do 都直接返回,而你的连接池是 nil,然后全链路 NPE。
怎么办? 两种:
- 在
f内部自己重试 / 保证不失败; - 或者:不要用 Once 做可能失败的初始化。用
atomic.Pointer+ CAS 的”lazy init”模式,失败时把指针留在 nil 让下一次重试。
顺便,Go 1.21 加了 OnceFunc / OnceValue / OnceValues,把”Once + 一个全局变量”这个样板封装掉了:
var getDB = sync.OnceValue(func() *sql.DB {
db, _ := sql.Open("mysql", dsn)
return db
})
// 调用点
db := getDB() // 比 once.Do(func(){...}) 后跟一次读全局变量干净得多它带来的额外好处是:返回的是一个闭包,调用点不再需要 once.Do(func(){...}) 那种笨重的结构,而且类型安全(返回值有具体类型,不需要类型断言)。
Once 的两个坑
坑一:f 里再调同一个 Once.Do → 死锁。 因为 slow path 持有了那把 Mutex,而 Mutex 不可重入。是的,就这么简单,但线上真的会发生(通常是”初始化函数里间接调用了依赖它的另一个函数”)。
坑二:把 Once 放进结构体,然后复制结构体。 复制出来的 done 是副本,两个 Once 各管各的,f 会被执行两次。go vet 会抓,但如果你用 unsafe 或者经过 interface 中转,vet 可能就看不见了。
坑三(算是提醒):Once 和 init() 不是一回事。 init() 是包级的、在 main 之前执行、由 runtime 串行调用、不能返回错误、不能按需跳过;Once 是运行时的、可以放在任何作用域(比如每个连接一个 Once)、可以条件触发。需要延迟到真正用到时才做 → Once;包级无条件的准备工作 → init() 或者干脆包级变量初始化。
子主题十:性能与选型——atomic < Mutex < channel,以及量级感
现在给你一张”价格表”。先看数字,再谈哲学。
以下是在一台典型 x86-64 服务器上、无竞争情况下的数量级(不是精确值,别拿去写论文,数量级才是重点):
| 原语 | 无竞争时的量级 | 竞争时 |
|---|---|---|
| 普通变量读写 | 亚纳秒(且可能被优化掉) | —— |
atomic.Load/Store | 几纳秒 | cache line 争用时飙到几十~上百纳秒 |
atomic.Add / CAS | 约十纳秒级别(CAS 比 Load 贵,要独占 cache line) | 重试循环,可能无界 |
Mutex Lock+Unlock(无竞争) | 几十纳秒(一次 CAS + 一次原子写) | 微秒级(走信号量,进内核) |
RWMutex RLock+RUnlock | 几十纳秒(比 Mutex 略贵) | 微秒级 |
RWMutex Lock+Unlock(写锁) | 几十~上百纳秒 | 微秒级,且比 Mutex 写路径更长 |
| channel send/recv(有缓冲、不阻塞) | 几十~上百纳秒 | 微秒级(goroutine 挂起 + 唤醒) |
| channel send/recv(无缓冲/阻塞) | 微秒级(必然要交接) | 微秒级 |
WaitGroup.Wait / Once.Do(fast path) | 一次原子读,几纳秒 | —— |
把这张表从上到下扫一遍,你会发现一个惊人的事实:无竞争时,atomic 和 Mutex 的差距只有 2~5 倍,而且都在纳秒级。
换句话说:在绝大多数代码里,把 Mutex 换成 atomic 省下的那点时间,你根本测不出来。
那为什么还要优化?
因为价格表下半部分才是真相。
一旦发生竞争,Mutex 从”几十纳秒”跳到”微秒级”——这是 100 到 1000 倍的跃迁,中间跨了一整个量级。而这一次跃迁的触发条件不是”你用了 Mutex”,而是”有人跟你抢”。
所以优化的优先级排序,你应该倒过来念:
- 第一优先:消灭竞争(不共享 / 分片 / 每 goroutine 一份),而不是换更快的原语;
- 第二优先:缩短临界区(锁内只做 O(1) 的操作,把 IO、计算、序列化全部挪出去);
- 第三优先:换原语(Mutex → RWMutex → atomic)。
大部分人的精力全花在第 3 条上,收益最小;真正的大头在第 1 条,而它往往只需要改一行——比如把全局计数器变成 [64]atomic.Int64 按 CPU/goroutine id 分片,或者把共享 map 改成每 goroutine 一份、定期合并。
类比:堵车时,你该做的是”多修几条路”或者”让大家错峰出行”(消灭竞争),而不是”把每辆车换成跑车”(换更快的原语)。 跑车在堵死的高架上,和拖拉机一样快。
选型决策树
来,遇到并发问题,按这个顺序问自己:
Q1:你是在”保护一个值”,还是”保护一段过程”?
- 一个值(计数器、标志位、指针)→ atomic(或
atomic.Value/atomic.Pointer[T]) - 一段过程(多步操作要作为一个整体生效)→ Mutex
Q2:你要表达的是”保护共享状态”,还是”传递数据所有权”?
- 传递所有权:我要把这条数据/这个连接/这个任务交给另一个 goroutine → channel
- 保护状态:这份数据待在原地,大家轮流来看/改 → Mutex / RWMutex
Q3:你只是在”等一批活干完”吗?
- 是 → WaitGroup(或
errgroup,如果你还要收集错误/做取消) - 不是用 channel 数数,更不是
time.Sleep。
Q4:你只是”想让某件事只做一次”吗?
- 是 → Once / OnceValue / OnceValues,或者包级
init() - 不是手写
if !initialized { initialized = true; ... }(那是教科书级的数据竞争)
Q5:你在等”某个条件成立”吗?
- 是 →
sync.Cond(配合 for 循环检查),或者干脆用 channel 当条件通知
一句话选型总结
- atomic 是螺丝刀:一次拧一个值,快、轻、但干不了组装活。
- Mutex 是上锁的车间:一整段过程都归你,安全、直白,代价是别人要等。
- channel 是传送带:货物在上面流动,所有权跟着货走。用它来”保护一个变量”就像用传送带送一颗螺丝去隔壁工位——能用,但你会成为同事口中的谈资。
反过来也别走火入魔
用 channel 当互斥锁(容量 1 的 buffered channel,send 抢 token、recv 还 token)——技术上完全可行,但:
- 语义绕,读代码的人要停顿三秒才反应过来这是把锁;
- 没有
TryLock; - 最致命的:持有 token 的 goroutine panic 了,token 永远不会还回去,整个系统永久卡死。而 Mutex 有
defer Unlock()兜底。
这种写法是典型的”聪明反被聪明误”。 用对工具,别炫技。
最后一条元规则
任何关于性能的结论,都必须有 benchmark 背书。 至少这样写:
func BenchmarkCounterMutex(b *testing.B) { /* ... */ }
func BenchmarkCounterAtomic(b *testing.B) { /* ... */ }
func BenchmarkCounterSharded(b *testing.B) { /* ... */ }然后跑:
go test -bench=. -benchmem -cpu=1,4,8,16
-cpu 这个参数是灵魂——很多并发优化在 1 核上快得飞起,在 16 核上一塌糊涂(因为 cache line 争用是核数的函数)。只看单核 benchmark 就下结论,等于闭着眼睛选股。
子主题十一:锁的九种死法——常见误用与反模式
这一节是”别人的事故报告”。每一条都不是理论,都在线上真实发生过。
死法一:锁顺序颠倒(ABBA 死锁)
最经典的死锁。两个 goroutine,两把锁,顺序相反:
// goroutine A:先 accounts[1] 再 accounts[2]
mu1.Lock(); mu2.Lock(); transfer(); mu2.Unlock(); mu1.Unlock()
// goroutine B:先 accounts[2] 再 accounts[1]
mu2.Lock(); mu1.Lock(); transfer(); mu1.Unlock(); mu2.Unlock()A 拿着 1 等 2,B 拿着 2 等 1。两个人在门口互相让路,谁也过不去。
解法(按推荐度排序):
- 建立全局锁序。 给每把锁定一个”级别”,规定永远按级别从低到高获取。比如按账户 ID 排序:
if id1 < id2 { lock(a); lock(b) } else { lock(b); lock(a) }。这是最根本、最可靠的解法。规则本身比规则是什么更重要——只要所有人都遵守同一个顺序,死锁就不可能发生。 - 合并成一把锁。 如果两个资源总是一起被访问,它们本来就该共享一把锁。
- 一次性获取(all-or-nothing)。 用一个”资源组锁”包住整个集合。粗,但简单可靠。
- TryLock + 全有或全无。 抢不到第二把就全退回去重试。注意:这不是首选,因为退避重试活锁的复杂度不低,只在特殊情况用。
死法二:锁粒度太大
一把大锁保护整个 map / 整个缓存 / 整个业务对象。结果是:所有业务请求被串行化,你的 32 核机器跑出了单核的性能。
解法:分片(sharding)。
type shard struct {
mu sync.Mutex
m map[string]int
}
type ShardedMap struct {
shards [64]shard
}
func (s *ShardedMap) forShard(key string) *shard {
h := fnv32(key)
return &s.shards[h%64]
}争用概率直接降到 1/64。这个思路的终极形态就是 sync.Map(内部用 read map + dirty map 双缓冲,读走无锁路径)。
另一个方向:读写分离(RWMutex)、不可变快照(atomic.Pointer)、每 goroutine 一份 + 定期合并(这是很多高性能计数器的做法)。
反问:你怎么知道粒度太大了? 用 pprof 的 mutex profile:
runtime.SetMutexProfileFraction(1) // 采样比例,生产环境别开 1,开销大
// ... 然后 pprof 看 contention或者看 goroutine dump:如果几百个 goroutine 都卡在同一个 semacquire 上,那就是它了。
死法三:锁内做耗时操作
mu.Lock()
data, err := http.Get(url) // ❌ 网络 IO,几十毫秒
cache[key] = data
mu.Unlock()持锁 50 毫秒,QPS 直接被锁死在 20。锁内做 IO,是”用一把锁废掉整个服务”的最短路径。
正确姿势:把耗时操作全部挪到锁外,锁内只做 O(1) 的交接。
// ✅ 锁外做 IO
data, err := http.Get(url)
if err != nil { return err }
// 锁内只做一次指针/值的替换
mu.Lock()
cache[key] = data
mu.Unlock()更进一步的模式是 “锁外计算,锁内提交”:先在无锁状态下把新值算好(可能很贵),然后进锁只做一次 CAS 或赋值。这也是无锁算法的核心思想——把”临界区”压到只剩一次原子操作。
锁内绝对禁止的清单:网络调用、文件 IO、数据库查询、time.Sleep、复杂计算、可能阻塞的 channel 操作、调用用户传入的回调。
死法四:复制含锁的结构体
type Cache struct {
mu sync.Mutex
data map[string]string
}
func NewCache() Cache { /* ... */ } // ❌ 返回了值!
func (c Cache) Get(k string) string // ❌ 值接收者,每次调用都复制整个 Cache(含锁)后果是什么?
- 复制一份”已加锁”的 Mutex → 副本从”已锁”状态诞生,一上来就死锁;
- 更常见的:复制后变成两把不同的锁,它们各自保护各自的 map,看起来加了锁,实际上保护形同虚设。
好消息是:go vet 的 copylocks 检查能抓住绝大多数情况。所以:
- 永远把
go vet ./...放进 CI; - 结构体的所有方法都用指针接收者(只要结构体里有锁,就全用指针,别混着用——混用值接收者和指针接收者本身就是 bug 温床);
- 小心
for _, v := range caches(v 是副本)、小心闭包捕获、小心把锁放进会被 copy 的 struct。
死法五:defer Unlock 的范围失控
func handle(req *Request) {
mu.Lock()
defer mu.Unlock() // ⚠️ 锁会一直持有到 handle 返回
// ... 后面还有 200 行业务逻辑,包括 IO ...
}defer 在函数返回时才执行。如果临界区其实只有开头三行,那你持锁的时间被拉长到了整个函数的生命周期——这是死法三的变种,而且非常隐蔽,因为代码看起来”很规范”。
解法:用匿名函数把临界区圈起来。
func handle(req *Request) {
v := func() int {
mu.Lock()
defer mu.Unlock()
return counter // 锁的范围就这三行
}()
// 后面 200 行业务逻辑,不持锁
doSomethingSlow(v)
}原则:defer Unlock() 要紧跟着 Lock(),而 Lock() 要离它需要保护的那几行越近越好。
死法六:忘了解锁(尤其是 panic 路径)
mu.Lock()
riskyOperation() // 这里 panic 了
mu.Unlock() // 永远执行不到 → 锁永久丢失一个 goroutine 忘了还锁,所有其他 goroutine 全部永久阻塞。这比死锁还难查,因为”忘了加锁的那个人”可能早就消失了。
唯一正确的姿势:defer mu.Unlock()。 而且 Go 1.14 之后的 open-coded defer 已经把 defer 的开销压到几纳秒(没有额外分配、几乎就是一次函数指针跳转),别再为了”性能”手写 Unlock——那点收益远不抵你漏掉一个分支的风险。
死法七:锁内调用用户代码 / 外部回调
mu.Lock()
for _, cb := range callbacks {
cb() // ❌ 你不知道 cb 里会不会来抢这把锁
}
mu.Unlock()回调是”未知代码”。它可能重入、可能加锁、可能 panic、可能阻塞。在持锁状态下把控制权交给未知代码,等于把系统的生死交给一个你不认识的人。
解法:先拷贝出快照,解锁,再调用。
mu.Lock()
snapshot := make([]Callback, len(callbacks))
copy(snapshot, callbacks)
mu.Unlock()
for _, cb := range snapshot {
cb() // 不持锁,安全
}这就是”锁内只做拷贝,锁外做事情”的通用模式。
死法八:用 time.Sleep 做同步
go worker()
time.Sleep(100 * time.Millisecond) // ❌ "等它跑完"
fmt.Println(result)这是在赌。Sleep 赌的是”100ms 一定够”,而”一定”这两个字在并发里从来不成立。 机器负载高一点、GC 卡一下、调度抖一下,100ms 就不够了,然后你的程序在压力测试时偶发失败,而本地永远复现不了。
同步的活,交给同步原语:WaitGroup / channel / Once / Cond。time.Sleep 唯一合法的用途是在测试里做”等待条件成立”的粗暴近似,以及 select 的超时分支。
死法九:sync.Cond 的 if 与 for
sync.Cond 是被用得最少、也最容易用错的同步原语,但它有不可替代的场景:“等到某个条件成立”(WaitGroup 只能等到”计数归零”,channel 适合”收到一个事件”,都不等于”等到条件”)。
var mu sync.Mutex
var cond = sync.NewCond(&mu)
var queue []Task
// ❌ 错误:用 if
cond.L.Lock()
if len(queue) == 0 {
cond.Wait()
}
task := queue[0]
cond.L.Unlock()
// ✅ 正确:用 for
cond.L.Lock()
for len(queue) == 0 { // 必须用循环!
cond.Wait()
}
task := queue[0]
cond.L.Unlock()为什么必须是 for? 因为 Wait() 返回不代表条件成立。
Wait内部会先解锁、再睡、被唤醒后再加锁——在你”睡→被唤醒→重新拿到锁”这段时间里,别的 goroutine 完全可能已经把任务取走了;Broadcast()会唤醒所有等待者,但只有一个能抢到锁并拿到任务,其他人醒来发现队列又空了;- 某些平台/实现上还可能存在虚假唤醒(spurious wakeup)。
用 for 是唯一的正确写法。这不是风格问题,是正确性问题。
Signal() vs Broadcast():Signal 唤醒一个(更省,但你要确定任何一个醒来都能干活);Broadcast 唤醒全部(更保险,比如”条件从成立变为不成立”这类状态广播)。不确定就用 Broadcast,多醒几次的代价远小于漏醒一次。
附:一份 Code Review 自检清单
把这张表贴在你的团队 wiki 上:
- 加解锁配对了吗?有没有
defer Unlock()? - 临界区里有没有 IO / RPC / 数据库 / Sleep / 回调?
-
defer Unlock的持锁范围是不是比实际需要的大? - 有多把锁吗?获取顺序全局一致吗?
- 结构体里有锁吗?方法是不是全用了指针接收者?
-
go vet ./...过了吗(copylocks / loopclosure)? -
Add在go之前吗?Done是defer的吗? - RWMutex 有没有嵌套 RLock / 锁升级?
-
Cond.Wait外面是for还是if? - 有没有用
Sleep当同步? - 这个场景真的需要锁吗?(无共享 / 不可变 / 分片 能不能解决?)
子主题十二:业界对照补强——C++ 的内存序、Rust 的所有权、Java 的锁升级
前面的”业界对照”给了一张速览表。这一节把三个最值得深挖的对照点展开。因为理解别人为什么那么设计,才能真正理解 Go 为什么这么设计。
对照一:C++ 的六档内存序 vs Go 的”只有一档”
C++ 的 std::atomic 允许你为每一个原子操作指定内存序,一共六档:
| 内存序 | 保证 | 典型用途 |
|---|---|---|
memory_order_relaxed | 只保证原子性,不保证任何顺序 | 纯计数器、统计数据(不需要和其他数据同步) |
memory_order_consume | 理论上比 acquire 弱,实践中基本等同于 acquire(已被标准”不鼓励使用”) | —— |
memory_order_acquire | 本操作之后的读写,不会被重排到它之前 | 加锁(lock)、获取引用 |
memory_order_release | 本操作之前的读写,不会被重排到它之后 | 解锁(unlock)、发布数据 |
memory_order_acq_rel | acquire + release | 读-改-写操作(如 exchange) |
memory_order_seq_cst | 全局唯一总序,所有线程看到的操作顺序完全一致 | 默认档,最强最慢 |
这套体系能干什么?举一个真实的优化:
引用计数的释放。 用 fetch_sub(1, memory_order_release) 而不是 seq_cst。为什么这样是对的?因为”减到 0 说明没人再引用了”这个语义,需要的是 release 语义(保证我之前的所有修改对后续看到 0 的人可见),而不需要全局总序。
这么一改,在 ARM/POWER 这类弱内存序架构上,每减少一次引用就少一条屏障指令。在 shared_ptr 这种被疯狂拷贝的东西上,累积收益非常可观。
那代价是什么?
代价是心智负担。写 memory_order 的人必须能回答:
- 这个操作和哪个操作配对?
- 我需要的是 acquire、release 还是 acq_rel?
- 如果这里用 relaxed,会不会让某个线程看到”半初始化”的对象?
- 在 x86 上测不出来(x86 硬件本身就偏强),换到 ARM 服务器上就偶发崩溃——你能复现吗?
这就是最难查的一类 bug:它对的概率是 99.9999%,它在你的开发机上永远不出现,它在客户的 ARM 服务器上每周崩一次,而崩溃现场的数据看起来”不可能是错的”。
Go 的取舍:只给一档——顺序一致(seq_cst)。
sync/atomic 里没有 relaxed、没有 acquire/release,所有操作都是顺序一致的。(Go 1.19 官方重写了内存模型文档,把 atomic 的顺序一致性、以及 sync 包各原语的 happens-before 关系,正式写进了语言规范——这是 Go 在并发语义上的一次重要”立法”。)
凭什么这么任性?三个理由:
- 主战场是 x86/x86-64。 在这上面,acquire/release 基本是免费的(硬件已经足够强,主要影响的是编译器重排),而 seq_cst 的额外代价主要体现在 store 需要
xchg或mfence——可控。真正的差距在 ARM/POWER 上才显著。 - 目标用户是业务工程师,不是内核作者。 Go 团队明确表示过:能正确写出 relaxed 内存序代码的人是极少数,而这类错误的代价是整个系统的不可靠。
- 多一个选项,就多一整类 bug。 少一个档位,不是”功能缺失”,是把一整类最难查的问题从生态里删掉。
反问一句:你真的需要那 20%~30% 吗? 如果你的程序真的卡在原子操作的屏障开销上,正确的答案通常不是”降内存序”,而是**“别用 atomic,改成不共享”**——让每个 goroutine 持有自己的计数器,最后合并。架构层面的优化,永远比指令层面的优化收益大。
这就是 Go 一以贯之的哲学:砍掉选择空间,用一点点性能上限,换掉一整类灾难性错误的可能性。 和它取消隐式数值转换、强制 {}、砍掉继承、只留一种循环,是同一个价值观。
对照二:Rust 的 Mutex<T>——让编译器替你记住加锁
Go 的写法:
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.n++ // 忘了 Lock?编译器不管。忘了 Unlock?编译器也不管。
c.mu.Unlock()
}注意这里的根本问题:mu 和 n 是”约定上相关”的两个字段,但这个相关性没有任何机制保证。 你可以不加锁直接读 c.n,编译器一声不吭,代码跑得好好的,然后在高并发下数据错乱。
Rust 的写法:
use std::sync::Mutex;
let counter = Mutex::new(0); // 锁包着数据
*counter.lock().unwrap() += 1; // 必须 lock() 才能碰数据Mutex<i32>,不是 (i32, Mutex)。 一字之差,天壤之别:
- 锁即数据:数据被装在锁里面,物理上就拿不出来。想读
i32?先lock(),拿到MutexGuard<T>,Guard 解引用才给你&mut i32。 - 忘加锁 = 编译不过。 不是运行时崩溃,不是偶发错误,是根本编译不过去。
- RAII 自动解锁:
MutexGuard离开作用域时自动drop→ 自动解锁。连defer都不用写。而且 panic 时栈展开会 drop Guard,锁也自动释放。
这是”把并发安全从运行时约定,提前到编译期强制”的典型案例。
再看一个 Go 里没有的机制:Poisoning(中毒)。
Rust 里,如果一个线程持锁时 panic 了,这把 Mutex 会被标记为 poisoned(中毒)。后续所有 lock() 都会返回 Err(PoisonError),你必须显式处理(通常是 unwrap() 直接崩,或者调 into_inner() 强行忽略)。
为什么这么设计? 逻辑很硬:
线程 panic 时,它可能正改到一半——数据结构可能处于不一致的中间状态。如果下一个 lock 的人拿到锁,看到的就是一堆自相矛盾的数据,然后基于错误数据继续跑,错误会像病毒一样扩散。所以宁可让锁”中毒”,逼你面对这个问题。
Go 的选择相反:Mutex 不 poison。 panic 时 defer Unlock() 照常执行,锁被释放,下一个 goroutine 拿到锁,看到什么数据算什么数据。
谁对?这是两种错误处理哲学的差异,不是对错:
| Rust | Go | |
|---|---|---|
| panic 的定位 | 可恢复的、局部的错误信号 | 通常是不可恢复的(Go 里 panic 绝大多数意味着”程序该崩了”) |
| 锁中毒 | 有(显式处理不一致状态) | 无(假定你要么崩、要么在 defer 里回滚) |
| 忘加锁 | 编译期拦截 | 靠 code review 和 vet |
| 解锁 | RAII 自动 | 靠 defer(约定,但很可靠) |
| 学习成本 | 高(生命周期、借用检查) | 低 |
Go 的假设是:“panic 就该崩,或者你在 recover 里已经把状态回滚干净了。” 在实际的 Go 服务里,这个假设大体成立(panic 通常在 HTTP handler 边界被 recover 掉,请求级状态随之丢弃)。但这个对比确实暴露了一个真实风险:Go 里如果 panic 发生在”修改共享结构到一半”的时候,而你又在顶层 recover 了让它继续服务,那份共享数据就已经是脏的了,而没有任何机制提醒你。
这是一个值得记住的 Go 实践建议:在 defer 里 recover 时,如果 panic 发生在持锁期间,你要么明确知道状态是干净的,要么干脆让进程退出(对于共享的、长生命周期的状态,退出往往比带病运行更安全)。
顺带一提,Rust 的 std::sync::Mutex 在 Linux 上也是 futex-based,同样会先自旋再进内核等待——和 Go 的思路殊途同归。这印证了一件事:这些不是”语言特性差异”,而是”面对同一个硬件现实(上下文切换很贵)时,所有人都收敛到了同一个答案”。
对照三:Java 的锁升级——一条单向的路
HotSpot JVM 的 synchronized 有一条著名的锁升级路径,由低到高:
- 无锁:对象刚创建,没人竞争。
- 偏向锁(Biased Locking):如果一个线程反复获取同一把锁,JVM 就把这个线程的 ID 直接记在对象头里。之后这个线程每次进出同步块,只需要比对一下 ID——连 CAS 都不用做,几乎零成本。这个优化针对的是一个真实存在的普遍事实:大量的锁在程序整个生命周期里只被一个线程用过。
- 轻量级锁:一旦有第二个线程来竞争,偏向锁被撤销,升级为轻量级锁。线程在自己的栈帧里创建一个 lock record,用 CAS 把对象头的 mark word 换成指向它的指针;抢不到就自旋一会儿。
- 重量级锁:自旋也拿不到,膨胀成
ObjectMonitor,走操作系统互斥量(pthread mutex),抢不到锁的线程进等待队列(cxq / EntryList)睡下去,等 JVM 唤醒。
和 Go 对照,你会发现结构惊人地相似:
| 阶段 | Java | Go |
|---|---|---|
| 快速路径 | 偏向锁(无 CAS) | fast path(一次 CAS) |
| 竞争初现 | 轻量级锁 + 自旋 | 自旋(有限次) |
| 竞争严重 | 重量级锁(OS 互斥量,睡眠) | 信号量(睡眠) |
| 公平性兜底 | 重量级锁的队列 + 非公平/公平可选 | 饥饿模式(FIFO 递锁) |
但有一个关键区别:方向性不同。
- Java 的锁升级基本是单向的。 一旦膨胀成重量级锁,就几乎不会退回轻量级(HotSpot 理论上能在 safepoint 做锁降级,但实际影响微乎其微,你可以认为”膨胀即永久”)。
- Go 的饥饿模式是可逆的。 队列空了、或者等待时间降下来了,立刻退回 normal 模式继续拼吞吐。
为什么 Go 要做成可逆的? 因为 Go 的并发模型是”goroutine 廉价、成千上万、短生命周期”,锁的竞争是突发性的——流量尖峰过去之后,竞争应该立刻消失、吞吐应该立刻恢复。而 Java 的重量级锁设计于”线程昂贵、数量有限、生命周期长”的时代,竞争往往持续存在于某些热点对象上,膨胀一次就一直用着,反而省事。
还有一个更有趣的故事:偏向锁被废弃了。
JDK 15 起,偏向锁默认关闭,后续版本逐步废弃。为什么?
因为随着现代程序和硬件的演进,“锁只被一个线程用”这个假设的收益,被”撤销偏向”的成本吃掉了。撤销偏向锁需要一个 safepoint(暂停所有线程),代价高昂;而当你的服务有大量短生命周期对象、大量线程池 worker 时,一个对象很可能先后被不同线程访问,偏向锁刚建好就要撤销——净亏损。
这个故事值得反复咀嚼:任何优化都不是免费的,优化本身也有成本,而收益是 workload-dependent 的。 十年前的天才优化,十年后可能变成负债。所以 Go 团队对”往 Mutex 里加新优化”极其克制——每加一层启发式,就多一份”在某些 workload 上变慢”的风险。
最后一问:为什么 Java 要把锁做得这么复杂?
因为 Java 背负着二十多年的存量代码,其中有海量”无脑 synchronized”的写法。JVM 不能指望程序员写出正确的并发代码,它必须在运行时替所有人兜底——于是复杂性被推进了 JVM 里。
Go 从零设计,站在 Java 的肩膀上,选择了更简洁的三段式(CAS → 自旋 → 信号量 + 一个可逆的饥饿模式)。复杂度没有消失,只是转移了——从”运行时帮你猜”,变成了”你自己选对工具”。
三条技术路线,三种价值观
| 语言 | 策略 | 复杂度放在哪 | 代价 |
|---|---|---|---|
| C++ | 把控制权和责任全交给你 | 你的脑子里 | 极高出错率,且错误不可复现 |
| Java | 运行时替你兜底 | JVM 里 | 行为难预测,调优靠玄学 |
| Rust | 把正确性提前到编译期 | 类型系统和你的学习曲线 | 上手门槛高,编译期折腾 |
| Go | 砍掉选择空间,默认够用且难用错 | 被删掉了 | 性能上限低一档,极端场景得自己造 |
没有最优解。 只有”你的团队、你的 workload、你的错误容忍度,配得上哪种成本”。
而 Go 的答案始终一致:把默认的那个选项,做得足够好、且足够难用错。 理解了这一点,你就理解了 sync 包里的每一个设计决定——Mutex 的饥饿模式、Once 的 fast path、atomic 的 seq_cst、WaitGroup 的”Add 必须在 Wait 之前”——它们全都是同一个价值观的不同切面。
业界对照
C++ std::mutex / std::atomic: mutex 有自旋/睡眠,atomic 有完整的内存序(relaxed/acquire/release/seq_cst),用户可以精确控制。C++ 的性能上限更高,但内存序心智负担极重,memory_order 用错就是最难查的 bug。Go 的 atomic 默认 seq_cst,是”用性能上限换简单安全”。
Java synchronized / Lock / Atomic: synchronized 有偏向锁、轻量级锁、重量级锁的升级路径(类似 Go 的自旋→信号量),Atomic 有 CAS + volatile 内存语义。Java 的并发工具(AQS、并发容器)极其丰富,但也更重、更复杂。
Rust std::sync: Mutex 包裹数据(Mutex<T>),锁即数据,所有权系统保证”不先拿到锁就别想碰数据”,从编译期杜绝”忘加锁”的错误。Rust 的 atomic 也提供多档内存序。Rust 的哲学是”把并发安全交给编译期,而不是运行时约定”。
Erlang: 没有锁和 atomic——因为进程间不共享内存,同步靠消息。锁的问题在 Erlang 里被”无共享”直接消解。
版本演进小结
| 版本 | 关键变化 |
|---|---|
| 早期 | Mutex 基础实现(自旋 + 信号量) |
| 1.9 | Mutex 引入饥饿模式,解决等待者饿死问题 |
| 1.9 | sync.Map 引入(读多写少专用) |
| 1.18 | Mutex / RWMutex 加入 TryLock |
| 1.19 | 类型化 atomic(atomic.Int64 / atomic.Pointer[T] 等),官方内存模型文档重写,把 atomic 的顺序一致性写进规范 |
| 1.21 | OnceFunc / OnceValue / OnceValues,懒初始化样板被收进标准库 |
| 持续 | atomic 优化、Once/WaitGroup 细节打磨 |
主线:Go 的同步原语,一直在”公平”和”吞吐”之间找平衡(饥饿模式)、在”性能”和”简单安全”之间找平衡(atomic 默认强内存序)。它不追求极限性能,追求”默认够快、且不容易用错”。
本章思考题
【思考题】
-
Mutex 为什么”先自旋再睡”?自旋太久会有什么问题?饥饿模式解决的是什么?
-
sync.Once 的 fast path 为什么能”无锁”?它是怎么保证”初始化只执行一次、且副作用对所有 goroutine 可见”的?
-
atomic 和 Mutex 的分界在哪?什么场景必须用 Mutex 而不是 atomic?
-
为什么说”锁和 channel 本质都是同步原语”?它们的根本区别是什么?
-
Mutex 的 normal 模式与 starvation 模式,各自的规则是什么?进入和退出饥饿模式的条件分别是什么?状态字里的”等待者计数”具体起到哪几个作用?
-
RWMutex 为什么要让”写者阻塞新来的读者”?请解释”递归读锁 + 恰好插入一个写者”导致的死锁链条,并给出至少一种修法。
-
sync/atomic的五种操作(Add / Load / Store / Swap / CAS)各自的用途是什么?CAS 循环的正确骨架长什么样?ABA 问题是什么,在 Go 里通常怎么规避? -
为什么 WaitGroup 的
Add不能写在 goroutine 内部?“计数器为 0 时的正 delta 必须先于 Wait”这条规则,背后的机制原因是什么?WaitGroup 内部为什么要把”任务计数”和”等待者计数”打包进同一个 64 位字? -
sync.Once里,为什么done标记要在解锁之前用 atomic 写?如果Do传入的函数 panic 了,Once 会重试吗?这会带来什么线上风险? -
请给出 atomic / Mutex / channel 的量级对比,并回答:性能优化的第一优先级是什么,为什么不是”把 Mutex 换成 atomic”?
-
列举至少六种锁的常见反模式,并给出各自的解法。特别说明:为什么
sync.Cond.Wait外面必须用for而不是if? -
三个业界对照问题:(a) C++ 有六档
memory_order,Go 只提供 seq_cst 一档,这个取舍的合理性在哪里?(b) Rust 的Mutex<T>和 Go 的 “锁 + 数据” 并列写法,本质差异是什么?Rust 的 poisoning 机制和 Go 的做法各自反映了怎样的错误哲学?(c) Java 的锁升级路径和 Go 的自旋→信号量结构很像,但有一个关键的方向性区别,是什么?偏向锁后来被废弃这件事说明了什么?
【参考答案】
-
因为”睡下再唤醒”要走 OS 调度,代价微秒级;若临界区很短、锁很快释放,自旋几纳秒就等到了,更便宜。但自旋在烧 CPU,锁迟迟不释放就是纯浪费,所以自旋有次数限制、只在多核且确有竞争时进行。饥饿模式解决的是”睡醒的老实人抢不过正在自旋的新人、被永远饿死”的问题——等待超时后切换 FIFO,把锁直接递给等最久的人。
-
fast path 用 atomic 读
done标记:若已是 1,直接返回,无锁。保证”只执行一次”靠 double-checked locking(加锁后再检查 done,防止两个 goroutine 都通过第一次检查导致执行两次)。保证”副作用可见”靠 atomic 写done=1+ Once 的 happens-before 语义:任何 goroutine 读到 done==1 时,初始化函数的副作用一定已完成。fast path 让”已初始化后”的调用几乎零成本,这是它比手动加锁高明之处。 -
分界是”保护一个值还是保护一段过程”。单个计数器的加减、单个标志位读写、单个指针 CAS,用 atomic(无锁、不阻塞、快)。多个变量的组合操作、需要保护一段临界区(多步逻辑),必须用 Mutex,因为 atomic 只能保证单个操作的原子性,保证不了”多步组合”的一致性。选择标准:你要保护的是一个值,还是一段过程。
-
因为它们都解决同一个底层问题:多个执行流之间,如何保证”对内存的修改能被正确看到”(可见性)和”不会互相踩”(互斥)。锁通过”互斥”同步——同一时刻只允许一人进临界区;channel 通过”交接”同步——数据所有权随消息转移。根本区别在于表达方式:锁保护”共享状态”,channel 传递”数据所有权”。Go 同时提供两套扎实的工具,该用锁用锁、该用 channel 用 channel,这才是它并发能力强的真相。
-
normal 模式:新来的 goroutine 可以和队首等待者竞争,且新来的赢面更大(它正在 CPU 上跑,队首还在被唤醒),这是用公平换吞吐。starvation 模式:新来者禁止自旋抢锁、只能排到队尾;解锁方不释放锁让别人抢,而是把锁直接 handoff 给队首等待者(FIFO),醒来者连 CAS 都不用做。进入条件:等待者的等待时间超过一个阈值(约 1 毫秒)时打开饥饿标志。退出条件(满足其一):等待队列已空(waiter 计数归零,即当前这个是最后一个),或者队首等待者的等待时间已经小于该阈值——说明积压消化完了,退回 normal 换吞吐。进出条件不同构成”滞回”结构,避免在临界点抖动。waiter 计数的作用有三:(1) 决定解锁时是否要走”信号量唤醒”这条慢路——没有等待者时,解锁只做一次原子清位就返回,不碰内核;(2) 判断队列是否为空,从而决定能否退出饥饿模式;(3) 饥饿模式下判断还有没有人等着被递锁。
-
原因:如果不阻止新读者,在读者源源不断的场景下,写者要等的”所有读者退出”这一刻永远不会到来,写者被饿死;阻塞新读者是防止写饥饿的必要代价(writer preference)。代价是会制造一次读停顿尖刺。死锁链条:① 外层函数拿到读锁(读者数=1);② 某写者调用
Lock()开始等待,点亮”有写者在等”信号;③ 外层函数内部调用的下层函数再次RLock(),因有写者在等而被阻塞;④ 写者要等读者数归零,而外层的第一次读锁要等内层返回才释放 → 互相等待,死锁。关键特征:只在写者恰好插入时才触发,所以测试环境跑一万次都没事,上线后在配置刷新时段偶发崩溃。修法:锁的持有不要跨函数——把被调函数写成不加锁的裸实现(命名为xxxLocked),由知道全貌的调用方统一加锁;或者用RLocker()传递读锁。另外要记住:RWMutex 不支持从读锁升级为写锁,真要升级必须先 RUnlock、再 Lock,然后重新检查条件(double-check)。附加提醒:RWMutex 并非一定比 Mutex 快——RLock 要原子地加减一个所有读者共享的计数器(cache line 争用),写锁路径比 Mutex 更长;临界区极短时 RWMutex 可能净亏。是否更快,只能由go test -bench加-cpu=1,4,8,16说了算。 -
Add:
new = old + delta并返回新值,用于计数器/累加器;无符号减法用补码技巧(Add(^uint64(n-1)))。Load / Store:原子读写,用于标志位、发布-订阅(Store 发布一个初始化好的对象,Load 拿到即保证数据完整可见)。普通读写不行有三个理由:32 位平台上 64 位变量会撕裂;编译器可能把循环内不变的标志位优化到寄存器导致死循环;普通读写不提供任何内存序保证。Swap:原子地”读旧值 + 写新值”,用于把槽位里的东西整个摘下换新(无锁缓冲区轮换)、一次性状态转换。CAS:乐观并发的核心,“我以为现在是 old,是就改成 new”,用于无锁算法和状态机推进。CAS 循环骨架:for { old := v.Load(); nv := compute(old); if v.CompareAndSwap(old, nv) { break } }——失败后必须重新 Load,禁止复用过期的 old;compute必须无副作用可重入。ABA:值从 A→B→A,CAS 看到”A 没变”于是成功,但世界已经变了(无锁栈会被破坏)。规避:加版本号打包成一体;Go 有 GC,只要还持有引用就不会被复用成”新的 A”,天然缓解;最实用的是不写无锁数据结构——用atomic.Value/atomic.Pointer[T]存不可变对象整体替换,不可变就没有中间态、就没有 ABA。 -
为什么不能放在 goroutine 里:
Wait()的 fast path 是”原子读一次状态字,若计数器已为 0 就直接返回、根本不睡”。如果主 goroutine 的 Wait 已经返回,而另一个 goroutine 才慢悠悠地Add(1)并启动任务,就没有任何人在等它;等它 Done 时计数器变成 -1,直接 panic(sync: negative WaitGroup counter)。所以”计数器为 0 时的正 delta 必须先于 Wait”是硬性正确性要求,正确写法是”先 Add 再 go”,最好一次性wg.Add(N)(少 N 次原子操作,且杜绝循环体内 panic 导致少 Add)。打包进同一个字的原因:两个计数的更新必须是原子且同时的。若分成两个变量,就会出现”计数器已归零、但等待者计数还没加进去”的中间态,等待者错过唤醒信号而永远睡死。打包后一次 CAS 同时更新两者,中间态不存在。这是无锁编程的通用手法:相关状态要么加锁,要么打包进一个字用 CAS。另外注意:WaitGroup 等的是”归零”这个事件,不是”等于某个值”;它不传递错误、不支持超时和取消——需要这些就用errgroup。 -
为什么在解锁前写 done:如果等解锁之后才置位,那么排在锁外、随后进入 slow path 的 goroutine 可能看到 done 仍是 0(写还没发生),于是它也会执行一遍 f,而且没人拦得住。先置 done 再放锁,保证任何后续进入者——无论走 fast path 还是 slow path——看到的 done 都是 1。配合 atomic 的顺序一致(seq_cst)语义,就有了 Once 的核心保证:看到
done == 1时 f 的副作用一定已完成。panic 行为:文档明确写了,Do认为 f 已经返回,done 照样置 1,后续调用不会重试 f。线上风险:如果你用 Once 做”初始化连接池/加载配置”这类可能失败的初始化,第一次失败后 Once 就永久”完成”了,后续所有 Do 直接返回,全局资源仍是 nil,然后全链路空指针。对策:在 f 内部自行保证成功或自行重试;或者不要用 Once 承载可失败的初始化,改用atomic.Pointer+ CAS 的 lazy-init,失败时把指针留在 nil 让下一次继续尝试。另外两个坑:f 内部再调同一个Once.Do会因 Mutex 不可重入而死锁;Once 放进结构体后被复制会导致 f 执行两次(go vet可抓)。 -
量级(典型 x86-64,无竞争):atomic Load/Store 几纳秒;atomic Add/CAS 约十纳秒级(CAS 更贵,要独占 cache line);Mutex 加解锁几十纳秒(fast path 就是一次 CAS);RWMutex 读锁几十纳秒、写锁更长;不阻塞的 channel 收发约几十到上百纳秒。一旦竞争:Mutex/channel/WaitGroup 唤醒全部进入微秒级,这是 100
1000 倍的跃迁。结论:无竞争时 atomic 和 Mutex 只差 25 倍、且都在纳秒级,绝大多数代码里根本测不出来;真正的成本来自”抢”。所以优化优先级要倒着念:① 第一优先是消灭竞争(不共享 / 分片 / 每 goroutine 一份最后合并),而不是换更快的原语;② 第二优先是缩短临界区(锁内只做 O(1) 的交接,把 IO、计算、序列化、回调全部挪出去);③ 最后才是换原语(Mutex → RWMutex → atomic)。比喻:堵车时该做的是多修路或错峰出行,而不是把每辆车换成跑车——跑车在堵死的高架上和拖拉机一样快。选型判据:保护一个值用 atomic;保护一段过程/一组变量间的不变量用 Mutex;传递数据所有权用 channel;等一批活干完用 WaitGroup(要错误传播和取消就用 errgroup);只做一次用 Once/OnceValue;等条件成立用 Cond。补充反模式:用容量 1 的 channel 当互斥锁——能跑,但语义绕、无 TryLock,最致命的是持有 token 的 goroutine panic 后 token 永不被归还,整个系统永久卡死;而 Mutex 有defer Unlock()兜底。最后一条元规则:任何性能结论必须有go test -bench=. -benchmem -cpu=1,4,8,16背书,-cpu是灵魂——很多并发优化在单核上快得飞起,在多核上因 cache line 争用而崩盘。 -
反模式与解法:① 锁顺序颠倒(ABBA 死锁)——建立全局锁序(永远按级别从低到高获取)、合并成一把锁、或用 TryLock 全有或全无(不推荐当首选)。② 锁粒度太大——分片(按 key 哈希分成 N 份,争用降为 1/N)、读写分离、不可变快照、每 goroutine 一份定期合并;用 mutex profile 和 goroutine dump 定位(几百个 goroutine 卡在同一个 semacquire 上就是它)。③ 锁内做耗时操作——锁内禁止网络调用、文件 IO、DB 查询、Sleep、复杂计算、阻塞式 channel 操作、用户回调;模式是”锁外算好,锁内只做一次 O(1) 交接”。④ 复制含锁的结构体——永远指针接收者、别返回值、小心 range 副本和闭包捕获;把
go vet ./...放进 CI(copylocks 能抓绝大多数)。⑤ defer Unlock 范围失控——defer到函数返回才执行,若真实临界区只有几行就用匿名函数把临界区圈起来;原则是Lock()紧贴需要保护的代码。⑥ 忘了解锁(尤其 panic 路径)——唯一正确姿势是defer Unlock();Go 1.14+ 的 open-coded defer 已降到几纳秒,别再为”性能”手写 Unlock。⑦ 锁内调用用户回调/未知代码——先拷贝快照,解锁,再回调。⑧ 用time.Sleep做同步——这是在赌”一定够”,而并发里没有”一定”;Sleep 只在测试近似和 select 超时分支里合法。⑨ Cond 用if而非for——必须用 for:Wait()返回不代表条件成立。因为 Wait 内部是”先解锁→睡→被唤醒→再加锁”,在这段窗口里别的 goroutine 完全可能已经把资源取走;Broadcast会唤醒所有等待者但只有一个能抢到;还可能存在虚假唤醒。用for不是风格问题,是正确性问题。不确定用 Signal 还是 Broadcast 时选 Broadcast——多醒几次的代价远小于漏醒一次。 -
(a) C++ 六档 vs Go 一档:六档是 relaxed / consume(已不鼓励)/ acquire / release / acq_rel / seq_cst,能让你为每一次操作精确指定屏障强度(典型优化是引用计数释放用 release 而非 seq_cst,在 ARM 上省掉一条屏障指令)。代价是心智负担:写错就是”开发机永不复现、客户 ARM 服务器每周崩一次、崩溃现场数据看起来不可能错”的一类 bug。Go 只给 seq_cst 的合理性有三:主战场 x86 上 acquire/release 基本免费、seq_cst 的额外代价主要体现为 store 需要 xchg/mfence,可控;目标用户是业务工程师而非内核作者,能正确写 relaxed 的人是极少数;少一个档位不是功能缺失,而是把一整类最难查的错误从生态里删掉。而且如果真的卡在屏障开销上,正解通常是”别用 atomic,改成不共享”,而不是降内存序——架构层的优化永远比指令层的收益大。(b) Rust
Mutex<T>vs Go:Rust 把数据装在锁里面(Mutex<i32>而非(i32, Mutex)),想读必须lock()拿到MutexGuard<T>,忘加锁是编译不过而不是运行出错;Guard 离开作用域自动解锁(RAII),panic 时栈展开也会 drop 掉,连 defer 都不用写。Go 里mu和n只是”约定上相关”的两个字段,忘了加锁编译器一声不吭。Poisoning 的差异:Rust 中持锁线程 panic 会把 Mutex 标记为 poisoned,后续lock()返回Err,逼你显式面对”数据可能处于不一致中间态”;Go 的 Mutex 不 poison,defer Unlock()照常释放,下一个 goroutine 拿到什么数据算什么数据。这反映了两种错误哲学:Rust 把 panic 视为可恢复的局部错误信号,所以要求显式处理污染;Go 默认 panic 意味着”程序该崩了”或”你已在 recover 里回滚干净”。实践建议:Go 里若在持锁期间 panic 又在顶层 recover 后继续服务,共享状态可能已脏且无人提醒——对于长生命周期的共享状态,宁可让进程退出。两者殊途同归之处:Rust 的 Mutex 在 Linux 上同样是 futex-based,也是先自旋再进内核等待,说明这是面对”上下文切换很贵”这一硬件现实时所有人的共同收敛。(c) Java 锁升级:无锁 → 偏向锁(把 owner 线程 ID 记在对象头,重入几乎零成本)→ 轻量级锁(栈上 lock record + CAS + 自旋)→ 重量级锁(膨胀为 ObjectMonitor,走 OS 互斥量,等待者睡进 cxq/EntryList)。与 Go 的对照:偏向锁/轻量级锁 ≈ Go 的 fast path 与自旋,重量级锁 ≈ Go 的信号量睡眠。关键的方向性区别:Java 的升级基本是单向的(膨胀后几乎不降级),而 Go 的饥饿模式是可逆的(队列空了或等待时间降下来就退回 normal)。原因是并发模型不同:Go 的 goroutine 廉价、海量、短命,竞争是突发性的,尖峰过后必须立刻恢复吞吐;Java 的线程昂贵、稳定、长命,竞争往往持续存在于热点对象上,膨胀一次一直用着反而省事。偏向锁被废弃的启示:JDK 15 起默认关闭、后续逐步废弃,因为”撤销偏向”需要 safepoint(暂停所有线程),代价高昂,而在大量短命对象 + 线程池 worker 的现代服务里,偏向刚建好就要撤销,净亏损。这说明——任何优化都不是免费的,优化本身也有成本,而收益是 workload-dependent 的。十年前的天才优化,十年后可能是负债。这也解释了 Go 团队往 Mutex 里加新启发式时为何极其克制:每加一层,就多一份”在某些 workload 上变慢”的风险。