谈一谈golang锁机制

针对内存中的临界资源,两个或者多个线程使用一个临界区时,就要为临界区加锁。锁本质也是一个变量。

乐观锁 悲观锁

乐观锁,悲观锁是一种思想。

乐观锁

并不是真的对数据上锁,而是监听数据是否发生改变,如果在使用数据期间,该数据被其它线程改动,就会放弃对该数据的操作。一般用于读多写少的环境。

悲观锁

悲观锁是只要访问一个临界区的数据就会对其上锁。一般多用于写入环境。

实际场景

在实际场景中首先考虑业务的读写要求。如果悲观锁和乐观锁都能够使用,看并发的效率,如果并发时资源竞争不激烈,可以使用乐观锁。并发冲突很严重时就要使用悲观锁,一些数据安全性很高的场景。

一般乐观锁用来做 Watch监听,Version版本号控制,CAS原子性操作,在go中的CAS原子性操作可以有效减少锁的开销,但是原子性操作是依赖于CPU性能的,会消耗CPU资源。

读写锁 互斥锁

go 的锁就两个,读写锁和互斥锁。其内部也是依赖于原子性操作进行判断。

RWMutex 读写锁

读写锁通过count++计数器 和 Sem信号量的方式记录读锁数量和写锁的数量。

1. Rlock获取读锁:当一个协程使用读锁时,就会readercount++一个计数,如果readcount小于0为负数就会进入等待状态,写就是有写操作进入,读操作就会被等待。

2. Lock获取写锁:写锁就是使用的go互斥锁。会减去最大的读锁数量,用0和负数来表示已经获取上写锁了。需要等待释放,通过监控写锁的信号来等待。

3. RUnlock释放读锁:读锁的计数器-1。然后释放读锁的goroutine。

4. Unlock 释放写锁:释放互斥锁,还原加锁时减去的读锁数量。唤醒写锁期间所有被阻塞的goroutine。最后释放互斥资源。

Fast path 快速路径:处理预期,理想情况的数据段,达到快速处理TCP数据段的目的。
Slow path 慢速路径:处理非预期,非理想情况的数据,不满足快速路径的数据段。

Mutex 互斥锁

Mutex的实现原理

Mutex 就两个字段一个 state状态标识,一个 sema 信号量。

state是int32位类型,
第1位=1(加锁),
第2位=1 (是否有goroutine被唤醒),
第3位=1(mutex工作模式,0正常,1饥饿模式)
其它位用来记录有多少个排队者在排队。

由Lock 和 UnLock 实现加锁解锁,具体的加锁,解锁逻辑由LockSlow和 UnLockSlow 来实现。

Lock() 加锁:

在Lock 中通过原子性操作实现处理预期,理想的锁状态;而那些非预期的数据放在了 lockslow 和 Unlockslow 方法中。

Lock 方法中的 fastpath 期望互斥锁当前处于解锁状态,没有goroutine排队,更不会进饥饿。理想状态下只需要一个自旋操作就能够获取到锁,但是如果一个CAS自旋没有获取到锁,就会进入slow path,也就是lockslow方法中处理出现的 goroutine队列+饥饿模式。

UnLock 解锁:

首先通过原子性操作从state 状态码中减去 mutexLocked,让其=0 也就是释放锁。然后根据 state的新值来判断是否需要继续处理slowpath非预期的数据。

如果新值=0,也就意味着没有其它的 goroutine在排队等待解锁,所以不需要指向其它额外操作。如果新值不=0,则要进入slow path,看看具体唤醒那个goroutine继续工作。

Mutex的锁有几种模式?

正常模式:
1. 正常状态得state状态=1,尝试加锁的 goroutine 会自旋几次,尝试通过原子性获得锁,若几次自旋之后不能获得锁,则通过信号量排队等待,所有的等待者都会按照先入先出的顺序排队。

