暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

深入浅出 — Go 语言 mutex 源码解读

架构狂人 2021-07-05
661

前言

  互斥锁是并发编程过程中对共享资源进行访问控制的重要手段,下面将从互斥锁设计优化与演进的角度,渐进地推出互斥锁在 Go 语言中的设计与实现。

1.0 版本(FIFO阻塞队列)

  互斥锁需要保证同一时刻只能被一个线程持有,因此,对于互斥锁最直观的设计方式就是使用一个变量来标记是否正在锁定。如下构造了一个 Mutex
结构体:

type Mutex struct {
state int32
sema uint32
}

  其中,state
成员用于记录程序运行过程中该互斥锁 Mutex
的锁定状态。state
为 int32 型变量,我们将 state
变量的第 1 位称为 Locked 域,用于标记该 Mutex
是否锁定, 0 表示没有锁定,1 表示已被锁定;我们将 state
的剩余 31 位称为 Waiter 域,用于标全局中等待该 Mutex
的协程个数,也就是所有未抢到锁的协程数量,这些等待者们有的可能正在阻塞队列中休眠,有的可能正在 CPU 上抢锁。

  sema
成员是信号量,当持锁者释放锁时,用于通知并唤醒阻塞队列的队头协程进行抢锁。

  众所周知,在并发编程领域,CAS 操作无需创建互斥量与临界区,即可实现并发安全地值替换。 因此,我们借助 Go 语言标准库 atomic
包中的 CompareAndSwapInt32
函数来实现加锁过程,如果能用 CAS 方式将 state
Locked 域置一 ,那么该协程就成功抢占到锁。下面我们尝试写出加解锁方法,为了让代码易读,我们首先定义如下常量来表征 state
中的各域:

const (
mutexLocked = 1 // 表示 Locked 域的锁定状态标记
mutexWaiterShift = 2 // 表示 Waiter 域为 state 变量的第 2 位及其更高位
)

  因此 Lock()
方法应当如下所示:

func (m *Mutex) Lock() {
isNewcomer := true //是新到来的协程
old := m.state
for {
new := old
new += 1 << mutexWaiterShift//新增自己,等待者数量加一

if isNewcomer {
//更新Waiter域
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//发现已有等待者
//则自己加入队尾休眠
if old>>mutexWaiterShift != 0 {
runtime_SemacquireMutex(&m.sema, false, 1) //false 表示加入队尾,true 表示加入队头

}
//若无等待者,则无需加入队尾
//或者已从队中唤醒
} else {
old = m.state
continue
}
}

isNewcomer = false //不再是新到来的协程

new |= mutexLocked //尝试加锁

//更新Locked域
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//更新成功
//并且原来Locked域未锁定
//则说明当前协程抢锁成功
if old&mutexLocked == 0 {
break
}
runtime_SemacquireMutex(&m.sema, false, 1) //false 表示加入队尾,true 表示加入队头
} else {
old = m.state
}
}
}

  此处的 atomic.CompareAndSwapInt32
原子操作确保了只会有一个协程成功调用返回 true
。在 Lock() 方法中可以看到,每个新到来的协程在抢锁前都要先检查全局是否还存在其他等待者。如果存在,则该新到来的协程需要加入队尾休眠;如果不存在其他等待者,则才可以参与抢锁。抢锁时,将会尝试把 Locked 域置一,并将该更新动作记录于 new
变量中,以 CAS 方式更新回 Mutex.state
成员。因此,协程之间的抢锁行为,实质上是在抢占给 Locked 域赋值的权利。

  CAS 成功后,再对原先的 Locked 域进行检查。如果发现原先的 Locked 域为零,那么说明本次 CAS 动作成功抢锁,然后直接 break
退出;否则该协程将会调用 runtime_SemacquireMutex
函数,加入阻塞队列的队尾进行休眠。一旦持锁协程解锁,Mutex.sema
信号量会唤醒队列头部的休眠协程,让其参与抢锁。

  另外,持锁协程的解锁过程如下所示,先将 Locked 域清零,并检查全局是否存在等待者。若无等待者或者锁已经被抢占,则无需做唤醒操作,直接返回;若有等待者,将等待者数量减一,然后 CAS 更新 Waiter域 ,若成功更新,则唤醒阻塞队列的头部协程去进行抢锁:

