
sync.Mutex:抢人锁难
sync.Mutex 看起来只有两个方法:Lock 和 Unlock。它的零值可以直接用,结构体也只有一个状态字段和一个信号量字段,于是很多人会把它当成一颗无害的螺丝:需要保护数据时拧上,结束时拧下。
真正的故事要长得多。一次没有竞争的 Lock,可能只是对一个 32 位整数做 CAS;一次有竞争的 Lock,会先在 CPU 上短暂自旋,再进入 Go runtime 的信号量队列,必要时让 goroutine 休眠、唤醒并发生调度。这个状态还要在缓存一致性协议、编译器重排、Go 内存模型和业务临界区之间保持一致。
我喜欢 sync.Mutex,因为它把复杂机制藏在了很小的 API 后面;我也警惕它,因为 API 越简单,越容易让人忘记代价。
一、先把承诺说清楚
sync.Mutex 的第一层承诺是同一时刻最多一个 goroutine 持有锁。它不是读写锁,也不是可重入锁,更不是和某个 goroutine 绑定的所有权对象。一个 goroutine 可以 Lock,另一个 goroutine 也可以 Unlock;这种用法在语义上允许,但通常会让控制流变得难以审查。
第二层承诺是内存可见性。Go 内存模型规定,对于同一个锁,前一次 Unlock 会在后续某次 Lock 返回前建立同步关系。也就是说,临界区内对普通变量的写入,在下一个成功获得同一把锁的 goroutine 中可以被可靠观察到。锁保护的不是某一行赋值,而是一组读写之间的顺序。
var (
mu sync.Mutex
value int
)
func write() {
mu.Lock()
value = 42
mu.Unlock()
}
func read() int {
mu.Lock()
defer mu.Unlock()
return value
}如果 read 不加锁,单纯因为「大多数机器都能读到 42」就认为代码正确,是把硬件偶然性当成语言保证。即使变量只有一个机器字大小,缺少同步仍可能产生数据竞争;如果对象是 map、切片或接口,竞态还可能破坏内部的指针与长度关系。
第三层承诺是失败边界。重复 Unlock 会触发运行时错误,复制一个已经使用过的 Mutex 是严重的工程错误,锁保护的数据和锁本身都必须有稳定的生命周期。Go 1.26 的公开 sync.Mutex 还明确提供了 TryLock,但官方文档提醒,正确使用它的场景很少;失败的 TryLock 不建立任何同步关系,不能被当作一次「观察到锁为空」的读操作。
二、Go 1.26 的 Mutex 长什么样
从 Go 1.26 的源码看,公开的 sync.Mutex 是一个带 noCopy 标记的薄包装,内部持有 internal/sync.Mutex。真正的实现仍然是:
type Mutex struct {
state int32
sema uint32
}state 的低三位是三个标志位,剩余高位记录等待者数量:
| 位 | 名称 | 作用 |
|---|---|---|
| bit 0 | mutexLocked | 当前是否已加锁 |
| bit 1 | mutexWoken | 是否已经有等待者被唤醒或正在自旋 |
| bit 2 | mutexStarving | 是否进入饥饿模式 |
| bit 3 及以上 | waiter count | 等待该锁的 goroutine 数量 |
sema 不是传统意义上可以随意 P/V 的用户态信号量,它是 runtime 用来睡眠和唤醒等待者的地址。两个字段只有 8 字节,却同时承载了锁状态、排队信息和睡眠入口。零值的 state 为 0,所以 Mutex 不需要初始化:
var mu sync.Mutex // 零值就是可用的未加锁状态但「零值可用」不代表可以复制。把包含 Mutex 的结构体按值放入 map、从函数按值返回,或者在第一次使用后把它复制给另一个对象,都会让两个锁的状态与保护对象脱钩。可以把锁放进结构体,但通常应该让该结构体使用指针语义。
Go 1.26 的一个重要源码组织变化是公开包把实现转到 internal/sync,同时保留外部 API 与内存模型语义。不要因为文件路径变化就认为 Mutex 算法被整体重写;阅读源码时应区分 API 包装层、内部实现层与 runtime 支撑层。
三、Fast path:一条 CAS 如何抢到锁
最理想的 Lock 是锁当前空闲、没有等待者、调用者恰好在 CPU 上运行。源码的快路径大意如下:
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
m.lockSlow()
}这不是普通的「读出 0,再写入 1」。如果两个 CPU 同时读到 0,普通读写会让两边都以为自己成功。CAS(Compare-And-Swap)把比较和写入合成为一个不可分割的原子操作:只有仍然等于旧值时才写入新值,成功者得到锁,失败者进入慢路径。
在 amd64 上,Go runtime 的原子实现会落到类似下面的汇编:
MOVQ ptr+0(FP), BX
MOVL old+8(FP), AX
MOVL new+12(FP), CX
LOCK
CMPXCHGL CX, 0(BX)
SETEQ ret+16(FP)
RETCMPXCHG 比较 EAX 与内存内容,相等时把 ECX 写回,否则把内存旧值写回 EAX;LOCK 前缀让这次读改写在多核系统中具有原子性,并提供足够的内存排序语义。现代 x86 上不一定真的锁住整条总线,通常是让缓存一致性协议确保目标 cache line 的独占访问。锁的成本常常不是「执行一条指令」,而是让一条被多个核心反复争抢的缓存线在核心之间来回转移。
在 arm64 上,等价实现会使用独占加载/存储一类的原子序列(常见形式是 LDAXR 配合 STLXR,或在支持的处理器上使用 CAS 指令)。如果存储期间发现别的核心改过这条 cache line,STLXR 会失败,代码重试。架构不同,Go 的内存模型承诺不变;不要把 x86 强排序的经验直接移植成「所有普通读写都天然安全」。
CAS 成功后,锁只修改了 state 的一个位,但这次成功同时完成两件事:取得互斥权,并建立之后临界区读写的入口顺序。CAS 失败不代表系统故障,它只说明竞争发生了;真正昂贵的部分往往从这里才开始。
四、缓存一致性:锁竞争首先是通信
CPU 核心各有缓存,内存中的同一条 cache line 会在核心之间按照一致性协议流转。假设一把锁的 state 位于地址 A:核心 0 获得锁后,A 处于它的修改状态;核心 1 的 CAS 需要观察 A 的最新值,于是缓存线被请求、失效、转移。核心越多、竞争越激烈,A 就越像一个不断被抢夺的乒乓球。
这也是为什么短临界区不一定意味着低成本。临界区只有几条指令,但如果每次都要跨核心同步,等待成本可能超过业务代码。更糟糕的是伪共享:两个本来无关的计数器恰好落在同一条缓存线,一个核心更新计数器 A,也会让持有计数器 B 的核心失去缓存有效性。
type Counters struct {
requests uint64
errors uint64 // 可能与 requests 共用 cache line
}高并发计数场景常用分片、缓存线填充或每个 P 一个槽位,最后再汇总。但填充不是免费的,平台的 cache line 大小、结构体布局、访问模式都要用 profile 和基准验证。单纯把所有字段之间塞 128 个字节,可能只是让内存占用变大。
五、Slow path:自旋、排队与饥饿模式
拿不到锁之后,Go 不会立刻让 goroutine 睡眠。短暂自旋有一个朴素理由:持锁者可能马上释放锁,此时切换到 runtime、排队、睡眠再唤醒,成本反而更高。
但自旋有严格条件。runtime 会综合 GOMAXPROCS、当前是否多核、是否存在空闲或正在运行的 P、当前 P 的本地运行队列是否为空,以及自旋次数上限来决定是否继续。源码中 runtime_doSpin 会执行有限次数的处理器 PAUSE 类指令。PAUSE 不是休眠,它是在告诉 CPU「这是一个自旋等待循环」,降低流水线资源浪费和超线程兄弟线程受到的影响。
自旋像在门口等几微秒内会回来的同事,适合短临界区;如果持锁者正在做磁盘 I/O、网络请求或被抢占,继续站在门口只是在烧 CPU。Go 的 runtime 因此把自旋限制在很短的窗口,并且在饥饿模式下彻底禁止新到达者自旋。
正常模式不追求绝对公平
正常模式下,等待者大致按 FIFO 排队,但被唤醒的 goroutine 并不自动拥有锁。它醒来后还要和刚刚到达、已经运行在 CPU 上的新 goroutine 竞争。新到达者因为少了一次唤醒和调度延迟,反而经常抢先拿锁。
这样做并不「公平」,却有吞吐收益:一个刚拿到锁的 goroutine 可能连续完成下一次快速加锁,减少频繁移交和调度。问题是极端情况下某个等待者可能一直输,于是 Go 引入了饥饿模式。
等待超过 1 ms,进入饥饿模式
当等待时间超过 starvationThresholdNs = 1e6 纳秒,也就是 1 ms,等待者会推动锁进入饥饿模式。在饥饿模式下,新的 goroutine 不再尝试抢锁,也不再自旋;Unlock 会把所有权直接交给队首等待者。队首拿到锁后,如果它是最后一个等待者,或者等待时间不长,锁再切回正常模式。
这里有一个容易传播的错误说法:「Go Mutex 是公平锁。」更准确的说法是:它在正常模式优先吞吐,在饥饿模式对尾延迟做补偿。 这是一种自适应策略,而不是严格 FIFO。Go 1.26 的内部源码仍保留这组明确的模式和 1 ms 阈值,但阈值是实现策略,不应拿来写业务级 SLA。
六、睡眠到底发生在哪里
拿不到锁时,Mutex 调用 runtime 的 runtime_SemacquireMutex。runtime 的信号量表不是一把全局大锁,而是一个按地址哈希的表。每个根节点维护等待者树和同一地址上的等待链,等待者以 sudog 形式挂在队列里;然后 goparkunlock 让当前 goroutine 停止运行。
解锁时,Unlock 先原子地清除 mutexLocked。如果新的 state 恰好为 0,快路径结束,不需要触碰等待队列;如果有等待者,才进入 unlockSlow,从信号量队列找出合适的 goroutine 并调用 goready。正常模式只唤醒一个等待者,饥饿模式则尝试直接交接。
这解释了几个工程现象:
- 没有竞争的锁不会每次都进入 runtime;
- 有竞争时,成本包含原子操作、缓存一致性、排队、调度和唤醒;
- 等待者很多时,解锁者承担的不只是「把锁位清零」,还可能承担唤醒与调度交接;
- 锁竞争可以被
mutex profile和block profile观察到,而不应只靠平均响应时间猜测。
信号量只负责睡眠/唤醒,不直接保护你的业务数据。它保证的是「一次睡眠对应一次唤醒」,即使唤醒在睡眠真正发生前到达,也不会丢失。把这层和操作系统的 futex 混为一谈并不准确:Go runtime 根据自己的 G/M/P 调度模型组织等待者,再在必要处落到操作系统。
七、从一条指令到一条请求,限制逐层放大
1. 临界区限制并行度
一把锁保护 1 个字段和保护 1 万行 JSON 序列化,名字相同,系统表现完全不同。锁内做 I/O、调用外部服务、等待 channel、执行复杂排序,都会把本来可并行的工作变成单车道。
mu.Lock()
defer mu.Unlock()
// 不要在这里访问远程数据库或调用不受控的插件。
result := expensiveRemoteCall()
cache[key] = result更合理的做法通常是锁内只读取必要快照,锁外执行慢操作,再用版本号或条件更新提交结果。否则吞吐上限大致受限于临界区耗时:即便机器有 64 个核心,这段代码仍只能一辆车过桥。
2. 单热点把所有核心串行化
如果 64 个 goroutine 争一把锁,而每次临界区耗时 2 µs,理论上这块共享状态最多提供约 500,000 次临界区/秒,其他核心只能排队。真实数字还要扣掉 CAS 失败、缓存迁移、调度和业务代码,但方向不会改变。
分片锁可以把一个热点拆成 64 个较小热点:按用户 ID、哈希桶或租户路由到不同锁。代价是跨分片查询、迁移、锁顺序与内存占用更复杂。分片数量不能只按 CPU 核数拍脑袋,访问分布若高度倾斜,64 个桶仍可能只有一个桶在挨打。
3. goroutine 多不等于并行度多
Go 可以轻松创建很多 goroutine,但真正同时运行的数量受 GOMAXPROCS 和可用 CPU 限制。等待同一把锁的 goroutine 越多,唤醒、排队和调度的管理成本越高。大量请求同时超时后又重试,还可能形成锁竞争的惊群:所有人一起醒来、一起抢锁、一起失败。
连接池、信号量、队列长度和限流应在锁外限制并发。Mutex 不是背压工具。用它把无限请求挡在共享 map 前面,最后得到的是一个越来越长的等待队列和越来越坏的尾延迟。
4. NUMA 与跨 socket 会让共享更贵
在多路服务器上,CPU 可能分属不同 NUMA 节点。共享锁的 cache line 在 socket 之间迁移,成本比同一节点内更高。把所有请求汇聚到一个全局锁,会放大内存访问和互联总线成本。高性能系统会尽量让数据、线程和分片保持局部性,减少跨节点共享,而不是只盯着 Lock 的几纳秒基准。
5. 进程边界外,Mutex 就失效
sync.Mutex 只在一个 Go 进程的共享内存里有效。多个进程、多个容器或多台机器之间各有一把互不相识的 Mutex。需要跨进程的互斥时,要重新讨论租约、故障、时钟、网络分区和恢复语义,选择数据库约束、队列、共识系统或专门的分布式锁。把本地 Mutex 放进 Redis 客户端周围,不会自动得到分布式正确性。
八、什么时候不用 Mutex
原子变量:一个状态,而不是一组事务
sync/atomic 适合计数器、指针交换、状态机标志和无锁快照。它的操作有顺序一致性语义,但多个字段之间的复合不变量仍需要锁。例如「余额减去订单金额,同时增加订单数」不能因为两个字段都能原子更新就变成一个原子事务。
分片与 sync.Map
分片锁适合读写热点能按 key 分散的 map。sync.Map 针对特定模式优化:键集合趋于稳定、读远多于写,或不同 goroutine 操作的 key 集合彼此分离。它不是普通 map 的无脑替代品,也不能解决业务上需要一起修改多个 key 的事务。
channel 与单所有者
如果共享状态可以由一个 goroutine 独占,其他 goroutine 通过 channel 发送命令,就能把「谁能修改」从运行时竞争转化为结构上的所有权。代价是消息队列、生命周期、背压和单 goroutine 吞吐上限。channel 不是比 Mutex 高级,只是把同步边界换了一个位置。
不可变快照与 Copy-on-Write
读多写少的配置、路由表和规则集,可以构建新快照后用原子指针替换,读路径完全不加锁。写入成本和内存峰值会上升,引用对象也必须真正不可变。若读操作必须看到多个字段的一致版本,这种方案很有价值;若写入频繁,它可能只是反复复制大对象。
九、性能分析应该看什么
不要先把 Mutex 换成自旋锁,也不要看到 sync.Mutex 就决定上 RWMutex。先回答三个问题:锁竞争发生在哪里?持锁时间由什么组成?等待是否真的影响了用户可见的尾延迟?
可以通过以下手段建立证据:
- 用
go test -bench比较无竞争、轻竞争和重竞争,而不是只测单线程; - 使用
mutex profile观察锁等待和持锁相关事件; - 使用
block profile查看 goroutine 在同步原语上的阻塞; - 用
pprof看 CPU、内存与 goroutine 数量是否因锁竞争联动恶化; - 用
go test -race找出没有正确同步的共享访问; - 用
runtime trace观察 goroutine 唤醒、调度、网络和 GC 是否互相放大。
一个值得警惕的指标组合是:CPU 使用率不高,但 P99 很高、goroutine 数持续增长、mutex profile 等待时间集中在一个地址。这通常不是「CPU 不够」,而是大家在排队。相反,如果 CPU 已经被 CAS 和哈希操作打满,单纯增加分片也未必有用,需要减少共享访问或改变数据结构。
锁的性能要和正确性一起测。去掉锁后 benchmark 变快,只能证明数据竞争能让程序更快;那不是优化,是删掉了定义。
十、几条我愿意坚持的规则
第一,锁保护不变量,不保护函数名。把锁与它保护的数据放在同一抽象边界里,调用者不必猜「这个字段到底要不要先加锁」。
第二,临界区尽可能短,但不要为了短而把不变量拆碎。先在锁内复制一个小快照,再锁外做慢事,是常见优化;如果复制后状态可能过期,就加版本检查或重新设计协议。
第三,统一锁顺序,避免嵌套锁反转。两个 goroutine 分别以 A→B、B→A 的顺序获取锁,死锁不是低概率事件,而是迟早会发生的控制流。
第四,不要把 defer Unlock 当作性能敌人,也不要把它当作永恒真理。普通业务代码优先保证异常路径释放;极热、极短的循环可以用基准证明手写解锁有收益,再牺牲一点可读性。
第五,TryLock 只用于明确的机会式路径,例如尝试刷新非关键缓存。用它绕开等待、然后在拿不到锁时继续读共享可变数据,通常只是把阻塞问题变成竞态问题。
第六,锁内不做不可控 I/O。若必须持锁调用回调,要先定义回调是否可能重入、阻塞或反向获取其他锁。把第三方代码放进临界区,等于把锁的 SLA 交给别人。
十一、回到那两行 API
Lock 和 Unlock 之所以值得深入,不是因为它们神秘,而是因为它们把多个层次压缩在了一起:
业务不变量
↓
Go 内存模型:Unlock synchronizes-before Lock
↓
Mutex 状态机:CAS、等待者、正常/饥饿模式
↓
runtime:自旋、sema、sudog、gopark/goready
↓
CPU:原子读改写、缓存一致性、内存排序
↓
机器:核心、cache line、NUMA、操作系统调度理解这条链之后,很多争论会自动降温:Mutex 不是「慢」或「快」,而是无竞争时极轻、短竞争时通过自旋争取低延迟、长竞争时把 goroutine 排队睡眠,并在尾延迟恶化后切换到饥饿模式。它已经替我们做了大量工程工作,但它不能改变共享状态天然限制并行度的事实。
我的选择通常是先用普通 sync.Mutex 表达正确的不变量,再用 profile 找出热点;只有当证据表明共享访问是瓶颈,才考虑分片、原子快照、单所有者或数据结构重写。真正高并发的优化,往往不是发明更聪明的锁,而是让更少的 goroutine 需要同一把锁。
