Golang-12:Mutex 详解
1. 快速了解
在一些多进程或多线程的场景下,可能会出现数据不一致的情况,这时候就需要通过加锁来保证数据的一致性,下面可以简单来看几个例子,快速了解一下锁的重要性。
- 不用锁的情况
由于没有加锁,可能会导致 total 最终得到的值不是期望的结果。
1 | package lock |
之所以期望不一致,是因为每次执行代码计算操作时,在编译器中只是显示一行 a+=1 或 a-=1。但在代码的底层其实是分为了三步执行,而由于没有加锁,所以这三步操作并不能保证使原子性的。
- 加锁
当代码带锁之后,每一步操作的同一时间段都不会有其他进程操作,从而可以保证数据的一致性。
1 | package lock |
- 使用 atomic 原子操作
如果想达到数据一致性的效果,除了使用加锁操作外,也可以使用 atomic 原子操作。
而原子操作简单来讲,就是在多线程或多进程环境下,不可被中断且执行过程具有原子性(不可分割),它能够保证数据一致性与并发安全的操作。
1 | package lock |
2. Mutex 的常见模式
Mutex 这个名称的由来应该是 mutual exclusion 的前缀组合,俗称「互斥体」或「互斥锁」。 go 语言 sync 包中 Mutex 的数据结构是下面这样的:
1 | type Mutext struct { |
因为足够简单,所以不需要额外的初始化。
- 此结构的零值就是一个有效的互斥锁,处于 unlock 的状态,state 存储的就是互斥锁的状态,加锁和解锁都是通过 atomic 包提供的函数原子性的操作该字段。
- 而 sema 用作一个信号量,主要用作等待队列。
1 | func (m *Mutex) Lock() { |
2.1 正常模式
Mutex 有两种模式,在正常模式下,一个尝试加锁的 goroutine 会先自旋几次,尝试通过原子操作获得锁。若几次自旋之后仍不能获得锁,则通过信号量排队等待,所有的等待者都会按照先入先出的顺序排队。
但是当锁被释放,第一个等待者被唤醒后,并不会直接拥有锁,而是需要和后来者竞争,也就是那些处于自旋阶段尚未排队等待的 goroutine。
这种情况下,后者更有优势,一方面它们已经在 CPU 上运行了,自然比刚被唤醒的 goroutine 更有优势。
另一方面,处于自旋状态的 goroutine 可以有很多,而被唤醒的 goroutine 每次只有一个,所以被唤醒的 goroutine 有很大概率拿不到锁,这种情况下它会被重新插入到队列的头部而不是尾部。而当一个 goroutine 本次加锁等待的时间超过了一毫秒后,它会把当前 Mutex 从正常模式切换至饥饿模式。
2.2 饥饿模式
在饥饿模式下,Mutex 的所有权从执行 unlock 的 goroutine 直接传递给等待队列头部的 goroutine。后来者不会自旋,也不会尝试获得锁,即使 Mutex 处于 unlock 的状态,它们会直接从队列的尾部排队等待。
当一个等待者获得锁之后,它会在以下两种情况时将 Mutex 由饥饿模式切换回正常模式。
- 第一种情况是他的等待时间小于一毫秒,也就是他刚来不久。
- 第二种情况是他是最后一个等待者,等待队列已经空了,后面自然就没有饥饿的 goroutine 了。
综上所述,在正常模式下,自旋和排队是同时存在的。执行 lock 的 goroutine 会先一边自旋一边抢锁,尝试过几次后,如果还没拿到锁就需要去排队等待了。这种在排队之前先让大家来抢的模式能够有更高的吞吐量,因为频繁的挂起和唤醒 goroutine 会带来较多的开销,但是又不能无限制地自旋,要把自旋的开销控制在较小的范围内。
所以在正常模式下,Mutex 有更好的性能,但是可能会出现队列尾端的 goroutine 迟迟抢不到锁的情况。而饥饿模式下不再自旋尝试,所有 goroutine 都要排队,严格的先来后到,对于防止出现尾端延迟来讲特别重要。
2.3 正常模式 Vs 饥饿模式
| 特性 | 正常模式(默认) | 饥饿模式 |
|---|---|---|
| 目标 | 追求效率(适合短时间持锁) | 追求公平(避免个别 goroutine 长期等待) |
| 新请求处理 | 先自旋,再排队 | 直接排队,不自旋 |
| 锁释放逻辑 | 唤醒等待队列中的第一个 goroutine(可能被新请求抢占) | 直接把锁交给队列头部的 goroutine(跳过抢占) |
| 切换条件 | 等待时间 < 1 毫秒 | 等待时间 ≥ 1 毫秒(或队列头部等待超 1 毫秒) |
举个例子:
- 正常模式像「自助餐取餐」:有人在门口张望(自旋),一旦空位出现就立刻抢位。
- 饥饿模式像「排队买票」:所有人按顺序等待,新来的人必须去队尾,不允许插队(自旋)。
3. Lock & Unlock 详解
接下来我们来展开 lock 和 unlock 的逻辑,首先来看一下关于 Mutex.state 的几个常量定义,state 的类型是 int32:
- 第 1 位用作锁状态标识,置为 1 就表示已加锁,对应掩码常量为 mutexLocked。
- 第 2 位用于记录是否已有 goroutine 被唤醒了,置为 1 表示已唤,醒对应掩码常量为 mutexWoken。
- 第 3 位标识 mutext 的工作模式,0 代表正常模式,1 则代表饥饿模式,对应掩码常量被 mutexStarving。
而常量 mutexWaiterShift 等于 3,表示除了最低 3 位以外,state 的其他位用来记录有多少个等待者在排队。
然后再来看一下 lock 和 unlock 方法,两个方法中主要通过 atomic 函数实现了 Fast path,相应的 Slow path 被单独放在了 lockSlow 和 unlockSlow 方法中。根据源码注释的说法,这样是为了便于编译器对 Fast path 进行内联优化。
1 | func (m *Mutex) Lock() { |
Fast path 和 Slow path 的含义:
- Fast path(快路径 ):可以理解成一条 “ 绿色通道 “ ,当 Mutex 处于理想状态,也就是没有被锁住,也没有其他 goroutine 在排队等着拿锁时,程序可以直接通过简单的操作(像
atomic.CompareAndSwapInt32这种原子操作 )快速获取锁。- Slow path(慢路径 ):如果走不了 “ 绿色通道 “,也就是 Mutex 已经被锁住,或者有其他 goroutine 在排队等着拿锁,那程序就没办法直接获取锁了。这时候就得走一条 “ 慢路 “,进入
lockSlow或unlockSlow这样的函数去处理,要经过更复杂的流程,比如处理排队、唤醒等操作。
lock 方法的 Fast path 期望 Mutex 处于 unlock 状态,没有 goroutine 在排队,更不会饥饿,理想状况下一个 CAS 操作就可以获得锁。但是如果 CAS 操作没能获得锁,就需要进入 Slow path,也就是 lockSlow 方法。
CAS 的含义:
CAS 是一种在多线程等场景下,能原子性地比较变量当前值与预期值,若相等则更新为新值的操作。
unlock 方法同理,首先通过原子操作从 state 中减去 mutexLocked,也就是释放锁,然后根据 state 的新值来判断是否需要执行 Slow path:
- 如果新值为 0,也就意味着没有其他 goroutine 在排队,所以不需要执行额外操作。
- 如果新值不为 0,那就需要进入 Slow path,看看是不是需要唤醒某个 goroutine。
lock 和 unlock 的 Fast path 就是这样,接下来我们展开 Slow path 的主要逻辑。
4. Mutex 自旋机制详解
4.1 何为 Mutex 自旋?
自旋(Spin)的核心思想: 在等待锁时不立即阻塞,而是循环检查锁是否释放,适合「锁被短暂占用」的场景。
假设你和朋友去食堂打饭,窗口只有一个阿姨(锁)。
- 正常情况:你发现前面有人排队(锁被占用),就乖乖站到队尾(阻塞等待)。
- 自旋场景:你站在窗口旁边「偷看」,发现阿姨马上就要打完饭(锁即将释放),于是原地等待,避免去队尾再回来的麻烦(减少上下文切换开销)。
4.2 Golang 的 Mutex 自旋
Golang 的 sync.Mutex 有两种工作模式:正常模式(默认)和饥饿模式。我们先聊正常模式下的自旋条件,这需要满足 4 个前提(缺一不可):
- 多核环境(
GOMAXPROCS > 1)
如果是单核 CPU(或GOMAXPROCS=1),自旋的 goroutine 和持有锁的 goroutine 共享同一个 CPU,你在自旋时对方无法运行,自旋毫无意义。 - 有其他处理器(P)在运行
Golang 的调度器(GMP 模型)中,P(Processor)是真正执行 goroutine 的单元。如果其他P都空闲,说明没有其他任务在跑,即使自旋也等不到锁释放,不如直接阻塞。 - 当前 P 的本地任务队列(runqueue)为空
如果本地还有其他 goroutine 在排队(比如go func()刚创建的任务),与其自旋浪费 CPU,不如先执行这些任务,等轮到自己时再抢锁,提升整体效率。 - 未进入饥饿模式
如果某个 goroutine 等待锁超过 1 毫秒,Mutex 会切换到饥饿模式,此时所有新请求直接排队,不再自旋。
4.3 自旋的底层逻辑
当满足上述条件时,goroutine 会进入自旋循环,具体做三件事:
- 抢「唤醒标识位」
Mutex 中有一个标记位(wakelock),用来避免唤醒过多的 goroutine 同时等待。就像食堂窗口放了一个「等待牌」,只有抢到牌子的人才能留在门口自旋,其他人必须去排队。 - 有限次数自旋
最多自旋 4 次(每次循环执行procyield函数,让 CPU 稍作休息),每次自旋都重新检查:- 锁是否已释放?
- 是否进入饥饿模式?
- 自旋次数是否超标?
一旦满足任意条件,立刻停止自旋,进入下一步操作(抢锁或排队)。
- 原子操作改状态
自旋结束后,goroutine 会通过原子操作(CAS)尝试修改 Mutex 的状态(state字段):- 如果锁处于正常模式且未被占用,直接抢锁成功。
- 如果锁被占用或处于饥饿模式,就加入等待队列(排队规模 +1)。
4.4 加锁流程
- 快速路径(Fast Path)
直接通过原子操作抢锁,成功则结束(无需自旋和排队)。 - 慢速路径(Slow Path)
- 自旋检查:满足条件则自旋(最多 4 次),否则跳过。
- 状态判断:
- 如果锁处于饥饿模式,或已被占用,当前 goroutine 加入等待队列(记录等待时间)。
- 如果正常模式且锁可用,通过原子操作抢锁。
- 唤醒逻辑:
- 解锁时,若在正常模式,会尝试唤醒一个等待的 goroutine(通过
semrelease函数)。 - 若在饥饿模式,直接把锁交给队首 goroutine,无需竞争。
- 解锁时,若在正常模式,会尝试唤醒一个等待的 goroutine(通过
为什么要自旋?
自旋的核心价值是减少上下文切换开销:
- 当锁被短暂持有时(比如几纳秒),自旋比阻塞(陷入内核、唤醒线程)更高效。
- 当锁被长期持有时,自旋会浪费 CPU,此时进入饥饿模式,通过排队保证公平。
4.5 机制小结
- 自旋一定比阻塞好?
错!单核、本地任务队列非空时,自旋反而降低效率,必须阻塞。 - 饥饿模式一定更公平?
对,但牺牲了效率。Golang 通过 1 毫秒的阈值平衡两者:短等待用正常模式(自旋抢锁),长等待用饥饿模式(排队保公平)。 - 自旋次数越多越好?
错!限制 4 次是为了防止 CPU 被过度占用,就像排队时不能一直盯着窗口,最多看 4 眼,没人出来就去队尾等着。
Golang Mutex 自旋的「生存法则」
- 自旋是优化,不是必须: 只有满足多核、有其他 P 运行、本地队列为空时才会触发。
- 有限且克制: 最多自旋 4 次,避免浪费 CPU。
- 动态切换模式: 短等待用正常模式(自旋抢锁),长等待用饥饿模式(排队保公平)。。
理解自旋机制,能帮你更好地设计并发程序:
- 若业务场景中锁竞争激烈且持锁时间长,可通过
sync.RWMutex或通道(Channel)减少锁冲突。 - 若持锁时间极短(如高频计数),自旋能显著提升性能。