func (m *Mutex) UnLock() {
new := atomic.AddInt32(&m.state, -mutexLocked) // Locked域清零
if new != 0 { // Locked域清零后,m.state仍不为零,说明有等待者
old := new
for {
// 全局没有等待者,无需唤醒任何协程
if old>>mutexWaiterShift == 0 {
return
}

if old&mutexLocked != 0 {
//锁已经被抢占
return //无需再唤醒休眠者起来抢锁
}

//全局存在等待者
//并且锁没被抢占
//那么即将做唤醒操作
//等待者被唤醒后必然会抢走锁
//因此等待者数量将会减少一个
new = old - 1<<mutexWaiterShift
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1) //唤醒队头协程
return
}
old = m.state
}
} // Locked域清零后,若m.state为零,说明无等待者,因此无需做唤醒操作

  1.0 版本的互斥锁设计方案只考虑实现了基本功能,性能方面十分欠缺。新到来的协程都要先检查全局是否存在等待者,如果存在,则自己只能严格按照 FIFO 顺序加入阻塞队列,等待解锁信号。新人不可插队,所有的等待者均会入队休眠,并且每次只会唤醒一个队头协程去抢锁。在这种抢锁方式下,需要频繁地执行相关系统调用来阻塞与唤醒协程,会使程序在用户态与内核态之间频繁切换,因此这种抢锁方式效率低下。

2.0 版本(Woken域、新人优先)

  如果能让新到来的协程和刚被唤醒的队头协程一起参与抢锁,而非立即将新到来的协程加入阻塞队列队尾,理论上提可以让 Mutex
更快被抢走 ,可以减少唤醒协程的次数,加速 Mutex
的流转,提高程序的并发量,带来更优的性能。
因此,在 2.0 版本中,我们让新到来的协程可以和刚被唤醒的队头协程一起参与抢锁,而非严格按照 FIFO 排队。

  由于唤醒的操作比较耗费性能,并且每次抢锁只会有一个协程获取锁,理论上不需要唤醒太多协程。每次执行的唤醒操作时最好只唤醒一个协程,并且如果已存在唤醒态的等待协程,则不需要再执行唤醒操作。因此,在 2.0 版本中,我们扩展了 Mutex.state
成员的功能,将 state
变量的第 2 位称为 Woken 域,用于标记全局中是否存在唤醒态的等待协程,0 表示全局不存在唤醒态的等待协程,1 表示全局已存在唤醒态的等待协程 。解锁时可以通过检查 Woken 域的状态,来决定是否执行唤醒操作。

  另外,state
的剩余 30 位依旧用作 Waiter 域

  由于 2.0 版本引入了对唤醒态协程存在与否的记录:Woken 域。因此我们需要新增一个 mutexWoken 常量来标识 Woken 域的存在唤醒态等待者的标记:

const (
mutexLocked = 1 // 表示 Locked 域的锁定状态标记
mutexWoken = 2 // 表示 Woken 域的存在唤醒态等待者的标记
mutexWaiterShift = 2 // (state右移两位)表示 Waiter 域为 state 变量的第 3 位及其更高位
)

  根据上述思想,我们写出了 2.0 版本的加解锁方法:

