Last updated
Last updated
Mutex
的结构非常简单,只有两个字段,一个 state
字段表示上锁的状态以及等待获取锁的协程数量;另一个 sema
字段表示信号量,用于控制等待锁的协程
通过 1<<iota
的语法可以看出,state
字段通过各位置的二进制 01
表示状态。最低三位分别表示 mutexLocked
、mutexWoken
和 mutexStarving
,剩下的位置用来表示当前有多少个 Goroutine 在等待互斥锁的释放。
state
字段也可以视为一层缓存,当申请锁时先尝试修改 state
字段,如果修改成功则可以直接返回
修改失败才使用处理逻辑更为复杂的 sema
字段。sema
也是先 CAS
修改,修改失败则将当前协程 park
并放入一个等待队列。
加锁时,先通过 CAS
尝试修改 state
字段为 1
,如果修改成功则视为成功获取锁,直接返回;否则进入慢路径:阻塞当前协程,等到获取锁为止。
分成快慢两部分,这样快速路径在一些情况下可以直接优化为内联函数。
慢路径
如果快速路径加锁失败,则进入慢路径,大家排队阻塞等待加锁,每个等待者叫 waiter
,放入一个队列里等待。
1. 正常模式
正常模式下等待锁的协程是先入先出,从队列中弹出一个等待者唤醒。
2. 饥饿模式
如果协程等待锁的时间超过 1ms
,会进入饥饿模式。
正常模式下 waiter
是先入先出。但该 waiter
不会直接获取锁,而是与新来的协程进行竞争。通常新来的协程更易获取锁(新协程正在 CPU
上运行,而被唤醒的 waiter
还要进行调度才能进入状态),所以 waiter
大概率抢不过,这个时候 waiter
会被放回队列的头部重新等待。
为了减少这种情况的出现,如果等待的时间超过了 1ms
,这个时候 Mutex
就会进入饥饿模式,防止部分协程被饿死。新来的协程会直接放入队列的尾部,不和老的 waiter
竞争,这样很好的解决了老的 goroutine
一直抢不到锁的场景。
饥饿模式下释放锁后,锁直接给队列里的第一个等待者。
当队列只剩一个 goroutine
并且等待时间没有 <= 1ms
时,互斥锁便会重新恢复到正常模式。
该方法的主体是一个非常大 for 循环,这里将它分成几个部分介绍获取锁的过程:
判断当前 Goroutine 能否进入自旋,尝试通过自旋等待互斥锁的释放;
计算互斥锁的最新状态;
更新互斥锁的状态并获取锁;
自旋 = 死循环持续占用 CPU。是一种多线程同步机制,当前的进程在进入自旋的过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。在多核的 CPU 上,自旋可以避免 Goroutine 的切换,使用恰当会对性能带来很大的增益,但是使用的不恰当就会拖慢整个程序。Lock()
通过 runtime_canSpin
函数检查自旋条件,条件非常苛刻:
互斥锁只有在普通模式才能进入自旋
runtime.sync_runtime_canSpin
需要返回 true
CPU核数大于1
当前 Goroutine 为了获取该锁进入自旋的次数小于4次
P和M的数量足够并且当前协程绑定的P的本地队列为空
可以进入自旋的话,会执行4次自旋,自旋通过汇编的 PAUSE
指令实现
先尝试直接 CAS
解锁,如果解锁不成功进入慢路径:
正常模式下:
如果互斥锁不存在等待者或者互斥锁的 mutexLocked
、mutexStarving
、mutexWoken
状态不都为 0,那么当前方法可以直接返回,不需要唤醒其他等待者;
互斥锁的加锁过程比较复杂,它涉及自旋、信号量以及调度等概念:
如果互斥锁处于初始化状态,会通过置位 mutexLocked
加锁;
如果互斥锁处于 mutexLocked
状态并且在普通模式下工作,会进入自旋,执行 30 次 PAUSE
指令消耗 CPU 时间等待锁的释放;
如果当前 Goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式;饥饿模式下等待锁的协程比新来的协程有更高优先级
如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,那么它会将互斥锁切换回正常模式;
互斥锁的解锁过程与之相比就比较简单,其代码行数不多、逻辑清晰,也比较容易理解:
当互斥锁处于饥饿模式时,将锁的所有权交给队列中的下一个等待者,等待者会负责设置 mutexLocked
标志位;
常见服务的资源读写比例会非常高,因为大多数的读请求之间不会相互影响,所以我们可以分离读写操作,以此来提高服务的性能。
在 Mutex
的基础上,加些变量存储并发读的数量即可:
调用 sync.RWMutex.Lock
尝试获取写锁时;
将 readerCount
减成负数以阻塞后续的读操作;
读写互斥锁在互斥锁之上提供了额外的更细粒度的控制,能够在读操作远远多于写操作时提升性能。
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives
当加锁仍然失败时,会调用 runtime_SemacquireMutex(&m.sema, queueLifo, 1)
将当前协程休眠并放入一个等待队列。这部分逻辑参考
如果互斥锁存在等待者,会通过 唤醒等待者并移交锁的所有权;
饥饿模式下,调用 将当前锁交给下一个正在尝试获取锁的等待者,等待者被唤醒后会得到锁,在这时互斥锁还不会退出饥饿状态
互斥锁在正常情况下会通过 将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒;
当互斥锁已经被解锁时,调用 会直接抛出异常;
当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,会直接返回;在其他情况下会通过 唤醒对应的 Goroutine;
读写互斥锁 是细粒度的互斥锁,它允许资源的并发读(读读),但是读写、写写操作无法并行执行。
当资源的使用者想要获取写锁时,使用 方法:
写锁的释放使用
读锁的加锁方法 很简单,该方法会通过 将 readerCount
加一:
读锁的释放会调用 方法:
虽然读写互斥锁 提供的功能比较复杂,但是因为它建立在 上,所以实现会简单很多。我们总结一下读锁和写锁的关系:
每次 都会将 readerCount
其减一,当它归零时该 Goroutine 会获得写锁;
调用 释放写锁时,会先通知所有的读操作,然后才会释放持有的互斥锁;