Golang Mutex的实现原理

Mutex演进过程以及原理详解

Table of Contents

前提知识

悲观锁和乐观锁

悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,不管读还是写,悲观锁在执行操作之前都先上锁。

对读对写都需要加锁导致性能低,所以悲观锁用的机会不多。但是在多写的情况下,还是有机会使用悲观锁的,因为乐观锁遇到写不一致的情况下会一直重试,会浪费更多的时间。

乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过。乐观锁的实现方案主要包含CAS和版本号机制。乐观锁适用于多读的场景,可以提高吞吐量。

CAS即Compare And Swap(比较与交换),是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步。CAS涉及三个关系:指向内存一块区域的指针V、旧值A和将要写入的新值B。CAS实现的乐观锁会带来ABA问题,同时整个乐观锁在遇到数据不一致的情况下会触发等待、重试机制,这对性能的影响较大。

版本号机制是通过一个版本号version来实现版本控制。

自旋锁

之前介绍的CAS就是自旋锁的一种。同一时刻只能有一个线程获取到锁,没有获取到锁的线程通常有两种处理方式:

  • 一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING);
  • 把自己阻塞起来,等待重新调度请求,这种是互斥锁。

自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。

但是如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被OS调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。

解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。

信号量

在OS中有P和V操作,P操作是将信号量-1,V操作是将信号量+1,所以信号量的运作方式为:

  • 初始化,给与它一个非负数的整数值。
  • 程序企图进入临界区块的进程,需要先运行P。
    • 当信号量S减为负值时,进程会被挡住,不能继续,这时该进程被阻塞;
    • 当信号量S不为负值时,进程可以获准进入临界区块。
  • 结束离开临界区块的进程,将会运行V。当信号量S不为负值时,先前被挡住的其他进程,将可获准进入临界区块。

信号量和锁

信号量和锁虽然看起来很相似,比如当信号量为1时就实现了互斥锁,但实际上他们表示的含义不同1。锁是用来保护临界资源的,比如读写不可以同时进行等;信号量是为了保证进程(或线程或goroutine)调度的,比如三个进程共同计算c=a+b,首先计算a+b和赋值操作不能同时进行,其次还要保证a+b先执行,对c的赋值后执行,因此这个地方需要采用信号量的方式来进行。

更进一步的,锁可以由信号量实现,那么goroutine可以遵循规定的被阻塞和唤醒,也可以由自旋锁实现,那么goroutine一直占用CPU直到解锁。他们这两种方式的区别在于是否需要goroutine调度,但本质上锁的实现都是为了保证临界资源不会被错误的访问。

综述

Golang Mutex其实是不断改进的,截止到目前为至主要改进了4版:

  • V1: 简单实现
  • V2: 新goroutine参与锁的竞争
  • V3: 再多给新goroutine一些机会
  • V4: 解决老goroutine饥饿问题

每一次的改进都是为了提高系统的整体性能,这个升级是逐步的连贯的,所以需要从V1版本慢慢开始看Mutex的演化进程。

这篇文章中参考了《golang中的Mutex设计原理详解(一)》《Go 中的 Mutex 设计原理详解(二)》《Go 中的 Mutex 设计原理详解(三)》《Go 中的 Mutex 设计原理详解(四)》,该系列文章讲解的比较细腻,把mutex历代的改进描述的比较清晰了,下面的文章是我在读上面系列文章时的读书笔记以及个人的一点思考。

V1: 简单实现

在V1版本中Mutex全部源码如下所示。

首先Mutex的结构非常简单,包括key和sema两个标志位。key表示当前有几个goroutine正在或准备使用该锁,如果key==0则说明当前mutex是解锁状态,反之key>0则说明mutex是加锁状态。sema是信号量,这是真正的导致goroutine被阻塞和唤醒的原因。

