浅谈Golang互斥锁sync.Mutex

简介: 浅谈Golang互斥锁sync.Mutex

概述

Mutex的数据结构

Go中Mutex的数据结构是这样的,因为足够简单,所以不需要额外的初始化,零值就是一个有效的互斥锁,处于Unlocked状态。state存储的是互斥锁的状态,加锁和解锁,都是通过atomic包提供的函数原子性,操作该字段。sema用作一个信号量,主要用于等待队列。

type Mutex struct {
  state int32
  sema  uint32
}

Mutex.state的状态

最低三位分别表示 mutexLocked、mutexWoken 和 mutexStarving,剩下的位置用来表示当前有多少个 Goroutine 等待互斥锁的释放:

在默认情况下,互斥锁的所有状态位都是 0,int32 中的不同位分别表示了不同的状态:

  • mutexLocked — 用作锁状态标识
  • mutexWoken — 记录是否已有goroutine被唤醒
  • mutexStarving — 工作模式,0代表正常模式,1代表饥饿模式
  • mutexWaiterShift — 表示除了低三位以外,state的其它位用来记录有多少个等待者在排队。

正常模式

正常模式下,所有等待锁的goroutine按照FIFO顺序等待。唤醒的goroutine不会直接拥有锁,而是会和新请求锁的goroutine(正在自旋)竞争锁。新请求锁的goroutine具有优势:它正在CPU上执行,而且可能有好几个,所以刚刚唤醒的goroutine有很大可能在锁竞争中失败。失败的情况下,这个被唤醒的goroutine会加入到等待队列的最前面。 如果一个等待的goroutine超过1ms没有获取锁,那么它将会把锁转变为饥饿模式。

饥饿模式

饥饿模式下,锁的所有权将从unlock的gorutine直接交给交给等待队列中的第一个。新来的goroutine将不会尝试去获得锁,即使锁看起来是unlock状态, 也不会去尝试自旋操作,而是放在等待队列的尾部。

如果一个等待的goroutine获取了锁,并且满足一以下其中的任何一个条件:(1)它是队列中的最后一个;(2)它等待的时候小于1ms。它会将锁的状态转换为正常状态。

正常状态有很好的吞吐量,饥饿模式也是非常重要的,因为它能阻止尾部延迟的现象。

所以sync.Mutex设计上互斥锁有两种状态:正常状态和饥饿状态。

源码解析

Lock

func (m *Mutex) Lock() {
  // 如果mutex的state没有被锁,也没有等待/唤醒的goroutine, 锁处于正常状态,那么获得锁并返回
    // 比如锁第一次被goroutine请求时,就是这种状态。或者锁处于空闲的时候,也是这种状态。
  if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return
  }
  // 如果锁已经有goroutine占用了,则进入lockslow阻塞
  m.lockSlow()
}
func (m *Mutex) lockSlow() {
  // 记录当前goroutine的等待时间
  var waitStartTime int64
  // 当前goroutine是否已经处于饥饿状态
  starving := false
  // 当前goroutine是否已唤醒
  awoke := false
  // 自旋次数
  iter := 0
  old := m.state
  for {
    // 第一个条件:1.mutex已经被锁了;2.不处于饥饿模式(如果时饥饿状态,自旋时没有用的,锁的拥有权直接交给了等待队列的第一个。)
    // 尝试自旋的条件:参考runtime_canSpin函数,详解在前言那篇博文,这里不展开
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
      // 进入这里肯定是正常模式
      // 自旋的过程中如果发现state还没有设置woken标识(别的goroutine释放了自己的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
    }
    // 到了这一步, state的状态可能是:
        // 1. 锁还没有被释放,锁处于正常状态
        // 2. 锁还没有被释放, 锁处于饥饿状态
        // 3. 锁已经被释放, 锁处于正常状态
        // 4. 锁已经被释放, 锁处于饥饿状态
        // 并且本gorutine的 awoke可能是true, 也可能是false (其它goutine已经设置了state的woken标识)
    // new 复制 state的当前状态, 用来设置新的状态
        // old 是锁当前的状态
    new := old
    // 如果old state状态不是饥饿状态, new state 设置锁, 尝试通过CAS获取锁,
        // 如果old state状态是饥饿状态, 则不设置new state的锁,因为饥饿状态下锁直接转给等待队列的第一个.
    if old&mutexStarving == 0 {//正常模式
      new |= mutexLocked
    }
    // 将等待队列的等待者的数量加1
    if old&(mutexLocked|mutexStarving) != 0 {
      new += 1 << mutexWaiterShift
    }
    // 如果当前goroutine已经处于饥饿状态, 并且old state的已被加锁,
        // 将new state的状态标记为饥饿状态, 将锁转变为饥饿状态.
    if starving && old&mutexLocked != 0 {
      new |= mutexStarving
    }
    // 如果本goroutine已经设置为唤醒状态, 需要清除new state的唤醒标记, 因为本goroutine要么获得了锁,要么进入休眠,
        // 总之state的新状态不再是woken状态.
    if awoke {
      // The goroutine has been woken from sleep,
      // so we need to reset the flag in either case.
      if new&mutexWoken == 0 {
        throw("sync: inconsistent mutex state")
      }
      new &^= mutexWoken
    }
    // 通过CAS设置new state值.
        // 注意new的锁标记不一定是true, 也可能只是标记一下锁的state是饥饿状态.
    if atomic.CompareAndSwapInt32(&m.state, old, new) {
      // 如果old state的状态是未被锁状态,并且锁不处于饥饿状态,
            // 那么当前goroutine已经获取了锁的拥有权,返回
      if old&(mutexLocked|mutexStarving) == 0 {
        break // locked the mutex with CAS
      }
      // 设置并计算本goroutine的等待时间
      queueLifo := waitStartTime != 0
      if waitStartTime == 0 {
        waitStartTime = runtime_nanotime()
      }
      // 既然未能获取到锁, 那么就使用sleep原语阻塞本goroutine
            // 如果是新来的goroutine,queueLifo=false, 加入到等待队列的尾部,耐心等待
            // 如果是唤醒的goroutine, queueLifo=true, 加入到等待队列的头部
      runtime_SemacquireMutex(&m.sema, queueLifo, 1)
      // sleep之后,此goroutine被唤醒
            // 计算当前goroutine是否已经处于饥饿状态.
      starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
      // 得到当前的锁状态
      old = m.state
      // 如果当前的state已经是饥饿状态
            // 那么锁应该处于Unlock状态,那么应该是锁被直接交给了本goroutine
      if old&mutexStarving != 0 {
        // If this goroutine was woken and mutex is in starvation mode,
        // ownership was handed off to us but mutex is in somewhat
        // inconsistent state: mutexLocked is not set and we are still
        // accounted as waiter. Fix that.
        if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
          throw("sync: inconsistent mutex state")
        }
        // 当前goroutine用来设置锁,并将等待的goroutine数减1.
        delta := int32(mutexLocked - 1<<mutexWaiterShift)
        // 如果本goroutine是最后一个等待者,或者它并不处于饥饿状态,
                // 那么我们需要把锁的state状态设置为正常模式.
        if !starving || old>>mutexWaiterShift == 1 {
           // 退出饥饿模式
          delta -= mutexStarving
        }
        // 设置新state, 因为已经获得了锁,退出、返回
        atomic.AddInt32(&m.state, delta)
        break
      }
      awoke = true
      iter = 0
    } else {
      old = m.state
    }
  }
}

