Go 动手实操来了解Mutex互斥锁原理

503 阅读6分钟

前言

Mutex互斥锁是并发中最常见的锁,本篇分析下Mutex的运行机制,并通过测试解读源码。

锁结构

Mutex锁源码结构如下:

type Mutex struct {
   state int32
   sema  uint32
}

Mutex结构体简单得令人惊讶,仅用两个字段就能实现互斥锁?其实里面暗藏玄机,图解一下:

image.png

  • state是int32类型,即有32位。

    • 第1位比特用于Locked,1表示Mutex被锁上了,0表示没被锁上。
    • 第2位比特用于Woken,1表示有协程从等待队列中被唤醒了,0表示没有协程被唤醒。
    • 第3位比特用于Starving,1表示饥饿模式,0表示正常模式,后面讲两者不同处。
    • 剩下的29位都用于WaiterShift计数,表示等待协程的数量有多少。
  • sema是一个信号量,将没拿到锁的协程放入等待队列。具体实现可看Go 底层锁:原子操作和sema信号量

运行机制

Mutex分两种模式,正常模式饥饿模式。一般情况下都是正常模式,只有某个协程等待时间超过1ms了,不公平了,才会变为饥饿模式,直接让该饥饿协程获得锁。

正常模式

Mutex.Lock 加锁

  • 协程间互相竞争锁
  • 某协程得不到锁后,可能会自旋几次,类似加载中转圈圈。目的是小等一会,期间其它协程释放锁了就趁机获得锁
  • 多次尝试失败,就进入sema队列休眠

图解一下:

首先有多个协程竞争锁

image.png

某个协程得到锁了,其余协程自旋

image.png

自旋期间,有可能第一个协程释放锁,随之第二个协程竞争得到锁,而第三个协程自旋几次得不到锁,就放入sema等待队列休眠

image.png

Mutex.Unlock 解锁

  • 先解锁,将state的Locked位赋值为0
  • 从等待队列队头中唤醒一个协程,唤醒的协程还得竞争锁,如果竞争不到继续放入等待队列,该协程很可能会饥饿。

饥饿模式

有协程饥饿了,为了兼顾公平,优先处理等待队列的协程。

Mutex.Lock 加锁

  • 当有协程等待时间超过1ms,等太久不公平了,进入饥饿模式
  • 饥饿模式下,新来的协程不需要自旋了,直接进入sema队列队尾
  • Mutex.Unlock唤醒等待队列中唤醒的协程后,该协程将直接获得锁
  • 直到等待队列中没有协程了,才切换回正常模式

图解一下:

首先等待队列中有协程,同时有个新协程打算竞争锁。

image.png

将等待队列出队,把队头的sudog封装的协程唤醒,该协程直接获得锁。新进入的协程直接加入等待队列队尾。

image.png

image.png

直到sema等待队列出队完了,Mutex才切换回正常模式。

Mutex.Unlock 解锁

  • 先解锁,将state的Locked置为0
  • 从等待队列队头中唤醒一个协程,该协程在唤醒后会直接加锁

开始实操

调试是阅读源码的好方法,以下通过调试测试来解读源码。

测试环境 -- Go版本:1.20,cpu核数:6。

const (
   mutexLocked = 1 << iota // 值为1
   mutexWoken // 值为2
   mutexStarving // 值为4
   mutexWaiterShift = iota // 值为3
)   

以上常量将在源码中使用。

单线程竞争锁

最简单情况,只有一个线程在竞争锁。

package main

import (
   "sync"
   "time"
)

func easyMode() {
   m := new(sync.Mutex)
   m.Lock()
   m.Unlock()
}

func main() {
   easyMode()
   time.Sleep(time.Minute)
}

easyMode函数将Mutex加解锁,在m.Lock()这一行打上断点,调试查看运行流程:

先加锁

image.png

还记得state各个位含义吧

image.png

没有等待者,没有加锁,因此state赋值为1,即第一位Locked为1,代表加锁,然后返回。

后解锁

image.png

正常模式

锁开始被占用,后来解锁

测试代码如下:

package main

