Go sync.Mutex 深入不浅出

4,432 阅读18分钟

一、基础概念

1.1、什么是 Mutex

Mutex(mutual exclusion) 是一种同步原语,用于多线程/并发环境下,限制对临界资源的访问,以达到不同线程互不影响地操作同一份资源。这块被保护起来的临界资源叫做临界区(critical section)。

在 golang 中,Mutex 就是一种互斥锁。需要访问临界资源的时候,执行 Lock() 进行加锁;其它请求锁的 goroutine 就会阻塞在 Lock() 方法的调用上;执行 Unlock() 会进行解锁,解锁后其他 goroutine 将以一定的规则进行锁抢占。而关于如何抢占锁,就是 golang 在实现 Mutex 时,在效率和公平上的考究。

1.2、为什么需要锁

1、因为对变量的操作是非原子性的。比如自增操作 i++,变量 i 的读取、修改、写入,在底层 CPU 真正执行时,和内存的交互是两次,所以没法保证操作过程的原子性。
注意的是,读取操作也是无法保证原子性的,比如一般操作系统操作内存的最小粒度是一个机器字,内存对齐的情况下,在 32 位系统上读取一个 int64 就不可能是原子的,更别说如果内存是不对齐的情况。

此时,锁的作用就是设置信号、锁定总线,阻止其他处理器接管总线访问内存。

2、写下的代码顺序与实际执行的顺序可能并不一致。
这是因为有内存重排的存在。内存重排的原因在于:编译期间为了优化而重排代码顺序;系统底层为了执行效率修改了指令执行的顺序。

锁在这里的作用就是引入内存屏障(memory barrier),内存屏障是强制处理器按照可预知的方式访问内存的 CPU 指令。

二、源码解析

2.1、官方的说明 —— Mutex fairness

Mutex can be in 2 modes of operations: normal and starvation. In normal mode waiters are queued in FIFO order, but a woken up waiter does not own the mutex and competes with new arriving goroutines over the ownership. New arriving goroutines have an advantage -- they are already running on CPU and there can be lots of them, so a woken up waiter has good chances of losing. In such case it is queued at front of the wait queue. If a waiter fails to acquire the mutex for more than 1ms, it switches mutex to the starvation mode.
In starvation mode ownership of the mutex is directly handed off from the unlocking goroutine to the waiter at the front of the queue. New arriving goroutines don't try to acquire the mutex even if it appears to be unlocked, and don't try to spin. Instead they queue themselves at the tail of the wait queue.
If a waiter receives ownership of the mutex and sees that either (1) it is the last waiter in the queue, or (2) it waited for less than 1 ms, it switches mutex back to normal operation mode.
Normal mode has considerably better performance as a goroutine can acquire a mutex several times in a row even if there are blocked waiters. Starvation mode is important to prevent pathological cases of tail latency.

互斥锁有两种状态:正常状态和饥饿状态。
在正常状态下,所有等待锁的 goroutine 按照 FIFO 顺序等待。但是,刚唤醒(即出队)的 goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 去竞争锁。新请求锁的 goroutine 具有一个优势:它正在 CPU 上执行。而且可能有好几个 goroutine 同时在新请求锁,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 在没有获得锁之后会加入到等待队列的最前面。
如果一个等待的 goroutine 超过 1ms 没有获取锁,那么它将会把锁转变为饥饿模式。在饥饿模式下,锁的所有权将从执行 unlock 的 goroutine 直接交给等待队列中的第一个等待锁者。新来的 goroutine 将不能再去尝试竞争锁,即使锁是 unlock 状态,也不会去尝试自旋操作,而是放在等待队列的尾部。如果一个等待的 goroutine 获取了锁,并且满足以下其中一个条件:(1)它是队列中的最后一个;(2)它等待的时候小于1ms,那么该 goroutine 会将锁的状态转换为正常状态。
正常模式具有较好的性能,因为 goroutine 可以连续多次尝试获取锁,即使还有其他的阻塞等待锁的 goroutine,也不需要进入休眠阻塞。
饥饿模式也很重要的,它的作用是阻止尾部延迟的现象。

2.2、sync.Mutex 数据结构

type Mutex struct {
    state int32
    sema  uint32
}
const (
   mutexLocked = 1 << iota // mutex is locked
   mutexWoken
   mutexStarving
   mutexWaiterShift = iota
)