正常模式

  1. mutex已经被locked了,处于正常模式下;
  2. 前 Goroutine 为了获取该锁进入自旋的次数小于四次;
  3. 当前机器CPU核数大于1;
  4. 至少存在一个正在运行的 P 并且当前P的运行队列为空;

满足上面四个条件的goroutine才可以做自旋。自旋就会调用sync.runtime_doSpin 和 runtime.procyield 并执行 30 次的 PAUSE 指令,该指令只会占用 CPU 并消耗 CPU 时间。

处理了自旋相关的特殊逻辑之后,互斥锁会根据上下文计算当前互斥锁最新的状态new。几个不同的条件分别会更新 state 字段中存储的不同信息 — mutexLocked、mutexStarving、mutexWoken 和 mutexWaiterShift。

计算最新的new之后,CAS更新,如果更新成功且old状态是未被锁状态,并且锁不处于饥饿状态,就代表当前goroutine竞争成功并获取到了锁返回。(这也就是当前goroutine在正常模式下竞争时更容易获得锁的原因)

如果当前goroutine竞争失败,会调用 sync.runtime_SemacquireMutex 使用信号量保证资源不会被两个 Goroutine 获取。sync.runtime_SemacquireMutex 会在方法中不断调用尝试获取锁并休眠当前 Goroutine, 等待信号量的释放,一旦当前 Goroutine 可以获取信号量,它就会立刻返回,sync.Mutex.Lock 方法的剩余代码也会继续执行。

饥饿模式

饥饿模式本身是为了一定程度保证公平性而设计的模式。所以饥饿模式不会有自旋的操作,新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。

  1. 在正常模式下,这段代码会设置唤醒和饥饿标记、重置迭代次数并重新执行获取锁的循环;
  2. 在饥饿模式下,当前 Goroutine 会获得互斥锁,如果等待队列中只存在当前 Goroutine,互斥锁还会从饥饿模式中退出;

Unlock

func (m *Mutex) Unlock() {
  // 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.
    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 {
      // If there are no waiters or a goroutine has already
      // been woken or grabbed the lock, no need to wake anyone.
      // In starvation mode ownership is directly handed off from unlocking
      // goroutine to the next waiter. We are not part of this chain,
      // since we did not observe mutexStarving when we unlocked the mutex above.
      // So get off the way.
      if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
        return
      }
      // Grab the right to wake someone.
      new = (old - 1<<mutexWaiterShift) | mutexWoken
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
        runtime_Semrelease(&m.sema, false, 1)
        return
      }
      old = m.state
    }
  } else {
    // Starving mode: handoff mutex ownership to the next waiter, and yield
    // our time slice so that the next waiter can start to run immediately.
    // Note: mutexLocked is not set, the waiter will set it after wakeup.
    // But mutex is still considered locked if mutexStarving is set,
    // so new coming goroutines won't acquire it.
    runtime_Semrelease(&m.sema, true, 1)
  }
}