import (
   "sync"
   "time"
)

func normalMode() {
   m := new(sync.Mutex)
   go func() {
      m.Lock()
      time.Sleep(time.Second * 15)
      m.Unlock()
   }()
   time.Sleep(time.Second)
   m.Lock()
   m.Unlock()
}

func main() {
   normalMode()
   time.Sleep(time.Minute)
}

normalMode函数中,先让协程获得锁,并持有15s再解锁。对主线程来说,锁一开始被占用,后来解锁了,这会有什么反应呢?我们给m.Lock()处加上断点,开始运行。

主线程进入m.Lock()image.png 此时state被协程加锁,值为1。而该线程就无法加锁,进入慢加锁阶段。

进入m.lockSlow函数后,初始化一些变量,如old = m.state = 1,然后开始for循环。 image.png

  • old&(mutexLocked|mutexStarving) == mutexLocked 这段是什么意思呢?

目的是判断old是否处于不饥饿且加锁状态。old是一个32位数值(回想下state每个位意义),第三位为0就不饥饿,第一位为1就加锁。显然m = 1处于不饥饿且加锁状态。

  • runtime_canSpin(iter)返回false,因此主线程不自旋。

判断不自旋后,新建一个变量new = old,new会根据情况改变值,后面可能用new覆盖state的值。继续执行: image.png 该截图操作如下:

  • 判断old不处于饥饿状态,new |= mutexLocked,new加锁了
  • 判断old处于加锁或饥饿状态(old=1处于加锁状态),让new += 1 << mutexWaiterShift,即线程即将休眠,等待者数量加一。

注意之前加锁的协程已经解锁了,state=0了。

改变完new状态后继续往下看。 image.png atomic.CompareAndSwapInt32(&m.state, old, new)返回false,因为state状态改变了,和old不相等了。

于是赶紧将old重新赋值为state。

同时state的改变意味着new失效了。于是进入下一轮for循环。

新一轮循环

重新声明new,并给new上锁。

new := old
if old&mutexStarving == 0 {
   new |= mutexLocked
}

此时state=old,将state赋值为new,于是该线程获得了锁。

之后发现old不处于加锁或者饥饿状态,break跳出循环。

if atomic.CompareAndSwapInt32(&m.state, old, new) {
   if old&(mutexLocked|mutexStarving) == 0 {
      break // locked the mutex with CAS
   }
   ...
} else {
   old = m.state
}

小结

如果锁在某时刻被解锁了,那么竞争锁的协程在下一轮循环中获得锁。

锁一直被其它协程占用

测试代码如下:

package main

import (
   "sync"
   "time"
)

func normalMode() {
   m := new(sync.Mutex)
   go func() {
      m.Lock()
      time.Sleep(time.Minute)
      m.Unlock()
   }()
   time.Sleep(time.Second)
   m.Lock()
   m.Unlock()
}

func main() {
   normalMode()
   time.Sleep(time.Minute)
}

此时协程先获得锁,然后休眠一小时。在m.Lock()处打断点,开始调试。

具体流程为:

Lock() -> lockSlow() -> 进入for循环 -> runtime_SemacquireMutex()

最后进入runtime_SemacquireMutex休眠等待。直到被唤醒后,假设处于正常模式,就会进入下一轮循环,继续去竞争锁。

当然,如果仍竞争不到锁,又会陷入休眠。如此反复,就出现协程饥饿问题。怎么解决呢?那就从正常模式切换为饥饿模式,饥饿模式下该协程被唤醒后直接获得锁。

小结

如果锁一直被占用,那么竞争的协程就会进入sema队列休眠等待。正常模式下被唤醒会进入下一轮循环竞争锁。

饥饿模式

测试代码如下:

package main

import (
   "sync"
   "time"
)

func hungryMode() {
   m := new(sync.Mutex)
   go func() {
      m.Lock()
      time.Sleep(time.Hour)
      m.Unlock()
   }()

   for i := 0; i < 10; i++ {
      go func() {
         m.Lock()
         time.Sleep(time.Minute)
         m.Unlock()
      }()
   }

   time.Sleep(time.Second)
   m.Lock()
   m.Unlock()
}

