golang并发编程基础 - Mutex的使用和源码分析

背景

在实际编码中,我们经常遇到并发问题,产生并发问题的原因主要有以下两点:

  1. 存在共享资源
  2. 存在非原子性,并发操作共享资源的场景

解决并发问题,常见的方式之一就是加锁。sync.Mutex就是golang提供的对锁的支持

快速入门

我们模拟一个并发买票的场景,没有加锁之前的代码如下:

go 复制代码
func TestBuyTicket(t *testing.T) {
   // 模拟10000张票
   ticketNum := 10000
   
   var wg sync.WaitGroup
   wg.Add(10000)
   for i := 0; i < 10000; i++ {
      go func() {
         defer func() {
            wg.Done()
         }()
         ticketNum--
      }()
   }
   wg.Wait()
   // 预期打印0
   fmt.Println(ticketNum)
}

存在的问题:

  • ticketNum--是非原子性操作,并发执行时,最终的ticketNum不一定是预期的0

使用sync.Mutex来解决并发问题,代码如下:

go 复制代码
func TestBuyTicket(t *testing.T) {
   ticketNum := 10000
   
   var mu sync.Mutex
   var wg sync.WaitGroup
   wg.Add(10000)
   for i := 0; i < 10000; i++ {
      go func() {
         defer func() {
            // 解锁
            mu.Unlock()
            wg.Done()
         }()
         // 加锁
         mu.Lock()
         ticketNum--
      }()
   }
   wg.Wait()
   fmt.Println(ticketNum)
}

源码分析

源码路径:src/sync/mutex.go

golang中的锁接口:

go 复制代码
// A Locker represents an object that can be locked and unlocked.
type Locker interface {
   Lock()
   Unlock()
}
  • Locker表示可以锁定和解锁的对象

Mutex实现了Locker接口中的方法,Mutex结构如下:

go 复制代码
// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
type Mutex struct {
   state int32
   sema  uint32
}

// Mutex提供了以下三个方法
// 加锁
func (m *Mutex) Lock() {}
// 尝试加锁
func (m *Mutex) TryLock() bool {}
// 解锁
func (m *Mutex) Unlock() {}
  • Mutex是一个互斥锁,不可重入
  • Mutex的零值是未锁定的互斥锁,不用做额外的初始化
  • Mutex在首次使用后禁止被复制
  • Mutex包含两个参数,其中state表示锁状态;sema表示信号量,用来阻塞和唤醒goroutine

前提知识

CAS

CAS是cpu硬件同步原语,全称为compare and swap(比较和交换)

CAS 操作包含三个操作数 --- 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值,并且保证这是个原子操作。否则,处理器不做任何操作

对应的伪代码为:

kotlin 复制代码
if V == A {
    V = B
    return true
} else {
    return false
}

state字段含义

Mutex中的state字段,占32位,被分成了四个部分:

  • 最后一位表示是否已加锁,0表示未加锁,1表示已加锁
  • 倒数第二位表示是否有goroutine已被唤醒,0表示没有被唤醒,1表示已被唤醒
  • 倒数第三位表示是否为饥饿模式,0表示正常模式,1表示饥饿模式
  • 其余29位用来表示排队等待的goroutine数量

源码中的常量:

go 复制代码
const (
   // 锁标识掩码常量 = 1
   mutexLocked = 1 << iota // mutex is locked
   // 唤醒标识掩码常量 = 2
   mutexWoken
   // 饥饿模式掩码常量 = 4
mutexStarving
   // 表示排队数量需要排除的位数 = 3
mutexWaiterShift = iota
   // 切换为饥饿模式等待的阈值 = 1ms
   starvationThresholdNs = 1e6
)

正常模式 vs 饥饿模式

一个好的互斥锁,需要考虑以下两个因素:

  • 性能
  • 公平

Mutex有两种模式:正常模式和饥饿模式

在正常模式下,一个尝试加锁的goroutine会先自旋几次,在锁被释放或者不能自旋后,会开始尝试获取锁,此时若排队等待的队列数不为空,则队列头部的goroutine会被唤醒,并和这些自旋goroutine一起竞争锁。因为这些自旋的goroutine不需要进行唤醒操作,且正在cpu上运行,同时自旋的goroutine可以有很多,而被唤醒的goroutine只有一个,所以这些自旋的goroutine获取锁的概率更大,减少了阻塞唤醒的时间,提高了获取锁的效率。此时更加注重性能,实现为非公平锁

