Go并发3 同步原语 - Mutex 的实现原理

136 阅读3分钟

Mutex演进的四个架构
初版
Mutex 使用一个 flag 来表示锁是否被持有,实现比较简单
给新人机会
照顾到新来的 goroutine,会让新的 goroutine 也尽可能地先获取到锁
多给些机会
照顾新来的和被唤醒的 goroutine
解决饥饿
第三阶段会带来饥饿问题,可能存在线程饿死的问题,所以引入第四阶段

image.png

Metux的实现
Metux初始阶段
初版的 Mutex 利用 CAS 原子操作,对 key 这个标志量进行设置。key 不仅仅标识了锁是否goroutine 所持有,还记录了当前持有和等待获取锁的 goroutine 的数量。

// CAS操作,当时还没有抽象出atomic包
 func cas(val *int32, old, new int32) bool
 func semacquire(*int32)
 func semrelease(*int32)
 // 互斥锁的结构,包含两个字段
 type Mutex struct {
 key int32 // 锁是否被持有的标识
 sema int32 // 信号量专用,用以阻塞/唤醒goroutine
 }
 
 // 保证成功在val上增加delta的值
 func xadd(val *int32, delta int32) (new int32) {
 for {
 v := *val
 if cas(val, v, v+delta) {
 return v + delta
 }
 }
 panic("unreached")
 }
 
 // 请求锁
 func (m *Mutex) Lock() {
 if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
 return
 }
 semacquire(&m.sema) // 否则阻塞等待
 }
 
 func (m *Mutex) Unlock() {
 if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者
 return
 }
 semrelease(&m.sema) // 唤醒其它阻塞的goroutine
 } 

字段 key:0 1 n
是一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个排外锁已经被持有;
字段 sema:
是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。

type Mutex struct {
 key int32 // 锁是否被持有的标识
 sema int32 // 信号量专用,用以阻塞/唤醒goroutine
 }

Go与Java中互斥锁的区别
Go Metux跟Java中的Lock最大的区别在于Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以,Unlock 也不会对此进行检查,Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的 goroutine,也可以进行这个操作。

Tips: 我们在使用 Mutex 的时候,必须要保证 goroutine 尽可能不去释放自己未持有的 锁,一定要遵循“谁申请,谁释放”的原则

defer内联
从 1.14 版本起,Go 对 defer 做了优化,采用更有效的内联方式,取代之前的生成 defer 对象到 defer chain 中,defer 对耗时的影响微乎其微了,所以基本上修改成下面简洁的写法也没问题。

这样做的好处就是 Lock/Unlock 总是成对紧凑出现,不会遗漏或者多调用,代码更少。

type Foo struct {
 mu sync.Mutex
 count int
}

func (f *Foo) Bar() {
 f.mu.Lock()
 if f.count < 1000 {
 f.count += 3
 f.mu.Unlock() // 此处释放锁
 return
 }
 f.count++
 f.mu.Unlock() // 此处释放锁
 return
}

//def优化后
func (f *Foo) Bar() {
 f.mu.Lock()
 defer f.mu.Unlock()
 if f.count < 1000 {
 f.count += 3
 return
 }
 f.count++
 return
}

Tips: 如果临界区只是方法中的一部分,为了尽快释放锁,还是应该第一时间调用 Unlock,而不是一直等到方法返回时才释放。

Metux解决饥饿阶段
Mutex 绝不容忍一个 goroutine 被落下,永远没有机会获取锁。不抛弃不放弃是它的宗旨,而且它也尽可能地让等待较长的 goroutine 更有机会获取到锁。

State字段
在最新的metux源码中,最开始的key被state字段替代,包含了更多的信息。 image.png

Mutex的工作流程
正常模式与饥饿模式
1.请求锁时调用的 Lock 方法中一开始是 fast path,当前的 goroutine 尝试获取锁(cas+自旋),获取成功则直接返回。
2.否则就进入了 lockSlow 方法。
3.正常模式下,waiter 都是进入先入先出队列,被唤醒的 waiter需要和新来的 goroutine 进行锁竞争。
4.新来的 goroutine 相对优先获取到锁,如果新来的G获取到锁,waiter会被插到队列前面,当获取不到锁的时间超过阈值1ms时,进入饥饿模式。
5.饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine 不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin,它会乖乖地加入到等待队列的尾部。 6.如果有一下两种情况,饥饿模式会再转换成正常模式。
此 waiter 已经是队列中的最后一个 waiter 了,没有其它的等待锁的 goroutine 了;
此 waiter 的等待时间小于 1 毫秒。