func main() {
   hungryMode()
   time.Sleep(time.Minute)
}

首先让一协程获得锁,然后开多个协程去竞争锁,让锁处于饥饿模式。再通过主线程竞争锁查看运行过程。

具体流程为:

Lock() -> lockSlow() -> 进入for循环 -> runtime_SemacquireMutex()

最后进入runtime_SemacquireMutex休眠等待。直到唤醒后,此时处于饥饿模式,会执行后续代码:

starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
if old&mutexStarving != 0 {
   if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
      throw("sync: inconsistent mutex state")
   }
   delta := int32(mutexLocked - 1<<mutexWaiterShift)
   if !starving || old>>mutexWaiterShift == 1 {
      delta -= mutexStarving
   }
   atomic.AddInt32(&m.state, delta)
   break
}
  • 该协程等待时间超过1ms,将starving置为true

  • 如果old处于饥饿模式,则计算一下state的变化值delta,然后将state加上delta,看下这一过程有什么意义:

    • delta := int32(mutexLocked - 1<<mutexWaiterShift),将delta加锁,并将等待者数量减一。
    • delta -= mutexStarving,如果不饥饿或者等待者数量只剩一个了,则delta减去mutexStarving,切换回正常模式。
    • state加上delta,意味着state加锁了,等待者数量减一了,可能切换回正常模式了。
    • 获得了锁,break退出循环。

小结

饥饿模式下,新协程直接进入休眠等待。被唤醒后直接获得锁。

扩展

为什么正常模式下测试代码没进入自旋流程?

进入自旋还需要runtime_canSpin(iter)函数返回true才行。

runtime_canSpin链接到sync_runtime_canSpin函数。源码不再展开,通过注释分析。

如果有很多空闲处理器P的话,协程就不会进入自旋,因为会浪费cpu资源。还不如休眠等待,在唤醒时被空闲的处理器P调度。

如果处理器P很忙的话,协程休眠被唤醒后,会来不及调度,所以这时协程自旋不休眠好些。

因此为了进入自旋流程,我们对代码改动下:

package main

import (
   "sync"
   "time"
)

func normalMode() {
   m := new(sync.Mutex)
   go func() {
      m.Lock()
      time.Sleep(time.Hour)
      m.Unlock()
   }()

   for i := 0; i < 100; i++ {
      go func(i int) {
         c := time.Tick(time.Millisecond)
         for next := range c {
            fmt.Printf("%v\n", next)
         }
      }(i)
   }
   time.Sleep(time.Second)
   m.Lock()
   m.Unlock()
}

func main() {
   normalMode()
   time.Sleep(time.Minute)
}

我们开一百个协程,每个协程都有个定时任务,以此让处理器P忙碌起来。

再次调试即可进入自旋流程中。

总结

互斥锁有两种操作模式:正常模式和饥饿模式。

在正常模式下,等待者按照先进先出的顺序排队,但是被唤醒的等待者不拥有互斥锁,与新到达的goroutine竞争所有权。新到达的goroutine有优势——它们已经在CPU上运行,并且可能有很多,因此被唤醒的等待者很有可能失败。在这种情况下,它将排在等待队列的前面。如果等待者未能在1毫秒内获取互斥锁,则它将切换到饥饿模式。

在饥饿模式下,互斥锁的所有权直接从解锁的goroutine移交给队列前面的等待者。即使互斥锁看起来已经解锁,新到达的goroutine也不会尝试获取互斥锁,也不会尝试自旋。相反,它们会将自己排队在等待队列的尾部。

如果等待者获得互斥锁的所有权并发现:

  • 它是队列中的最后一个等待者
  • 它等待时间少于1毫秒

则等待把互斥锁切换回正常操作模式。

正常模式具有相当好的性能,因为goroutine即使有被阻塞的等待者,也可以连续多次获取互斥锁。饥饿模式对于防止尾延迟的病态情况非常重要。

结语

千言万语不如自己动手实践。觉得文章写得不错的,请点个赞,谢谢!