func (m *Mutex) Lock() {
awoke := false //当前协程的唤醒状态
old := m.state
for {

//当前协程还没阻塞入队,因此全局至少已知存在当前协程这么一个唤醒态协程,因此我们希望将 Woken 域置一,以告知解锁时无需做唤醒操作。
//如果全局没有唤醒态协程 但等待者数量不为零,说明其他等待者们都正在休眠
//则将 Woken 域置一
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true //全局目前自己是第一个唤醒态等待者,其他等待者都在休眠中,作标记以便使程序知道已有唤醒态等待者,解锁时无需再做唤醒操作
}
new := old

new |= mutexLocked //尝试加锁

if old&mutexLocked != 0 {
//如果锁已被抢占,自己也成为等待者
new += 1 << mutexWaiterShift
}

if awoke {
//自己全局第一个唤醒态等待者,可能还有第二、第三...个唤醒态等待者,以及休眠中的等待者
//抢锁成功的等待者会退出,抢锁失败的等待者会休眠
//无论哪种情况,届时全局都将不再有任何处于唤醒态的等待者了
//因此需要清零Woken域,告知解锁者做唤醒操作
new &^= mutexWoken
}

//更新Locked域和Woken域
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//更新成功
//并且原来Locked域未锁定
//则说明当前协程抢锁成功
if old&mutexLocked == 0 {
break
}
runtime_SemacquireMutex(&m.sema, false, 1) //false 表示加入队尾,true 表示加入队头

//当前协程被唤醒
//此时全局必定只有当前一个唤醒态协程
//因为如果还有其他唤醒态协程的话,解锁者是不会进行唤醒操作的
awoke = true
} else {
old = m.state
}
}
}

  在上述代码中,为了让新到来的协程也能参与抢锁而非入队休眠,我们移除了 1.0 版本中直接让新到来协程入队休眠的代码逻辑。

  由于当前协程刚刚到来,还未阻塞入队,全局至少存在当前协程这么一个唤醒态等待协程,因此我们将 Woken 域置一,以便告知持锁者在解锁时无需再做唤醒操作。

  倘若 Woken 域已被置一,即说明全局中已存在其他唤醒态的等待协程,解锁者也无需再做唤醒操作。因此上述代码做了对 Woken 域的检查判断: old&mutexWoken == 0

  倘若全局没有其他等待者,只有当前一个协程,那么持锁者即当前线程,其只管解锁即可,也无需再做唤醒操作。因此上述代码做了对 Waiter 域的检查判断:old>>mutexWaiterShift != 0

  综上所述,因此如下这段语句的意思就是,除了当前协程以外的其他等待者们都在休眠中,目前全局中自己是第一个处于唤醒态的等待者。后续还可能到来第二、第三...个唤醒态等待者,因此第一个唤醒态等待者需要将 Woken域置一,使程序知道已有唤醒态等待协程,解锁时无需再做唤醒操作。

if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}

  接下来,和 1.0 版本一样,作尝试 CAS 更新,并在更新后检查抢锁是否成功。抢锁成功的等待者会退出,抢锁失败的等待者会休眠。无论哪种情况,届时全局都将不再有任何处于唤醒态的等待者了,因此第一个唤醒态等待者还需 CAS 清零 Woken 域,以便告知解锁者做唤醒操作。

  2.0 版本中引入了 Woken 域,用于标识目前全局是否存在唤醒态协程;引入了 awoke
变量,用于标识当前协程是否是目前全局第一个唤醒态协程
。我们只需要让第一个唤醒态协程更新全局的 Woken 域状态,即可告知解锁者是否需要做唤醒操作。

  同样地,我们写出了如下的解锁方法:

