Golang之Mutex源码

106 阅读7分钟

把 Mutex 的架构演进分成了四个阶段。 “初版”的Mutex 使用一个 flag 来表示锁是否被持有,实现比较简单;后来照顾到新来的goroutine,所以会让新的 goroutine 也尽可能地先获取到锁,这是第二个阶段,我把它叫作“给新协程机会”;那么,接下来就是第三阶段“多给些机会”,照顾新来的和被唤醒的goroutine;但是这样会带来饥饿问题,所以目前又加入了饥饿的解决方案,也就是第四阶段“解决饥饿“。

初版的互斥锁

我们先来看怎么实现一个最简单的互斥锁。在开始之前,你可以先想一想,如果是你,你会怎么设计呢? 你可能会想到,可以通过一个 flag 变量,标记当前的锁是否被某个 goroutine 持有。如果这个flag 的值是1,就代表锁已经被持有,那么,其它竞争的 goroutine 只能等待;如果这个flag 的值是0,就可以通过 CAS (compare-and-swap,或者 compare-and-set) 将这个flag 设置为 1,标识锁被当前的这个 goroutine 持有了

func cas(val *int32, old, new int32) bool
func semacquire(*int32)
func semrelease(*int32)

// 互斥锁的结构,包含两个字段
type Mutex struct {
	key int32 // 锁是否被持有的标识
	sema int32 // 信号量专用,用以阻塞/唤醒goroutine
}

// 保证成功在val上增加delta的值
func xadd(val *int32, delta int32) (new int32) {
	for {
		v := *val
		if cas(val, v, v+delta) {
			return v + delta
		}
	}
	panic("unreached")
}

// 请求锁
func (m *Mutex) Lock() {
	if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
		return
	}
	semacquire(&m.sema) // 否则阻塞等待
}

func (m *Mutex) Unlock() {
	if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者
		return
	}
	semrelease(&m.sema) // 唤醒其它阻塞的goroutine
}

CAS是什么? CAS 指令将给定的值和一个内存地址中的值进行比较,如果它们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性的。那啥是原子性呢?如果你还不太理解这个概念,那么在这里只需要明确一点就行了,那就是原子性保证这个指令总是基于最新的值进行计算,如果同时有其它线程已经修改了这个值,那么,CAS 会返回失败。 CAS是实现互斥锁和同步原语的基础,我们很有必要掌握它。

我们来分析下刚才的这段代码

Mutex 结构体包含两个字段:

  • 字段key: 是一个flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key大于等于1,说明这个排外锁已经被持有;
  • 字段sema:是个信号量变量,用来控制等待goroutine 的阻塞休眠和唤醒

调用Lock 请求锁的时候,通过 xadd 方法进行CAS 操作(第24行)xadd 方法通过循环执行 CAS 操作直到成功,保证对 key 加1的操作成功完成。如果比较幸运,锁没有被别的goroutine 持有,那么,Lock 方法成功地将 key 设置为 1,这个 goroutine 就持有了这个锁;如果锁已经被别的 goroutine 持有了,那么,当前的 goroutine 会把 key 加1,而且还会调用 semacquire 方法(第27 行),使用信号量将自己休眠,等锁释放的时候,信号量会将它唤醒。

持有锁的 goroutine 调用 Unlock 释放锁时,它会将 key 减1(第31行)。如果当前没有其它等待这个锁的 goroutine,这个方法就返回了。但是,如果还有等待此锁的其它goroutine,那么,它会调用 semrelease 方法(第 34 行),利用信号量唤醒等待锁的其它goroutine 中的一个

所以,到这里,我们就知道了,初版的 Mutex 利用CAS 原子操作,对 ey 这个标志量进行设置。key 不仅仅标识了锁是否被 goroutine 所持有,还记录了当前持有和等待获取锁的goroutine 的数量

Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的goroutine 的信息,所以,Unlock 也不会对此进行检查Mutex 的这个设计一直保持至

这就带来了一个危险的功能。为什么这么说呢? 你看,其它 goroutine 可以强制释放锁,这是一个非常危险的操作,因为在临界区的goroutine 可能不知道锁已经被释放了,还会继续执行临界区的业务操作,这可能会带来意想不到的结果,因为这个 goroutine 还以为自己持有锁呢,有可能导致 data race 问题

所以,我们在使用 Mutex 的时候,必须要保证 goroutine 尽可能不去释放自己未持有的锁一定要遵“谁申请,谁释放”的原则。在真实的实践中,我们使用互斥锁的时候,很少在-个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。

以前,我们经常会基于性能的考虑,及时释放掉锁,所以在一些 if-else 分支中加上释放锁的代码,代码看起来很臃肿。而且,在重构的时候,也很容易因为误删或者是漏掉而出现死锁的现象。

type Foo struct {
	mu sync.Mutex
	count int
}
func (f *Foo) Bar() {
	f.mu.Lock()
	if f.count < 1000 {
		f.count += 3
		f.mu.Unlock() // 此处释放锁
		return
	}
	f.count++
	f.mu.Unlock() // 此处释放锁
	return
}