1、state 是一个共用的字段,低 3 位表示锁的三个标志位(是否加锁、唤醒、饥饿等状态),高 29 位表示等待锁的 goroutine 数量。

  • 第 0 个 bit 标记这个 Mutex 是否已被某个 goroutine 所拥有,值为 1 表示锁已经被某个 goroutine 占有。
  • 第 1 个 bit 标记这个 Mutex 是否已唤醒,即有某个唤醒的 goroutine 要尝试获取锁。
  • 第 2 个 bit 标记这个 Mutex 是否已处于饥饿状态。
  • state>>mutexWaiterShift 的值是等待锁的 goroutine 数量。代码中的锁等待者数量加/减一时,都要进行左移 mutexWaiterShift 位的操作,相当于是 state+/-8。

2、sema 是一个信号量,是用来实现阻塞/唤醒申请锁的 goroutine。

2.3、Lock

func (m *Mutex) Lock() {
   // 1. Fast path: grab unlocked mutex.
   if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
      if race.Enabled {
         race.Acquire(unsafe.Pointer(m))
      }
      return
   }

   // 2. Slow path (outlined so that the fast path can be inlined)
   m.lockSlow()
}

func (m *Mutex) lockSlow() {
   var waitStartTime int64  // 标记本goroutine的等待时间
   starving := false  // 本goroutine是否已经处于饥饿状态
   awoke := false // 本goroutine是否已唤醒
   iter := 0     // 自旋次数
   old := m.state  // 锁的当前状态
   
   for {
      // 3.自旋逻辑
      if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
         // 自旋的过程中如果发现state还没有设置woken标识,则设置它的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
      }

      // 此时可能:饥饿状态下,锁已释放、锁未释放;正常状态下,锁已释放、锁未释放
      // 本gorutine的 awoke 可能是true, 也可能是false (其它goutine也可能已经设置了state的woken标识)

      // 4. 用 new 来设置新的状态;old 是锁当前的状态
      new := old
      // 非饥饿模式,就可以尝试进行锁的获取
      if old&mutexStarving == 0 {
         new |= mutexLocked
      }

      // 将等待队列的等待者的数量加1,因为这时候当前协程只能进入等待队列了
      // 饥饿状态下直接sleep不用尝试获取锁
      if old&(mutexLocked|mutexStarving) != 0 {
         new += 1 << mutexWaiterShift
      }

      // 有锁且当前goroutine已经饥饿,那么就将 new state 改为饥饿模式
      if starving && old&mutexLocked != 0 {
         new |= mutexStarving
      }

      // 如果本goroutine已经设置为唤醒状态, 需要清除new state的唤醒标记, 因为本goroutine要么获得了锁,要么进入休眠
      if awoke {
         if new&mutexWoken == 0 {  // 如果没有自旋则awoke=false;如果自旋了则m.state一定标记了mutexWoken
            throw("sync: inconsistent mutex state")
         }
         new &^= mutexWoken
       }

      // 5. cas 设置 m.state
      if atomic.CompareAndSwapInt32(&m.state, old, new) { 

         // 6. 原来锁的状态已释放且无饥饿,则表示当前goroutine已经得到锁,直接 break
         if old&(mutexLocked|mutexStarving) == 0 {
            break // locked the mutex with CAS
         }

         // 第一次等待放入队尾;第n(>1)次等待放入队头;由 queueLifo bool 控制
         queueLifo := waitStartTime != 0
         // 设置当前时间,等等计算本goroutine的等待时间
         if waitStartTime == 0 {
            waitStartTime = runtime_nanotime()
         }

         // 通过队列的方式进行 sleep 阻塞
         runtime_SemacquireMutex(&m.sema, queueLifo, 1)
         
         // 7. 唤醒之后,计算当前goroutine是否已经处于饥饿状态;
         // const starvationThresholdNs = 1e6 判断标准为等待超过1ms
         starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs

         old = m.state
         // 如果是饥饿状态,则当前 groutine 直接获得锁
         if old&mutexStarving != 0 {
            // 如果是饥饿状态一定有 waiter、被唤醒后一定是无锁状态、一定是未 mutexWoken
            if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {  // 这里只是状态判断,我感觉没什么用
               throw("sync: inconsistent mutex state")
            }

            // delta是要设置的 mutexLocked;将等待的 goroutine 数减 1
            delta := int32(mutexLocked - 1<<mutexWaiterShift)

            // 如果本 goroutine 是最后一个等待者,或者并不处于饥饿状态,则把锁的 state 状态设置为正常模式
            if !starving || old>>mutexWaiterShift == 1 {
               delta -= mutexStarving
            }

            atomic.AddInt32(&m.state, delta)
            break // 获得锁
         }

         // 正常模式下,本goroutine被唤醒,自旋次数清零,从for循环开始处重新开始
         awoke = true  
         iter = 0
         
      } else { // 如果设置失败则继续 for 循环
         old = m.state
      }
   }

   if race.Enabled {
      race.Acquire(unsafe.Pointer(m))
   }
}