func (m *Mutex) UnLock() {
new := atomic.AddInt32(&m.state, -1) // Locked域清零
if new != 0 {
old := new
for {
// 全局没有等待者,无需唤醒任何协程
if old>>mutexWaiterShift == 0 {
return
}

//全局中已存在唤醒态等待者
//或者锁已经被抢占
if old&(mutexLocked|mutexWoken) != 0 {
return //无需再唤醒休眠者起来抢锁
}

//全局存在等待者
//并且所有等待者均处于休眠
//并且锁没被抢占
//那么即将做唤醒操作
//某等待者被唤醒后必然会抢走锁
//因此等待者数量将会减少一个
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {//更新Waiter域和Woken域
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
}//Locked域清零后,若m.state为零,说明无等待者,因此无需做唤醒操作
}

  与 1.0 版本的 UnLock()
方法相比,我们新增了对全局是否存在唤醒态等待者的判断(对 Woken域的检查),因为在 2.0 版本中,等待者既可能处于休眠态,又可能处于唤醒态。如果全局中存在唤醒态等待者,那么也无需再唤醒其他休眠者起来抢锁。 如果全局所有等待者均处于休眠,那么此时的 Woken 域为零,我们唤醒一个休眠者后,需要将 Woken 域置一,即以上代码中的 | mutexWoken
操作。

  2.0 版本中每个新到来的协程都有一次直接参与抢锁的机会,即“新人可以插队”,这样可以加快 Mutex
的流转 。抢锁失败后,才会再入队休眠。另外,还维护了一个 Woken 域,用于记录全局中是否存在唤醒态的等待者。解锁时会检查 Woken 域,如果存在唤醒态的等待者,则不再做唤醒操作。这样一来,通过使用 Woken 域,降低了唤醒操作的频率,节约了性能开销。

3.0 版本(自旋)

  2.0 版本虽然一定程度上减少了唤醒操作,但是 2.0 版本仍然存在问题:唤醒态协程在抢锁时只会执行一次抢锁操作,如果此次抢锁时发现锁还未释放或者已被抢走,则该唤醒态协程就需要入队休眠了。考虑如下两种情形:

  • 对于新来到的协程,其有一次抢锁机会,抢锁失败就会入队休眠,这还算合理;
  • 对于排队良久、刚被唤醒的协程,其也只有一次抢锁机会,抢锁失败就会再次入队休眠。因此可能会有协程每次都抢不到锁,反复重回队尾,造成严重的饥饿问题,这种情况就相当不合理。

  因此,在 3.0 版本中,我们引入自旋机制。唤醒态协程在抢锁时,如果发现还未释放,就会自旋等锁,而非立即入队休眠。自旋的次数是有上限的,我们设置上限为 4 次。也就是说,我们给每个唤醒态协程 4 次检查锁状态的机会,如果 4 次检查都发现锁未释放,那么它只能进入队尾休眠。如下为 3.0 版本的 Lock()
方法:

func (m *Mutex) Lock() {
awoke := false
iter := 0
old := m.state
for {
//Locked域为一,mutex 已锁定,则自旋
if old&mutexLocked == mutexLocked && runtime_canSpin(iter) {

//检查当前协程是否是目前全局唯一的那个唤醒态协程
//若是,则将 Woken 域置一
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

new |= mutexLocked //尝试加锁

if old&mutexLocked != 0 {
//如果锁已被抢占,自己也成为等待者
new += 1 << mutexWaiterShift
}

if awoke {
//自己是全局第一个唤醒态等待者,可能还有第二、第三...个唤醒态等待者,以及休眠中的等待者
//抢锁成功的等待者会退出,抢锁失败的等待者会休眠
//无论哪种情况,届时全局都将不再有任何处于唤醒态的等待者了
//因此需要清零Woken域,告知解锁者做唤醒操作
new &^= mutexWoken
}

//更新Locked域和Woken域
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//更新成功
//并且原来Locked域未锁定
//则说明当前协程抢锁成功
if old&mutexLocked == 0 {
break
}
runtime_SemacquireMutex(&m.sema, false, 1) //false 表示加入队尾,true 表示加入队头

//当前协程被唤醒
//此时全局必定只有当前一个唤醒态协程
//因为如果还有其他唤醒态协程的话,解锁者是不会进行唤醒操作的
awoke = true
} else {
old = m.state
}
}
}

  如上述代码所示,我们在 2.0 版本基础上,Lock()
方法仅新增了决定是否自旋的代码:如果锁还未释放,则进入自旋等待。runtime_canSpin
函数是用来判断自旋次数是否用完的。 3.0 版本的 UnLock()
方法和 2.0 版本一致,此处不再展示。

  3.0 版本给予了每个唤醒态协程多几次的抢锁机会,延长了唤醒态协程的“清醒时间”,降低了协程阻塞休眠的频率,同样也会减少唤醒操作的次数,节约了性能开销。

4.0 版本(Starving 域)

  在3.0 版本中,如果某个协程自旋后仍未能抢到锁,那么它依然会入队休眠。在极端情况下,如果某个协程反复自旋与排队,还是未能抢到锁,同样会引起饥饿问题。

  为了解决这种饥饿问题,我们推出 4.0 版本,再次扩展 Mutex.state
成员的功能,将 state
变量的第 3 位称为 Starving 域,用于标记 Mutex
是否处于饥饿模式(即全局中是否存在饥饿协程),0 表示不处于饥饿模式,1 表示处于饥饿模式;将 state
的剩余 29 位依旧用作 Waiter 域

  我们将会测量每个协程的排队休眠时长。如果某个协程的休眠时长大于 1 ms,那么该协程即为饥饿协程,并且会将 Starving 域置一,表示 Mutex
转变为饥饿模式。

  由于 4.0 版本引入了对 Mutex
是否处于饥饿模式的记录,因此我们需要新增一个 mutexStarving 常量来标识 Starving 域的饥饿状态:

const (
mutexLocked = 1 // 表示 Locked 域的锁定状态标记
mutexWoken = 2 // 表示 Woken 域的存在唤醒态等待者的标记
mutexStarving = 4 // 表示 Starving 域的是否处于饥饿模式的标记
mutexWaiterShift = 2 // (state右移两位)表示 Waiter 域为 state 变量的第 3 位及其更高位
)

  对于那些休眠被唤醒后仍然抢锁失败的协程,我们将会把它直接放入队头,以防它发生饥饿。也就是说,协程第一次入队休眠是加入队尾,第二次以后的入队休眠是加入队头。

  另外,在 Mutex
饥饿状态下,我们将实施“战时共产主义”,不再让唤醒态协程们自由地竞争,而是实行“计划经济”:

  • 将唤醒信号从解锁者直接发送给队头协程;
  • 唤醒态协程不能自旋,不能竞争抢锁,严格按照 FIFO 顺序加入队尾。

  如下所示,我们在 3.0 版本的基础上,新增了 4.0 版本的优化思想:

func (m *Mutex) Lock() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
//Locked域为一,mutex 已锁定,则自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {

//检查当前协程是否是目前全局唯一的那个唤醒态协程
//若是,则将 Woken 域置一
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

// 正常模式下尝试加锁
if old&mutexStarving == 0 {
new |= mutexLocked
}

if old&(mutexLocked|mutexStarving) != 0 {
//如果锁已被抢占,自己也成为等待者
//如果为饥饿模式,不能抢锁,自己也成为等待者
new += 1 << mutexWaiterShift
}

//如果当前协程饥饿,且锁被抢占
//则将Starving域置一,使mutex转为饥饿模式
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}

if awoke {
//自己是全局第一个唤醒态等待者,可能还有第二、第三...个唤醒态等待者,以及休眠中的等待者
//抢锁成功的等待者会退出,抢锁失败的等待者会休眠
//无论哪种情况,届时全局都将不再有任何处于唤醒态的等待者了
//因此需要清零Woken域,告知解锁者做唤醒操作
new &^= mutexWoken
}

//更新Locked域和Woken域
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//更新成功
//如果mutex原来是正常模式,则会将Locked域置一,尝试加锁
//并且如果mutex原来未锁定,则当前协程抢锁成功
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 // 休眠时间大于1ms,则为饥饿
old = m.state

//当前协程被唤醒后,发现mutex处于饥饿模式
//则直接即可获得锁,使用AddInt32加锁。
//当前协程被唤醒后,发现mutex处于正常模式
//则回到上面代码,使用 CAS 竞争抢锁。
if old&mutexStarving != 0 {
delta := int32(mutexLocked - 1<<mutexWaiterShift)//Locked域置一,等待者数量减一
atomic.AddInt32(&m.state, delta)
break
}

//当前协程不饥饿或全局只有一个等待者
//则恢复正常模式
if !starving || old>>mutexWaiterShift == 1 {
delta -= mutexStarving //Starving域清零
}

//当前协程被唤醒
//此时全局必定只有当前一个唤醒态协程
//因为如果还有其他唤醒态协程的话,解锁者是不会进行唤醒操作的
awoke = true
} else {
old = m.state
}
}
}

  与 3.0 版本相比,我们多出了对 Starving域的更新。如果当前协程饥饿,并且锁已被抢占,则需要将 Starving域置一,表示 Mutex