xadd函数是一个基于CAS的加减法函数。Lock和Unlock函数则是对当前mutex加锁的核心,但是逻辑非常简单:Lock函数通过xadd方法对key+1,如果结果为1则说明原先锁是解锁的状态,那么不需要理会信号量直接获取锁即可,如果反之则调用semacquire方法阻塞当前的goroutine;Unlock函数通过xadd方法对key-1,如果结果不为0,则说明当前是有goroutine正在等待的,就需要调用semrelease来唤醒一个goroutine。

当前V1版本中,完全是按照FIFO的方式来加锁、解锁的,虽然这种方式非常的公平,但是从效率角度来说不是最优的。试想一种场景:在被阻塞的goroutine(将当前goroutine命名为g1)是一定不占有CPU的,因此g1被唤醒后需要进行上下文切换,若此时又新来一个goroutine(g2),它拥有CPU资源。如果把锁给g2,那么它可以立即执行并返回结果(不等g1的上下文切换),这样总体的效率就可以更上一层楼。

V2: 新Goroutine参与锁的竞争

在V2版本中,核心特点是一个goroutinue被唤醒后不立即执行任务,而是仍然重复一遍抢占锁的流程,这样新来的goroutine就有机会获取到锁,这就是所谓的给新人机会。

Mutex结构体和常量定义如下所示。

虽然Mutex结构体的定义基本上没有变动,但是从V1的key到V2的state他们在内部结构却千差万别,第0位表示锁状态(L),即0->解锁,1->加锁,第1位表示唤醒状态(W),第2位到第31位表示阻塞等待的数量。

GolangMutex的实现原理-Z3kMBt
state的含义

mutexLocked的值为0x1,mutexWoken的值为0x2,mutexWaiterShift的值为0x2,mutexWaiterShift表示任意表示阻塞等待数量的数组需要左移两位。

V2版本的主要改进存在于Lock方法中,代码如下。

在第2-4行的代码逻辑适用于解锁状态,goroutine通过CAS获取锁,其具体做法是将L位从0置1。此时不存在竞争,所以获取锁几乎没有任何消耗。

后面的代码被访问到就说明当前已上锁,即L位为1。随后,goroutine进入了一个循环,在这个循环中通过CAS方法,确保新状态(new)正确被叠加。旧状态(old)到新状态(new)主要的改动包括:

  • 尝试获取锁(第9行)
  • 尝试使等待者数量加1(第11行)

由于该变动不是原子操作,可能会导致旧状态过时。比如当前是解锁状态,有两个goroutine同时获取旧状态,都是解锁状态,但是总是有一个可以拿到锁,一个不能拿到锁,即注定有一个旧状态是失效的,同理即使当前是上锁状态,也会因为等待者数量的不同而发生旧状态过时问题。所以需要通过循环继续获取新的旧状态。

在旧状态不过时且覆盖了新状态后,进入真正的加锁环节(第17-21行)。如果旧状态是解锁状态,那么直接获得锁,否则通过信号量机制讲当前goroutine阻塞(第20行)。在被唤醒后,与V1不同的是当前进程依然会掉入for循环重新抢锁,这就是给新人机会的体现:

  • 如果老goroutine在切换上下文期间有新goroutine进入,那么锁将给新goroutine;
  • 如果老goroutine在切换上下文完成后依然没有新goroutine进入,那么锁将给老goroutine。

Unlock方法就相对简单很多了,代码如下:

主要的解锁逻辑分为两种:如果当前没有等待者或者当前系统已解锁且有已被唤醒的Goroutine(第8行)则直接返回;如果不符合上述的要求,则将等待数量减一并通过信号量唤醒队首的Goroutine(第13行)。

V3: 再多给新Goroutine一些机会