1、结合代码、注释,说一说代码的逻辑:
(a) 先说说所谓的“成功获取锁”,就是设置 mutext. state&1 + 退出 mutex.Lock() 函数
(c) 如果 mutex 没有任何状态(state=0),即没有被锁、没有等待/唤醒的 goroutine,那么就获得锁。比如锁第一次被 goroutine 请求时,就是这种状态;或者锁处于空闲的时候,也是这种状态。
(c) 进入 lockSlow() 逻辑,开始锁竞争,其实就是利用 for 循环不断地去进行重试
(d) 如何重试?如何抢锁?首先进入自旋操作,条件为:非饥饿模式、m.state 有锁状态、自旋次数小于4(runtime_canSpin)

  • 饥饿模式下,会直接把锁交给队列第一个 goroutine,所以当前 goroutine 不需要任何操作
  • m.state 有锁时,就会让 goroutine 进行自旋操作,去不断查询锁状态,即抢锁:一旦发现 m.state 未被上锁,就执行后续的逻辑(真正的获取锁操作)。可以想象,多个 goroutine 都是正执行着的时候(队列第一个 goroutine 出队时,以及刚执行 Lock 的 goroutine)会同时去竞争锁。这也造成了队尾不公平现象,因为队列第一个 goroutine 不一定拿到锁,所以需要利用饥饿模式去解决。
  • 自旋最大的问题就是浪费 cpu 时钟周期(自旋会占用一些 cpu),除非锁能很快获取到的。所以设置了最多自旋 4 次,且每次空转 30 个 cpu 时钟周期,以期能短期内获取锁,避免让 goroutine 长时间轮询空等待。也因此,自旋结束的时候,m.state 不一定是无锁状态。
  • 所以在自旋逻辑结束后,可能的场景有:饥饿状态下,锁已释放、锁未释放;正常状态下,锁已释放、锁未释放.

(e) 用 new 变量来设置新的状态;用 old 变量记录锁当前的状态

  • 如果 old state 状态不是饥饿状态,则将 new state 赋予 mutexLocked 状态,即尝试上锁(后面会进行 cas 操作,但是 cas 成功却不一定是抢锁成功)
  • 如果此时锁被其他 goroutine 占有或者饥饿模式,说明抢不到锁,就将等待队列的等待者的数量加 1
  • 锁被其他 goroutine 占有且当前 goroutine 已经饥饿,那么就将 new state 改为饥饿模式
  • 清除 new state 的 mutexWoken 标记。 为什么要清除标志位呢?因为当前 goroutine 要么获得了锁,要么进入休眠,两种情况都不再需要 woken 标记。

(f) cas 设置 new.state,如果设置失败,说明操作过程中有其他并发的 goroutine 修改了 old.mutex 值,所以继续 for 循环重试。设置成功则是下面的逻辑:
(g) 若原 old.state 未锁且无饥饿,此时 cas 成功,说明当前 goroutine 将 new.state 标记了 mutexLocked,即得到锁,可以直接 break 退出。
否则 old.state 已经有锁或者饥饿模式下无法抢占锁,将当前 goroutine 放入队列进行基于信号量的阻塞等待。如果是第一次进队需要放入队尾,如果非第一次,即 goroutine 被唤醒后再次 sleep 阻塞则放入队头,用 queueLifo 变量控制。
(h) goroutine被唤醒后,会先去判断是否饥饿并设置 mutexStarving 标识位(用等待锁的时间和常量 starvationThresholdNs 进行对比),然后:

  • 如果 m.state 是饥饿状态,则当前 groutine ,即第一个出队的,获得锁。并且会去更新 waiter 和 mutexStarving 标示。
  • 如果是正常模式,则重新开始自旋等抢锁行为。