当锁竞争十分激烈时,有的调用方可能等待了很长的时间,但一直得不到锁。此时更加注重公平,实现为公平锁,对应mutex的饥饿模式

在饥饿模式下,Mutex的所有权从执行Unlock的goroutine,直接传递给等待队列头部的goroutine,后来的goroutine不会自旋,也不会尝试获得锁,即使Mutex处于未加锁状态,这些新来的goroutine会直接去队列的尾部进行排队,严格的先来后到

正常模式切换为饥饿模式:

  • 当有goroutine获取锁的等待时间大于等待阈值1ms时,mutex切换为饥饿模式

饥饿模式切换为正常模式,满足以下条件之一即可:

  • 当前waiter的等待时间小于等于等待阈值1ms
  • 当前goroutine已经是排队等待队列的最后一个waiter

Lock()

Fast path

go 复制代码
func (m *Mutex) Lock() {
   // Fast path: grab unlocked mutex.
   // CAS快速加锁
   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)
   // 方便编译器对fast path进行内联优化
   // Fast path加锁失败了,则执行Slow path   
   m.lockSlow()
}

先执行Fast path,通过CAS进行快速加锁

  • 若CAS加锁成功,则直接返回
  • 若CAS加锁失败,则执行Slow path

Slow path

go 复制代码
func (m *Mutex) lockSlow() {
   var waitStartTime int64
   starving := false
   awoke := false
   iter := 0
   old := m.state
   // 循环尝试加锁
   for {
      // Slow path加锁核心逻辑
      // ...
   }
}

接下来对Slow path中的加锁核心逻辑进行详细分析,主要可以分成以下三个步骤:

  1. 自旋
  2. 判断状态
  3. 设置状态

自旋

go 复制代码
// old&(mutexLocked|mutexStarving) == mutexLocked 判断当前锁状态是否为非饥饿模式的已加锁状态
// runtime_canSpin(iter) 判断是否可以进行自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
   // !awoke 判断当前goroutine是否已被唤醒
   // old&mutexWoken == 0 判断当前锁状态是否为未被唤醒
   // old>>mutexWaiterShift != 0 判断排队等待队列是否不为空
   // atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) 尝试把锁设置为已唤醒状态
   if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
      atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
      // 把当前goroutine标记为已唤醒
      awoke = true
   }
   // 进行自旋
   runtime_doSpin()
   // 自旋次数+1
   iter++
   old = m.state
   continue
}

先判断是否需要进行自旋,需要同时满足以下三个条件:

  1. 当前模式为正常模式。若当前模式为饥饿模式,不要进行自旋操作了,直接去等待队列尾部进行排队,严格的先来后到
  2. 当前锁状态为已加锁。若当前锁状态为未加锁,不要进行自旋操作了,赶快去尝试获取锁
  3. 可以进行自旋操作

源码路径:src/runtime/proc.go

go 复制代码
const (
   active_spin = 4
)

// Active spinning for sync.Mutex.
//go:linkname sync_runtime_canSpin sync.runtime_canSpin
//go:nosplit
// 判断是否可以进行自旋
func sync_runtime_canSpin(i int) bool {
   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
}

可以进行自旋的判断逻辑如下:

  1. 自旋次数小于4
  2. cpu大于1,也就需要是多核cpu
  3. GOMAXPROCS > 1并且至少有一个其他正在运行的P并且本地runq为空

执行的自旋操作:

  1. 若当前的goroutine是没有被唤醒的,且当前锁状态为未被唤醒状态,且排队等待队列不为空,且成功把锁状态设置为已唤醒状态。则把当前goroutine标记为已唤醒
  2. 进行自旋
  3. 自旋次数+1
  4. 重新获取state
  5. 继续下一次循环

为什么需要自旋操作

如果不进行自旋操作,直接就去判断状态和设置状态,那么当前goroutine大概率是进行排队。进行合理的自旋操作后,可以避免当前goroutine被阻塞和唤醒的次数,提高获取锁的效率

判断状态

