【Go并发编程】Mutex源码阅读

639 阅读3分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 20 天,点击查看活动详情

Mutex

Mutex就是互斥锁,但是go语言的互斥锁有很多深入的设计,具有:给新来的和被唤醒的gorouting更多机会、防止等待太久饥饿问题等特点。下面才源码进行分析:

初版Mutex

初版互斥锁比较简单: 结构体包含key:持有和等待锁的人数;sema信号量

  • 加锁时:key++,如果key==1说明没人占用锁,直接进入即可,否则开始阻塞,直到被唤醒
  • 解锁时:key--,如果没人等,直接退出,反之唤醒一个阻塞的人

注意:CAS保证了加法的原子性

type Mutex struct {
   key  int32 // 持有和等待锁的人数
   sema int32 // 信号量专用,用以阻塞/唤醒goroutine
}

// CAS加操作
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) Lock() {
   // key+1,没人加过锁,直接加
   if xadd(&m.key, 1) == 1 {
      return
   }
   // 反转阻塞等待
   semacquire(&m.sema)
   // 唤醒后相当于获得了锁
}

func (m *Mutex) Unlock() {
   // key-1,没人等直接退出
   if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者
      return
   }
   // 有人等,就唤醒
   semrelease(&m.sema) // 唤醒其它阻塞的goroutine
}

这个初版mutex比较直接,就像先来后到,看起来没什么毛病。但是对于每一个后来的gorouting,他看一眼就进入了阻塞,相当于不断的上下文切换。

如果可以把锁交给新来的gorouting,就能减少两次上下文切换,提高了效率。

Go 1版mutex

type Mutex struct {
   state int32
   sema  uint32
}

const (
   mutexLocked = 1 << iota // mutex is locked
   mutexWoken
   mutexWaiterShift = iota
)

这里state是一个复合字段:

  • 最后一位代表是否有人获得锁
  • 倒数第二位表示是否有被唤醒的人
  • 剩余的表示阻塞者的数量

常量记录了这些含义的位置。

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 {
         new &^= mutexWoken
      }
      // 原子操作更新state
      if atomic.CompareAndSwapInt32(&m.state, old, new) { //设置新状态
         // 如果之前没人加锁,直接获得锁
         if old&mutexLocked == 0 { // 锁原状态未加锁
            break
         }
         // 休眠
         runtime.Semacquire(&m.sema) // 请求信号量
         // 标记唤醒状态
         awoke = true
      }
   }
}

func (m *Mutex) Unlock() {
   // 快速路径:去掉锁标志
   new := atomic.AddInt32(&m.state, -mutexLocked)
   // 之前没有加过锁就不能解锁,panic
   if (new+mutexLocked)&mutexLocked == 0 {
      panic("sync: unlock of unlocked mutex")
   }

   old := new
   for {
      // 如果没有其它的 waiter,说明对这个锁的竞争的 goroutine 只有一个,那就可以直接返回了
      // 如果这个时候有唤醒的 goroutine,或者是又被别人加了锁,我们可以放心走了,剩下的事情无需操心
      if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 {
         return
      }
      // 有等待者,并且没有唤醒的 waiter,那就需要唤醒一个等待的 waiter。
      // 等待者减一,有一个唤醒者
      new = (old - 1<<mutexWaiterShift) | mutexWoken
      // CAS更新state
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
         // 唤醒一个人
         runtime.Semrelease(&m.sema)
         return
      }
      old = m.state
   }
}

还是比较复杂的,简而言之,他的优化是给新来的人有直接获得锁的机会,减少了上下文切换的开销。

对于lock:

  • 首先尝试快速路径,直接获得锁
  • 快速路径失败,进入循环:
    • 尝试加锁,如果加锁成功,直接获得锁
    • 加锁失败进入阻塞,下次唤醒后再尝试 对于unlock:
  • 原子操作修改锁的状态,如果之前未lock就panic
  • 剩下的事情就是要唤醒一个阻塞的人
    • 如果没有其它的 waiter,可以直接返回了
    • 如果这个时候有唤醒的 goroutine,或者是又被别人加了锁,也可直接返回,他们会唤醒别人
    • 否则就自己去唤醒一个:waiter减一,唤醒一个人

