“互斥锁”对于并发编程是必不可少的,Go语言虽然推崇使用Channel来解决对并发资源的访问,但同样实现了Sync.Mutex互斥锁供编程人员使用。有人做过专门的统计,在知名的开源软件Docker、Kubernutes、etcd、gRPC中,使用Mutex的频率是最高的。Go语言随着版本的迭代,对Sync.Mutex的实现也愈发精细化,当前版本的Sync.Mutex核心实现代码已经有100多行,其中大量使用了复杂位运算和流程控制来解决各种问题,因此使得Sync.Mutex源码达到了不可读的状态,本文从历史发展的角度剖析Sync.Mutex,希望能帮助读者读懂Sync.Mutex的源码并掌握其设计思想。
1. 初版互斥锁——先到先得
github地址:初级版本的互斥锁
代码量很少,我们来分析一下:
package sync
import (
"runtime"
"sync/atomic"
)
//互斥锁结构
type Mutex struct {
key int32
sema uint32
}
//请求锁
func (m *Mutex) Lock() {
if atomic.AddInt32(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
return
}
runtime.Semacquire(&m.sema) //否则阻塞等待
}
//释放锁
func (m *Mutex) Unlock() {
switch v := atomic.AddInt32(&m.key, -1); { //标识减1
case v == 0: //如果等于0,则没有等待者
return
case v == -1: //如果等于-1,这种是异常情况,或者超过了最大可等待goroutine的数量
panic("sync: unlock of unlocked mutex")
}
runtime.Semrelease(&m.sema) //唤醒其他阻塞的goroutine
}
初级版本的Mutex包含两个字段:
当goroutine调用Lock方法请求锁的时候,通过atomic.AddInt32方法原子性的给key加1,如果比较幸运,当前没有等待者,那么key的值就会等于1,成功获取到锁;如果锁已经被别的 goroutine 持有了,当前的 goroutine 会在将 key 加 1的同时,调用 runtime.Semacquire 方法,使用信号量将自己休眠,等锁释放的时候,信号量会将它唤醒。释放锁的操作也比较简单,就是原子性的给key减1,不过当减1之后值变成-1的时候,程序就会panic,例如没有请求锁,直接对锁进行释放就会panic这个错误;此外还有一种情况,Mutex结构中的key是一个int32类型,它能表达的最大整数是40多亿,当争抢锁的goroutine数目达到这个阈值的时候,也会panic,但是这种情况在服务器资源有限的情况下,是不可能会发生的。
我们先来探讨两个问题:
- 为什么请求锁时,需要使用原子操作?
- 为什么需要信号量?它的实现机制是什么?
1.1 原子操作的必要性
我们先来看一个简单技术器程序 counter.go。
package main
import (
"fmt"
"sync"
)
func main() {
//runtime.GOMAXPROCS(1)
var counter int64
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
for j := 0; j < 10000; j++ {
counter++ //atomic.AddInt64(&counter, 1)
}
}()
}
wg.Wait()
fmt.Println(counter)
}
我们启动2个goroutine并发的累加counter,每个goroutine将counter累加1万,运行程序大概率打印出来的counter值不会是2万,而是一个小于2万的数,每次运行结果都不一样。熟悉并发编程的读者应该早都看出了端倪,这段程序中存在竞态条件。counter++不是一个原子操作,它包含三个步骤:1.读取counter变量当前值;2.对counter当前值加1,保存到寄存器临时变量中;3.将临时变量的结果再保存到counter中。其中的每一个步骤,都有对应的汇编实现。
当我们使用一个CPU核心的时候(使用runtime.GOMAXPROCS(1)设置),多次运行程序,似乎我们总能得到2万的准确计数,但是这并不是一个强保证,当我们使用go run -race counter.go
竞态检测就大概率能检测到这段程序对counter变量的内存有非同步的并发读写:
==================
WARNING: DATA RACE
Read at 0x00c000136008 by goroutine 8:
main.main.func1()
/Users/guozhaoran/goCode/basic/goConcurrent/mutex/example.go:17 +0x78
Previous write at 0x00c000136008 by goroutine 7:
main.main.func1()
/Users/guozhaoran/goCode/basic/goConcurrent/mutex/example.go:17 +0x91
Goroutine 8 (running) created at:
main.main()
/Users/guozhaoran/goCode/basic/goConcurrent/mutex/example.go:14 +0xe4
Goroutine 7 (finished) created at:
main.main()
/Users/guozhaoran/goCode/basic/goConcurrent/mutex/example.go:14 +0xe4
==================
所以解决方案就是使用atomic包,它非常适合于这种全局单体变量的原子性加减,而这种原子性的实现是不同的CPU架构硬件提供的能力,通过LOCK汇编指令锁定数据总线来完成。对于Mutex的应用场景,原子操作当然是必不可少的,它保证了多个goroutine对共享变量key累加的一致性。
1.2 信号量
信号量的概念是荷兰计算机科学家 Edsger Dijkstra 在 1963 年左右提出来的,广泛应用在不同的操作系统中。在系统中,会给每一个进程一个信号量,代表每个进程目前的状态。未得到控制权的进程,会在特定的地方被迫停下来,等待可以继续进行的信号到来。
Dijkstra 在他的论文中为信号量定义了两个操作 P 和 V。P 操作(descrease、wait、acquire)是减少信号量的计数值,而 V 操作(increase、signal、release)是增加信号量的计数值。使用伪代码表示如下(中括号代表原子操作):
function V(semaphore S, integer I):
[S ← S + I]
function P(semaphore S, integer I):
repeat:
[if S ≥ I:
S ← S − I
break]
可以看到,初始化信号量 S 有一个指定数量(n)的资源,它就像是一个有 n 个资源的池子。P 操作相当于请求资源,如果资源可用,就立即返回;如果没有资源或者不够,那么,它可以不断尝试或者阻塞等待。V 操作会释放自己持有的资源,把资源返还给信号量。信号量的值除了初始化的操作以外,只能由 P/V 操作改变。
现在,我们来总结下信号量的实现。初始化信号量:
- 设定初始的资源的数量。
- P 操作:将信号量的计数值减去 1,如果新值已经为负,那么调用者会被阻塞并加入到等待队列中。否则,调用者会继续执行,并且获得一个资源。
- V 操作:将信号量的计数值加 1,如果先前的计数值为负,就说明有等待的 P 操作的调用者。它会从等待队列中取出一个等待的调用者,唤醒它,让它继续执行。
在运行时,Go 内部使用信号量来控制 goroutine 的阻塞和唤醒。比如互斥锁的第二个字段sema,信号量的 P/V 操作是通过函数实现的(Go内部运行时的信号量也是通过atomic和gopark实现的,具体实现可以看runtime/sema.go)。
func runtime_Semacquire(s *uint32)
func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int)
func runtime_Semrelease(s *uint32, handoff bool, skipframes int)
值得一提的是,Go运行时的信号量实现的是一个优先级等待队列,这也是当前阶段Mutex饥饿模式实现的基础。信号量的 P/V 操作函数,可以将goroutine休眠后添加到优先级队列的头部或尾部;也可以从优先级队列的头部或尾部将goroutine取出唤醒。
2. 互斥锁初步优化——给要抢锁的goroutine一次机会
解读完初版互斥锁的实现,读者可能会发现一个问题,当锁被持有的情况下,新到来争抢锁的goroutine直接被运行时的信号量休眠并添加到了优先队列中,虽然这样严格保证了锁争抢的先来先得顺序,但是goroutine的休眠和唤醒非常影响性能,针对这一点,Go开发者对Mutex做了一次大的调整。
github地址:互斥锁进一步优化
此时的Mutex结构如下:
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexWaiterShift = iota
)
Mutex结构体的key被改成了state,代表的含义也被拆分成了三个。
- MutexLocked:state的第一个位代表锁是否被持有
- MutexWoken:state的第二个位代表是否有唤醒的 goroutine
- MutexWaiters:state剩下的位代表的是等待此锁的 goroutine 数
在分析这个版本的代码之前,我们先来补充一个要用到的非常重要的知识点:CAS。
2.1 自旋锁(CAS)及其实现原理
CAS指令的实现原理是将给定的值与内存中的值进行比较,如果是同一个值,就用新值替换掉内存中的值,然后返回。如果不是就返回第一步的比较,因此得名“自旋锁”。下面画一个图来描述一下CAS算法,并和Go语言中的atomic.CompareAndSwap函数簇做一个比较:
有一点需要特别注意,CAS中值的比较与交换过程是原子性的,这个过程中如果有其他的goroutine修改了内存中的值,那么CAS会返回false。
CAS也是Mutex实现的基础,读者可能会有疑问:有了CAS为什么还要有信号量呢?其实无论是原子操作,还是自旋锁,都不适合长时间等待的情况,因为有很多资源(数据)它有一定的时间性,你想去获取它,CPU 并不能立即返回给你,而是要等待一段时间,才能把数据返回给你。这种情况,你用自旋锁来同步访问这种资源,你会发现这是对 CPU 时间的巨大浪费。当然Mutex的实现非常适合使用CAS。
2.2 互斥锁初步优化后的Lock实现
介绍完了CAS,我们来看当前版本的Lock实现,直接上代码:
func (m *Mutex) Lock() {
// Fast path: 幸运case,能够直接获取到锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
awoke := false
for {
old := m.state
new := old | mutexLocked //新状态加锁
if old&mutexLocked != 0 {
new = old + 1<<mutexWaiterShift //等待者数量加一
}
if awoke {
//goroutine是被唤醒的,新状态清除唤醒标记
new &^= mutexWoken
}
if atomic.CompareAndSwapInt32(&m.state, old, new) { //设置新状态
if old&mutexLocked == 0 { //锁原状态未加锁
break
}
runtime.Semacquire(&m.sema) //请求信号量
awoke = true //设置唤醒标记
}
}
}
我们重点看一下对state的操作。首先通过CAS检测Mutex是否没有被goroutine持有并且没有等待者,如果是这样,那么当前goroutine很幸运,可以直接获取到锁,这也就是代码中标注的Fast path。
如果当前goroutine不够幸运,那么会走到下边的循环检查阶段,for 循环不断尝试获取锁,如果获取不到,就通过 runtime.Semacquire(&m.sema) 休眠,休眠醒来之后 awoke 置为 true,尝试争抢锁。我们知道state有三个含义:
- 通过
new := old | mutexLocked
设置state中的mutexLocked,给Mutex加锁; - 通过
new = old + 1<<mutexWaiterShift
给Mutex设置mutexWaiterShift,等待者加1; - 通过
new &^= mutexWoken
给Mutex清除Mutex的唤醒标记。
那么接下来atomic.CompareAndSwapInt32(&m.state, old, new)
执行成功说明给state设置了新值,就要区分两种情况考虑了,第一种情况是state新值中包含加锁成功了,那么直接break,goroutine抢到了锁,程序结束;否则只能说明state 只是清除 mutexWoken 标志或者增加一个 waiter 而已。
这里的循环状态检查的代码有两种goroutine会同时执行:
- 新来抢锁的goroutine(可能有多个)
- 从信号量优先队列中唤醒的goroutine(最多只可能有一个)
上边的描述可能比较抽象,我们结合下边的流程图帮助理解一下吧:
2.3 互斥锁初步优化后的Unlock实现
Unlock方法也变得复杂了,但是不像Lock,仔细研究一下,还是能看得懂的,下边是代码:
func (m *Mutex) Unlock() {
// Fast path: drop lock bit.
new := atomic.AddInt32(&m.state, -mutexLocked) //去掉锁状态
if (new+mutexLocked)&mutexLocked == 0 { //未被锁定的mutex释放锁会panic
panic("sync: unlock of unlocked mutex")
}
old := new
for {
//锁上没有goroutine等待或者有被唤醒的goroutine,或者又被别的goroutine加了锁,那么不需要做任何事情,返回即可
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 {
return
}
//将mutexWaiterShift数量减1并设置mutexWoken为true
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) { //CAS设置成功,唤醒一个新的goroutine争抢锁即可
runtime.Semrelease(&m.sema)
return
}
old = m.state //记录当前mutex的状态,继续循环
}
}
Unlock方法先定义一个新变量将锁标志去掉,如果对一个未加锁的Mutex进行Unlock会panic,然后程序还需要进行一些额外的判断,并不能直接返回。下面情况之一调用Unlock的goroutine可以直接返回:
- Mutex上没有waiter
- Mutex又被别人上了锁
- 有goroutine被唤醒了
否则,先设置Mutex中mutexWaiterShift减1,并标记mutexWoken为true,使用CAS方法如果设置成功,则从信号量的优先队列中唤醒一个goroutine,程序返回。否则记录当前mutex的状态,继续循环判断。直到返回为止。
相比较最初版本的设计,这个版本的Sync.Mutex实现主要是给新来的goroutine一次获取到锁的机会,打破了原来先来先得的逻辑,代码的复杂度也增加了不少。
3. 互斥锁进一步优化——给要抢锁的goroutine更多机会
我们前边对Sync.Mutex的优化是基于一种猜想:新来的争抢锁的goroutine很大概率上能够获取到锁!持有锁的goroutine在持有锁时间越短的情况下,这种概率越大,那么我们为什么不给争抢锁的goroutine更多机会呢?也就是让它们稍微等一会,如果等一会也获取不到,那么就乖乖的添加进信号量的优先队列就好了。实际上,Go官方团队也是这么做的。
gihub地址:给要抢锁的goroutine更多机会
我们来看一下代码:
func (m *Mutex) Lock() {
// Fast path: 幸运case,能够直接获取到锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
awoke := false
iter := 0
for { // 不管是新来的请求锁的goroutine, 还是被唤醒的goroutine,都不断尝试请求锁
old := m.state
new := old | mutexLocked //新状态加锁
if old&mutexLocked != 0 { // 锁还没被释放
if runtime_canSpin(iter) { // 还可以自旋
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
continue //自旋,再次尝试获取锁
}
new = old + 1<<mutexWaiterShift //等待者数量加一
}
if awoke { //唤醒状态,去掉标记
new &^= mutexWoken
}
if atomic.CompareAndSwapInt32(&m.state, old, new) { //设置新状态
if old&mutexLocked == 0 { //锁原状态未加锁
break
}
runtime_Semacquire(&m.sema) //请求信号量
awoke = true //设置信号量
iter = 0 //重新设置自旋计数器
}
}
}
这次的优化很小,只增加了runtime_canSpin的检测,有一个自旋计数器iter,如果抢锁的goroutine还能自旋的话,就自旋等待持有锁的goroutine释放锁,这样能够增大抢到锁的概率。
我想读者应该对代码中的这部分很迷惑:
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
这四个&&符号确实让人眼花撩乱,不过结合上一小节对Sync.Mutex的解读,我们能分析出这段代码的用途。首先&&是短路运算符,有一个为false判断就不会进行下去了。!awoke
表示程序逻辑是由新加入抢锁的goroutine进来的,而不是从sema优先级队列中唤醒的goroutine进来的;再然后如果Mutex的旧值是没有唤醒新的goroutine(old&mutexWoken == 0
)的并且有等待者(old>>mutexWaiterShift != 0
)的话,就尝试通过CAS给Mutex设置一个唤醒标记(atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken)
),如果成功的话,将awoke设置为true,这样做可以让调用Unlock的goroutine快速返回,而不用从优先队列中再唤醒goroutine来争抢锁。
经过这一版本的优化,我们可以看到,Sync.Mutex对新加入抢锁的goroutine相当友好,表面上看这样似乎没有什么问题,能够让更多的goroutine在最短的时间内获取到锁。但是我们考虑一下那些一直在信号量优先队列中的等待者怎么办?锁有可能一直被新来的goroutine抢到,这就产生了“饥饿问题”。
4.终极版本的互斥锁——小康社会,不再饥饿
Sync.Mutex的“饥饿问题”早在Go 1.9版本中就解决了,后续也进行了一些优化工作,到此Sync.Mutex才算是实现的比较完美。我们接下来会解读当前最新版本Go1.17的代码实现,读者要和我一起开启烧脑模式,细细的品一品了。
github地址:终极版本的Sync.Mutex
4.1 Mutex结构体实现
为了解决饥饿问题,Mutex结构体从state字段中又分出一个位标识当前Mutex是否饥饿,并定义了一个常量,将获取锁的goroutine等待时间设置了 1 毫秒阈值。
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving // 从state字段中分出一个饥饿标记
mutexWaiterShift = iota
starvationThresholdNs = 1e6 //1000000ns = 1ms
)
4.2 Sync.Mutex 终极版本的Lock函数实现
最新版本的Sync.Mutex的Lock方法和Unlock方法将fast path 和slow path拆成独立的函数,以便内联,提高性能。本节我们先来看一下Lock方法的实现:
func (m *Mutex) Lock() {
// Fast path: 顺利的获取到锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (缓慢之路,通过自旋、竞争或者饥饿状态下的锁竞争)
m.lockSlow()
}
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false //标识当前goroutine是否饥饿
awoke := false //唤醒标记
iter := 0 //自旋次数
old := m.state //当前的锁状态
for {
//锁是非饥饿状态,并且未释放,尝试自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 主动自旋的场景
// 尝试设置 mutexWoken 标志以通知 Unlock 不唤醒其他阻塞的 goroutine
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin() //自旋
iter++
old = m.state
continue
}
new := old
// 不要尝试获取饥饿的互斥锁,新到达的 goroutine 必须排队
if old&mutexStarving == 0 {
new |= mutexLocked //非饥饿状态,加锁
}
if old&(mutexLocked|mutexStarving) != 0 { //饥饿状态,或者锁被抢占,等待者 + 1
new += 1 << mutexWaiterShift
}
// 当前 goroutine 将互斥锁切换到饥饿模式。
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
if awoke {
//清除awoke标识
new &^= mutexWoken
}
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
break // 上锁成功
}
// 第一次等待,添加到信号量队列的队首
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
//设置饥饿标记
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
if old&mutexStarving != 0 {
//加锁并将waiter数量减1
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
//非饥饿状态的goroutine,最后一个waiter已经不饥饿了,清除标记
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
}
代码结合注释也很难看懂,再结合流程图看一下。
获取锁的goroutine很幸运的话,可以通过Fast path很快获取锁,我们来一步步分析一下lockSlow函数的实现。 首先定义了当前goroutine用到的一些变量信息,然后使用old保存当前锁的状态。
var waitStartTime int64
starving := false //标识当前goroutine是否饥饿模式
awoke := false //唤醒标记,初次进入for循环为false,之后以从sema优先队列中唤醒的身份进入for循环
iter := 0 //自旋次数,用来判断是否可以继续自旋获取锁
old := m.state //当前的锁状态
然后代码就进入到了for循环,lockSlow的实现效果是尽可能少的循环,但是一定使获取锁的goroutine得到锁。for循环中,当前抢锁的goroutine发现锁还没有被释放(正常模式下),则调用runtime_canSpin自旋等待一会,期望锁能够被释放,这期间,程序还做了其他一些事情,比如通过设置Mutex的awoke状态,尽量使得Unlock程序不要再唤醒sema优先队列中的goroutine了,因为参与竞争锁的goroutine越多,得到锁的概率越小!
for {
//锁是非饥饿状态,并且未释放,尝试自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 主动自旋的场景
// 尝试设置 mutexWoken 标志以通知 Unlock 不唤醒其他阻塞的 goroutine
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin() //自旋
iter++
old = m.state
continue
}
自旋等待之后,获取锁的goroutine命运有两个:
- 当前锁已经被释放了,那么它就可以参与竞争了
- 当前锁没有被释放,又或者它参与锁竞争失败了,那么它就要进入sema的优先队列了
不过不管结果如何,它都要走下边的通用逻辑,就是给Mutex设置新状态。有下边几个步骤:
- 判断锁的状态是否是正常模式,是的话就设置mutexLocked标志准备抢锁
- 如果锁的状态是没有被释放,或者是饥饿模式的话,当前goroutine一定要进sema优先队列了,这时候设置锁的等待者+1
- 如果锁已经被标记为饥饿模式了,并且原来锁并没有被释放,那么将锁给打上饥饿的标记。有的读者可能会问了,为什么只有锁没有被释放的时候才打上饥饿标记呢?这是因为Unlock方法是根据锁是否为饥饿模式来从sema优先队列中唤醒goroutine的,也就是锁为饥饿模式时,优先队列中必须还有等待的goroutine。
- 如果有awoke标记的话,将锁上的mutexWoken标记给擦除掉,因为不管当前goroutine最终进入sema优先队列还是获取到锁,新状态都应该清楚awoke标记。
new := old
// 不要尝试获取饥饿的互斥锁,新到达的 goroutine 必须排队
if old&mutexStarving == 0 {
new |= mutexLocked //非饥饿状态,加锁
}
if old&(mutexLocked|mutexStarving) != 0 { //饥饿状态,或者锁被抢占,等待者 + 1
new += 1 << mutexWaiterShift
}
// 当前 goroutine 将互斥锁切换到饥饿模式。
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
if awoke {
//清除awoke标识
new &^= mutexWoken
}
设置状态的步骤走完了,接下来就是CAS了,有两个结局:CAS成功 or CAS失败。失败是成功之母,CAS失败记录当前锁的状态,再次走for循环就好了,注意这时候不用更新spin计数,因为抢锁失败并没有进入sema优先队列!当然CAS成功了也并不是万事大吉了,如果锁是在正常情况下被上锁成功的话,那么恭喜当前goroutine成功获取到了锁;否则是一定要进入sema优先队列的,但是具体是被插入到头部还是尾部,则要分情况而定,这时候waitStartTime就发挥作用了,根据它不仅能判断出来抢锁的goroutine是否是第一次要被插入sema优先队列,还能判断当前锁状态是否已经达到饥饿阈值了。
我们先来说goroutine是否是第一次要被插入sema优先队列的情况,是插入到尾部,这样只能等到下一轮调度唤醒了;如果当前goroutine不是第一次插入到sema优先队列,程序会将它插入到头部,这样下一个唤醒的依然是它,增加了它获取到锁的概率!
计算当前锁状态模式的代码是goroutine从sema优先队列中被唤醒之后,根据waitStartTime的记录和上边starving的标识,如果当前goroutine等待锁时间超过了1ms,那么当前goroutine就会将锁标记为饥饿(注意这个时候并没有给Mutex打上饥饿的标识,只是标记为饥饿,如果接下来当前goroutine仍然获取不到锁,那么接下来的循环中会做这个事情)。这个时候再次判断锁的状态,如果锁为饥饿模式,那么就直接把锁交给当前请求锁的goroutine就好了,否则当前被唤醒的goroutine只能进行新一轮抢锁了(重新设置自旋计数器)。
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
break // 上锁成功
}
// 第一次等待,添加到信号量队列的队首
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
//设置饥饿标记
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
if old&mutexStarving != 0 {
//加锁并将waiter数量减1
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
//非饥饿状态的goroutine,最后一个waiter已经不饥饿了,清除标记
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
以上就是终极版本Mutex的Lock函数实现,接下来回答几个常见问题:
-
- 如果Mutex已经被标记成为“饥饿模式”了,什么时候会变为“正常模式”呢?
拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式;第一此 waiter 已经是队列中的最后一个 waiter 了(通过代码!starving || old>>mutexWaiterShift == 1
判断),没有其它的等待锁的 goroutine 了;第二是此 waiter 的等待时间小于 1 毫秒(通过代码starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
判断)。
-
- 为什么要有饥饿模式?
饥饿模式是对公平性和性能的一种平衡,它避免了某些 goroutine 长时间的等待锁。在饥饿模式下,优先对待的是那些一直在等待的 waiter。正常模式拥有更好的性能,因为即使有等待抢锁的 waiter,goroutine 也可以连续多次获取到锁。
-
- 饥饿模式下Mutex是如何工作的?
饥饿模式下,会直接把锁交给队列第一个 goroutine。这块再代码中有体现。
本节内容建议读着多读几遍,最好是从文章开始顺着历史发展的脉络思考,如果还有什么问题,可以在留言区和我一块交流。
我们再接着看Unlock方法。
4.3 Sync.Mutex 终极版本的Unlock函数实现
相对于Lock函数,Unlock函数的代码逻辑比较好理解,我们先看一下流程图:
代码如下:
func (m *Mutex) Unlock() {
// Fast path: 将锁标记去掉
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 { //还需要做其他的事
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
//无锁的mutex释放锁会panic
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
if new&mutexStarving == 0 {
old := new
for {
//锁上没有goroutine等待或者有被唤醒的goroutine改变了锁的状态,直接return即可
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// 减少等待者并设置Mutex唤醒标记,CAS释放锁
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1) //锁在正常模式下从sema优先队列尾部唤醒新的goroutine
return
}
old = m.state
}
} else {
//饥饿模式下,从优先队列的头部唤醒等待的goroutine,Lock方法会直接将锁给它
runtime_Semrelease(&m.sema, true, 1)
}
}
先将锁标记去掉,但是state其他字段并不为0的话,还需要做一些额外的工作,这就是unlockSlow
的代码逻辑,如果是饥饿模式的话,从优先队列的头部唤醒一个goroutine,分析Lock代码的时候我们知道,饥饿模式下会将锁直接给这个唤醒的goroutine; 正常模式下进入for循环,如果锁的的其他状态已经被改变的情况下,unlockSlow什么也不需要做,return即可,否则将锁等待者数量-1,并设置唤醒标识,CAS操作成功之后,从sema优先队列尾部唤醒一个goroutine参与到抢锁工作中。
以上就是Sync.Mutex终极版本的全部实现了。下边我们再来看一下Sync.Mutex使用中都有哪些坑。
5. Sync.Mutex使用时常见的坑
关于Mutex的使用,一不小心就会产生死锁或者panic,使用不得当会有很大的性能开销,接下来我们结合上边的源码分析一下。
- Mutex对goroutine无状态性
看过源代码,我们不难看出不同的goroutine操作Mutex这一个全局变量是没有状态记录的,这样会出现两种情况:1.一个goroutine可以释放掉另一个goroutine的锁;2.goroutine一旦重入设置两次Lock,就会死锁;建议使用Mutex时,Lock/Unlock要成对出现,最好是封装到一个函数中,使用defer是一个好的方案。也可以对Mutex的无状态性做一个封装,例如实现锁的重入,添加一些锁的检测机制等等。
- Mutex 千万不能被复制
这里所说的千万不能被复制并不是Go语言在语法上做了限制,而是在使用过程中,为了避免不必要的麻烦,不要拷贝Mutex,从源码分析我们看到,Mutex可以被千千万万个goroutine使用,拷贝Mutex时,只是拷贝的一个临时状态而已。复制之后,一个新 Mutex 可能莫名处于持有锁、唤醒或者饥饿状态,甚至等阻塞等待数量远远大于0。而原锁 Unlock 的时候,却不会影响复制锁。
关于锁复制后产生的严重后果,推荐阅读:当 Go struct 遇上 Mutex
- 产生强烈的锁竞争时怎么办
当线上QPS很高时,如果使用了Mutex,可能会发现大量的goroutine阻塞在Mutex的Lock函数上,这个时候就要想办法优化程序,首先应该尽量少使用Mutex,如果非要使用,可以采用分片管理数据的方式,Mutex保护的程序逻辑不应该过于复杂,因为大量goroutine阻塞在Mutex的Lock函数上会产生饥饿,饥饿带来的问题是性能更差,不能更快的恢复到正常模式将是一场灾难。
关于锁竞争的优化,推荐阅读:一次错误使用 go-cache 导致出现的线上问题
6. 小结
DDD设计中有关战略模型的分析指出,每一个复杂系统都有一个演进的过程,我们不要期望一次就将它做的很好。我觉得这个观点应用在Go语言的Sync.Mutex设计上非常恰当,“罗马不是一天建成的”,我们不得不佩服Sync.Mutex开发者的匠心精神,一次次打磨,精益求精的实现了现在的Sync.Mutex,虽然它可能还不够完美,还能够继续雕琢,但是这个发现问题并解决问题的思路和精神,是我们应该学习的。本文还分析了很多关于Sync.Mutex实现的基础知识:原子性、信号量、CAS;还指出了使用Mutex时应该注意的问题,希望感兴趣的读者在留言区继续和我交流!
Reference:
-
Mutex 源码分析: colobu.com/2018/12/18/…
-
原子操作是如何实现的 github.com/luohaha/MyB…
-
百度百科:信号量 baike.baidu.com/item/%E4%BF…
-
一文彻底搞懂CAS实现原理 zhuanlan.zhihu.com/p/94762520
-
Go sync.Mutex 深入不浅出 juejin.cn/post/698498…
-
一次错误使用 go-cache 导致出现的线上问题 mp.weixin.qq.com/s/tcsSgCRj-…
-
当 Go struct 遇上 Mutex mp.weixin.qq.com/s/OYGVR0d-f…