转为饥饿模式。

  按照 4.0 版本的“计划经济”思想,我们在尝试加锁之前,需要先检查 Mutex
是否处于饥饿模式,若是,则不能进行加锁操作。而且现在不但锁未释放,协程需要入队休眠,Mutex
饥饿模式下协程也需入队休眠。

  “计划经济”虽然可以公平、有序地解决协程的饥饿问题,但是所有协程都需要被挂起,也都需要被唤醒。不但减慢了锁的流转、降低了程序的吞吐量,而且频繁的系统调用极大增加了性能开销。因此,我们还需要及时结束“计划经济”制度,恢复自由竞争的“市场经济”,让协程们重新自旋起来,自己去抢锁,而不是挂起等信号量。在 4.0 版本中,我们增加了如下代码来消除 Mutex
的饥饿模式:

if !starving || old>>mutexWaiterShift == 1 {
delta -= mutexStarving //Starving域清零
}

  此处我们重点看一下,为什么“如果当前协程不饥饿,那么就消除 Mutex
的饥饿模式”?

  如果当前协程不饥饿,那么就消除 Mutex
的饥饿模式。即使后续哪个协程再发生饥饿,那该协程可以自行更新 Starving域,让 Mutex
重新转为饥饿模式。

  这种策略下,可以尽可能减短 Mutex
