37DATA

Golang sync.Mutex 深度剖析(二)

前文回顾:见wiki Golang sync.Mutex 深度剖析(一) 

上次简单介绍了初版的 sync.Mutex 实现,在并发量不大的情况下是够用的。但还是回到那一点,初版 sync.Mutex 单靠一个信号量去控制协程的等待与唤醒,无论“上锁”的协程有多少个,都是先到先得的机制,逐个排队上锁。看似公平,但如果在解锁时能够把锁给正在占用 CPU 时间片的协程(即正在执行上锁的新协程),没有上下文的切换,性能损耗会更小。

那么这个问题是如何去解决的呢?

基本组成

我们先来看看 Sync.Mutex 第二版的设计。首先第二版的结构中,采用了 state 这个复合字段代替了第一版用于计数器的 key:

Image

其中,结合 consts 中的枚举值,state 字段有以下几个含义:

Image

从右边数起

  • 第1位为是否持有锁的标志位,1 表示已上锁(常用“与”运算符 m.state & mutexLocked == 1 表示),0 表示未上锁

  • 第2位为是否持唤醒的标志位,1 表示已有协程唤醒(常用“与”运算符 m.state & mutexWoken == 1 表示),0 表示未有协程唤醒

  • 剩余30位,表示处于等待中的协程数量。

  • 至于为何选用了 int32,这是一个非常有趣的问题,留个悬念,有兴趣的朋友可以去找找答案(狗头)

发展历程

上篇文章提到过,sync.Mutex 的发展过程是“给新人机会”的过程。

新来协程都正占用着 CPU 的时间片,相比起初版的睡眠等待唤醒,这么做可以使整体的性能消耗更小。那么升级版的 sync.Mutex 是如何给新人机会的呢?

第二版,给新人机会

和初版不一样的点在于当一个 goroutinue 被唤醒后,不是立即执行任务,而是仍然重复一遍抢占锁的流程(参考下面流程图中,被唤醒后还会进行一轮 CAS 操作),这样新来的 goroutine 就有机会获取到锁,这便是所谓的给新人机会。

为了更好的说明,这里我简单画了一张流程图:

Image

在上锁过程中,大体流程参考如下

  1. 尝试与 state=0 做CAS操作,如果当前的锁还未被其他协程上锁、没有协程被唤醒、没有协程处于等待状态则上锁成功。

  2. 在第一步未上锁成功的情况下进入循环。尝试修改持有锁的标志位,如果当前再次取锁的状态时,已处于上锁状态,则等待数量增加1;如果自己曾经休眠过是被唤醒的,则将唤醒标志位重置为 0(在解锁时这个标志位在特定条件下会置为1);然后再次进行CAS操作

  3. CAS 操作执行成功后,如果上锁成功则直接返回;否则等待唤醒

源码实现如下:

Image

在解锁过程中,大体流程参考如下

  1. 尝试去把持有锁的标志位 -1,如果锁之前不处于已上锁的状态,则会抛出 panic。这点在业务开发的时候需要尤其注意。

  2. 进入循环体;如果当前没有在等待的协程,或者别的协程已被唤醒或已上锁,则不进行任何操作。否则尝试将唤醒标志位置为1,并将等待协程数减1并唤起处于等待中的协程。

源码实现如下:

Image

如此一来,第二版便能够实现新来的协程和被唤醒的协程共同竞争锁的特性,一定程度上解决了初版“先进先出”带来的性能损耗,但是还是不够彻底。这里引用公众号《码农的自由之路》的一段说明:

大多数时候,协程在独占锁的期间,对数据进行的操作其实耗时很小,比唤醒操作的消耗还小。

被唤醒的协程没有抢到锁立刻就沉睡,然后下次还要被再次唤醒,整体上性能是存在浪费的。

也就是说第二版虽然给了“新来的协程”一些机会,但是如果在那个瞬间新来的协程或被唤醒的协程“取不到锁,(重新)进入睡眠状态”的这个操作有可能比锁内进行的数据操作性能损耗更大。基于上面这一点,便有了第三版 sync.Mutex,进一步对CPU进行压榨。

第三版,多给些机会

在这里相信很多朋友也能猜到,既然再次“睡眠唤起”会有潜在的性能浪费,那干脆“自旋等待”不就好了?sync.Mutex 也确实是如此发展。

第三版在上锁的过程中,加入了自旋,如图中红框中所展示的一致:

Image

对于临界区代码执行非常短的场景来说,这是一个非常好的优化。因为临界区的代码耗时很短,锁很快就能释放,而抢夺锁的 goroutine 不用通过休眠唤醒方式等待调度,原地自旋 几次,可能就获得了锁。至于自旋的条件,相对而言是比较严苛的,这里暂不展开(后面待补充),有兴趣的朋友可以先行预习~

总结

经过上面两轮优化,sync.Mutex 的性能相较初版已经有了较大的进步,能够满足基本使用,应对高并发争抢锁的场景也更加公平。

但也因为新来的协程和被唤醒的协程之间存在竞争,在极端情况下,有可能出现新来的协程成为了“常胜将军”,而被唤醒的某个 goroutine 一直获取不到锁,这就是饥饿问题;解决完饥饿问题的 sync.Mutex 即将进入“最终形态”。具体如何请听下回分解~

本人才学疏浅,主要将学习到的知识与大家分享,若有错漏之处还望各位大大不吝赐教。

参考资料

  1. 第二版源码(go1 远古版本):https://github.com/golang/go/blob/release-branch.go1/src/pkg/sync/mutex.go

  2. 第三版源码(go1.5 远古版本):https://github.com/golang/go/blob/release-branch.go1.5/src/sync/mutex.go

  3. 公众号《码农的自由之路》AFreeCoder 相关论述

  4. 极客时间《Go 并发编程实战课》基本并发原语:https://time.geekbang.org/column/article/295850