Go Mutex
Contents
从问题入手:
如果 Mutex 已经被一个 goroutine 获取了锁,其它等待中的 goroutine 们只能一直等待。那么,等这个锁释放后,等待中的 goroutine 中哪一个会优先获取 Mutex呢?
在正常状态下,所有等待锁的 goroutine 按照 FIFO 顺序等待。但是,刚唤醒(即出队)的 goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 去竞争锁。新请求锁的 goroutine 具有一个优势:它正在 CPU 上执行。 而且可能有好几个 goroutine 同时在新请求锁,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 在没有获得锁之后会加入到等待队列的最前面。
如果一个等待的 goroutine 超过 1ms 没有获取锁,那么它将会把锁转变为饥饿模式。
在饥饿模式下,锁的所有权将从执行 unlock 的 goroutine 直接交给等待队列中的第一个等待锁者。 新来的 goroutine 将不能再去尝试竞争锁,即使锁是 unlock 状态,也不会去尝试自旋操作,而是放在等待队列的尾部。
如果一个等待的 goroutine 获取了锁,并且满足以下其中一个条件:
- (1)它是队列中的最后一个;
- (2)它等待的时候小于1ms,那么该 goroutine 会将锁的状态转换为正常状态。
正常模式具有较好的性能,因为 goroutine 可以连续多次尝试获取锁,即使还有其他的阻塞等待锁的 goroutine,也不需要进入休眠阻塞。 饥饿模式也很重要的,它的作用是阻止尾部延迟的现象。
版本更迭
- 初版: 使用一个flag字段标记是否持有锁
- 第二版: 新的goroutine也能有机会竞争锁
- 第三版: 新来的和被唤醒的有更多的机会竞争锁
- 第四版: 解决竞争问题,不会让goroutine等待太久
初版
|
|
CAS指令将给定的值和一个内存地址中的值进行比较,如果它们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性的。
由代码可知
- 字段key: 是一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个排外锁已经被持有;
- 字段 sema: 是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。
Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今
第二版
Go 开发者在 2011 年 6 月 30 日的 commit 中对 Mutex 做了一次大的调整,调整后的 Mutex 实现如下:
|
|
state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有唤醒的 goroutine,剩余的位数代表的是等待此锁的 goroutine 数。所以,state 这一个字段被分成了三部分,代表三个数据
请求锁
请求锁代码也变的复杂
|
|
其请求锁有以下几步
1)通过 CAS 检测 state 字段中的标志,如果没有goroutine持有锁,则直接获取锁;
2)state 不是零值,那么就通过一个循环进行检查,如果atomic.CompareAndSwapInt32(&m.state, old, new)
能成功将state设置为新值,那么则表示抢夺锁成功,
但是需要注意的是,如果成功地设置了 state 的值,但是之前的 state 是有锁的状态, 那么,state 只是清除 mutexWoken 标志或者增加一个 waiter 而已。
请求锁的 goroutine 有两类,
- 一类是新来请求锁的 goroutine
- 另一类是被唤醒的等待请求锁的goroutine。
锁的状态也有两种:加锁和未加锁。
以下表格表示goroutine不同来源不同状态下的处理逻辑
释放锁
|
|
同样释放锁也有几个步骤
1)尝试将持有锁的标识设置为未加锁的状态,这是通过减 1 而不是将标志位置零的方式实现
2)通过信号量的方式唤醒等待这个锁的goroutine中的一个,其中涉及的逻辑又有两种情况
- 如果没有其它的 waiter,说明对这个锁的竞争的 goroutine 只有一个,那就可以直接返回了
- 如果有等待者,并且没有唤醒的 aiter,那就需要唤醒一个等待的 waiter。在唤醒之前,需要将 waiter 数量减 1,并且将 mutexWoken 标志设置上
第三版
在 2015 年 2 月的改动中,如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不到锁,它们就会通过自旋的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑
加锁逻辑
|
|
第四版
经过几次优化,Mutex 的代码越来越复杂,应对高并发争抢锁的场景也更加公平。 但是极端情况下,等待中的 goroutine 可能会一直获取不到锁
Mutex 不能容忍这种事情发生。所以,2016 年 Go 1.9 中 Mutex 增加了饥饿模式,让锁 变得更公平,不公平的等待时间限制在 1 毫秒,并且修复了一个大 Bug:总是把唤醒的goroutine 放在等待队列的尾部,会导致更加不公平的等待时间
现在的 Mutex 代码已经复杂得接近不可读的状态了。
Java中的ReentrantLock
在Java中,ReentrantLock的实现是基于AbstractQueuedSynchronizer(AQS)的。它有两种状态:未锁定和锁定。当一个线程请求锁定时,如果当前没有被锁定,则它可以获得锁定并继续执行。如果已经被锁定,则请求锁定的线程就会进入阻塞状态,等待锁定被释放并唤醒它。
性能比较
在性能方面,Go语言中的Mutex和Java中的ReentrantLock都是高效的锁。它们都允许多个goroutine或线程同时访问共享变量,而只有一个goroutine或线程可以修改变量。在单个goroutine或线程的情况下,Go语言中的Mutex比Java中的ReentrantLock稍快。但是,当涉及到多个goroutine或线程时,性能差异就会很小,因为它们的底层实现都依赖于操作系统的原语。
实现方式比较
在实现方式方面,Go语言中的Mutex是基于操作系统提供的原语实现的,而Java中的ReentrantLock是基于AQS实现的。AQS提供了一些高级功能,例如可重入性、公平性和条件等待等。这些功能使ReentrantLock比Mutex更灵活和强大。
锁的特性比较
Go语言中的Mutex只有两种状态,即锁定和未锁定,而Java中的ReentrantLock则提供了许多特性,例如可重入性、公平性、超时等待、条件等待等。这些特性使ReentrantLock更加灵活和适应性更强。
总体而言,Go语言中的Mutex和Java中的ReentrantLock都是可靠和高效的锁,它们的底层实现都依赖于操作系统的原语。在选择哪种锁时,需要根据实际需求和场景来进行评估。如果只需要简单的锁定机制,则可以选择Go语言中的Mutex。如果需要更多的功能和特性,例如
最后
-
目前 Mutex 的 state 字段有几个意义,这几个意义分别是由哪些字段表示的?
- 前三个 bit 是 mutexLocked(锁标记)、mutexWoken(唤醒标记)、mutexStarving(饥饿标记)
- 剩余 bit 标示 mutexWaiter(等待数量)
-
等待一个 Mutex 的 goroutine 数最大是多少?是否能满足现实的需求? 目前的设计来看取决于 state 的类型,目前是 uint32,由于3个字节代表了状态,还有: 2^(32 – 3) – 1 等于 536870911, 一个 goroutine 初始化的为 2kb, 536870911 & 2kb 是相当大的数,所以可以满足现实的需求。
常见错误的四种场景
Lock/Unlock 不是成对出现、Copy 已使用的 Mutex、重入和死锁。
Author longtb
LastMod 2023-03-26