2. 当一个等待的goroutine被唤醒后,并不会直接拥有锁,而是需要根后来者竞争,这这后来者就是处于自选阶段且尚未排队的 goroutine。这种情况下,后来者更具有优势。一方面自旋是在CPU上运行的。另一方面处于自旋阶段的goroutine会有很多,而被唤醒的只有一个。所以被唤醒的goroutine很有可能拿不到锁,这种情况下它会被重新插入到队列的头而不是尾部。

3. 当一个goroutine本次加锁的等待时间超过1ms后,会将当前的mutex切换成饥饿模式。
饥饿模式:
1. 在饥饿模式下,Mutex的所有权从执行Unlock的 goroutine,直接传递给等待队列头部的 goroutine,后来者不会自旋,也不会尝试获取锁。
2. 即使Mutex当前处于 Unlock的状态,它们也会到队列尾部排队等待。当一个等待者获取锁之后,会有两种情况将饥饿模式转回正常模式。
  • 队列首部的等待时间小于1ms;
  • 它是队列尾部,队列已经空了,后来自然就没有饥饿的 goroutine了。
总结:

在正常模式下,自旋和goroutine执行队列是同时存在的,执行lock的goroutine会率先自旋,如果尝试几次后还没有拿到锁,就需要去排队等待。

这种排队让大家一起来抢锁的模式,会拥有更高的吞吐量,因为频繁的挂机和唤醒goroutine会带来较多的开销。但是又不能无限制的开销,要把自旋控制在较小的范围之内,所以在正常模式下,mutex有更好的性能,但是可能会出现队列抢不到锁的情况。

而饥饿模式不能抢占自旋,所有的goroutine都需要排队,严格的先来后到。

深入理解 Lock - Slowpath 和 UnLock -SlowPath

Lock - Slowpath:

当一个goroutine 要加锁时,如果其它goroutine已经加锁且没有释放。slowpath会判断当前的工作场景,如果是单核场景则不需要等待自旋的goroutine让出CPU。只有在多核场景下,且GOMAXPROCS>1,至少有两个 P 队列正在运行中,而当前的P队列中为空的情况,才会自旋。

进入自旋的 goroutine 会去抢 Mutex的唤醒标志位,设置标志位的目的是在正常模式下告诉持续锁的 goroutine在 Unlock时不需要再唤醒其它gouroutine了,已经有自旋的goroutine在等待了。以免唤醒太多的等待协程。

goroutine的自旋上限是4次,而且每自旋一次都会判断锁的状态,如果锁被释放,或者进入了饥饿模式,又或者已经自旋了4次,就会结束自旋。当无需自旋操作后就会使用原子操作修改 mutex。

把此时mutex.State保存到old中,把要修改的新state记为new; 如果old 处于饥饿模式,或者加锁状态,goroutine就得去排队,所以这些情况下排队规模要+1。

如果是正常模式,就要尝试设置 lock位,所以new中这一位置要设置为1。

如果当前goroutine等待的时候超过1毫秒,且锁还没被释放,就要将锁的状态,切换为饥饿模式。这里之所以还要求锁没被释放是因为如果锁已经释放了,那怎么都得去抢一次。要是直接进入饥饿模式就只能去排队了。

把排队规模和标识位都设置好之后,在执行原子操作修改 state之前,若是当前goroutine持有唤醒标识的话,还要将唤醒标识位重置,因为无论接下来是要去抢锁,还是单纯的要去排队。如果原子性操作成功了,要么是成功抢到了锁,要么是成功进入到等待队列。当前goroutine不再是需要被唤醒的 groutine 了,所以要释放唤醒标识。

如果原子性操作不成功,就意味着其它goroutine在我们保存 Mutext.state到 old中之后,又修改了 state的值,当前goroutine就要回过头去,继续从自旋检查开始,再次尝试。

所以也需要释放自己之前抢到的唤醒标识位,从头再来。继续展开这个原子操作成功的分支,如果是抢锁操作成功了,那么加锁的slow path 就可以宣告结束了。