从1.14版本起,Go对defer 做了优化,采用更有效的内联方式,取代之前的生成defer对象到 defer chain 中defer 对耗时的影响微乎其微了,所以基本上修改成下面简洁的写法也没问题:

func (f *Foo) Bar() {
	f.mu.Lock()
	defer f.mu.Unlock()
	
	if f.count < 1000 {
		f.count += 3
		return
	}
	f.count++
	return
}

这样做的好处就是 Lock/Unlock 总是成对紧凑出现,不会遗漏或者多调用,代码更少。 但是,如果临界区只是方法中的一部分,为了尽快释放锁,还是应该第一时间调用 Unlock而不是一直等到方法返回时才释放。

初版的 Mutex 实现之后,Go开发组又对 Mutex 做了一些微调,比如把字段类型变成了uint32 类型,调用 Unlock 方法会做检查,使用 atomic 包的同步原语执行原子操作等等,这些小的改动,都不是核心功能,你简单知道就行了,我就不详细介绍了。

但是,初版的 Mutex 实现有一个问题: 请求锁的 goroutine 会排队等待获取互斥锁。虽然这貌似很公平,但是从性能上来看,却不是最优的。因为如果我们能够把锁交给正在占用 CPU时间片的 goroutine 的话,那就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。

给新协程机会

调整后的Mutex

type Mutex struct {
	state int32
	sema uint32
}

const (
	mutexLocked = 1 << iota // 持有锁的标记
	mutexWoken              // 唤醒标记
	mutexWaiterShift = iota // 堵塞等待的waiter数量
)

state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有唤醒的goroutine,剩余的位数代表的是等待此锁的 goroutine 数。所以,state 这一个字段被分成了三部分,代表三个数据。

请求锁的方法 Lock 也变得复杂了。复杂之处不仅仅在于对字段 state 的操作难以理解,而且代码逻辑也变得相当复杂。

func (m *Mutex) Lock() {

	// Fast path: 幸运case,能够直接获取到锁
	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 //等待者数量加一
		}
		
		if awoke {
			// goroutine是被唤醒的,
			// 新状态清除唤醒标志
			new &^= mutexWoken
		}
		
		if atomic.CompareAndSwapInt32(&m.state, old, new) {//设置新状态
			if old&mutexLocked == 0 { // 锁原状态未加锁
				break
			}
			runtime.Semacquire(&m.sema) // 请求信号量
			awoke = true
		}
	}
}

首先是通过 CAS 检测 state 字段中的标志(第3行),如果没有 goroutine 持有锁,也没有等待持有锁的 gorutine,那么,当前的 goroutine 就很幸运,可以直接获得锁,这也是注释中的Fast path 的意思 如果不够幸运,state 不是零值,那么就通过一个循环进行检查。接下来的第7行到第26行这段代码虽然只有几行,但是理解起来却要费一番功夫,因为涉及到对 state 不同标志位的操作。这里的位操作以及操作后的结果和数值比较,并没有明确的解释,有时候你需要根据后续的处理进行推断。所以说,如果你充分理解了这段代码,那么对最新版的 Mutex 也会比较容易掌握了,因为你已经清楚了这些位操作的含义。

我们先前知道,如果想要获取锁的 goroutine 没有机会获取到锁,就会进行休眠,但是在锁释放唤醒之后,它并不能像先前一样直接获取到锁,还是要和正在请求锁的 goroutine 进行竞争。这会给后来请求锁的 goroutine 一个机会,也让CPU 中正在执行的 goroutine 有更多的机会获取到锁,在一定程度上提高了程序的性能

for 循环是不断尝试获取锁,如果获取不到,就通过runtime.Semacquire(&m.sema)休眠休眠醒来之后 awoke 置为 true,尝试争抢锁 代码中的第 10行将当前的 flag 设置为加锁状,如果能成功地通过CAS 把这个新值赋子state (第19 行和第20行),就代表抢夺锁的操作成功了

不过,需要注意的是,如果成功地设置了 state 的值,但是之前的 state 是有锁的状态,那么,state 只是清除 mutexWoken 标志或者增加一个 waiter 而已。

请求锁的 goroutine 有两类,一类是新来请求锁的 goroutine,另一类是被唤醒的等待请求锁的 goroutine。锁的状态也有两种: 加锁和未加锁。

接下来,我们再来看看释放锁。释放锁的 Unlock 方法也有些复杂,我们来看一下。

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 old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0
		return
	}
	
	new = (old - 1<<mutexWaiterShift) | mutexWoken // 新状态,准备唤醒gor
	
	if atomic.CompareAndSwapInt32(&m.state, old, new) {
		runtime.Semrelease(&m.sema)
		return
	}
	old = m.state
}

第3行是尝试将持有锁的标识设置为未加锁的状态,这是通过减1而不是将标志位置零的方式实现。第4到6行还会检测原来锁的状态是否已经未加锁的状态,如果是 Unlock 一个未加锁的Mutex 会直接 panic。