2、为什么要把锁的饥饿模式改回正常模式?
因为饥饿模式下所有的 goroutine 没有自旋操作,直接进行 sleep 阻塞等待,存在较大的性能消耗。

3、未锁且无饥饿时可以抢到锁,这是在 runtime_SemacquireMutex 前判断;饥饿模式获取锁是在 runtime_SemacquireMutex 后判断,因为饥饿模式是唤醒 goroutine 后就开始发挥作用。

4、raceenabled 是用来判断是否开启数据竞争检测的。go build 或者 run 的时候,如果带上 -race 命令,raceenabled 就是true。

5、自问自省环节,看看学废多少:

  • 为什么要有饥饿模式?
  • 怎么描述 Mutex 锁的公平性?
  • 自旋操作的目的是?
  • 什么时候会 throw error?

2.3、Unlock

func (m *Mutex) Unlock() {
   if race.Enabled {
      _ = m.state
      race.Release(unsafe.Pointer(m))
   }

   // 1. drop lock bit.
   new := atomic.AddInt32(&m.state, -mutexLocked)
   if new != 0 {
      // Outlined slow path to allow inlining the fast path.
      // To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
      m.unlockSlow(new)
   }
}

func (m *Mutex) unlockSlow(new int32) {
   // 2. 如果原来的 m.state 不是处于锁的状态,那么 Unlock 一个没有加锁的mutex,会出现报错
   if (new+mutexLocked)&mutexLocked == 0 {
      throw("sync: unlock of unlocked mutex")
   }// 至此,m.state 状态是未锁状态

   // 3. 正常模式下
   if new&mutexStarving == 0 {
      old := new
      for {
         // 如果没有等待的goroutine, 或者锁不处于空闲的状态,直接返回.
         if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
            return
         }

         // 将等待的goroutine数减一,并设置woken标识
         new = (old - 1<<mutexWaiterShift) | mutexWoken

         // 通过信号量会唤醒一个阻塞的goroutine去获取锁
         if atomic.CompareAndSwapInt32(&m.state, old, new) {
            runtime_Semrelease(&m.sema, false, 1)
            return
         }

         old = m.state
      }
   } else {
      // 饥饿模式下,直接唤醒阻塞队列首位的 goroutine 即可
      runtime_Semrelease(&m.sema, true, 1)
   }
}

1、解锁执行流程:
(a) 通过原子操作 AddInt32(&m.state, -mutexLocked) 直接消除 mutexLocked 状态。如果 m.state!=0,说明有 goroutine 在等待/抢占锁,则进入 unlockSlow 逻辑
(b) Unlock校验:如果原来的 m.state 不是处于锁的状态,那么现在就是 Unlock 一个没有加锁的 mutex,会进行报错。
注意,此时 m.state 的状态是未锁状态
(c) 若处于正常模式下

  • 如果没有了等待的 goroutine,直接返回
  • 如果 mutex 是 mutexWoken 状态,直接返回。这会发生在新来的 goroutine 正在自旋时进行 Unlock,所以说等待唤醒的 goroutine 抢不过新来的 goroutine。
  • 如果 mutex 是 mutexLocked|mutexStarving 状态,这是什么情况下发生的???
  • 通过信号量会唤醒一个阻塞的 goroutine 去获取锁,因为唤醒了一个 goroutine,所以需要同时设置woken标识,并将等待的goroutine数减一。 (d) 若处于饥饿模式下,直接将锁的拥有权传给等待队列中的第一个。
    此时 state 有 mutexStarving 标识、无 mutexLocked 标识,而(Lock()代码中)被唤醒的 goroutine 会设置 mutexLocked 状态、将等待的 goroutine 数减 1。注意的是,并不会马上清除 mutexStarving 标识,而是一直在饥饿模式下操作,直到遇到第一个符合条件的 goroutine 才会清除 mutexStarving 标识。
    在饥饿模式期间,如果有新的 goroutine 来请求抢占锁,mutex 会被认为还处于锁状态,所以新来的goroutine 不会进行抢占锁操作。

