Go 中的 Mutex 设计原理详解(二)

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 的性能。

参考

1、第二版 Mutex 源码

都看到这里了,不如顺手点个 赞/关注?


原创不易,欢迎关注公众号:码农的自由之路

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值