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 小时前
(LeetCode 面试经典 150 题 ) 11. 盛最多水的容器 (贪心+双指针)
java·c++·算法·leetcode·面试·go
Nejosi_念旧2 小时前
解读 Go 中的 constraints包
后端·golang·go
漫步向前9 小时前
gin问题知识点汇总
go
塞尔维亚大汉11 小时前
鸿蒙内核源码分析(消息封装篇) | 剖析LiteIpc 进程通讯内容
harmonyos·源码阅读
钩子波比11 小时前
🚀 Asynq 学习文档
redis·消息队列·go
Jayconscious11 小时前
React源码解析(五):hook原理
前端·react.js·源码阅读
塞尔维亚大汉13 小时前
鸿蒙内核源码分析(共享内存) | 进程间最快通讯方式
harmonyos·源码阅读
漫步向前13 小时前
beegoMVC问题知识点汇总
go
一个热爱生活的普通人17 小时前
Go 泛型终极指南:告别 interface{},写出更安全、更强大的代码!
后端·go