在V2的基础上,如何能再进一步的提升性能呢?在大多数时候,协程在独占锁的期间对数据进行的操作其实耗时很低,可能比唤醒+切换上下文的耗时还低。试想一个场景:GoroutineA拥有CPU资源,GoroutineB在阻塞队列的对首,那么在GoroutineA尝试获取锁时,发现当前的锁被占用了,按照V2的策略GoroutineA此时立即被阻塞,假设就在这个瞬间锁被释放,那么GoroutineB按照计划会被唤醒,也就是整个运行时长包括GoroutineB的唤醒+切换上下文的耗时。在V3中则是允许新Goroutine(GoroutineA)通过自旋等待一定的时间,如果在等待时间中锁被释放,则新Goroutine立即获得锁资源,避免了旧Goroutine的唤醒+切换上下文的耗时,提升了整体的工作效率。

同样V3的改进也主要集中于Lock方法,代码如下。

与V2的实现代码对比来看,主要集中于第14-25行,多了两个函数runtime_canSpin和runtime_doSpin,这两个函数则是自旋锁的核心。

首先是runtime_canSpin函数,传入的参数是iter(代表当前自旋次数),runtime_canSpin函数的实现功能主要是判断当前是否可以进入自旋等待状态。前面有提到过,自旋锁是在不释放CPU资源的情况下等待,没有上下文切换的消耗,但是如果自旋时间过长会导致CPU无意义空耗,反而更近一步影响性能。所以在使用自旋锁的时候必须要严格控制自旋锁的进入流程。然后是runtime_doSpin函数,可以简单的理解为CPU空耗一段时间,即自旋过程。

整个过程就很明晰了,所有持有CPU的Goroutine,在runtime_canSpin函数的检查通过后做自旋操作,如果在完成了自旋操作后依然没有持有锁,则该Goroutine被阻塞。其他的逻辑与V2保持一致。

V4: 解决老Goroutine饥饿问题

从V2到V3的改进中都是多给新Goroutine机会,这导致了老Goroutine很可能抢不过新Goroutine而导致饥饿问题,所以在V4中重点改进了这个问题。

首先是Mutex结构体的State字段有了新的变化,多了一个饥饿指示(S),即0->没有发生饥饿,1->发生了饥饿。

state-mq4HyF

Lock方法的逻辑如下所示:

我们重点关注的是lockSlow方法,在深入代码之前首先我们要了解什么情况下会被认为当前锁进入饥饿状态:

  • (情景1)老Goroutine被唤醒但是锁被新Goroutine抢走了,对于老Goroutine来说“我被唤醒了什么也没做立即又被阻塞了”
  • (情景2)某Goroutine被阻塞的总时长超过阈值(默认为1ms)

所以核心是记录当前Goroutine开始等待的时间:对于初次进入锁的Goroutine来说开始等待时间为0,对于情景1其判断标准为开始等待时间是否为0,如果否则说明之前已经被阻塞过(第45行);对于情景2其判断标准为当前时间与开始等待时间的差值是否超过阈值,如果是则说明该Goroutine等的太久了,则应该进入饥饿状态(第50行)。

进一步的当我们知道了饥饿状态是如何评判的,那么饥饿模式和非饥饿模式的区别是什么?首先,如果当前锁处于饥饿状态,任何新Goroutine将不会做自旋操作(第15行)。其次,如果当前Goroutine处于饥饿状态,那么在阻塞的时候会被添加到等待队列队首(下一次的唤醒操作必将唤醒当前处于饥饿的Goroutine,第45-49行)。最后,处于饥饿状态的Goroutine被唤醒后允许立刻持有锁,而不需要与其他Goroutine重新竞争锁(可以对比V2来看,第52-62行)。

V4对应的Unlock也根据饥饿状态做了一定的调整,代码如下:

我们可以看到在饥饿状态下唤醒等待队列队首的Goroutine(第27行)。

总结

Golang的Mutex实现看上去非常复杂,但是如果是循序渐进得了解每一次改进背后的原因后,这块晦涩难懂的知识就变得容易理解了很多。再次感谢@AFreeCoder编写的《Mutex设计原理》系列文章,对于我理解Mutex的运作起到了很大的帮助。

  1. https://www.jianshu.com/p/c6ba8bcc22bc

All rights reserved
Except where otherwise noted, content on this page is copyrighted.