你会如何设计Mutex?
上一篇公众号文章,体验了Mutex的使用,只有两个方法,Lock 和 Unlock,竟是那么简单,进入临界区之前调用 Lock 方法,退出临界区的时候调用Unlock方法。小伙伴儿们问:“真的,就这么简单吗?”
其实没那么简单。如果你查看标准库里Mutex的源代码,你会发现,从一个简单易于理解的互斥锁的实现,到一个非常复杂的数据结构,这是一个逐步迭代完善的过程。
如果让你设计一个完善的同步原语,并能对复杂度、性能、结构设计的等指标考量,你会怎么做?下面我们从0到1的实现它。
写代码大家都知道,不是一次就能把所有功能和性能做到完美的,总是要迭代的,那我们就把这次目标分解成3个阶段,你可以把自己当成一个“菜鸟”,然后逐渐披荆斩棘,升级为“老鸟”。
第一版
只要实现功能就可以,最简单的方式,用一个变量,可以是bool也可以是0和1的数字型。
1 定义一个result变量,标记当前的锁是否被某个goroutine持有。如果result的值是1,就代表已经被持有了,其他竞争的goroutine只能等待;如果值是0,就可以通过CAS将result设置为1,表示锁被当前的这个gotoutine持有了。
这里,简单说一下CAS吧
CAS指令将给定的值和一个内存地址中的值进行比较,如果他们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性的。
什么是原子性?
原子性保证这个指令总是基于最新的值的进行计算,如果同时有其他线程已经修改了这个值,CAS就会返回失败。
CAS是实现互斥锁和同步原语的基础,小伙伴们,要掌握啊~
看下面初版的Mutex的结构体
type Mutex struct {
key int32
sema uint32
}
key是用来标识这个排他锁是否被某个goroutine所持有,如果key>1,就说明这个排他锁已经被持有。
0 锁未被持有
1 锁被持有,没有其他goroutine等待
m 锁被持有,还有m-1个等待着
sema是信号量变量,用来控制等待goroutine的阻塞休眠和唤醒。
调用Lock请求锁的时候,通过xadd方法进行CAS操作,xadd方法通过循环执行CAS操作直到成功,保证对key+1的操作成功完成。如果锁没有被其他的goroutine持有,那么Lock方法成功地将key设置1,这个goroutine就持有了这个锁,如果锁被其他goroutine持有了,当前的gorouine会把key+1,而且还会调用semacquire方法,使用信号量将自己休眠,等待锁释放的时候,信号量会将它唤醒。
持有锁的goroutine调用Unlock释放锁时,它会将key-1。如果没有其他等待这个锁的goroutine,这个方法就返回了,如果有等待此锁的其他goroutine,那么会调用semrelease方法,利用信号量唤醒等待锁的其他goroutine中的一个。
到这里,第一版的Mutex利用CAS原子操作,对key这个标志量进行设置。key不仅仅标识了锁是否被goroutine所持有,还记录了当前持有和等待获取锁的goroutine的数量。
Unlock方法可以被任何的gorotuine调用释放锁,即便是没有持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex自身并没有包含持有这把锁的goroutine信息,所以,Unlock也不会对此进行检查。这里要遵循是 Lock/Unlock成对出现,就是谁申请,谁释放。这里建议要使用defer一起搭配使用。
如果请求锁的goroutine会排队等待获取锁,那看起来是公平的,但是性能不是最优,如果能把锁交给正在执行CPU时间片的goroutine,就不需要上下文的切换,在高并发的情况下,性能会更好。
第二版
我们要看看Go的设计者如何解决这个问题。
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota //持有锁的标记
mutexWoken //唤醒标记
mutexWaiterShift = iota //阻塞等待的waiter数量
)
上面也是2个字段,但是第一个字段改成了state,是一个符合的字段,表示多个意义。
第一位表示这个锁是否被持有
第二位表示是否有唤醒的goroutine
其他的位数表示的是等待此锁的groutine数量
请求锁的goroutine有2种:
1 新goroutine
2 被唤醒的goroutine
锁分为2种:
1 加锁
2 未加锁
下面是gorotine与锁的对应关系
goroutine | 当前锁被持有 | 当前锁未被持有 |
新goroutine | 休眠 | 获取到锁 |
被唤醒goroutine | 重新休眠,加入等待队列 | 获取到锁 |
相对于第一版,这次的改动主要意图是,新来的goroutine也有机会先获取到锁,当然,一个goroutine可能连续幸运地获取到锁。这又会造成不公平,Go语言的设计者又做了进一步改造。
第三版
Go设计者修改Mutex,如果新来的goroutine或者被唤醒的goroutine首次获取不到锁,那么久会通过自旋的方式,尝试检查锁是否被释放。在通过一定的自旋次数后,再执行原来的逻辑。
上面我们让新来的goroutine可以参与抢锁竞争的场景中,但是如果在极端竞争中,有可能每次都被新来的goroutine抢走,那么,等待的goroutine就会产生饥饿的问题。
Go的设计者要尽量保持更加公平,让不公平的等待时间限制在1毫秒,增加了mutexStarving(饥饿标记),目的就是,不抛弃,不放弃,永远不会让任何一个goroutine被落下。一旦等待者等待时间超过了1毫秒,Mutex的处理就有可能进入饥饿模式,优先让等待者先获取到锁,新来的goroutine主动谦让一下,多么和谐啊。通过这种方式,让请求锁的goroutine获取锁更加公平。对于业务,不会一直等待锁不被处理。
其他内容可以参阅我的新书如下