go 复制代码
// new表示需要重新设置的状态
new := old
// old&mutexStarving == 0 判断是否是正常模式
// 如果是正常模式,则设置lock位,尝试加锁
// 如果是饥饿模式,不要尝试加锁,新到达的goroutine直接去排队   
if old&mutexStarving == 0 {
   new |= mutexLocked
}
// old&(mutexLocked|mutexStarving) != 0 判断是否是已加锁或者是饥饿模式
// 若是的话,排队数+1
if old&(mutexLocked|mutexStarving) != 0 {
   new += 1 << mutexWaiterShift
}
// 切换饥饿模式
// 当前goroutine等待超过1ms 且 当前锁没有被释放
// 为什么要求锁没有被释放???
// 锁被释放了,可以直接去抢锁,此时设置成饥饿模式,那么就只能去排队了
if starving && old&mutexLocked != 0 {
   new |= mutexStarving
}
// 当前唤醒标识为true,则释放唤醒标识
if awoke {
   if new&mutexWoken == 0 {
      throw("sync: inconsistent mutex state")
   }
   new &^= mutexWoken
}

判断当前锁状态是否为正常模式

  • 如果是正常模式,则设置lock位,尝试加锁。不需要关心原来的锁状态为已加锁还是未加锁
  • 如果是饥饿模式,不要尝试加锁,新到达的goroutine直接去等待队列尾部排队

判断是否是已加锁或者是饥饿模式

  • 如果是的话,排队等待数+1

判断当前goroutine是否已经饥饿(等待加锁时间超过了1ms),且当前锁状态为已加锁

  • 如果是的话,则切换为饥饿模式
  • 这里还需要当前锁状态为已加锁的原因是,如果当前锁为未加锁,那么我们直接去抢锁就好了,直接设置成饥饿模式,我们就只能去排队等待了

判断当前goroutine是否已被唤醒

  • 如果已经被唤醒,则释放锁的唤醒标识
  • 释放唤醒标识的原因,只要下一步的设置状态成功,那么当前goroutine要么是加锁成功退出,要么是进行排队阻塞,都不是已唤醒的状态了

设置状态

go 复制代码
// 尝试设置锁的状态
if atomic.CompareAndSwapInt32(&m.state, old, new) {
   // 设置状态成功
   // 两种可能:1加锁成功 2排队成功
   // 加锁成功,则直接返回
   if old&(mutexLocked|mutexStarving) == 0 {
      break // locked the mutex with CAS
   }
   // 排队成功
   // 需要判断当前goroutine是否是第一次进行排队
   // 如果是第一次进行排队,则排到队列尾部
   // 如果已经排过队,则排到队列头部
   queueLifo := waitStartTime != 0
   if waitStartTime == 0 {
      waitStartTime = runtime_nanotime()
   }
   // 进入排队队列,当前goroutine阻塞
   // queueLifo为true,则排到队列头部
   // queueLifo为false,则排到队列尾部
   runtime_SemacquireMutex(&m.sema, queueLifo, 1)
   // 被唤醒后继续执行
   // 判断等待时间是否超过1ms
   starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
 // 重新获取state
   old = m.state
   // 判断是否为饥饿模式
   if old&mutexStarving != 0 {
      if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
         throw("sync: inconsistent mutex state")
      }
      // 直接加锁,同时设置等待队列数-1
      delta := int32(mutexLocked - 1<<mutexWaiterShift)
      // 当前goroutine排队时间没有超过1ms 或者 当前goroutine是最后一个排队的waiter
      if !starving || old>>mutexWaiterShift == 1 {
         // 退出饥饿模式
         delta -= mutexStarving
}
      atomic.AddInt32(&m.state, delta)
      break
   }
   // 正常模式,当前goroutine被唤醒,继续进行抢锁操作
   awoke = true
   // 自旋次数清0
   iter = 0
} else {
   // 设置失败,重新进行循环
   old = m.state
}

尝试设置锁的状态

  • 设置失败,重新进行循环
  • 设置成功,那么有两种可能:加锁成功或者排队成功

正常模式,加锁成功,则直接返回

饥饿模式,排队成功

判断当前goroutine是否是第一次进行排队

  • 如果是第一次进行排队,则排到队列尾部,严格的先来后到
  • 如果已经排过队,则排到队列头部

当前goroutine阻塞

当前goroutine被唤醒后继续执行

判断当前goroutine等待时间是否超过1ms

  • 如果超过1ms,则标记当前goroutine已经饥饿