Tips:
正常模式拥有更好的性能,因为即使有等待抢锁的 waiter,goroutine 也可以连续多次获取到锁。
饥饿模式是对公平性和性能的一种平衡,它避免了某些 goroutine 长时间的等待锁。在饥饿模式下,优先对待的是那些一直在等待的 waiter。

Go Mutex中的饥饿模式和正常模式跟Java中的非公平/公平锁很是类似,但是由于其没有线程/协程标记位,所以就不存在可重入锁的概念。

源码

type Mutex struct {
 state int32
 sema uint32
 }
 
 const (
 mutexLocked = 1 << iota // mutex is locked
 mutexWoken
 mutexStarving // 从state字段中分出一个饥饿标记
 mutexWaiterShift = iota
 
 starvationThresholdNs = 1e6
 )
 
 func (m *Mutex) Lock() {
 // Fast path: 幸运之路,一下就获取到了锁
 if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
 return
 }
 // Slow path:缓慢之路,尝试自旋竞争或饥饿状态下饥饿goroutine竞争
 m.lockSlow()
 }
 
 func (m *Mutex) lockSlow() {
 var waitStartTime int64
 starving := false // 此goroutine的饥饿标记
 awoke := false // 唤醒标记
 iter := 0 // 自旋次数
 old := m.state // 当前的锁的状态
 for {
 // 锁是非饥饿状态,锁还没被释放,尝试自旋
 if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSp
 if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0
 atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) 
 awoke = true
 }
 runtime_doSpin()
 iter++
 old = m.state // 再次获取锁的状态,之后会检查是否锁被释放了
 continue
 }
 new := old
 if old&mutexStarving == 0 {
 new |= mutexLocked // 非饥饿状态,加锁
 }
 if old&(mutexLocked|mutexStarving) != 0 {
 new += 1 << mutexWaiterShift // waiter数量加1
 }
 if starving && old&mutexLocked != 0 {
 new |= mutexStarving // 设置饥饿状态
 }
 if awoke {
 if new&mutexWoken == 0 {
 throw("sync: inconsistent mutex state")
 }
 new &^= mutexWoken // 新状态清除唤醒标记
 }
 
 // 成功设置新状态
 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, 1)
 // 唤醒之后检查锁是否应该处于饥饿状态
 starving = starving || runtime_nanotime()-waitStartTime > star
 old = m.state
 // 如果锁已经处于饥饿状态,直接抢到锁,返回
 if old&mutexStarving != 0 {
 if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterSh
 throw("sync: inconsistent mutex state")
 }
 // 有点绕,加锁并且将waiter数减1
 delta := int32(mutexLocked - 1<<mutexWaiterShift)
 if !starving || old>>mutexWaiterShift == 1 {
 delta -= mutexStarving // 最后一个waiter或者已经不饥饿了,清
 }
 atomic.AddInt32(&m.state, delta)
 break
 }
 awoke = true
 iter = 0
 } else {
 old = m.state
 }
 }
 }
 
 func (m *Mutex) Unlock() {
 // Fast path: drop lock bit.
 new := atomic.AddInt32(&m.state, -mutexLocked)
 if new != 0 {
 m.unlockSlow(new)
 }
 }
 
 func (m *Mutex) unlockSlow(new int32) {
 if (new+mutexLocked)&mutexLocked == 0 {
 throw("sync: unlock of unlocked mutex")
 }
 if new&mutexStarving == 0 {
 old := new
 for {
 if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|m
 return
 }
 new = (old - 1<<mutexWaiterShift) | mutexWoken
 if atomic.CompareAndSwapInt32(&m.state, old, new) {
 runtime_Semrelease(&m.sema, false, 1)
 return
 }
 old = m.state
 }
 } else {
 runtime_Semrelease(&m.sema, true, 1)
 }
 

image.png