golang中Mutex互斥锁的发展历程与底层实现
序言
本文简单介绍了golang1.22版本中sync.Mutex互斥锁的底层实现原理,将从mutex的发展历程、数据结构、mutex底层抢占锁的流程展开介绍。
1.mutex的发展历程
首先,简要概述symc.mutex的发展历程,简单了解为什么现如今的mutex要这样设计, 1.最早期的mutex的实现,通过一个int32位的key字段和int32的sema字段记录mutex的状态和gorutine等待队列,新来申请锁的gorutine统一排队等待。请求锁时对key+1,尝试获取锁失败就加入到sema映射到的等待队列中。 缺点:请求锁的gorutine会排队等待获取锁,性能太差 2.第一次改进,修改了第一个字段的用法,key->state,state字段的用法更复杂,state包含了多个意义,新来的gorutine也有可能先获取到锁,增加了获取锁资源的快慢路径(快路径通过CAS操作直接修改锁字段值获取锁,慢路径会进入mutex资源的业务逻辑中,锁的抢占、加入等待队列等),使得锁资源的调度更为高效。 缺点:新唤醒的gorutine可能一直获取不到锁的资源,抢不过新来处于CPU的其他goruntine,导致饥饿现象。 3.增加了自旋,提高了gorutine竞争锁的能力,自旋指的是一个gorutine一次获取不到锁,会尝试多获取几次,单核场景下,自旋没有意义,等待获取锁的goroutine等待占有锁的goroutine,而等待释放锁的goroutine在等待获取CPU,单核场景下的自旋会导致死锁问题,所以只有在多核CPU环境下才可能发生自旋。 缺点:新唤醒的gorutine可能一直获取不到锁的资源,抢不过新来处于CPU的其他goruntine,导致饥饿现象。 4.增加饥饿模式,由于各个gorutine竞争锁的能力不一样指,这样就会导致队列头部或新来的gorutine完全竞争不过其他gorutine每次需要抢占锁时,这个gorutine都抢不过,此时就会产生饥饿现象,这个gorutine迟迟无法获取到锁资源,饥饿模式的引入解决了这样的问题,当发现某个goruntine等待锁超过1ms后,会认为这个goroutine等待时间过长。饥饿模式下,mutex的所有权会直接传递给等待队列头部的goroutine,这样便可以避免饥饿现象。
2.mutex的数据结构
golang1.22版本中的mutex采用了两个字段, 1.一个int32类型的state状态,用来标识锁的状态
从低位到高位,
第一位用作锁的状态标识,0标识解锁状态,1标识加锁状态,对应掩码常量(用来取出指定位)为mutexLocked=ob1
第二位用于记录是否已有goroutine被唤醒了,1标识有已唤醒的goroutine,对应掩码常量为mutexWoke=ob10
第三位标识mutex的工作模式,0代表正常模式,1代表饥饿模式。对应掩码常量为mutexStarving=ob100
前面三位所得值,如果等于3,也就是最低三位都等于1时,表示除了最低位以外,前面的29位用来记录有多少个等待的goruntine在排队等待。
2 一个uint32类型的sema信号量,根据sema信号量的数值,映射到semaTable表中的某个二叉树的根节点上,也就是映射到某棵平衡树上,找到树上的对应的节点,树的节点位置存储了sudog类型的结构体,节点内部使用了队列用来记录等待协程的信息,从而找到了该mutex的等待队列,当某个协程需要加入到该mutex的等待队列时,就会通过sema信号量字段,实现排队等待。
// mutex的数据结构
type Mutex struct {
state int32// 锁的状态标识
sema uint32// 锁的信号量
}
// 判断state时,需要使用到的掩码常量
const (
mutexLocked = 1 << iota //1= ob1
mutexWoken //2= ob10
mutexStarving //4= ob100
mutexWaiterShift = iota //3= ob11
starvationThresholdNs = 1e6//100 0000
)
3.锁的抢占流程
在正常模式下,一个尝试加锁的goroutine会先自旋几次,尝试通过原子操作获得锁,如果几次自旋之后仍然不能获得锁,就会通过seman信号量将该goroutine加入等待队列中,所有等待者都会按照先进先出(FIFO)的顺序入队伍,当当锁被释放,第一个等待者被唤醒后不会直接拥有锁,还需要和后来者竞争锁,也就是那些处于自旋阶段的,尚未排队等待的goroutine,这种情况下后来者更有优势,一方面,他们正在CPU上运行,调度开销小,另一方面,处于自旋状态的goruntine可以有很多,但是处理唤醒态的goroutine只有一个,所以被唤醒的goroutine就有很大概率拿不到锁,这种情况下它会被插入到队列的头部,而不是尾部,如果当一个goroutine本次加锁等待时间超过了1毫秒时,他会把当前的mutex从正常模式切换为饥饿模式,饥饿模式下,会直接调度等待队列头部的goroutine运行,其他后来的goroutine不需要自旋也不需要尝试获得锁,他们直接从队列的尾部排队等待, 那么,什么情况下会从饥饿模式切换会正常模式呢,显而易见,当等待队列只有一个gorutine时,也就是没有那么饥饿时,队列没有饥饿的goroutine了,会从饥饿模式切换会正常模式,另一种情况,当等待时间小于1毫秒时,也就是它刚来不久,也会切换回正常模式。 综上所述,自旋和排队等待是同时存在的,执行lock加锁的goroutine会一边自旋,尝试过几次后,如果还没拿到锁,就会去排队等待,同时为了避免队列尾端的goroutine迟迟抢不到资源,引入了饥饿模式,提高抢占能力弱的goroutine调度的优先级,从而避免了尾端延迟,给新来的goroutine一些机会。
下节预告: Mutex的常见易错场景以及Mutex使用进阶
转载自:https://juejin.cn/post/7386875278423867427