go Mutex 深入理解

go Mutex 深入理解

Mutex 互斥锁

Mutex 定义如下:

go 复制代码
type Mutex struct {
    _ noCopy

    mu isync.Mutex
}

type Mutex struct {
    // 复合字段
    state int32
    //是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。
    sema  uint32 
}

state 字段含义 state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有唤醒的 goroutine,剩余的位数代表的是等待此锁的 goroutine 数。所以,state 这一个字段被分成了三部分,代表三个数据

Mutex CAS操作(幸运case)

CAS 指令将给定的值 和一个内存地址中的值 进行比较,如果它们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性的。那啥是原子性呢?如果你还不太理解这个概念,那么在这里只需要明确一点就行了,那就是原子性保证这个指令总是基于最新的值进行计算,如果同时有其它线程已经修改了这个值,那么,CAS 会返回失败。

Mutex 的CAS具体实现

检查有没有持有锁和锁等待的goroutine,也被称为幸运case state不是零值会进循环检测,实现代码如下:

go 复制代码
func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
       // Don't spin in starvation mode, ownership is handed off to waiters
       // so we won't be able to acquire the mutex anyway.
       
       if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { // 锁是非饥饿状态 没有释放 并且可以自旋
          // Active spinning makes sense.
          // Try to set mutexWoken flag to inform Unlock
          // to not wake other blocked goroutines.
          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
       // Don't try to acquire starving mutex, new arriving goroutines must queue.
       if old&mutexStarving == 0 {
          new |= mutexLocked // 非饥饿状态,加锁
       }
       if old&(mutexLocked|mutexStarving) != 0 {
          new += 1 << mutexWaiterShift // waiter 数量加1
       }
       // The current goroutine switches mutex to starvation mode.
       // But if the mutex is currently unlocked, don't do the switch.
       // Unlock expects that starving mutex has waiters, which will not
       // be true in this case.
       if starving && old&mutexLocked != 0 {
          new |= mutexStarving // 设置饥饿状态
       }
       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 // 新状态 清楚唤醒表锁
       }
       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()
          }
          runtime_SemacquireMutex(&m.sema, queueLifo, 2) // 阻塞等待
          // 唤醒之后检测是否是饥饿状态
          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")
             }
             // 加锁,waiiter-1
             delta := int32(mutexLocked - 1<<mutexWaiterShift)
             if !starving || old>>mutexWaiterShift == 1 {
                // 最后一个waiter 或者已经不饥饿,清除
                delta -= mutexStarving
             }
             atomic.AddInt32(&m.state, delta)
             break
          }
          awoke = true // 唤醒
          iter = 0
       } else {
          old = m.state
       }
    }

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

饥饿模式和正常模式

Mutex 可能处于两种操作模式下:正常模式和饥饿模式。接下来我们分析一下 Mutex 对饥饿模式和正常模式的处理。请求锁时调用的 Lock 方法中一开始是 fast path,这是一个幸运的场景,当前的goroutine 幸运地获得了锁,没有竞争,直接返回,否则就进入了 lockSlow 方法。这样的设计,方便编译器对 Lock 方法进行内联,你也可以在程序开发中应用这个技巧。 正常模式 下,waiter 都是进入先入先出队列,被唤醒的 waiter 并不会直接持有锁,而是要和新来的 goroutine 进行竞争。新来的 goroutine 有先天的优势,它们正在 CPU 中运行 ,可能它们的数量还不少,所以,在高并发情况下,被唤醒的 waiter 可能比较悲剧地获取不到锁,这时,它会被插入到队列的前面。如果 waiter 获取不到锁的时间超过阈值 1 毫秒,那么,这个 Mutex 就进入到了饥饿模式。

饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin,它会乖乖地加入到等待队列的尾部。

如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式:

  1. 此 waiter 已经是队列中的最后一个 waiter 了,没有其它的等待锁的 goroutine 了;
  2. 此 waiter 的等待时间小于 1 毫秒。

正常模式拥有更好的性能,因为即使有等待抢锁的 waiter,goroutine 也可以连续多次获取到锁。饥饿模式是对公平性和性能的一种平衡,它避免了某些 goroutine 长时间的等待锁。在饥

饿模式下,优先对待的是那些一直在等待的 waiter。

避免死锁、

接下来,我们来看第四种错误场景:死锁。

我先解释下什么是死锁。两个或两个以上的进程(或线程,goroutine)在执行过程中,因争夺共享资源而处于一种互相等待的状态,如果没有外部干涉,它们都将无法推进下去,此时,我们称系统处于死锁状态或系统产生了死锁。 我们来分析一下死锁产生的必要条件。如果你想避免死锁,只要破坏这四个条件中的一个或者几个,就可以了。

  1. 互斥: 至少一个资源是被排他性独享的,其他线程必须处于等待状态,直到资源被释放。
  2. 持有和等待:goroutine 持有一个资源,并且还在请求其它 goroutine 持有的资源,也就是咱们常说的"吃着碗里,看着锅里"的意思。
  3. 不可剥夺:资源只能由持有它的 goroutine 来释放。
  4. 环路等待:一般来说,存在一组等待进程,P={P1,P2,...,PN},P1 等待 P2 持有的资源,P2 等待 P3 持有的资源,依此类推,最后是 PN 等待 P1 持有的资源,这就形成 了一个环路等待的死结。
相关推荐
梁梁梁梁较瘦2 天前
边界检查消除(BCE,Bound Check Elimination)
go
梁梁梁梁较瘦2 天前
指针
go
梁梁梁梁较瘦2 天前
内存申请
go
半枫荷2 天前
七、Go语法基础(数组和切片)
go
梁梁梁梁较瘦2 天前
Go工具链
go
半枫荷3 天前
六、Go语法基础(条件控制和循环控制)
go
月弦笙音3 天前
【Vue3】Keep-Alive 深度解析
前端·vue.js·源码阅读
半枫荷4 天前
五、Go语法基础(输入和输出)
go
小王在努力看博客4 天前
CMS配合闲时同步队列,这……
go
Anthony_49264 天前
逻辑清晰地梳理Golang Context
后端·go