重新获取state,判断当前锁是否为饥饿模式

  • 如果是正常模式,则标记当前goroutine已被唤醒,清空自旋次数,继续开始循环尝试获取锁
  • 如果为饥饿模式,直接进行加锁,且对排队数进行-1

为什么这里判断为饥饿模式,可以直接进行加锁?

因为在饥饿模式下,被唤醒就是等待队列头部的goroutine,饥饿模式下也不允许其他goroutine进行自旋和尝试加锁操作,此时当前goroutine直接进行加锁操作即可

何时退出饥饿模式?

以下两种情况下,会把锁模式从饥饿模式切换为正常模式:

  • 当前goroutine等待的时间小于等于1ms
  • 当前goroutine是等待队列的最后一个waiter

注意点

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

Unlock()

Fast path

go 复制代码
func (m *Mutex) Unlock() {
   if race.Enabled {
      _ = m.state
      race.Release(unsafe.Pointer(m))
   }

   // Fast path: 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.
      // 方便编译器对fast path进行内联优化
      // Fast path解锁失败了,则执行Slow path 
      m.unlockSlow(new)
   }
}

通过原子操作修改锁的状态

  • 如果修改后的锁状态为0,则解锁成功,直接退出
  • 如果修改后的锁状态不为0,则执行Slow path

Slow path

go 复制代码
func (m *Mutex) unlockSlow(new int32) {
   // 锁已被释放,再次解锁,则报错
   if (new+mutexLocked)&mutexLocked == 0 {
      throw("sync: unlock of unlocked mutex")
   }
   if new&mutexStarving == 0 {
      // 正常模式
      old := new
      for {
         // 等待队列为空 或者 已经有其他goroutine已经获得了锁,已经被唤醒,锁进入饥饿模式
         if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
            // 不需要唤醒某个goroutine,直接返回即可
            return
         }
         // 等待队列数-1 加 唤醒goroutine
         new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 抢占成功后,唤醒等待队列的第一个goroutine
            runtime_Semrelease(&m.sema, false, 1)
            return
         }
         // 抢占不成功,继续循环
         old = m.state
      }
   } else {
      // 饥饿模式
      // Mutex的所有权从执行Unlock的goroutine,直接传递给等待队列头部的goroutine
      runtime_Semrelease(&m.sema, true, 1)
   }
}

锁已被释放,再次解锁,则报错

正常模式

  • 如果等待队列为空 或者 已经有其他goroutine已经获得了锁,或者已经被唤醒,或者锁进入饥饿模式,则直接返回即可
  • 如果不满足,则等待队列数-1,唤醒等待队列的第一个goroutine。对应加锁方法Slow path标记当前goroutine已被唤醒,清空自旋次数,继续开始循环尝试获取锁
  • 如果设置状态不成功,则继续循环,进行下一次尝试

饥饿模式

  • Mutex的所有权从执行Unlock的goroutine,直接传递给等待队列头部的goroutine

注意点

  • 不同goroutine可以Unlock同一个Mutex,但是Unlock一个无锁状态的Mutex就会报错
  • 因为mutex没有记录goroutine_id,所以要避免在不同的协程中分别进行上锁/解锁操作,不然很容易造成死锁

TryLock()

go 复制代码
func (m *Mutex) TryLock() bool {
   old := m.state
   // 已加锁或为饥饿模式,直接返回
   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
}

已加锁或为饥饿模式,直接返回false

CAS尝试加锁,加锁成功,返回true,加锁失败,返回false

结束

Mutex是最基础的组件之一,golang里面很多其他的组件都是基于Mutex来实现的,深入理解了Mutex,有利于我们对其他组件的理解

相关推荐
二闹8 分钟前
三个注解,到底该用哪一个?别再傻傻分不清了!
后端
用户490558160812520 分钟前
当控制面更新一条 ACL 规则时,如何更新给数据面
后端
林太白21 分钟前
Nuxt.js搭建一个官网如何简单
前端·javascript·后端
码事漫谈23 分钟前
VS Code 终端完全指南
后端
该用户已不存在1 小时前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
怀刃1 小时前
内存监控对应解决方案
后端
码事漫谈1 小时前
VS Code Copilot 内联聊天与提示词技巧指南
后端
Moonbit2 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言
Moonbit2 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞(下):llvm IR 代码生成
后端·程序员·代码规范
Moonbit2 小时前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言