哈喽,大家好,我是asong。,当提到并发编程、多线程编程时,都会在第一时间想到锁,锁是并发编程中的同步原语,他可以保证多线程在访问同一片内存时不会出现竞争来保证并发安全;在Go语言中更推崇由channel通过通信的方式实现共享内存,这个设计点与许多主流编程语言不一致,但是Go语言也在sync包中提供了互斥锁、读写锁,毕竟channel也不能满足所有场景,互斥锁、读写锁的使用与我们是分不开的,所以接下来我会分两篇来分享互斥锁、读写锁是怎么实现的,本文我们先来看看互斥锁的实现。,本文基于Golang版本:1.18,sync 包下的mutex就是互斥锁,其提供了三个公开方法:调用Lock()获得锁,调用Unlock()释放锁,在Go1.18新提供了TryLock()方法可以非阻塞式的取锁操作:,mutex的结构比较简单只有两个字段:,初看结构你可能有点懵逼,互斥锁应该是一个复杂东西,怎么就两个字段就可以实现?那是因为设计使用了位的方式来做标志,state的不同位分别表示了不同的状态,使用最小的内存来表示更多的意义,其中低三位由低到高分别表示mutexed、mutexWoken 和 mutexStarving,剩下的位则用来表示当前共有多少个goroutine在等待锁:,截屏2022-06-26 下午12.08.46,mutex最开始的实现只有正常模式,在正常模式下等待的线程按照先进先出的方式获取锁,但是新创建的gouroutine会与刚被唤起的 goroutine竞争,会导致刚被唤起的 goroutine获取不到锁,这种情况的出现会导致线程长时间被阻塞下去,所以Go语言在1.9中进行了优化,引入了饥饿模式,当goroutine超过1ms没有获取到锁,就会将当前互斥锁切换到饥饿模式,在饥饿模式中,互斥锁会直接交给等待队列最前面的goroutine,新的 goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式。,mutex的基本情况大家都已经掌握了,接下来我们从加锁到解锁来分析mutex是如何实现的;,从Lock方法入手:,上面的代码主要两部分逻辑:,lockSlow代码段有点长,主体是一个for循环,其主要逻辑可以分为以下三部分:,在locakSlow方法内会先初始化5个字段:,自旋的判断条件非常苛刻:,自旋这里的条件还是很复杂的,我们想让当前goroutine进入自旋转的原因是我们乐观的认为当前正在持有锁的goroutine能在较短的时间内归还锁,所以我们需要一些条件来判断,mutex的判断条件我们在文字描述一下:,old&(mutexLocked|mutexStarving) == mutexLocked 用来判断锁是否处于正常模式且加锁,为什么要这么判断呢?,mutexLocked 二进制表示为 0001,mutexStarving 二进制表示为 0100,mutexLocked|mutexStarving 二进制为 0101. 使用0101在当前状态做 &操作,如果当前处于饥饿模式,低三位一定会是1,如果当前处于加锁模式,低1位一定会是1,所以使用该方法就可以判断出当前锁是否处于正常模式且加锁;,runtime_canSpin()方法用来判断是否符合自旋条件:,自旋条件如下:,判断当前goroutine可以进自旋后,调用runtime_doSpin方法进行自旋:,循环次数被设置为30次,自旋操作就是执行30次PAUSE指令,通过该指令占用CPU并消费CPU时间,进行忙等待;,这就是整个自旋操作的逻辑,这个就是为了优化 等待阻塞->唤醒->参与抢占锁这个过程不高效,所以使用自旋进行优化,在期望在这个过程中锁被释放。,自旋逻辑处理好后开始根据上下文计算当前互斥锁最新的状态,根据不同的条件来计算mutexLocked、mutexStarving、mutexWoken 和 mutexWaiterShift:,首先计算mutexLocked的值:,计算mutexWaiterShift的值:,计算mutexStarving的值:,计算mutexWoken的值:,上面我们已经得到了锁的期望状态,接下来通过CAS将锁的状态进行更新:,这块的逻辑很复杂,通过CAS来判断是否获取到锁,没有通过 CAS 获得锁,会调用 runtime.sync_runtime_SemacquireMutex通过信号量保证资源不会被两个 goroutine 获取,runtime.sync_runtime_SemacquireMutex会在方法中不断尝试获取锁并陷入休眠等待信号量的释放,一旦当前 goroutine 可以获取信号量,它就会立刻返回,如果是新来的goroutine,就需要放在队尾;如果是被唤醒的等待锁的goroutine,就放在队头,整个过程还需要啃代码来加深理解。,相对于加锁操作,解锁的逻辑就没有那么复杂了,接下来我们来看一看UnLock的逻辑:,使用AddInt32方法快速进行解锁,将m.state的低1位置为0,然后判断新的m.state值,如果值为0,则代表当前锁已经完全空闲了,结束解锁,不等于0说明当前锁没有被占用,会有等待的goroutine还未被唤醒,需要进行一系列唤醒操作,这部分逻辑就在unlockSlow方法内:,我们在唤醒goroutine时正常模式/饥饿模式都调用func runtime_Semrelease(s *uint32, handoff bool, skipframes int),这两种模式在第二个参数的传参上不同,如果handoff is true, pass count directly to the first waiter.。,Go语言在1.18版本中引入了非阻塞加锁的方法TryLock(),其实现就很简洁:,TryLock的实现就比较简单了,主要就是两个判断逻辑:,TryLock并不被鼓励使用,至少我还没想到有什么场景可以使用到它。,通读源码后你会发现互斥锁的逻辑真的十分复杂,代码量虽然不多,但是很难以理解,一些细节点还需要大家多看看几遍才能理解其为什么这样做,文末我们再总结一下互斥锁的知识点:,锁处于完全空闲状态,通过CAS直接加锁,当锁处于正常模式、加锁状态下,并且符合自旋条件,则会尝试最多4次的自旋,若当前goroutine不满足自旋条件时,计算当前goroutine的锁期望状态,尝试使用CAS更新锁状态,若更新锁状态成功判断当前goroutine是否可以获取到锁,获取到锁直接退出即可,若不同获取到锁子则陷入睡眠,等待被唤醒,goroutine被唤醒后,如果锁处于饥饿模式,则直接拿到锁,否则重置自旋次数、标志唤醒位,重新走for循环自旋、获取锁逻辑;,原子操作mutexLocked,如果锁为完全空闲状态,直接解锁成功,如果锁不是完全空闲状态,,那么进入unlockedslow逻辑,如果解锁一个未上锁的锁直接panic,因为没加锁mutexLocked的值为0,解锁时进行mutexLocked - 1操作,这个操作会让整个互斥锁魂村,所以需要有这个判断,如果锁处于饥饿模式直接唤醒等待队列队头的waiter,如果锁处于正常模式下,没有等待的goroutine可以直接退出,如果锁已经处于锁定状态、唤醒状态、饥饿模式则可以直接退出,因为已经有被唤醒的 goroutine 获得了锁.
© 版权声明
文章版权归作者所有,未经允许请勿转载。