# 02 | Mutex:庖丁解牛看实现 你好,我是鸟窝。 上一讲我们一起体验了Mutex的使用,竟是那么简单,只有简简单单两个方法,Lock和Unlock,进入临界区之前调用Lock方法,退出临界区的时候调用Unlock方法。这个时候,你一定会有一丝好奇:“它的实现是不是也很简单呢?” 其实不是的。如果你阅读Go标准库里Mutex的源代码,并且追溯Mutex的演进历史,你会发现,从一个简单易于理解的互斥锁的实现,到一个非常复杂的数据结构,这是一个逐步完善的过程。Go开发者们做了种种努力,精心设计。我自己每次看,都会被这种匠心和精益求精的精神打动。 所以,今天我就想带着你一起去探索Mutex的实现及演进之路,希望你能和我一样体验到这种技术追求的美妙。我们从Mutex的一个简单实现开始,看看它是怎样逐步提升性能和公平性的。在这个过程中,我们可以学习如何逐步设计一个完善的同步原语,并能对复杂度、性能、结构设计的权衡考量有新的认识。经过这样一个学习,我们不仅能通透掌握Mutex,更好地使用这个工具,同时,对我们自己设计并发数据接口也非常有帮助。 那具体怎么来讲呢?我把Mutex的架构演进分成了四个阶段,下面给你画了一张图来说明。 “**初版**”的Mutex使用一个flag来表示锁是否被持有,实现比较简单;后来照顾到新来的goroutine,所以会让新的goroutine也尽可能地先获取到锁,这是第二个阶段,我把它叫作“**给新人机会**”;那么,接下来就是第三阶段“**多给些机会**”,照顾新来的和被唤醒的goroutine;但是这样会带来饥饿问题,所以目前又加入了饥饿的解决方案,也就是第四阶段“**解决饥饿**”。 ![](https://static001.geekbang.org/resource/image/c2/35/c28531b47ff7f220d5bc3c9650180835.jpg?wh=4000*2250) 有了这四个阶段,我们学习的路径就清晰了,那接下来我会从代码层面带你领略Go开发者这些大牛们是如何逐步解决这些问题的。 # 初版的互斥锁 我们先来看怎么实现一个最简单的互斥锁。在开始之前,你可以先想一想,如果是你,你会怎么设计呢? 你可能会想到,可以通过一个flag变量,标记当前的锁是否被某个goroutine持有。如果这个flag的值是1,就代表锁已经被持有,那么,其它竞争的goroutine只能等待;如果这个flag的值是0,就可以通过CAS(compare-and-swap,或者compare-and-set)将这个flag设置为1,标识锁被当前的这个goroutine持有了。 实际上,Russ Cox在2008年提交的第一版Mutex就是这样实现的。 ``` // CAS操作,当时还没有抽象出atomic包 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是实现互斥锁和同步原语的基础,我们很有必要掌握它。 好了,我们继续来分析下刚才的这段代码。 虽然当时的Go语法和现在的稍微有些不同,而且标准库的布局、实现和现在的也有很大的差异,但是,这些差异不会影响我们对代码的理解,因为最核心的结构体(struct)和函数、方法的定义几乎是一样的。 Mutex 结构体包含两个字段: * **字段key:**是一个flag,用来标识这个排外锁是否被某个goroutine所持有,如果key大于等于1,说明这个排外锁已经被持有; * **字段sema:**是个信号量变量,用来控制等待goroutine的阻塞休眠和唤醒。 ![](https://static001.geekbang.org/resource/image/82/25/825e23e1af96e78f3773e0b45de38e25.jpg?wh=3032*2199) 调用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原子操作,对key这个标志量进行设置。key不仅仅标识了锁是否被goroutine所持有,还记录了当前持有和等待获取锁的goroutine的数量。 Mutex的整体设计非常简洁,学习起来一点也没有障碍。但是,注意,我要划重点了。 **Unlock方法可以被任意的goroutine调用释放锁,即使是没持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex本身并没有包含持有这把锁的goroutine的信息,所以,Unlock也不会对此进行检查。Mutex的这个设计一直保持至今。** 这就带来了一个有趣而危险的功能。为什么这么说呢? 你看,其它goroutine可以强制释放锁,这是一个非常危险的操作,因为在临界区的goroutine可能不知道锁已经被释放了,还会继续执行临界区的业务操作,这可能会带来意想不到的结果,因为这个goroutine还以为自己持有锁呢,有可能导致data race问题。 所以,我们在使用Mutex的时候,必须要保证goroutine尽可能不去释放自己未持有的锁,一定要遵循“**谁申请,谁释放**”的原则。在真实的实践中,我们使用互斥锁的时候,很少在一个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。 如果你接触过其它语言(比如Java语言)的互斥锁的实现,就会发现这一点和其它语言的互斥锁不同,所以,如果是从其它语言转到Go语言开发的同学,一定要注意。 以前,我们经常会基于性能的考虑,及时释放掉锁,所以在一些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的话,那就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。 接下来,我们就继续探索Go开发者是怎么解决这个问题的。 # 给新人机会 Go开发者在2011年6月30日的commit中对Mutex做了一次大的调整,调整后的Mutex实现如下: ``` type Mutex struct { state int32 sema uint32 } const ( mutexLocked = 1 << iota // mutex is locked mutexWoken mutexWaiterShift = iota ) ``` 虽然Mutex结构体还是包含两个字段,但是第一个字段已经改成了state,它的含义也不一样了。 ![](https://static001.geekbang.org/resource/image/4c/15/4c4a3dd2310059821f41af7b84925615.jpg?wh=2978*1756) 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 == 0 || old&(mutexLocked|mutexWoken) != 0 { // 没有等待者,或者有唤醒的waiter,或者锁原来已加锁 return } new = (old - 1<>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() iter++ continue // 自旋,再次尝试请求锁 } new = old + 1<>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() iter++ old = m.state // 再次获取锁的状态,之后会检查是否锁被释放了 continue } new := old if old&mutexStarving == 0 { new |= mutexLocked // 非饥饿状态,加锁 } if old&(mutexLocked|mutexStarving) != 0 { new += 1 << mutexWaiterShift // waiter数量加1 } if starving && old&mutexLocked != 0 { new |= mutexStarving // 设置饥饿状态 } if awoke { if new&mutexWoken == 0 { throw("sync: inconsistent mutex state") } new &^= mutexWoken // 新状态清除唤醒标记 } // 成功设置新状态 if atomic.CompareAndSwapInt32(&m.state, old, new) { // 原来锁的状态已释放,并且不是饥饿状态,正常请求到了锁,返回 if old&(mutexLocked|mutexStarving) == 0 { break // locked the mutex with CAS } // 处理饥饿状态 // 如果以前就在队列里面,加入到队列头 queueLifo := waitStartTime != 0 if waitStartTime == 0 { waitStartTime = runtime_nanotime() } // 阻塞等待 runtime_SemacquireMutex(&m.sema, queueLifo, 1) // 唤醒之后检查锁是否应该处于饥饿状态 starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs old = m.state // 如果锁已经处于饥饿状态,直接抢到锁,返回 if old&mutexStarving != 0 { if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 { throw("sync: inconsistent mutex state") } // 有点绕,加锁并且将waiter数减1 delta := int32(mutexLocked - 1<>mutexWaiterShift == 1 { delta -= mutexStarving // 最后一个waiter或者已经不饥饿了,清除饥饿标记 } atomic.AddInt32(&m.state, delta) break } awoke = true iter = 0 } else { old = m.state } } } func (m *Mutex) Unlock() { // Fast path: drop lock bit. new := atomic.AddInt32(&m.state, -mutexLocked) if new != 0 { m.unlockSlow(new) } } func (m *Mutex) unlockSlow(new int32) { if (new+mutexLocked)&mutexLocked == 0 { throw("sync: unlock of unlocked mutex") } if new&mutexStarving == 0 { old := new for { if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 { return } new = (old - 1<