引言
在并发编程的世界中,互斥锁是守护共享资源的基石。Go语言的sync.Mutex以其简洁的API背后,隐藏着精巧而高效的设计。从快速路径的极致优化到慢速路径的智能自适应,从正常模式的高性能到饥饿模式的强公平,每一个细节都凝聚着Go团队对并发编程的深刻理解。本文将深入sync.Mutex的源码世界,探寻其背后的设计哲学与工程智慧。
一、核心数据结构、状态标志和方法
go
type Mutex struct {
state int32
sema uint32
}
state:表示锁的状态,包含多个信息(是否锁定、是否唤醒、是否饥饿、等待者数量)。sema:信号量,用于阻塞和唤醒goroutine。
状态标志
go
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
starvationThresholdNs = 1e6
)
- 使用位操作来在一个32位的整数中存储多个状态。
- 常量定义:
mutexLocked:最低位,表示锁是否被持有。mutexWoken:第二位,表示有goroutine被唤醒。mutexStarving:第三位,表示当前处于饥饿模式。mutexWaiterShift:mutexWaiterShift = 3,等待者数量占用第3-31位。
- 解释状态组合,例如:
state为0001(二进制)表示锁被持有,无等待者。
text
| 31..3 | 2 | 1 | 0 |
|----------|----------|-------|-------|
| 等待者数量 | starving | woken |locked |
方法
go
func (m *Mutex) Lock() {}
func (m *Mutex) TryLock() bool {}
func (m *Mutex) lockSlow() {}
func (m *Mutex) Unlock() {}
func (m *Mutex) unlockSlow(new int32) {}
二、模式介绍
在并发编程中,锁的设计永远面临一个根本性矛盾:如何在性能 和公平性 之间找到最佳平衡点。Go 语言的 sync.Mutex 通过创新的双模式设计优雅地解决了这个问题。
2.1 正常模式(Normal Mode):
-
在正常模式下,等待的goroutines会按照FIFO的顺序排队,但是被唤醒的等待者(刚刚被唤醒的goroutine)并不会立即获得锁,而是需要和 newly arriving goroutines(新到达的goroutine)竞争。新到达的goroutine有一个优势,因为它们已经在CPU上运行了,所以可能比刚被唤醒的goroutine更有竞争力。因此,刚被唤醒的goroutine很可能竞争失败。在这种情况下,这个被唤醒的goroutine会加入到等待队列的前面。如果它竞争失败超过1ms,那么mutex就会切换到饥饿模式。
-
在正常模式下,性能较高,因为新到达的goroutine可以有机会直接获取锁,而不需要等待。
2.2 饥饿模式(Starvation Mode):
-
在饥饿模式下,mutex的所有权直接从解锁的goroutine移交给等待队列最前端的goroutine。新到达的goroutines不会尝试去获取锁,即使mutex看起来是解锁状态。它们也不会自旋。相反,它们会把自己加入到等待队列的尾部。
-
饥饿模式是为了防止等待队列中的goroutines长时间获取不到锁,即防止"尾延迟"。
2.3 模式切换的时机:
- 正常模式 -> 饥饿模式:当一个goroutine等待锁的时间超过1ms时,mutex切换到饥饿模式。
- 饥饿模式 -> 正常模式:当获取到锁的goroutine是等待队列中的最后一个等待者,或者它等待的时间小于1ms时,mutex切换回正常模式。
这种设计既保证了新到达goroutine的竞争机会(提高性能),又避免了等待队列中的goroutine永远无法获取锁(保证公平性)。
在代码中,我们通过state字段的mutexStarving位来表示饥饿模式,通过等待时间(waitStartTime)和starvationThresholdNs(1e6纳秒,即1ms)来触发模式切换。
三、悲观锁与乐观锁思想
在一般的设计锁设计中,有2种基本思想------悲观锁和乐观锁,由此产生了加锁失败后的2种基本策略:阻塞(Blocking)和自旋(Spinning)。
3.1 乐观锁思想与自旋(Spinning):
乐观锁思想:总是假设最好的情况,每次访问共享资源时都认为没有其他线程同时访问,因此不会加锁。在互斥锁中,如果加锁失败,线程不会阻塞,而是不断重试(自旋)。
go
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 乐观假设:锁很快会释放,自旋等待
runtime_doSpin()
iter++
old = m.state
continue
}
- 操作:线程不断循环检查锁是否被释放,在此期间不会让出CPU。
- 优点:避免上下文切换,如果锁的持有时间很短,则能很快获取到锁。
- 缺点:消耗CPU,如果锁持有时间较长,则会浪费大量CPU周期。
注意,在数据库或CAS(Compare-And-Swap)操作中,乐观锁通常通过版本号或CAS操作来实现,而不使用互斥锁。
3.2 悲观锁思想与阻塞(Blocking):
悲观锁思想:总是假设最坏情况,认为共享资源每次被访问时都会发生冲突,因此每次访问时都会先加锁,防止其他线程访问。
go
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 悲观现实:进入阻塞
runtime_SemacquireMutex(&m.sema, queueLifo, 2)
}
- 操作:将当前线程(或goroutine)放入等待队列,并让出CPU,进入睡眠状态。
- 优点:不消耗CPU周期,适合长时间等待或高竞争场景。
- 缺点:上下文切换(用户态到内核态)的开销较大,如果锁的持有时间很短,那么唤醒线程的开销可能比锁持有时间还长。
Go sync.Mutex 的设计没有简单地二选一,而是设计了一个自适应的混合策略
四、加锁过程(Lock)
go
func (m *Mutex) Lock() {
// 快速路径:当锁完全空闲时,通过一次原子操作直接获取锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// 慢速路径
m.lockSlow()
}
注意:m.lockSlow()被单独拿出来,这样快速路径就可以被Go编译器内联优化。
快速路径
- 通过CAS尝试将
state从0设置为mutexLocked,成功则返回。
慢速路径(lockSlow)
- 初始化一些变量:
waitStartTime(开始等待的时间)、starving(是否饥饿)、awoke(是否被唤醒)、iter(自旋计数器)。
go
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
-
循环尝试获取锁:
- 自旋条件 :正常模式、锁被持有、自旋次数未超过上限(由go运行时判断),则进行自旋。
- 自旋中尝试设置
mutexWoken,以通知解锁者不要唤醒其他goroutine。
- 自旋中尝试设置
goif old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() iter++ old = m.state continue }text| 31..3 | 2 | 1 | 0 | |----------|----------|-------|-------| | 等待者数量 | starving | woken |locked | | x | 0 | y | 0 |上述条件:
-
mutexWoken == 0对应的是表格中的y = 0(即 woken 位为 0)。 -
old>>mutexWaiterShift != 0对应的是表格中的x > 0(即等待者数量大于 0)。 -
状态计算:
- 如果不在饥饿模式,则尝试设置锁标志。
- 如果锁被持有或饥饿,则增加等待者计数。
- 如果当前goroutine已经标记为饥饿且锁被持有,则设置饥饿模式。
- 如果当前goroutine是被唤醒的,需要清除
mutexWoken标志,即将y设置为0。
-
CAS更新状态:如果成功,则根据旧状态判断:
- 如果旧状态未锁定且非饥饿,则成功获取锁,退出循环。
- 否则,排队等待(如果是第一次等待,记录开始时间,并排队到队列尾部;如果不是第一次,则排队到队列头部)。
- 通过信号量阻塞等待。
- 被唤醒后,检查等待时间是否超过1ms,更新饥饿标志。
- 如果处于饥饿模式,则调整状态(减少等待者计数,并设置锁标志,如果满足条件则退出饥饿模式)。
- 退出饥饿模式的条件(二者满足其一即可):a. 竞争不激烈(阻塞小雨1ms),b. 当前为最后一个等待者。
-
如果CAS失败,则重新读取状态,继续循环。
- 自旋条件 :正常模式、锁被持有、自旋次数未超过上限(由go运行时判断),则进行自旋。
思考:为什么被唤醒后处于饥饿模式可以直接调整状态并返回(即获取锁)?
go
runtime_SemacquireMutex(&m.sema, queueLifo, 2)
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
if old&mutexStarving != 0 {
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}
因为在饥饿模式下,锁的所有权通过直接移交 机制保证------解锁操作会直接将锁交给等待队列头部的 goroutine。当前 goroutine 被唤醒时,它已经被选定为锁的下一个持有者,无需再参与竞争。同时,新来的 goroutine 被强制排到队尾,确保了严格的 FIFO 顺序,从根本上防止了饥饿现象。
五. 解锁过程(Unlock)
go
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}
快速路径
- 将
state减去mutexLocked,如果结果为0,则没有等待者,直接返回。
慢速路径(unlockSlow)
-
检查解锁状态:如果对未锁定的锁解锁,则panic。
-
根据是否处于饥饿模式分别处理:
-
正常模式:
- 如果没有等待者,或者已经有goroutine被唤醒或获取锁,则直接返回。
- 否则,尝试减少一个等待者并设置唤醒标志为1,然后唤醒一个goroutine。
-
饥饿模式:
- 直接通过信号量唤醒队列头部的goroutine(移交锁的所有权)。
-
在解锁过程中,通过 runtime_Semrelease 的 handoff 参数控制锁的移交方式:
- 正常模式 (
handoff=false):唤醒队头等待者参与锁竞争 - 饥饿模式 (
handoff=true):直接将锁移交给队头等待者,新来的 goroutine 只能排队。
直接移交的实现涉及到GMP模型的调度,可以阅读博客Go语言GMP调度模型解析
其核心代码实现 runtime/sema.go 中
go
if handoff && cansemacquire(addr) {
s.ticket = 1
}
readyWithTime(s, 5+skipframes)
if s.ticket == 1 && getg().m.locks == 0 && getg() != getg().m.g0 {
goyield()
}
简单来说,在饥饿模式下,解锁操作会通过运行时信号量的特殊机制,将锁的所有权直接移交给等待队列最前端的goroutine,并通过goyield()立即让出CPU,使被唤醒的goroutine能马上开始执行。
思考:为什么在正常模式下解锁,需要将唤醒标志置为1?
go
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 2)
return
}
在正常模式下,解锁操作中设置mutexWoken为了协调多个解锁者并发解锁唤醒多个等待者,确保只有一个等待的gorutine被唤醒,从而避免惊群效应和不必要的上下文切换。
六、TryLock方法
- 检查锁的状态,如果已锁定或处于饥饿模式,则返回false。
- 否则,尝试通过CAS获取锁。
总结
Go语言的sync.Mutex是一个工程实践的典范,它通过精巧的设计在性能、公平性和复杂度之间找到了最佳平衡点:
设计智慧体现在:
- 分层优化:快速路径与慢速路径分离,为无竞争场景提供极致性能
- 智能自适应:根据运行时情况在正常模式与饥饿模式间动态切换
- 混合策略:结合自旋与阻塞,在短等待与长等待场景下都能表现优异
- 状态驱动:通过位操作实现紧凑的状态管理,保证操作的原子性
工程价值在于:
- 简单易用 :对外提供简洁的
Lock()/Unlock()接口 - 自动优化:内部自适应各种竞争强度,无需手动调优
- 健壮可靠:严格的状态检查确保在各种边界情况下正确工作