2、unlockSlow->正常模式的逻辑下,设置 state 状态时,也是通过 CAS 不断重试进行的。这也是因为 Lock() 并发时,m.state 可能会被改变。

2.4、几个指令说明

  • runtime_SemacquireMutex:将 goroutine 置为 sleep 阻塞状态;阻塞顺序是 FIFO 逻辑;但是可以通过第二个 bool 参数控制入队头还是入队尾。
  • runtime_Semrelease:唤醒阻塞队列中的 goroutine;第二个 bool 参数控制了是否唤醒队头的 goroutine。
  • runtime_canSpin:判断当前 goroutine 是否可以自旋,目前是限制最多自旋四次。
  • runtime_doSpin:执行自旋操作。

2.5、分析几个case

1、正常模式下,解锁的 goroutineA 已经解除了 mutexLocked 状态(22行),上锁的 goroutineB 在自旋中。

此时 m.state&mutexLocked=0,goroutineB 自旋结束并设置 new|=mutexLocked,之后进行 CAS 操作。此时 m.state&mutexWoken!=0,goroutineA 会直接退出 Unlock 函数(28行)。而 goroutineB CAS 成功并得到锁。
如果上锁的 goroutineBs 并发有多个,那么只有一个能够 CAS 操作成功(即获取锁),其他的 goroutine 会重跑 for 循环逻辑。

2、正常模式下,解锁的 goroutineA 已经解除了 mutexLocked 状态(22行),上完锁的 goroutineB 正在 CAS 时(60行)。

如果 goroutineA->AddInt32(&m.state, -mutexLocked) 先于 goroutineB-> CompareAndSwapInt32(&m.state, old, new),那么 goroutineB CAS 失败。

如果后于,mutex 的 mutexWoken 状态就被清除了,那么 goroutineA 在 Unlock 中会唤醒一个阻塞的 goroutineC,唤醒的 goroutineC 会和新请求锁或正在自旋的 goroutine 一起抢占锁。而 goroutineB 进入阻塞。

3、饥饿模式下,解锁的 goroutineA 解除 mutex.mutexLocked 状态后,唤醒阻塞队列的第一个goroutineC(43行),上锁的 goroutineB 正在自旋。

goroutineB 其实不会进入自旋,因为饥饿模式。此时 goroutineB 增加等待者计数、清除 mutexWoken 状态后,就进入阻塞队列。
goroutineC 被唤醒后,会设置锁状态、减少等待者计数,如果满足条件就会清除饥饿状态。这里就没有再用 CAS 操作,而是直接 AddInt32,因为不会有竞争者。

4、goroutineA 一直不 Unlock,goroutineB 新请求锁。

goroutineB 结束自旋逻辑后(饥饿模式就不自旋),设置好对应的状态位后,进入阻塞等待。

5、无新请求锁的 goroutine,goroutineA 执行 Unlock。

goroutineA 唤醒一个等待者 goroutineC,如果是饥饿模式 goroutineC 直接获取锁,如果是正常模式 goroutineC 重新开始 for 循环逻辑。

三、注意的坑

1、不同 goroutine 可以 Unlock 同一个 Mutex,但是 Unlock 一个无锁状态的 Mutex 就会报错。

2、因为 mutex 没有记录 goroutine_id,所以要避免在不同的协程中分别进行上锁/解锁操作,不然很容易造成死锁。
建议: 先 Lock 再 Unlock、两者成对出现。

3、Mutex 不是可重入锁

Mutex 不会记录持有锁的协程的信息,所以如果连续两次 Lock 操作,就直接死锁了。

如何实现可重入锁?记录上锁的 goroutine 的唯一标识,在重入上锁/解锁的时候只需要增减计数。

type RecursiveMutex struct {
   sync.Mutex
   owner     int64 // 当前持有锁的 goroutine id // 可以换成其他的唯一标识
   recursion int32 // 这个 goroutine 重入的次数
}

func (m *RecursiveMutex) Lock() {
   gid := goid.Get()  // 获取唯一标识
   // 如果当前持有锁的 goroutine 就是这次调用的 goroutine,说明是重入
   if atomic.LoadInt64(&m.owner) == gid {
      m.recursion++
      return
   }
   m.Mutex.Lock()

   // 获得锁的 goroutine 第一次调用,记录下它的 goroutine id,调用次数加1
   atomic.StoreInt64(&m.owner, gid)
   m.recursion = 1
}