这一版改进使得新人在没有空闲的锁或者竞争失败才加入到等待队列中,其实新人的机会还是比较少,可以多给新人一些机会,减少上下文切换的次数。

Go 1.4.2 版本

一个简单的增加机会的方法就是多试几次,引入了runtime包的span,也称自旋:

func (m *Mutex) Lock() {
   if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
      return
   }

   awoke := false
   // 自旋计数器
   iter := 0
   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 new&mutexWoken == 0 {
            panic("sync: inconsistent mutex state")
         }
         new &^= mutexWoken // 新状态清除唤醒标记
      }
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
         if old&mutexLocked == 0 { 
            break
         }
         runtime_Semacquire(&m.sema) 
         awoke = true    
         // 计数器清零
         iter = 0
      }
   }
}

如果可以 spin 的话,for 循环会重新检查锁是否释放。对于临界区代码执行非常短的场景来说,这是一个非常好的优化。因为临界区的代码耗时很短,锁很快就能释放,而抢夺锁的 goroutine 不用通过休眠唤醒方式等待调度,直接 spin 几次,可能就获得了锁。

还有一个问题就是在极端情况下,等待中的 goroutine 可能会一直获取不到锁,这就是饥饿问题。

Go 1.19

2016 年 Go 1.9 中 Mutex 增加了饥饿模式,让锁变得更公平,不公平的等待时间限制在 1 毫秒,并且修复了一个大 Bug:总是把唤醒的 goroutine 放在等待队列的尾部,会导致更加不公平的等待时间。

2018 年,Go 开发者将 fast path 和 slow path 拆成独立的方法,以便内联,提高性能。

2019 年对于 Mutex 唤醒后持有锁的那个 waiter,调度器可以有更高的优先级去执行,这已经是很细致的性能优化。

最重要的是对互斥锁公平性的提升,解决了饥饿问题互斥锁有两种操作模式:

  • 正常模式和饥饿模式。在正常模式下,等待者按照先进先出的顺序排队,但是被唤醒的等待者并不拥有互斥锁,而是与新到达的 goroutine 竞争所有权。新到达的 goroutine 有一个优势它们已经在 CPU 上运行,可能有很多,所以被唤醒的等待者很可能输掉竞争。在这种情况下,它会排在等待队列的前面。如果一个等待者无法在1ms内获得互斥锁,则它会将互斥锁切换到饥饿模式。
  • 在饥饿模式下,互斥锁的所有权直接从解锁的 goroutine 移交给队列前面的等待者。新到达的 goroutine 即使看起来已经解锁,也不会尝试获取互斥锁,也不会自旋。相反,它们会排在等待队列的尾部。
  • 互斥锁切换回正常操作模式:如果等待者获得了互斥锁的所有权,并发现自己是队列中的最后一个等待者,或者它等待的时间少于1ms。
  • 正常模式具有更好的性能,因为即使有被阻塞的等待者,goroutine 也可以连续多次获取互斥锁。饥饿模式非常重要,以防止尾延迟的病理情况。

结构体、接口、常量

type Mutex struct {
   state int32
   sema  uint32
}

type Locker interface {
   Lock()
   Unlock()
}

const (
   mutexLocked = 1 << iota // mutex is locked
   mutexWoken
   mutexStarving
   mutexWaiterShift = iota
   starvationThresholdNs = 1e6
)

image.png

  • state:复合字段(节省内存),包含了很多含义:

  • 最右一位:锁是否被持有

  • 倒数第二位:是否有唤醒的 goroutine

  • 倒数第三位:现在的模式:正常、饥饿

  • 剩余部分:等待此锁的 goroutine 数

  • sema: 信号量专用,用以阻塞/唤醒goroutine

lock、unlock、trylock

func (m *Mutex) Lock() {
   // 没别人占用锁,快速路径,直接得到锁
   if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
      if race.Enabled {
         race.Acquire(unsafe.Pointer(m))
      }
      return
   }
   // Slow path (outlined so that the fast path can be inlined)
   m.lockSlow()
}