不过,即使将加锁置为未加锁的状态,这个方法也不能直接返回,还需要一些额外的操作,因为还可能有一些等待这个锁的 goroutine (有时候我也把它们称之为 waiter) 需要通过信号量的方式唤醒它们中的一个。所以接下来的逻辑有两种情况。

第一种情况,如果没有其它的 waiter,说明对这个锁的竞争的 goroutine 只有一个,那就可以直接返回了;如果这个时候有唤醒的 goroutine,或者是又被别人加了锁,那么,无需我们操劳,其它 goroutine 自己干得都很好,当前的这个 goroutine 就可以放心返回了。 第二种情况,如果有等待者,并且没有唤醒的 waiter,那就需要唤醒一个等待的 waiter。在唤醒之前,需要将 waiter 数量减 1,并且将 mutexWoken 标志设置上,这样,Unlock 就可以返回了

通过这样复杂的检查、判断和设置,我们就可以安全地将一把互斥锁释放了

相对于初版的设计,这次的改动主要就是,新来的 goroutine 也有机会先获取到锁,甚至一个goroutine 可能连续获取到锁,打破了先来先得的逻辑。但是,代码复杂度也显而易见。

多给些机会

在2015年2月的改动中,如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不到锁,它们就会通过自旋 (spin,通过循环不断尝试,spin 的逻辑是在@runtime 实现的)的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑

func (m *Mutex) Lock() {

	// Fast path: 幸运之路,正好获取到锁
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	}
	
	awoke := false
	iter := 0
	for { // 不管是新来的请求锁的goroutine, 还是被唤醒的goroutine,都不断尝试请求锁
		old := m.state // 先保存当前锁的状态
		new := old | mutexLocked // 新状态设置加锁标志
		
		if old&mutexLocked != 0 { // 锁还没被释放
			if runtime_canSpin(iter) { // 还可以自旋
			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift !=
			atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
				awoke = true
			}
			runtime_doSpin()
			iter++
			continue // 自旋,再次尝试请求锁
			}
			new = old + 1<<mutexWaiterShift
		}
	
		if awoke { // 唤醒状态
			if new&mutexWoken == 0 {
				panic("sync: inconsistent mutex state")
			}
			new &^= mutexWoken // 新状态清除唤醒标记
		}
		
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			if old&mutexLocked == 0 { // 旧状态锁已释放,新状态成功持有了锁,直接
				break
			}
			
			runtime_Semacquire(&m.sema) // 阻塞等待
			awoke = true // 被唤醒
			iter = 0
		}
	}
}

这次的优化,增加了第13 行到 21 行、第25 行到第27 行以及第 36行我来解释一下主要的逻辑,也就是第 13 行到21 行

如果可以spin 的话,第9行的 for 循环会重新检查锁是否释放。对于临界区代码执行非常短的场景来说,这是一个非常好的优化。因为临界区的代码耗时很短,锁很快就能释放,而抢夺锁的 goroutine 不用通过休眠唤醒方式等待调度,直接 spin 几次,可能就获得了锁。

解决饥饿

经过几次优化,Mutex 的代码越来越复杂,应对高并发争抢锁的场景也更加公平。但是你有没有想过,因为新来的 goroutine 也参与竞争,有可能每次都会被新来的 goroutine 抢到获取锁的机会,在极端情况下,等待中的 goroutine 可能会一直获取不到锁,这就是饥饿问题。

先前版本的Mutex遇到的也是同样的困境,“悲惨”的 goroutine 总是得不到锁。 Mutex 不能容忍这种事情发生。所以,2016 年 Go1.9 中Mutex 增加了饥饿模式,让锁变得更公平,不公平的等待时间限制在1 毫秒,并且修复了一个大 Bug:总是把唤醒的goroutine 放在等待队列的尾部,会导致更加不公平的等待时间。 之后,2018 年,Go 开发者将 fast path 和 slow path 拆成独立的方法,以便内联,提高性能。2019 年也有一个 Mutex 的优化,虽然没有对 Mutex 做修改,但是,对于 Mutex 唤醒后持有锁的那个 waiter,调度器可以有更高的优先级去执行,这已经是很细致的性能优化。

Mutex 绝不容忍一个goroutine 被落下,永远没有机会获取锁不抛弃不放弃是它的宗旨,而且它也尽可能地让等待较长的 goroutine 更有机会获取到锁。

最新的Mutex实现代码比较复杂,有兴趣可以去查看源码,sync.Mutex。

跟之前的实现相比,当前的 Mutex 最重要的变化,就是增加饥饿模式。第 12 行将饥饿模式的最大等待时间闻值设置成了1毫秒,这就意味着,一旦等待者等待的时间超过了这个闻值,Mutex 的处理就有可能进入饥饿模式,优先让等待者先获取到锁,新来的同学主动谦让下,给老同志一些机会。 通过加入饥饿模式,可以避免把机会全都留给新来的 goroutine,保证了请求锁的 goroutine获取锁的公平性,对于我们使用锁的业务代码来说,不会有业务一直等待锁不被处理。