func (m *RecursiveMutex) Unlock() {
   gid := goid.Get()
   // 非持有锁的 goroutine 尝试释放锁,错误的使用
   if atomic.LoadInt64(&m.owner) != gid {
      panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
   }

   // 调用次数减1
   m.recursion--
   if m.recursion != 0 { // 如果这个 goroutine 还没有完全释放,则直接返回
      return
   }

   // 此 goroutine 最后一次调用,需要释放锁
   atomic.StoreInt64(&m.owner, -1)
   m.Mutex.Unlock()
}

4、多高的 QPS 才能让 Mutex 产生强烈的锁竞争?

模拟一个 10ms 的接口,接口逻辑中使用全局共享的 Mutex,会发现在较低 QPS 的时候就开始产生激烈的锁竞争(打印锁等待时间和接口时间)。

解决方式:首先要尽量避免使用 Mutex。如果要使用 Mutex,尽量多声明一些 Mutex,采用取模分片的方式去使用其中一个 Mutex 进行资源控制。避免一个 Mutex 对应过多的并发。

一个事故分析(靠 pprof 来人肉分析):打开 pprof 发现大量 Goroutine 都集中 Lock 上

简单总结:压测或者流量高的时候发现系统不正常,打开 pprof 发现 goroutine 指标在飙升,并且大量 Goroutine 都阻塞在 Mutex 的 Lock 上,这种现象下基本就可以确定是锁竞争。

6、Mutex 千万不能被复制

因为复制的时候会将原锁的 state 值也进行复制。复制之后,一个新 Mutex 可能莫名处于持有锁、唤醒或者饥饿状态,甚至等阻塞等待数量远远大于0。而原锁 Unlock 的时候,却不会影响复制锁。

相关阅读:当 Go struct 遇上 Mutex

四、版本迭代

4.1、初版--先来后到

github.com/golang/go/b…

用先来后到的方式解决锁的竞争问题

type Mutex struct {
    key int32;   // 是否上锁的标识
    sema int32;  // 信号量专用,用以阻塞/唤醒goroutine
}

func (m *Mutex) Lock() {
   // (a)key标识加1;(b)如果加1后等于1,则获取到锁
   if xadd(&m.key, 1) == 1 {
      return;
   } 
   // 否则阻塞
   sys.semacquire(&m.sema);
}

func xadd(val *int32, delta int32) (new int32) {
    for {
        v := *val;
        if cas(val, v, v+delta) {
            return v+delta;
        }
    }
    panic("unreached")
}

func (m *Mutex) Unlock() {
   / / 将key标识减去1,如果减后等于0,则说明没有其它等待者
   if xadd(&m.key, -1) == 0 {
      return;
   }

   // 有其他等待者则唤醒
   sys.semrelease(&m.sema);
}

1、cas 指令的功能是将给定的值 old 和内存中的值 val 进行比较比较,如果相等,将 new 赋值给 val 并返回 true,否则失败返回 false。
semacquire 和 semrelease 是利用信号量 sema 实现了阻塞和唤醒功能。

2、Mutex.key 表示是否上锁,大于 0 则表示有锁。同时也表示了阻塞等待 goroutine 的数量。

3、加锁原理:cas key+1 后,如果 key==1,说明该 goroutine 占有了锁;如果 key>1,说明当前锁被其他 goroutine 持有,则通过 semacquire 进行阻塞等待。

4、解锁原理:cas key-1,并唤醒一个等待的goroutine。被唤醒的 goroutine 就相当于是持有了锁,其他等待的 goroutine 继续阻塞。

5、存在的问题:

  • Unlock 可以无限制调用。这会导致 Unlock 相关的业务代码出 bug 时,mutex 失去作用。
  • 顺序唤醒 goroutine 就导致了后来 Lock 的 goroutine 都要进行阻塞,即内核的上下文的切换。这种实现,虽然公平,但是性能低下。

4.2、第二版--给新人机会

github.com/golang/go/b…

type Mutex struct {
   state int32
   sema  uint32
}
const (
   mutexLocked = 1 << iota // mutex is locked
   mutexWoken
   mutexWaiterShift = iota
)