互斥锁的解锁过程 sync.Mutex.Unlock 与加锁过程相比就很简单,该过程会先使用 AddInt32 函数快速解锁,这时会发生下面的两种情况:

  1. 如果该函数返回的新状态等于 0,当前 Goroutine 就成功解锁
  2. 如果该函数返回的新状态不等于 0,这段代码会调用 sync.Mutex.unlockSlow 方法

sync.Mutex.unlockSlow 方法首先会校验锁状态的合法性 — 如果当前互斥锁已经被解锁过了就会直接抛出异常 sync: unlock of unlocked mutex 中止当前程序。

在正常情况下会根据当前互斥锁的状态,分别处理正常模式和饥饿模式下的互斥锁:

正常模式

  1. 如果 互斥锁没有等待者 或者互斥锁mutexLocked、mutexStarving、mutexWoken 状态不都为 0,那么当前方法就可以直接返回,不需要唤醒其他等待者
  2. 如果互斥锁存在等待者,会通过 sync.runtime_Semrelease 唤醒等待者并移交锁的所有权

饥饿模式

上述代码会直接调用 sync.runtime_Semrelease 将当前锁交给下一个正在尝试获取锁的等待者,等待者被唤醒后会得到锁,在这时互斥锁还不会退出饥饿状态


目录
相关文章
|
7月前
|
安全 Go
Golang深入浅出之-互斥锁(sync.Mutex)与读写锁(sync.RWMutex)
【4月更文挑战第23天】Go语言并发编程中,`sync.Mutex`和`sync.RWMutex`是保证线程安全的关键。互斥锁确保单个goroutine访问资源,而读写锁允许多个读者并发访问。常见问题包括忘记解锁、重复解锁以及混淆锁类型。使用`defer`可确保解锁,读写锁不支持直接升级或降级,需释放后再获取。根据读写模式选择合适锁以避免性能下降和竞态条件。理解并正确使用锁是编写并发安全程序的基础。
142 3
|
3月前
|
安全 Go
Golang语言goroutine协程并发安全及锁机制
这篇文章是关于Go语言中多协程操作同一数据问题、互斥锁Mutex和读写互斥锁RWMutex的详细介绍及使用案例,涵盖了如何使用这些同步原语来解决并发访问共享资源时的数据安全问题。
95 4
|
4月前
|
SQL 安全 Java
golang为什么不支持可重入锁?
本文对比分析了Java与Go语言中锁机制的不同。在Java中,无论是`synchronized`关键字还是`ReentrantLock`都支持可重入特性,通过维护一个计数器来跟踪锁的嵌套级别,确保同一线程可以多次获取同一把锁而不会造成死锁。然而,Go语言的`sync.Mutex`并不支持这一特性,其设计理念认为可重入锁往往指向代码设计问题,鼓励开发者重构代码以避免此类需求。文章进一步解释了这种设计理念背后的原因,并提供了替代方案示例。总体而言,Go语言试图从设计层面避免潜在的代码问题,尽管这可能会增加一定的开发复杂性。
golang为什么不支持可重入锁?
|
7月前
|
Go
Golang 中的互斥锁是什么?
# go # programming # beginners # architecture
|
7月前
|
存储 安全 Go
Golang分段锁
Golang分段锁
100 0
|
7月前
|
存储 编译器 Go
Golang底层原理剖析之互斥锁sync.Mutex
Golang底层原理剖析之互斥锁sync.Mutex
111 0
|
3月前
|
Go
Golang语言之管道channel快速入门篇
这篇文章是关于Go语言中管道(channel)的快速入门教程,涵盖了管道的基本使用、有缓冲和无缓冲管道的区别、管道的关闭、遍历、协程和管道的协同工作、单向通道的使用以及select多路复用的详细案例和解释。
125 4
Golang语言之管道channel快速入门篇
|
3月前
|
Go
Golang语言文件操作快速入门篇
这篇文章是关于Go语言文件操作快速入门的教程,涵盖了文件的读取、写入、复制操作以及使用标准库中的ioutil、bufio、os等包进行文件操作的详细案例。
70 4
Golang语言文件操作快速入门篇
|
3月前
|
Go
Golang语言之gRPC程序设计示例
这篇文章是关于Golang语言使用gRPC进行程序设计的详细教程,涵盖了RPC协议的介绍、gRPC环境的搭建、Protocol Buffers的使用、gRPC服务的编写和通信示例。
108 3
Golang语言之gRPC程序设计示例
|
3月前
|
Go 调度
Golang语言goroutine协程篇
这篇文章是关于Go语言goroutine协程的详细教程,涵盖了并发编程的常见术语、goroutine的创建和调度、使用sync.WaitGroup控制协程退出以及如何通过GOMAXPROCS设置程序并发时占用的CPU逻辑核心数。
56 4
Golang语言goroutine协程篇