深入理解 Go sync.Mutex

介绍

Mutex结构如下:

type Mutex struct {
   // 状态
   state int32
   // 信号量
   sema  uint32
}

其中state被分为4个部分:
在这里插入图片描述

对应的常量如下:

const (
   // 1:加锁标志位
   mutexLocked = 1 << iota  // mutex is locked
   // 2:唤醒标志位
   mutexWoken
   // 4:饥饿标志位
   mutexStarving
   // 3:第三位以后的位,代表等待者数量
   mutexWaiterShift = iota
   // 进饥饿模式的阈值:1e6纳秒 = 1毫秒
   starvationThresholdNs = 1e6
)

有两种操作模式:正常和饥饿

  • 正常模式下:

    • 等待者按FIFO顺序排队,但被叫醒的等待者不拥有锁,而是与新到达的goroutines竞争所有权新来的goroutines有一个优势——它们是已经在CPU上运行了,所以被唤醒的等待者很有可能竞争不过。这种情况下,它在队列的头等待如果等待者在1ms以上未能获取锁,就将锁切换到饥饿模式
  • 饥饿模式下:

    • 锁的所有权直接从刚解锁的goroutine传递给队列前面的等待者。新到达的goroutines不会尝试获取互斥体,即使锁是未加锁状态。也不会自旋。相反,他们在/等待队列的尾部

正常模式具有更好的性能,而饥饿模式有更好的公平性,避免了某些goroutine长时间的等待锁

因此,go Mutex有以下特性:

  • 新协程友好:新来的协程首先可以通过快速路径CAS获取到锁,在慢路径中也有4次自旋机会
  • 保证公平性:当锁处于饥饿模式时,等待者队列的中的协程优先级就高于新协程

  • 关于唤醒位标志:

    • 刚接触Mutex,可能会对唤醒位标志比较费解,该位有啥用?
    • 整个Mutex只有解锁时会使用该标志位,即如果该位为1,则解锁后不唤醒等待者,因为已经有被唤醒的协程正在尝试加锁了,后文也会详细分析

源码

Lock

func (m *Mutex) Lock() {
   // 能将锁从0改为 mutexLocked,加锁成功
     if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
          return
     }
       // 执行慢路径
     m.lockSlow()
}
func (m *Mutex) lockSlow() {
   // 等待时间
   var waitStartTime int64
   // 是否饥饿
   starving := false
   // 是否被唤醒
   awoke := false
   // 自旋次数
   iter := 0
   old := m.state
   for {
      // 判断是否自旋:
      // 1.已经加锁,且不处与饥饿模式
      // 2.可以进行自旋
      if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
         // 这里尝试将mutexwoken标志改为true,避免正在解锁的协程唤醒其他没必要唤醒的阻塞协程
      if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
            atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
            awoke = true
      }
         // 执行30次PAUSE指令,通过该指令占用CPU并消费CPU时间,进行忙等待
         runtime_doSpin()
         iter++
         old = m.state
         continue
     }
      
      // 结束自旋:要么超过自旋次数,要么锁变为饥饿模式,要么已经解锁
      new := old
      // 如果不是饥饿,则这里尝试加锁
      // 饥饿模式不能加锁,而只能进入等待队列

     if old&mutexStarving == 0 {
          new |= mutexLocked
     }
      
      // 如果锁是已加锁状态,或处于饥饿模式,这里都要当自己阻塞,即等待者数量+1
      if old&(mutexLocked|mutexStarving) != 0 {
          new += 1 << mutexWaiterShift
      }
      
      // 如果饥饿,且锁已经被锁住,进入饥饿模式
      // 如果没被加锁,则不进入饥饿模式
      if starving && old&mutexLocked != 0 {
          new |= mutexStarving
      }
      
      // 如果被唤醒,清除唤醒标志
      if awoke {
         if new&mutexWoken == 0 {
            throw( "sync: inconsistent mutex state" )
         }
         new &^= mutexWoken
      }
      
      // 尝试CAS将new更新到m.state
      if atomic.CompareAndSwapInt32(&m.state, old, new) {// 如果以前没加锁,且不是饥饿,这里又更新m.state成功了,表明加锁成功
         if old&(mutexLocked|mutexStarving) == 0 {
            break
         }
         
         // 如果第一次到这,加入队列尾部,否则加入队列头部
         queueLifo := waitStartTime != 0
         if waitStartTime == 0 {
            waitStartTime = runtime_nanotime()
         }
         // 入队,阻塞
         runtime_SemacquireMutex(&m.sema, queueLifo, 1)
         
         // 被唤醒
         // 更新饥饿状态,如果被唤醒后发现等待时间超过1ms,就需要将锁更新为饥饿模式
         starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
         // 如果不是饥饿模式,则被唤醒后重试获取锁,和其他协竞争,不进下面的if
         // 否则被唤醒后是饥饿模式,此时该协程拥有锁
         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
         }
         awoke = true
         iter = 0
         
      // CAS失败,则重试   
      } else {
         old = m.state
      }
   }
}

