拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Go sync.Mutex源码解析:从CAS自旋到饥饿模式

刚把一个线上服务的锁竞争问题排查完顺手又把 Go 源码里锁相关的实现整个翻了一遍。sync.Mutex 大概是 Go 里被用得最多的并发原语但绝大多数人停留在 Lock/Unlock 的 API 层对锁的内部性格一无所知。比如为什么高并发下锁会进入饥饿模式为什么 Lock 并不是严格公平的为什么有人稍微嵌套一下锁就死锁。这些问题的答案全在底层实现里。这篇文章我从源码层面把 sync.Mutex 和 sync.RWMutex 完整拆开讲清楚 CAS、自旋、信号量、运行时调度这几样东西是怎么配合的。适合真心想搞懂 Go 并发模型的人、写高并发服务时被锁竞争折磨的人以及准备系统啃 Go 源码的读者。读完你会知道 Lock 和 Unlock 的完整执行路径、state 状态位每一位的含义、饥饿模式的触发条件和退出逻辑还有从底层视角看锁竞争到底该怎么优化。1. 先看懂 Mutex 的门面两个字段和四个状态位1.1 Mutex 比你想象的更小sync.Mutex 总共就两个字段type Mutex struct { state int32 sema uint32 }就一个 int32 管状态一个 uint32 当信号量没了。第一次看到的时候我也挺意外一个支撑起整个 Go 并发编程最核心的锁内部数据结构简单到这种程度。但越简单的结构越考验设计的精细程度状态机全被压缩进state这一个 int32 里了。1.2 state 里每一位都有讲究state不是普通数字它是按位拆分的。源码里定义了这么几个常量const ( mutexLocked 1 iota // 第 0 位锁是否被持有 mutexWoken // 第 1 位是否有协程被唤醒 mutexStarving // 第 2 位是否进入饥饿模式 mutexWaiterShift iota // 第 3 位起等待者数量 )每位的作用先记在脑子里第 0 位mutexLocked1 表示锁被某个 goroutine 持有0 表示空闲。第 1 位mutexWoken1 表示已经有一个协程被唤醒、正在准备抢锁其他新来的协程就别再重复唤醒别人了。第 2 位mutexStarving1 表示当前处于饥饿模式。第 3 位及更高位记录等待队列里有几个协程在排队数值通过state mutexWaiterShift取出来。理解了这几个位的含义后面所有逻辑都好懂了。很多人看源码觉得绕就是因为没把这几个位先印在脑子里。1.3 快速路径为什么快Mutex 设计上第一原则是尽量让没竞争的情况最快。Lock的入口就一个 CASfunc (m *Mutex) Lock() { // 快速路径直接尝试把 state 从 0 改成 mutexLocked if atomic.CompareAndSwapInt32(m.state, 0, mutexLocked) { return } // 慢速路径单独拆出来方便快速路径被内联 m.lockSlow() }注意这里 CAS 的期望值是 0也就是锁完全空闲、没有等待者、没有唤醒标记、没进饥饿模式。只要满足这个条件一条 CAS 指令就抢锁成功。Unlock也一样一条原子减操作就完事func (m *Mutex) Unlock() { // 快速路径把 locked 位减掉 new : atomic.AddInt32(m.state, -mutexLocked) if new ! 0 { // 说明还有别的状态要处理走慢速路径 m.unlockSlow(new) } }为什么设计成AddInt32(-1)而不是直接 CAS 清零因为如果 state 里还有其他位比如有等待者、有饥饿标记直接清零会把这些信息也弄丢。先减掉 locked 位再看结果等于把单纯解锁和解锁后还要处理等待者两种情况天然区分开了。这是非常经典的写法。提示快速路径之所以能做到这么快核心是它把所有有竞争的情况都甩给了慢速路径。这也直接告诉了我们一个优化方向——想让锁快就得尽量减少锁空闲时的额外判断。2. Lock 的完整执行路径从自旋到挂起2.1 快速路径失败后进入 lockSlow快速路径失败意味着有竞争于是进入lockSlow。这段代码是整个 Mutex 的核心我把它拆成几段来讲。先看整体循环框架func (m *Mutex) lockSlow() { var waitStartTime int64 starving : false awoke : false iter : 0 old : m.state for { // ... 每一轮循环尝试推进状态 } }几个局部变量先解释清楚waitStartTime本协程开始排队等待的时间点用来算等待时长判断是否触发饥饿模式。starving本协程是否已经处于饿得受不了的状态标记为 starving 的协程会申请把锁切到饥饿模式。awoke本协程是否已经被唤醒过、正在参与抢锁。iter自旋次数。2.2 自旋先原地转一会别急着去睡循环里第一步是判断要不要自旋// 正常模式下锁被持有且没进饥饿并且当前协程还有自旋配额 if old(mutexLocked|mutexStarving) mutexLocked runtime_canSpin(iter) { // 如果自己还没被唤醒标记且确实有人在排队尝试帮自己打上唤醒标记 if !awoke oldmutexWoken 0 oldmutexWaiterShift ! 0 atomic.CompareAndSwapInt32(m.state, old, old|mutexWoken) { awoke true } runtime_doSpin() iter old m.state continue }自旋的哲学很简单锁被持有但持有者可能马上释放与其立刻把自己挂起到操作系统/调度器不如占用 CPU 空转几个纳秒等一下。挂起协程是有成本的涉及上下文切换、恢复调度、再排队这些开销可能比等锁本身还大。但这里有几个关键条件old(mutexLocked|mutexStarving) mutexLocked锁确实是普通持有状态还没饿到别人。一旦进入饥饿模式自旋直接禁止因为饥饿模式就是要让队首的协程尽快拿到锁自旋会继续抢走机会。runtime_canSpin(iter)这个函数会检查活动自旋次数上限、GOMAXPROCS1、当前 P 没有在执行不可抢占的代码等条件。单核跑自旋基本是浪费因为持有锁的协程可能根本没机会调度执行。自旋期间如果发现没有唤醒标记且有等待者会尝试自己把mutexWoken位置 1。这个动作有意义——告诉后来者别折腾了已经有人在盯着锁了。runtime_doSpin()在 amd64 上实际执行的是procyield本质上就是循环执行PAUSE指令。PAUSE指令的作用是让 CPU 流水线不要太激进地空转省电又有利于超线程。实测下来一次自旋大概是几十到几百纳秒的量级不是我想象中那样整块时间都在转。2.3 抢不到就去信号量上排队自旋解释不了或者自旋到上限后进入真正的排队逻辑。这一大段里有个比较开脑洞的设计它先把自己假设能抢到锁的状态 CAS 上去而不是等确认了再改new : old // 非饥饿模式下先假定自己能拿到锁 if oldmutexStarving 0 { new | mutexLocked } // 如果锁仍被持有或者已经在饥饿模式把自己计入等待者数量 if old(mutexLocked|mutexStarving) ! 0 { new 1 mutexWaiterShift } // 如果本协程已经饿够久了申请开启饥饿模式 if starving oldmutexLocked ! 0 { new | mutexStarving } // 如果自己被唤醒过现在要消费掉唤醒标记 if awoke { if newmutexWoken 0 { throw(sync: inconsistent mutex state) } new ^ mutexWoken }这是整个算法最精妙的地方每轮循环都在预测系统的未来状态。预测对了一次 CAS 就锁到手预测错了CAS 失败重新读old再来一轮。CAS 成功之后分两种情况if atomic.CompareAndSwapInt32(m.state, old, new) { // 竞态下幸运地拿到了锁 if old(mutexLocked|mutexStarving) 0 { break } // 否则挂起自己进入信号量等待队列 queueLifo : waitStartTime ! 0 if waitStartTime 0 { waitStartTime runtime_nanotime() } runtime_SemacquireMutex(m.sema, queueLifo, 1) ... }runtime_SemacquireMutex是 Go 运行时提供的信号量原语内部通过gopark把当前 goroutine 挂起。这里有个值得注意的细节queueLifo参数。如果这个协程是第一次来等待放进队列尾部如果它曾经被唤醒过、结果没抢到锁又回来排队就插到队列头部。这个设计的意图很直白——你已经被溜过一次了再排到队尾对你不公平下一次应当优先让你拿到锁。这是防止协程被反复溜的一种保护。2.4 从信号量被唤醒之后发生了什么挂起不可能是永久的总会有Unlock时通过runtime_Semrelease把它叫醒。被叫醒后协程继续从runtime_SemacquireMutex返回然后更新自己的状态starving starving || runtime_nanotime()-waitStartTime starvationThresholdNs old m.state if oldmutexStarving ! 0 { // 饥饿模式下锁直接交接给自己不再竞争 if old(mutexLocked|mutexWoken) ! 0 || oldmutexWaiterShift 0 { throw(sync: inconsistent mutex state) } delta : int32(mutexLocked - 1mutexWaiterShift) if !starving || oldmutexWaiterShift 1 { delta - mutexStarving } atomic.AddInt32(m.state, delta) break } awoke true iter 0第一次从睡眠中醒来时starving还是 false但如果算出来等待时间已经超过starvationThresholdNs源码里定义是 1 毫秒这个协程就被标记为饥饿协程下一轮循环它会尝试把mutexStarving位置上把整个锁踢进饥饿模式。如果醒来发现锁已经处于饥饿模式那就简单了不需要再参与 CAS 竞争直接把锁交接给自己——减少一个等待者加上 locked 位。如果自己是最后一个等待者顺便把饥饿标记清掉。这种情况下没有竞争也不会有别的协程来抢所以这里用的是原子加而不是 CAS。这就是饥饿模式的交接机制。3. Unlock 的底层决策到底唤醒谁3.1 快速解锁之外还有一条慢速分支前面说了Unlock的快速路径就是对 state 做减一操作。如果减完之后是 0说明锁上没有任何等待者、没有饥饿标记、也没有唤醒中的协程直接结束干净利落。但如果new ! 0说明锁还有尾巴要处理进入unlockSlow。这里首先要做一个防御性检查func (m *Mutex) unlockSlow(new int32) { if (newmutexLocked)mutexLocked 0 { fatal(sync: unlock of unlocked mutex) } ... }new是解锁后的 state如果再加回mutexLocked还没有 locked 位说明这个锁在解锁前就已经是未持有状态这就是对未加锁的 Mutex 调 Unlock直接抛致命错误。写代码的人常犯这毛病底层用一次 fatal 兜住了。3.2 正常模式下唤醒的复杂权衡接下来是正常模式下的唤醒逻辑这段代码非常有启发性if newmutexStarving 0 { old : new for { // 没有等待者或者锁又被抢走了/已有人被唤醒/已进入饥饿就不管了 if oldmutexWaiterShift 0 || old(mutexLocked|mutexWoken|mutexStarving) ! 0 { return } new (old - 1mutexWaiterShift) | mutexWoken if atomic.CompareAndSwapInt32(m.state, old, new) { runtime_Semrelease(m.sema, false, 1) return } old m.state } }为什么是循环因为解锁的这个瞬间别的协程也可能在同时改 state比如新来的协程把锁抢走了或者另一个协程正在进入睡眠把等待者数量加一。必须用 CAS 反复确认我要唤醒的对象的队列状态没变。如果 CAS 失败重新读 state 再来一轮。几个提前 return 的条件值得琢磨oldmutexWaiterShift 0没有等待者那还唤醒谁oldmutexLocked ! 0锁已经被某个新来的协程抢走了。这种情况不需要再唤醒等待者因为新的持锁者将来 Unlock 时会负责唤醒。oldmutexWoken ! 0已经有别的协程被唤醒、正在路上。这时候再唤醒一个就是浪费。oldmutexStarving ! 0状态已经切换成饥饿模式正常模式的逻辑不再适用。这个循环本质上是在回答一个问题这个锁释放出来的所有权应不应该立刻交到一个等待者手里答案是只有当下这个瞬间既没人拿锁、也还没人接到通知、且确实有人在排队才值得唤醒。存活下来后CAS 把等待者数量减一、设置mutexWoken然后用runtime_Semrelease唤醒一个信号量队列里的协程。注意这里用的是false非 LIFO也就是说信号量唤醒的是队列头部的等待者保持先到先得的顺序感。3.3 饥饿模式下的唤醒简单粗暴饥饿模式的解锁完全反着来} else { runtime_Semrelease(m.sema, true, 1) }true表示用 LIFO 方式唤醒也就是唤醒最近一个排队的协程。但不用被这个细节误导饥饿模式下信号量队列里基本只剩队首那个锁的继承者其余协程都已经通过别的方式被管理了。这里用 LIFO 是刻意为之的——保证被唤醒的一定是那个最需要拿锁的协程不经过任何 CAS直接释放。为什么饥饿模式下不做正常模式那些复杂判断因为饥饿模式的设计目标就是消除竞争持锁者在 Unlock 时直接把锁所有权交给队首协程锁的这一轮生命周期内不会出现多个协程抢 CAS 的局面自然也就不需要关心锁有没有被抢走这类问题。提示正常模式下的 Unlock 其实是个非常消极的操作——它不是主动安排未来而是只在确认当前状态允许时才叫醒一个人。这也解释了为什么高并发下 Mutex 的调度是概率性的你无法预测下一个拿到锁的到底是谁。4. 公平性设计正常模式和饥饿模式怎么切换4.1 为什么必须要有饥饿模式正常模式下一个新来的协程在锁刚被释放的一瞬间参与 CAS是有机会直接抢到锁的。这本身没问题但极端情况下会形成新协程源源不断抢锁老等待者永远抢不到的局面。因为对跑在 CPU 上的协程来说抢占的时机优势太明显了——它人就在现场而老等待者还挂在信号量队列里等着被叫醒等它跑回来的路上锁可能又被抢走了。源码用一个非常朴素的阈值来定义不公平等待时间超过 1 毫秒starvationThresholdNs 1e6纳秒。如果你排队排了 1 毫秒还没拿到锁就把自己标记为饥饿协程并且在后续的 CAS 里主动申请把锁切到饥饿模式。runtime_nanotime返回的是单调时钟不受系统时间调整影响所以这个超时判断是可靠的。实际生产里1 毫秒看起来很短但在高并发下足够辨识出极度不公平的场景了。4.2 模式切换的完整链条把第 2 节里零散的逻辑串成一条完整的链协程 A 进入lockSlowCAS 失败挂起到信号量记录waitStartTime。A 等待期间锁被其他协程反复抢走A 始终没有被唤醒或偶尔被唤醒却抢不到。A 被唤醒或者一直在等发现等待时间超过 1 毫秒starving变为 true。下一轮循环A 在构造new状态时带上mutexStarvingCAS 成功锁进入饥饿模式。饥饿模式下Unlock 直接调用runtime_Semrelease(m.sema, true, 1)把锁所有权交给队首协程。队首协程被唤醒后发现oldmutexStarving ! 0走交接路径直接拿锁同时减少等待者数量。如果交接后等待者只剩自己就顺手清掉mutexStarving锁回到正常模式新一轮循环重新开始。这个链条里最反直觉的一点是饥饿模式的退出不是持锁者决定的而是由最后一个交接的协程决定的。也就是说锁只有把队列里的等待者全部消化完才恢复到公平竞争的正常模式。4.3 对业务代码的启发理解了饥饿机制很多线上现象就说得通了。比如你用go test -race或者 pprof 发现某个锁的等待时间长时间很高别急着加锁粒度先想想是不是触发了饥饿模式。如果锁的临界区比较小但持有频率极高新来的协程几乎总能在解锁瞬间抢到老等待者饿肚子饥饿模式就会频繁进入和退出。这种场景的特征是 CPU 占用不高但 goroutine 等待时间波动很大。反过来如果临界区很大持有时间远大于 1 毫秒饥饿模式几乎必然被触发此时锁的行为退化成严格的 FIFO 交接。这是 Go 保证公平性的兜底策略代价是吞吐量会下降——因为新到的协程即使有空闲 P 也拿不到锁。一个常见的认识误区是Mutex 是绝对公平的。真实情况是正常模式下它尽量高效但不保证公平饥饿模式下它保证公平但牺牲部分吞吐。设计层面的取舍不是 bug。5. RWMutex读写锁底层是怎么协同的5.1 四个字段撑起读写锁sync.RWMutex比 Mutex 复杂一些但也很有限type RWMutex struct { w Mutex writerSem uint32 // 写锁等待信号量 readerSem uint32 // 读锁等待信号量 readerCount int32 // 正在执行的读锁数量 readerWait int32 // 写锁等待时还在执行中的读锁数量 }这里的w是一个普通的 Mutex但它不是用来保护读锁的而是保护写锁之间的互斥和状态更新的。真正的读写计数逻辑全落在readerCount和readerWait两个 int32 上。rwmutexMaxReaders 1 30这个常量很关键它在信号位上做了文章。正常的 readerCount 在[0, 130)范围内是普通读数但如果哪个协程想申请写锁它会把readerCount减去130让读数变成负数。从此任何新来的读锁看到负数就知道写锁在等待要乖乖排队。5.2 读锁获取一行原子操作两个含义func (rw *RWMutex) RLock() { if atomic.AddInt32(rw.readerCount, 1) 0 { // 有写锁正在等待或持有读者需要挂起 runtime_SemacquireMutex(rw.readerSem, false, 0) } }正常情况下读锁就是一次原子自增没有 CAS、没有循环、没有锁内部的锁快得飞起。只有自增后变负数才说明写锁已经捷足先登读者必须去readerSem上排队。这里我想到一个实战细节读锁设计成纯自增意味着 Go 的 RWMutex 是写优先还是读优先取决于谁先进场。如果写锁先到它把readerCount拉成负数后续读者全部被挡在外面如果读者先到写锁只能等readerWait归零。源码里没有专门的公平队列来调度读写顺序只有这个负数标志在做隐式仲裁。5.3 写锁获取拉负数立 flagfunc (rw *RWMutex) Lock() { // 先用互斥锁保证写锁之间互斥 rw.w.Lock() // 把 readerCount 拉成负数通知读者写锁来了 r : atomic.AddInt32(rw.readerCount, -rwmutexMaxReaders) rwmutexMaxReaders // 如果还有读者在执行记录到 readerWait等它们走完 if r ! 0 atomic.AddInt32(rw.readerWait, r) ! 0 { runtime_SemacquireMutex(rw.writerSem, false, 0) } }AddInt32(-rwmutexMaxReaders) rwmutexMaxReaders这行有点绕实际效果就是先给当前的 readerCount 做个快照r这个r是写锁进来这一刻还在执行的读者数量。如果 r 不为 0写锁就挂到writerSem上等读者全部退出。关键点来了从这之后任何新的 RLock 自增出来的都是负的 readerCount这些新读者会直接挂起永远不会把 readerCount 推回正数。这保证了写锁等待时不会有无穷无尽的读者插队。5.4 解锁时怎么号召开路的读者func (rw *RWMutex) RUnlock() { if r : atomic.AddInt32(rw.readerCount, -1); r 0 { rw.rUnlockSlow(r) } } func (rw *RWMutex) rUnlockSlow(r int32) { if r1 -rwmutexMaxReaders { fatal(sync: RUnlock of unlocked RWMutex) } // 最后一个读者退出时唤醒等待中的写锁 if atomic.AddInt32(rw.readerWait, -1) 0 { runtime_Semrelease(rw.writerSem, false, 1) } }最后一个读者退出时readerWait减到 0runtime_Semrelease唤醒写锁。写完的 Unlock 则会恢复readerCount正数并广播释放readerSem上排队的读者func (rw *RWMutex) Unlock() { r : atomic.AddInt32(rw.readerCount, rwmutexMaxReaders) if r rwmutexMaxReaders { throw(sync: RWMutex.Unlock) } for i : 0; i int(r); i { runtime_Semrelease(rw.readerSem, false, 0) } rw.w.Unlock() }这里有个隐含的坑Unlock 只会一次性唤醒在readerSem上等待的这一批读者不会管之后新来的读者。新来的读者要重新尝试 RLock看readerCount是不是正数。也就是说写锁释放后等待中的读者和未到达的读者处于不完全公平的竞争状态新读者甚至可能因为时机好而比老读者先进去。注意RWMutex 不支持读锁重入后再拿写锁也不支持写锁重入后再拿读锁。比如读锁持有期间调 Lock会把readerCount拉负数但自己又被当成读者挂起来直接死锁。这种坑我在代码评审里见过不止一次底层实现没有任何防护全靠写代码的人自己想清楚。6. 从底层视角看锁竞争的排查与优化6.1 一个真实的高锁竞争案例之前接手的服务里有个全局配置对象用 RWMutex 保护读多写少。正常流量下没什么问题但大促时读 QPS 冲到几十万接口 RT 开始抖动。一开始大家以为是数据库慢后来用 pprof 抓 goroutine 栈发现大量 goroutine 卡在sync.(*RWMutex).RLock上才意识到是锁的问题。原因现在回头看很清楚配置热更新频繁时写锁短暂持有但readerCount被拉负数的窗口里所有读者全部撞上runtime_SemacquireMutex瞬间在 readerSem 上积累大量等待者。写锁一释放一批读者一起被唤醒又一起抢读形成周期性的读风暴。锁本身没有错是高频写读放大把这个窗口放大了。排查工具上推荐两个go tool pprof http://localhost:6060/debug/pprof/mutex需要代码里调runtime.SetMutexProfileFraction(1)开启能看到锁竞争的采样。go tool pprof http://localhost:6060/debug/pprof/goroutine看 goroutine 阻塞在哪些锁上最直观。特别提醒mutex profile 只在fraction0时采样而且它统计的是等锁时间超过阈值的事件底层的实现就是通过状态位和时间戳算出来的线上开启后开销很小可以常开。6.2 锁粒度和模式调整从底层实现倒推优化方向能想明白很多事缩小临界区是最有效的。锁的快速路径开销只有一次 CAS竞争越少越快把计算、IO、网络调用全部挪出临界区锁的持有时间缩短自然不触发饥饿。读多写少但写频率不低时RWMutex 不一定比 Mutex 好。RWMutex 的写锁获取要经历 Mutex 加锁 两个原子操作 可能挂起比 Mutex 重。如果写占比超过一定阈值直接上普通 Mutex 反而更稳。热点 key 可以拆锁。比如分片锁[N]sync.Mutex按 key hash 选锁把竞争分散。底层看就是让每个锁的state高位等待者数量都低一点减少信号量挂起和唤醒的开销。纯计数器、累加器别用锁直接上atomic.AddInt64或者更高层的atomic.Int64。从源码角度看锁的底层最后也是靠原子操作和信号量既然没有复杂逻辑原子操作就够了。6.3 什么时候该彻底放弃锁锁不是银弹Go 的很多并发原语在特定场景下比锁更合适。一写多读的共享变量用atomic.Value。高并发只读缓存用sync.Map它内部是读写分离加原子 map 指针最怕的也是锁竞争。协程间协作用 channel本质上 channel 内部也有锁但它的语义更清晰不容易出现锁嵌套问题。一次性初始化sync.Once内部实现也是基于 Mutex 加原子位但它把状态机封装好了避免自己写错。从锁的实现原理可以看出一个共同点这些同步原语最后都落在原子操作 运行时信号量上区别只是上层的状态机和语义。理解了 Mutex 的状态机你再看sync.Once、sync.Map、channel 的源码会发现它们的设计逻辑是一脉相承的——先快路径再慢路径慢路径里用信号量挂起并想尽办法减少唤醒-再竞争-再挂起的循环。最后说点我自己的体会。花时间啃锁的实现最大的收获不是背下哪几行代码而是建立起一种直觉写并发代码时能想象出每个 goroutine 在这台状态机里处于什么位置。比如线上出现大量semacquire mutex的 goroutine你能立刻意识到这是正常模式下等待者排队还是饥饿模式下的严格交接看到 RWMutex 的 reader 堆积能判断是写锁太频繁还是读锁临界区太大。这种直觉比任何并发编程框架都顶用。如果你打算自己动手验证建议按这个路径走先写个带竞争的 benchmark用-benchmem和 mutex profile 看锁等待时间再改改临界区大小、并发数观察饥饿模式对吞吐的影响最后用go tool compile -dssa看下加锁代码被编译器改写后的样子加深对快速路径内联的理解。这个过程走完你对 Go 并发模型的理解会上一个台阶。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门