sync.mutex 作用:
sync.Mutex是Go标准库中常用的一个排外锁。当一个 goroutine 获得了这个锁的拥有权后, 其它请求锁的 goroutine 就会阻塞在
Lock 方法的调用上,直到锁被释放。
初代mutext
1 | type Mutex struct { |
通过cas对 key 进行加一, 如果key的值是从0加到1, 则直接获得了锁。否则通过semacquire进行sleep, 被唤醒的时候就获得了锁。
2016年 Go 1.9中增加了饥饿模式,让锁变得更公平,不公平的等待时间限制在1毫秒,并且修复了一个大bug,唤醒的goroutine总是放在等待队列的尾部会导致更加不公平的等待时间。
现有的锁实现是很复杂的。粗略的瞄一眼,很难理解其中的逻辑, 尤其实现中字段的共用,标识的位操作,sync函数的调用、正常模式和饥饿模式的改变等
sync.mutex
在分析源代码之前, 我们要从多线程(goroutine)的并发场景去理解为什么实现中有很多的分支。
- 当一个goroutine获取这个锁的时候, 有可能这个锁根本没有竞争者, 那么这个goroutine轻轻松松获取了这个锁。
- 而如果这个锁已经被别的goroutine拥有, 就需要考虑怎么处理当前的期望获取锁的goroutine。
- 同时, 当并发goroutine很多的时候,有可能会有多个竞争者, 而且还会有通过信号量唤醒的等待者。
mutex的结构:state 是一个共用的字段,第 0 个bit 表示是被 goroutine 所拥有加锁。第一个 bit 表示mux 是否被唤醒,1
2
3
4type Mutex struct {
state int32
sema uint32
}
也就是有某个唤醒的goroutine要尝试获取锁。第二个 bit 标记这个mutex状态, 值为1表明此锁已处于饥饿状态。
同时 goroutine也有自身的状态:有可能它是新来的goroutine,也有可能是被唤醒的goroutine,
可能是处于正常状态的goroutine, 也有可能是处于饥饿状态的goroutine。
也就是说在变更mutex状态的时候,也和当前goroutine的状态有关。
mutex 的状态
mutext 饥饿状态主要是为了保持锁的公平性,防止早运行的goroutine一直获取不到 锁,从而得不到执行。
互斥锁有两种状态:正常状态和饥饿状态:
- 正常状态:
在正常状态下,所有等待的goroutine按照FIFO的顺序去正常等待获取锁。
唤醒的goroutine不会直接拥有锁,而是去和正在运行的goroutime去竞争锁。
goroutine竞争锁的拥有,新请求锁的goroutine具有优势,因为它正在CPU上执行, 而且可能有好几个.
所以刚刚唤醒的goroutine有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的goroutine会加入到等待队列的前面。
如果一个等待的goroutine超过1ms没有获取锁,那么它将会把锁转变为饥饿模式。【当前goroutine 要设置改变锁的状态时候:要么设置为加锁,要么设置为饥饿模式。饥饿模式权限是为了让等待执行较长时间的goroutine获取到执行权限】。
锁可能同时具有加锁和饥饿的标志,因为G1第一次获取到的时候会加锁,G2长时间等待后会标记为饥饿。G1解锁的时候,会解除加锁状态,会判断当前锁是不是具有饥饿锁,如果具有饥饿会通知G2执行,G2执行的时候会解除饥饿,同时设置锁为锁状态。
也就是说,只有一个goroutine会获取到饥饿状态。 - 饥饿状态:
在饥饿模式下,锁的所有权将从unlock的gorutine直接交给交给等待队列中的第一个。
新来的goroutine将不会尝试去获得锁,即使锁看起来是unlock状态, 也不会去尝试自旋操作,而是放在等待队列的尾部。
如果一个等待的goroutine获取了锁,并且满足一以下其中的任何一个条件:
(1)它是队列中的最后一个;
(2)它等待的时候小于1ms。它会将锁的状态转换为正常状态。
正常状态有很好的性能表现,饥饿模式也是非常重要的,因为它能阻止尾部延迟的现象。
mutex之lock操作
如果:当goroutine 尝试加锁的时候,锁处于空闲状态。那么直接设置为加锁状态,或者进入自旋逻辑。
当尝试加锁的时候:
- 如果当前处于加锁,且是非饥饿状态,且未达到最大自旋次数。尝试标记自己需要被唤醒。
1
2
3
4
5
6
7
8
9
10
11if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 自旋的过程中如果发现state还没有设置woken标识,则设置它的woken标识, 并标记自己为被唤醒。
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++ // 是计数器,标记自旋的次数
old = m.state // old 是lock函数的内部变量,用来获取当前mutex的状态
continue
} - 被唤醒执行下边的逻辑:【或者说不满足自旋后的操作】
当不在自旋的时候,有可能被唤醒,有可能锁已经被解除,有可能锁处于饥饿状态。
当前锁的可能状态:当前协程尝试重置锁状态:1
2
3
4
5// 1. 锁还没有被释放, 锁处于正常状态
// 2. 锁还没有被释放, 锁处于饥饿状态
// 3. 锁已经被释放, 锁处于正常状态
// 4. 锁已经被释放, 锁处于饥饿状态
// 5. 锁空闲状态- 如果mutx 处于未饥饿状态.
把mutx 设置为加锁。【可以已经从别的协程的饥饿锁状态回归,此时是未饥饿的,但是可能是有锁,也可能是无锁】 - 如果mutx 处于锁状态或者饥饿状态。
增加获取锁的队列等待数。 - 如果当前协程处于饥饿状态下:
说明锁状态是处于加锁状态或者饥饿状态。饥饿状态也是一种锁状态,优先级比锁状态高,以便当前goroutine能获取到优先执行权。
如果mutx 处于加锁,且当前goroutine是饥饿状态,设置mutx为饥饿状态。【抢占锁】
如果mutx 未加锁,说明是自己在尝试获取锁。【那么case 1 已经设置当前锁状态为 lock】1
2
3if starving && old&mutexLocked != 0 {
new |= mutexStarving
} - 尝试使用cas 设置锁状态。【基于1, 2,3重置后的锁状态】
设置成功:- 如果老的锁状态【重置之前的状态】为 未加锁,不是饥饿。直接跳出循环【此时锁,已经被当前goroutine锁住】。执行业务逻辑。
- 添加自身到等待队列
如果是新的添加到队尾,如果是老的添加到队头去 1
2queueLifo := waitStartTime != 0
runtime_SemacquireMutex(&m.sema, queueLifo, 1) - 判断是否超过最大锁等待时间,超过设置自身为饥饿状态【下次循环的时候设置 锁状态为 饥饿状态。】,
- 重新获取最新的锁状态,如果是 饥饿状态,那么本身应该获取到.执行权限如果设置失败:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29// 说明当前goroutine 要么是新的goroutine,要么是饥饿的goroutine或者未达到饥饿态的处于自旋中的goroutine.
// 如果自身未达到饥饿状态的条件,会一直自旋,等待达到饥饿状态的时候,设置锁状态是饥饿状态,那么下次执行一定是自身。
// unlokc 会解除饥饿状态。
// 再次获取锁状态,如果是饥饿状态,那么一定是当前goroutine 设置的。因为当前goroutine获取到了执行权限,且在自旋过程中。
// 而且 锁的状态变更是通过 CAS操作,只有一个执行权限的goroutine可以设置成功。
old = m.state
if old&mutexStarving != 0 {
// If this goroutine was woken and mutex is in starvation mode,
// ownership was handed off to us but mutex is in somewhat
// inconsistent state: mutexLocked is not set and we are still
// accounted as waiter. Fix that.
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
delta := int32(mutexLocked - 1<<mutexWaiterShift)
// 非饥饿,或者自身处于队列第一个的时候。都要解除饥饿状态,以免死锁。
if !starving || old>>mutexWaiterShift == 1 {
// Exit starvation mode.
// Critical to do it here and consider wait time.
// Starvation mode is so inefficient, that two goroutines
// can go lock-step infinitely once they switch mutex
// to starvation mode.
delta -= mutexStarving
}
// 解除饥饿状态,跳出循环,执行业务逻辑
atomic.AddInt32(&m.state, delta)
break
}
// 如果不是饥饿状态,重置自旋次数和唤醒状态不为真,让再次执行获取到唤醒状态时候执行。
重新循环
- 如果mutx 处于未饥饿状态.
lock代码
1 | func (m *Mutex) Lock() { |
sync.unlock
重点:锁定的Mutex与特定的goroutine没有关联,允许一个goroutine锁定一个Mutex,然后安排另一个goroutine解锁它.
但是如果没有执行lock,而去unlock会异常。
1 | func (m *Mutex) Unlock() { |
资料:
https://colobu.com/2018/12/18/dive-into-sync-mutex/
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/
https://www.ququ123.top/2022/04/golang_sync_mutex_principle/
https://gohandbook1.haohongfan.com/docs/sync-chapter/2021-04-01-mutex/