编译器一判断AST树节点少于80个,就将Lock() 这个方法在业务代码里进行内联,因为lockSlow() 是绝对无法在Lock() 里进行内联的,所以缩小Lock() 方法,是为了提高文中说的fast path效率,也就是没有竞争,锁立马被请求的goroutine给获取到了,因为这段代码内联到业务代码中,所以就减少了找函数地址时间,大大提高fast path的效率,毕竟锁的竞争在实际使用中还是不太常见的。

func (m *Mutex) TryLock() bool {
   old := m.state
   // 锁被别人持有或者饥饿状态,直接返回false
   if old&(mutexLocked|mutexStarving) != 0 {
      return false
   }

   // CAS尝试加锁,失败就返回
   if !atomic.CompareAndSwapInt32(&m.state, old, old|mutexLocked) {
      return false
   }

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

   // 快速路径
   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)
   }
}

lock slow、unlock slow

func (m *Mutex) lockSlow() {
   var waitStartTime int64 //记录此 goroutine 请求锁的初始时间
   starving := false       // 此goroutine的饥饿标记
   awoke := false          // 唤醒标记
   iter := 0               // 自旋次数
   old := m.state          // 当前的锁的状态
   for {
      // 锁是非饥饿状态,锁还没被释放,尝试自旋
      if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
         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 // waiter数量加1
      }
      // 锁已经被持有或者锁处于饥饿状态,我们最好的归宿就是等待
      if starving && old&mutexLocked != 0 {
         new |= mutexStarving // 设置饥饿状态
      }
      if awoke {
         if new&mutexWoken == 0 {
            throw("sync: inconsistent mutex state")
         }
         new &^= mutexWoken // 新状态清除唤醒标记
      }
      // 成功设置新状态
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
         // 原来锁的状态已释放,并且不是饥饿状态,正常请求到了锁,返回
         if old&(mutexLocked|mutexStarving) == 0 {
            break // locked the mutex with CAS
         }
         // 处理饥饿状态

         // 如果以前就在队列里面,加入到队列头
         queueLifo := waitStartTime != 0
         if waitStartTime == 0 {
            waitStartTime = runtime_nanotime()
         }
         // 将此 waiter 加入到队列,如果是首次,加入到队尾,先进先出。如果不是首次,那么加入到队首,这样等待最久的 goroutine 优先能够获取到锁。此 goroutine 会进行休眠。
         runtime_SemacquireMutex(&m.sema, queueLifo, 1)
         // 唤醒之后检查锁是否应该处于饥饿状态
         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")
            }
            // 有点绕,加锁并且将waiter数减1
            delta := int32(mutexLocked - 1<<mutexWaiterShift)
            if !starving || old>>mutexWaiterShift == 1 {
               delta -= mutexStarving // 最后一个waiter或者已经不饥饿了,清除饥饿标记
            }
            atomic.AddInt32(&m.state, delta)
            break
         }
         awoke = true
         iter = 0
      } else {
         old = m.state
      }
   }
}
func (m *Mutex) Unlock() {
   // Fast path: drop lock bit.
   new := atomic.AddInt32(&m.state, -mutexLocked)
   if new != 0 {
      m.unlockSlow(new)
   }
}

func (m *Mutex) unlockSlow(new int32) {
   if (new+mutexLocked)&mutexLocked == 0 {
      throw("sync: unlock of unlocked mutex")
   }
   if new&mutexStarving == 0 {
      old := new
      for {
         // Mutex 处于正常状态,如果没有 waiter,或者已经有在处理的情况了,那么释放就好,不做额外的处理
         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 {
      //如果 Mutex 处于饥饿状态,直接唤醒等待队列中的 waiter。
      runtime_Semrelease(&m.sema, true, 1)
   }
}

总结

go的mutex做了以下优化:

  • 尽可能把锁交给正在占用 CPU 时间片的 goroutine避免上下文切换的开销
    • 给被唤醒者更多机会
    • 给新人更多机会
  • 饥饿模式相当于给阻塞队列开了优速通,新来的先往后站

其实自旋之类的操作还是会增加系统的复杂度,也是最好的取舍了。