处于饥饿模式的时间,也不会影响其他饥饿协程的公平性:因为如果某个协程唤醒后,发现自己饥饿,但是Mutex
处于正常模式,在正常模式下该协程可以直接抢锁,无需再次入队;由于其已经进行过休眠,即使再次入队也是加入队头,下次可以被解锁者点对点直接唤醒。

  4.0 版本在解锁时,也要区分 Mutex
饥饿模式与正常模式。正常模式下,解锁逻辑退化为 3.0 版本。饥饿模式下,直接发送唤醒信号给队头协程即可。4.0 版本的 UnLock()
方法如下所示:

func (m *Mutex) UnLock() {
new := atomic.AddInt32(&m.state, -1) // Locked域清零
if new != 0 {
if new&mutexStarving == 0 {
old := new
for {
// 全局没有等待者,无需唤醒任何协程
if old>>mutexWaiterShift == 0 {
return
}

//全局中已存在唤醒态等待者
//或者锁已经被抢占
//由于上面已作判断,所以此处Starving域为零
if old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return //无需再唤醒休眠者起来抢锁
}

//全局存在等待者
//并且所有等待者均处于休眠
//并且锁没被抢占
//那么即将做唤醒操作
//某等待者被唤醒后必然会抢走锁
//因此等待者数量将会减少一个
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {//更新Waiter域和Woken域
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
//如果mutex为饥饿模式,则唤醒队头协程
runtime_Semrelease(&m.sema, true, 1)
}
}//Locked域清零后,若m.state为零,说明无等待者,因此无需做唤醒操作
}

总结

  至此,我们通过 4 个版本的迭代,得到了 4.0 版本的 Mutex
互斥锁代码。现在我们简单回顾一下这 4 个版本的优化过程:

  1. 在 1.0 版本中,引入严格 FIFO 阻塞队列,让协程排队等锁,先到先得,不争不抢;
  2. 在 2.0 版本中,移除了严格 FIFO 先到先得的规则,新人不用排队可以先与刚唤醒的协程一起抢锁;引入 Woken 域,记录了全局是否存在唤醒态协程,如果已存在唤醒态协程,则解锁者不再进行唤醒操作;
  3. 在 3.0 版本中,引入自旋机制,唤醒态协程有多次抢锁机会;
  4. 在 4.0 版本中,照顾了多次排队仍未抢到锁的协程,让其直接加入队头。引入 Starving 域,记录了 Mutex
    是否处于饥饿模式,饥饿模式下实行“计划经济”:不能自由抢锁与自旋,必须入队等待唤醒。最大程度地保障了抢锁的公平性。

  本篇文章中的互斥锁 4.0 版本便是 Go 语言标准库的设计方案。由于 Go 语言 Mutex
的设计精妙、状态流转十分复杂,因此本篇文章通过逐一拆解、迭代优化的方式解读了 Go Mutex
的源码。


文章转载自架构狂人,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论