lockSlow流程如下:

  • 首先尝试自旋:

    • 只有已经被加锁,且不是饥饿模式,且经过runtime_canSpin(关于该方法下文分析)判断可以自旋,才进行自旋
    • 每次自旋尝试将锁设为被唤醒标志,这样正在解锁的协程就不用唤醒等待者了,因为已经有协程在尝试加锁
  • 一旦超过自旋次数,或者锁变为饥饿模式,或者已经解锁,就会结束自旋
  • 如果不是饥饿模式,尝试加锁:new |= mutexLocked

    • 饥饿模式是不能加锁的,只能进入等待队列
  • 如果已经进入饥饿模式,或者已经被加锁,则将等待者数量+1:

    • ` new += 1 <<  `***`mutexWaiterShift`***
      
    • 因为这两种情况下,当前协程都要进入等待者队列等待
  • 如果当前协程判断要进入饥饿模式,且已经被加锁,则进入饥饿模式:new |= mutexStarving

    • 如果没被加锁,就没必要进入饥饿模式
  • 清除唤醒标志位
  • CAS将m.state从old修改为new,如果成功:

    • 如果以前没加锁,且不是饥饿,表明加锁成功
  • 否则就是加锁失败:

    • 根据是被唤醒,还是刚来,决定加入队列头部还是尾部,然后陷入阻塞
  • 被唤醒:

    • 判断阻塞时间是否大于阈值,如果是,则需要在下次CAS操作时将锁设为饥饿状态
    • 如果是饥饿模式,说明此时该协程拥有锁
    • 设置锁标志位,并减一个等待者,也就是自己
    • 如果发现不再饥饿,即当前协程的阻塞时间小于1ms,或者只有自己这个等待者,就将锁退出饥饿模式

判断是否能自旋

func sync_runtime_canSpin(i int) bool {
   // active_spin = 4
   if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1    {
      return  false
   }
   if p := getg().m.p.ptr(); !runqempty(p) {
      return  false
   }
   return  true
}
  • 自旋次数在4次以内
  • CPU必须为多核

    • 单核就没必要自旋,因为只有一个给程序运行,应该都给持有锁的协程使用,这里自旋只会推迟自己获得锁的时间
  • gomaxprocs > 1

    • 同理,如果逻辑核心数为1,也没必要自旋
  • 至少还有1个其他正在运行的P

    • 如果一个其他正在运行的P也没用,那么这里自旋也没用,因为持有锁的协程需要在P上运行才能解锁,即这里持有锁的协程也陷入阻塞

    • 例如:

      • 若gomaxprocs=5,sched.npidle=2,sched.nmspinning=2
      • 总共5个P,两个空闲,两个自旋,加上当前协程所在的P,满足gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1,即没有其他P正在运行
      • 否则就是有其他P正在运行
  • 当前g绑定的p的本地队列没有其他g

    • 防止其他g饥饿

Unlock