如果是排队规模设置成功了,还要绝对是排在等待队列头部还是尾部。如果当前goroutine已经排过队了,是在Unlock时从等待队列中唤醒的,那就要排到等待队列头部。如果是第一次排就要到队列尾部。并且从第一次排队记录开始,就会记录当前goroutine的等待时间, 接下来就会让出,进入到等待队列中。

当等待队列中的 goroutine被唤醒时,要从上次让出的地方开始继续执行,接下来会判断,如果mutex处于正常模式,那就接着从自旋开始抢锁。如果唤醒后处于饥饿模式,那就没有其它goroutine会和自己抢了,所以已经轮到自己这里。

所以只需要把 mutex.state中lock标识位设置为加锁,把等待队列规模减去1,再看看是不是要切换到正常模式,也就是看看自己的等待时间是否小于1ms,或者队列是不是空了。

最后设置好 mutex.state 就一切OK了。

Mutex Unlock - Slowpath:

说明除去lock标识位外,剩下的去位不全为0,如果处于正常模式,若等待队列位空,或者已经有 goroutine 被唤醒并获得了锁,或者锁进入了饥饿模式,那就不需要唤醒某个goroutine,直接返回即可。否则就要尝试抢占 mutex.Worken 标识位,获取唤醒一个 goroutine的权利。

当抢占成功后,就会通过 runtime_Semrelease 函数唤醒一个 goroutine。如果抢占不成功,就进行循环尝试,直到等待队列为空,或者已经有一个goroutine被唤醒或者获得了锁。或者锁进入了饥饿模式,则退出循环。

而在饥饿模式下,后来的goroutine不会争抢锁,而是直接排队,锁的所有权是直接执行从Unlock的goroutine,传递给等待队列中首个等待者的,所以不用抢占 mutex.Woken 标识位。

第一个等待着唤醒后,会继承当前goroutine的时间片继续运行,也就是继续lock slow这里goroutine 被唤醒之后的逻辑,这就是Unlock 的 slow path。

正常模式与饥饿模式哪个更好

在正常模式下,自旋和goroutine执行队列是同时存在的,执行lock的goroutine会率先自旋,如果尝试几次后还没有拿到锁,就需要去排队等待。

这种排队让大家一起来抢锁的模式,会拥有更高的吞吐量,因为频繁的挂机和唤醒goroutine会带来较多的开销。但是又不能无限制的开销,要把自旋控制在较小的范围之内,所以在正常模式下,mutex有更好的性能,但是可能会出现队列抢不到锁的情况。

而饥饿模式不能抢占自旋,所有的goroutine都需要排队,严格的先来后到。

sema信号量

信号量是进程间通信处理同步互斥机制,通过一个计数器来控制对共享资源的访问次数。是一个非负数的全局变量,通过pv操作实现互斥,p获取资源,v释放资源。

自旋锁CAS

CAS是CPU中的一个轮询指令,自旋锁的实现依赖于CAS。

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断是否能够被成功获取,知直到获取到锁才会退出循环。获取锁的线程一直处于活跃状态 Golang中的自旋锁用来实现其他类型的锁,与互斥锁类似,不同点在于,它不是通过休眠来使进程阻塞,而是在获得锁之前一直处于活跃状态(自旋)。

死锁

go中的可重用资源,被两个或两个以上的协程同时执行,由于竞争资源而造成的一种阻塞现象。这种阻塞互相等待的进程,称为死锁进程。

出现死锁的原因:
1. 请求互斥:线程对资源的访问是排他性的,如果一个线程对资源进行了占用,那么其它线程一定是等待状态。

2. Channel互相等待写入数据后读取,管道间互相等待也会造成死锁。
3. 无缓冲channel只写不读会死锁。
4. channel不关闭会死锁
5. 有缓冲的channel,缓存满了不读也会死锁。