func (m *Mutex) Lock() {
   // Fast path: grab unlocked mutex.
   if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
      return
   }

   awoke := false
   for {
      old := m.state  // 当前锁状态
      new := old | mutexLocked  // 新状态赋值上 mutexLocked 状态
      if old&mutexLocked != 0 {  // 如果 state 的旧状态为 mutexLocked
         new = old + 1<<mutexWaiterShift  // 等待者加1
      }

      // 消除 mutexWoken 状态,因为接下来要么能抢到锁、要么抢不到就睡眠,都不再是 mutexWoken 状态
      if awoke {  
         new &^= mutexWoken
      }

      if atomic.CompareAndSwapInt32(&m.state, old, new) {
         // 如果旧状态没有mutexLocked,则说明当前 goroutine 得到了锁
         if old&mutexLocked == 0 { 
            break
         }
         runtime.Semacquire(&m.sema)
         awoke = true
      }
   }
}

func (m *Mutex) Unlock() {
   // 将持有锁的标志位清零
   new := atomic.AddInt32(&m.state, -mutexLocked)
   if (new+mutexLocked)&mutexLocked == 0 {
      panic("sync: unlock of unlocked mutex")
   }

   old := new
   for {
      // 如果没有等待的 goroutine,
      // 有 woken 的 goroutine,说明有 goroutine 被唤醒了,可以理解为该 for 循环的第二次
      // mutex 又被锁了,说明新请求锁的 goroutine 获取了锁
      if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 {
         return // 不用进行任何操作,直接返回
      }

      // 唤醒一个 goroutine
      new = (old - 1<<mutexWaiterShift) | mutexWoken
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
         runtime.Semrelease(&m.sema)
         return
      }

      old = m.state
   }
}

1、将等待者的数量 (waiter count)和锁标识分开来表示:Mutex.state 的第 0 位表示这个锁是否被持有;第 1 位表示当前是否有唤醒的 goroutine,高 30 位表示等待锁的 gotoutine 数量。

2、Fast path 的逻辑就不再细讲了。

3、【Lock逻辑】阻塞的 goroutine 被唤醒后(33行),并不是直接获得锁,而是和新来请求锁的 goroutine 进行竞争,即重跑 for 循环。好处是,新来的 goroutine 不会马上进入阻塞等待(有机会马上获取锁),浪费 CPU 性能。

4、【Lock逻辑】for 循环就是不断检测当前 goroutine 是否能获取锁:cas 前的 mutex 状态(old 值)没有锁,即获取锁;否则进入 runtime.Semacquire 阻塞等待。

5、【Unlock逻辑】Unlock 首先会清除 mutexLocked 状态。然后命中三种 case 的话就直接 return;否则的话,唤醒一个等待锁的 goroutine。

4.3、第三版--多给新人些机会

github.com/golang/go/b…

mutex.Lock 中在抢占锁的时候增加了 spin 机制,增加获取锁的时间。

for {
   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 {
      ...
   }

   if atomic.CompareAndSwapInt32(&m.state, old, new) {

      ....
   }
}

1、新申请锁的 goroutine 尝试抢占锁,如果没有抢到,会先进入自旋。如果自旋一定次数后,还是没有抢到锁,才进入等待队列。

2、为什么要自旋机制?
大多数时候,goroutine 在独占锁的期间,对数据进行的操作其实耗时很小,比唤醒操作的消耗还小。被唤醒的 goroutine 没有抢到锁立刻就沉睡,然后下次还要被再次唤醒,整体上性能是有些浪费的。这一版的优点是给新申请锁的 goroutine 更多的机会占有锁,即给占有 CPU 时间片的 goroutine 更多的机会,降低整体的性能消耗。

3、存在问题:有一种极端情况,新来的 goroutine 每次都能抢占到锁,那么等待中的 goroutine 就会一直处于等待之中,即尾部延迟现象。

4.4、第四版--饥饿模式

Go 1.9 中增加了饥饿模式,让锁变得更公平。不公平的等待时间限制在1毫秒(写死在常量中)。并且将刚唤醒却没有抢到锁的 goroutine 放在阻塞队列的第一个,因为放回等待队列的尾部会导致更加不公平的等待时间。

详细的讲解,请回转到第二章。

参考

鸟窝的 sync.mutex 源代码分析
Go之深入理解mutex
这可能是最容易理解的 Go Mutex 源码剖析
Mutex 版本迭代的简述
Go Mutex 之4种易错场景盘点