func (m *Mutex) Unlock() {
   // 消除锁标志
   new := atomic.AddInt32(&m.state, -mutexLocked)
   // 如果有等待者,或处于饥饿模式,就执行unlockSlow
   if new != 0 {
      m.unlockSlow(new)
   }
}
func (m *Mutex) unlockSlow(new int32) {
   // 非饥饿模式
   if new&mutexStarving == 0 {
      old := new
      for {
         // 如果没有等待者,已经被加锁,已经有被唤醒标志,或者饥饿标志,这里啥也不用做,直接返回
         if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
            return
      }
         
         // 减少一个等待者,设置唤醒模式
     new = (old - 1<<mutexWaiterShift) | mutexWoken
     if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 设置成功,唤醒一个等待者
            runtime_Semrelease(&m.sema, false, 1)
            return
          }
         old = m.state
     }
   // 饥饿模式,直接唤醒头部的等待者   
   } else {
      // 这一步不会将锁标志位设为1,而是由被唤醒的协程来设置
      // 并且其他协程不会尝试获得锁,因为锁还是处于饥饿模式
      runtime_Semrelease(&m.sema, true, 1)
   }
}

解锁流程如下:

  • 清除锁标志位,如果清除后有等待者,或处于饥饿模式,就执行unlockSlow
  • 如果锁处于饥饿模式:

    • 直接唤醒头部的等待者
    • 这一步不会将锁标志位设为1,而是由被唤醒的协程来设置,并且其他协程不会尝试获得锁,因为锁还是处于饥饿模式
  • 如果不是饥饿模式,会根据当然锁的状态,判断是直接返回,还是需要唤醒等待者。以下情况啥也不用做:

    • 没有等待者

      • 没有需要唤醒的等待者,直接返回
    • 已经被加锁

      • 非饥饿模式下,上一步清除标志位后,就有可能被新来的协程加锁,那么这里就没必要唤醒等待队列中的等待者,又刚加锁的协程后续解锁后再去唤醒
    • 已经有被唤醒标志

      • 加锁的自旋操作中,有一步是尝试将锁设置唤醒标志,如果设置成功,表明有协程正在尝试加锁,那么这里也没必要唤醒等待队列中的等待者。因为已经有协程在尝试
    • 已有饥饿标志

      • 说明在清除标志位,到这一步判断之前,以后有协程加锁解锁,并把锁设为饥饿模式,那这里也什么都不用做
  • 否则需要尝试唤醒一个等待者:

    • 设置唤醒标志,减少一个等待者
    • 为啥是尝试:因为如果后面CAS失败,重新进入for循环,这次第一个if成立,就不用唤醒等待者

关于饥饿模式

通过源码分析可以看出,锁要进入饥饿模式,一定是某个被唤醒的协程发现阻塞时间超过阈值

而通过解锁的流程可以发现,如果一直有新的协程不断获取到锁,那么解锁的协程就不会去唤醒等待者队列的协程,也就是说锁一直无法进入饥饿模式,也就是说可能有等待者等待超过1ms很久,也无法使锁进入饥饿模式

但是一旦有等待者被唤醒,就会不用CAS将锁置位饥饿模式
一旦设置成功,新协程就不会尝试加锁,而是乖乖入队等待,直到后面某个协程发现等待时间少于1ms,或者没有等待者时,才会解除饥饿模式

注意事项

  • 加解锁需要成对出现

    • 一般在调用Lock()后,调用defer Unlock(),保证即使panic了也一定能解锁
  • Copy已使用的Mutex

    • 因为Mutex是值对象,一旦Copy,state也会跟着复制,这样其加锁信息也会跟着复制,一般不符合业务预期
    • 当需要一把新锁时,使用零值的Mutex结构体对象
  • Mutex不可重入

    • 如果按可重入使用,会陷入死锁
    • 因为Mutex没有记录当前持有锁的协程是哪个,也没有记录重入次数,因此其是不可重入的
    • 如果想支持可重入,可以自己改造,增加goroutineid,或者自定义token,来标识是谁加锁和重入次数,并包装Lock和UnLock方法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值