Mutex系列是根据我对晁岳攀老师的《Go 并发编程实战课》的吸收和理解整理而成,如有偏差,欢迎指正~
初版 Mutex 回顾
在前一章节中,讲解了初版 Mutex 源码和设计思路。初版 Mutex 核心思路就是通过一个 Key 来标志当前资源是否被抢占,以及处于等待中的协程有多少个;同时通过信号量的机制来实现协程的等待和唤醒机制。
在一个性能要求不是很高的并发场景下,我们完全可以依照这个思路来实现这样的一个锁的机制。比如在分布式的场景下,我们可以利用 redis 操作的原子原子性以及 incr 等指令实现类似初版 Mutex 的功能。
初版互斥锁 Mutex 的问题
但是,初版 Mutex 有一个问题:不同的协程只能按先来后到的方式,排队等候获取锁。这样看似公平,但是整体性能却不高。因为如果把锁给正在占用 CPU 时间片的协程的话,没有上下文的切换,性能损耗会更小。
所以,在初版 Mutex 的基础上,Go 开发者在 2011年6月30日 对 Mutex 有了一次较大的改版,这里简称:给新人机会。
第二版 Mutex : 给新人机会
新的定义
在这一版中,Mutex 的定义发生了一个较大的改变。原本定义中的 key 字段因为表达含义太简单(其实就是个计数器),被替换成了 state 字段。新的定义如下:
// A Mutex is a mutual exclusion lock.
// Mutexes can be created as part of other structures;
// the zero value for a Mutex is an unlocked mutex.
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexWaiterShift = iota
)
state 字段在这里有了更加丰富的含义,那它的多重含义是怎么表达的呢?state 类型是 int32,它是一个32位的整型,通过不同的位来表达。
state 的第0位表示这个锁是否被持有,第1位表示是否有唤醒的 goroutine(协程),剩余的2-31位表示等待此锁的 gotoutine 数量。
相比于原来的 key 字段,多了一个唤醒标志位,同时把是否持有锁以及等待锁的 goroutine 数量这两个表示进行了一个区分。
关于这个唤醒标志位,下面讲 Lock 操作的时候会说到。
新的加锁操作
先上源码:
// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
awoke := false
for {
old := m.state
new := old | mutexLocked
if old&mutexLocked != 0 { // 锁已经被其它协程持有
new = old + 1<<mutexWaiterShift // 阻塞等待的数量加1
}
if awoke {
// The goroutine has been woken from sleep,
// so we need to reset the flag in either case.
new &^= mutexWoken // 将new中唤醒标志位清零
}
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&mutexLocked == 0 {
break
}
runtime.Semacquire(&m.sema)
awoke = true
}
}
}
这段代码虽然短,但是理解起来却需要一番功夫。
这一版 Mutex 的核心特点是一个 goroutinue 被唤醒后,不是立即执行任务,而是仍然重复一遍抢占锁的流程,这样新来的 goroutine 就有机会获取到锁,这就是所谓的给新人机会。
接下来看具体的代码。这段代码之所以比较难理解,是因为位操作比较多,如果理解了这些位操作,那么后几版的 Mutex 代码理解起来就会快很多。
先说下三个常量 mutexLocked,mutexWoken 和 mutexWaiterShift,它们的值分别是1,2,2。这三个常量的作用就是避免代码中出现硬编码,这样如果以后升级 state 的定义,只需要改这些常量的值,而不需要改代码逻辑。
第6行,其实就是原来的 cas 操作,只是现在这些原子操作都被放到了内置库 atomic 中。这里判断如果能直接占有锁,就修改 state 字段,然后直接退出,继续接下来的任务。
第11行,这个循环干的事情就是不断的检测能否抢占到锁,能抢到就在24行退出,不能就在26行阻塞,等待唤醒。如果当前 groutine 被其它 goroutine 唤醒,就继续循环,看能否抢占锁。
还有一些比较难理解的代码,一个是第15行就是将等待者的数量加1。因为 state 字段的 2-31位表示等待者数量,所以1需要向左移两位。第二个就是第17行有这个判断是因为当前 goroutine 如果被唤醒,一定会走到27行。第三个就是第20行进行的操作是 new 先和 mutexWoken进行异或操作,得到的结果再和 new 进行与操作,它的实际效果就是将唤醒标志位清零(因为当前协程接下来要么能抢到锁,继续执行,要么抢不到就睡眠)。
新的解锁操作
有了加锁操作的基础之后,解锁操作就变得比较好理解了。
上源码:
// Unlock unlocks m.
// It is a run-time error if m is not locked on entry to Unlock.
//
// A locked Mutex is not associated with a particular goroutine.
// It is allowed for one goroutine to lock a Mutex and then
// arrange for another goroutine to unlock it.
func (m *Mutex) Unlock() {
// Fast path: drop lock bit.
new := atomic.AddInt32(&m.state, -mutexLocked)
if (new+mutexLocked)&mutexLocked == 0 {
panic("sync: unlock of unlocked mutex")
}
old := new
for {
// If there are no waiters or a goroutine has already
// been woken or grabbed the lock, no need to wake anyone.
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 {
return
}
// Grab the right to wake someone.
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime.Semrelease(&m.sema)
return
}
old = m.state
}
}
第9-12行,是将持有锁的标志位清零。-metexLocked 这个操作是便于第10行来判断,是不是对一个未加锁的 Mutex 进行了解锁操作(前文golang中的Mutex设计原理详解(一)提到,Mutex 的 Unlock 操作其实没有使用限制)。
重点是第15开始的 for 循环。第18行判断,如果没有等待的 goroutine,或者当前有醒着的 goroutine,就不用进行任何操作,直接返回,否则进入下一步,去唤醒某一个 goroutine,并将唤醒标志置为1。
显然,首次循环的时候,第18行后面的条件是不会满足的,只有执行了第22-27行之后,当前 goroutine 完成了唤醒其它 goroutine的任务,才能满足第18行后面的判断条件,才能退出。
第二版 Mutex 的问题
相比初版 Mutex,其实第二版给新人机会已经有了不错的提升,但是它还是有优化空间的。
在加锁解锁的过程中,涉及到频繁的 goroutine 睡眠和唤醒的过程。这个过程涉及到不小的系统开销(可以看一下 runtime.Semacquire 和 runtime.Semrelease 的实现 )。如果 Lock 和 Unlock 之间的代码耗时很短,那么让新来的 goroutine 或者是醒着的 goroutine 抢占锁失败后,不立即睡眠,而是再尝试几次,说不定就能拿到锁了。尝试一定的次数之后,再进行原有的逻辑。
总结
第二版 Mutex 的实现相比第一版复杂了许多,一个核心的改变就是 goroutine 不再按先进先出的方式获得锁,而让被唤醒的 goroutine 和新来的 goroutine 重新竞争。
在下一个版本的 Mutex 中,我会重点讲解 Mutex 是如何进一步压榨 CPU 的性能。
参考
都看到这里了,不如顺手点个 赞/关注?
原创不易,欢迎关注公众号:码农的自由之路