• Go-知识并发控制mutex


    gitio: https://a18792721831.github.io/

    1. 介绍

    互斥锁是并发程序中对共享资源进行访问控制的主要手段,Go 语言提供了非常简单易用的 Mutex。
    Mutex 是结构体类型,对外暴露了 Lock 和 Unlock 两个方法,用于加锁和解锁。

    2. 数据结构

    2.1 Mutex 结构体

    在源码包 src/sync/mutex.go中定义了互斥锁的数据结构:
    在这里插入图片描述

    Mutex.state 表示互斥锁的状态,比如是否被锁定等等
    Mutex.sema 表示信号量,协程阻塞等待该信号量,解锁的写成释放信号量,从而唤醒等待信号量的协程

    其中 Mutex.state 是 32 位的整型变量,内部实现时,把该变量分成四部分,用于记录 Mutex 的四种状态。
    在这里插入图片描述

    • Locked: 表示该 Mutex 是否已被锁定,0表示没有锁定,1表示已被锁定.
    • Woken: 表示是否有协程已被唤醒,0表示没有协程唤醒,1表示已有协程唤醒,正在加锁过程中.
    • Starving: 表示该 Mutex 是否处于饥饿状态,0表示没有饥饿,1表示饥饿,说明有协程阻塞超过了1ms
    • Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。

    协程之间的抢锁实际上是抢给 Locked 赋值的权利,能给 Locked 赋值 1,就说明抢锁成功。
    抢不到就阻塞等待 Mutex.sema 信号量,一旦持有锁的协程解锁,等待的协程就会依次被唤醒。

    2.2 Mutex 方法

    Mutex 对外提供的方法主要是加锁和解锁:

    • Lock(): 加锁
    • Unlock(): 解锁
    • TryLock(): 非阻塞的方式尝试加锁(Go 1.18 引入)

    3. 加锁解锁过程

    3.1 简单加锁

    假设当前只有一个协程在加锁,没有其他协程干扰:
    在这里插入图片描述

    加锁过程中会判断 Locked 的标志位是否为0,如果是0则把 Locked 位置为1,代表大锁成功。
    如上图所示,加锁成功后,只有 Locked 位置为1,其他位置都没变。

    func (m *Mutex) Lock() {
    	// 快速尝试,使用 cas 进行加锁,如果 state 等于 0 表示无锁
    	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    		return
    	}
    	// 否则就需要慢加锁了
    	m.lockSlow()
    }
    
    func (m *Mutex) lockSlow() {
        // 等待开始时间
    	var waitStartime int64
    	// 是否饥饿
    	starving := false
    	// 是否有协程被唤醒
    	awoke := false
    	iter := 0
    	// 获取 state 值
    	old := m.state
    	// 死循环
    	for {
    		// old & mutexLocked 表示取出 Locked 位
    		// old & mutexStarving 表示取出 Starving 位
    		// old & (mutexLocked | mutexStarving) 表示取出 Locked 或 starving 位
    		// 所以 old & (mutexLocked|mutexStarving) == mutexLocked 表示 Locked 或 Starving 等于 1。
    		// 要么有锁,要么饥饿
    		// 并且当前 goroutine 可以进行自旋
    		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
    			// 没有饥饿 而且 没有协程被唤醒 并且 Waiter 等待的协程不为空
    			// 尝试使用 CAS 设置 Worken 值,表示有 goroutine 被唤醒了,在自旋等待锁
    			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
    				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
    				// 是否有协程被唤醒标志置为 true 
    				awoke = true
    			}
    			// 自旋 ,我认为可以理解为 continue ..
    			runtime_doSpin()
    			// 自旋次数加1
    			iter++
    			// 重新获取最新的 state 值
    			old = m.state
    			// 自旋
    			continue
    		}
    		// 重新获取 state
    		new := old
    		// 没有饥饿
    		if old&mutexStarving == 0 {
    		    // 新值设置为加锁了
    			new |= mutexLocked
    		}
    		// 如果存在饥饿或者有锁
    		if old&(mutexLocked|mutexStarving) != 0 {
    		    // Waiter 数量增加 1 
    		    // 1< starvationThresholdNs
    			// 重新获取 state 
    			old = m.state
    			// 如果存在饥饿
    			if old&mutexStarving != 0 {
    			    // 有锁 或 有唤醒的协程 或者
    			    // 没有等待的协程
    				if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
    				    // 不能即处于饥饿,有存在唤醒,还没有锁
    				    // 不能没有等待的协程,又处于饥饿状态
    					throw("sync: inconsistent mutex state")
    				}
    				// 表示 1 减去 1000 = -7 
    				delta := int32(mutexLocked - 1<>mutexWaiterShift == 1 {
    					// -7 减去 -4 ,移除饥饿模式
    					delta -= mutexStarving
    				}
    				// 更新 state
    				// 防止自旋死锁
    				atomic.AddInt32(&m.state, delta)
    				break
    			}
    			awoke = true
    			iter = 0
    		} else {
    			old = m.state
    		}
    	}
    }
    

    3.2 加锁被阻塞

    假设加锁时,锁已经被其他协程占用了:
    在这里插入图片描述

    当协程B对一个已被占用的锁再次加锁时,Waiter 计数器增加1,此时协程B被阻塞,直到 Locked 值变为0后才会被唤醒。

    3.3 简单解锁

    假设解锁时,没有其他协程阻塞:
    在这里插入图片描述

    由于没有其他协程阻塞等待加锁,所以此时解锁时只需要把 Locked 位置为0即可,不需要释放信号量。

    func (m *Mutex) Unlock() {
    	// 使用 CAS 快速解锁
    	new := atomic.AddInt32(&m.state, -mutexLocked)
    	// 如果 CAS 解锁失败,那么尝试慢解锁
    	if new != 0 {
    		m.unlockSlow(new)
    	}
    }
    
    func (m *Mutex) unlockSlow(new int32) {
        // 如果 无锁 new 最低位 1,因为在 Unlock 中已经减去 1 了 ,1+1 = 10 , 0 & 1 = 0 , 0 == 0 
        // 如果 有锁 new 最低位 0,0+1 = 1, 1 & 1 = 1, 1 == 0
        // 所以,无锁异常,有锁继续 
    	if (new+mutexLocked)&mutexLocked == 0 {
    		throw("sync: unlock of unlocked mutex")
    	}
    	// 如果没有饥饿
    	if new&mutexStarving == 0 {
    	    // 临时赋值
    		old := new
    		for {
    		    // Waiter 等于 0 ,没有等待的协程 或
    		    // 处于 饥饿,有锁,有唤醒 ,结束
    			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
    				return
    			}
    			// Waiter 减 1,并且 保留唤醒状态
    			new = (old - 1<

    3.4 解锁并唤醒协程

    假设解锁时,有一个或多个协程阻塞:
    在这里插入图片描述

    协程A解锁过程分为两个步骤,一是把 Locked 位置为0,二是看到 Waiter > 0 ,释放1个信号量,唤醒一个阻塞的协程,被唤醒的协程 B 把 Locked 位置为1,于是协程B获得锁。

    4. 自旋过程

    加锁时,如果当前 Locked 位为1,则说明该锁当前由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测 Locked 位是否变为 0 ,这个过程称为自旋过程。
    自旋时间很短,如果在自旋过程中发现锁已经被释放,那么协程可以立即获取锁。此时即便有协程被唤醒也无法获取锁,只能再次阻塞。
    自旋的好处是,当加锁失败时,不必立即转入阻塞,有一定机会获取到锁,这样可以避免协程的切换。

    4.1 什么是自旋

    自旋对应于CPU的PAUSE指令,CPU对该指令什么都不做,相当于CPU空转,对程序而言相当于 sleep 了一小段时间,时间非常短,当前实现是 30个时钟周期。
    自旋过程中会持续探测 Locked 是否变为0 ,连续两次探测奸恶就是在执行这些 PAUSE 指令,不同于sleep,不需要将协程转为睡眠状态。

    4.2 自旋条件

    加锁时,程序会自动判断是否可以自旋,无限制的自旋会给CPU带来巨大压力,所以判断是否可以自旋就很重要了。
    自旋必须满足一下所有条件:

    • 自旋次数要足够小,通常为 4 ,即自旋最多4次
    • CPU核数大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁
    • 协程调度机制中的 Process 数量要大于1,比如使用 GOMAXPROCESS() 将处理器设置为1就不能启动自旋
    • 协程调度机制中的可运行队列必须为空,否则会延迟协程调度。

    简单来说就是 不忙 的时候才会启动自旋

    4.3 自旋的优势

    自旋的优势是更充分地利用CPU,尽量避免协程切换。因为当前申请加锁的协程拥有CPU,如果经过短时间的自旋可以获得锁,则蒋倩协程可以继续运行,不必进入阻塞状态。

    4.4 自旋的问题

    如果自旋过程中获得锁,那么之前被阻塞的协程将无法获得锁,如果加锁的协程特别多,每次都通过自旋获得锁,那么之前被阻塞的协程将很难获得锁,从而进入 Starving 状态。
    为了避免协程长时间无法获取锁,自 1.8 版本依赖增加了一个状态, Starving 。在这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。

    5. Mutex 模式

    每个 Mutex 都有两个模式,Normal 和 Starving 。

    5.1 Normal 模式

    默认情况下,Mutex 的模式是 Normal。
    在Normal模式下,如果协程加锁不成功不会立即转为阻塞排队,而是判断是否满足自旋的条件,如果满足则会启动自旋,尝试抢锁。

    5.2 Starving 模式(饥饿模式)

    自旋过程中能抢到锁,一定意味着同一时间有协程释放了锁。释放锁时,如果发现有阻塞等待的协程,那么还会释放一个信号量来唤醒一个等待协程,
    被唤醒的协程得到CPU后开始运行,此时发现锁已经被抢占了,只能再次阻塞。阻塞钱会判断自上次阻塞到本次阻塞经过了多长时间,如果超过1ms,则会将Mutex 标记为 Starving 模式,然后阻塞。
    在 Starving 模式下,不会启动自旋过程,即一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将乘公共获取锁,同时会把等待计数减1.

    6. Woken 状态

    Woken 状态用与加锁和解锁过程的通信。
    比如同一个时刻,两个协程一个在加锁,另一个在解锁,加锁的协程可能在自旋过程中,此时把 Woken 标记为1,用于通知解锁协程不必释放信号量。
    存在唤醒中的协程,不需要信号量唤醒。

    7. 重复解锁引发 panic

    如果 Mutex 处于无锁状态,执行 Unlock 会触发 panic。
    Unlock 的逻辑分为两个过程: 将 Locked 置为 0 ;判断 Waiter 数量,Waiter > 0,释放一个信号量。
    如果多次执行Unlock,那么可能每次都释放一个信号量,这样会唤醒多个协程,多个协程被唤醒后继续在 Lock 的逻辑中抢锁,就需要在 Lock 的逻辑中增加更多场景,
    并且引发更多的协程切换。

    8. 总结

    在使用 Mutex 的时候,使用 defer 避免死锁。加锁后立即使用 defer 解锁,可以有效避免死锁。
    加锁和解锁应该成对出现,最好是出现在同一个层次的代码块中,否则很容易引发因重复解锁导致的panic 。

    (Java允许重复 release)

  • 相关阅读:
    SQL 杂谈 1
    RANKGEN: Improving Text Generation with Large Ranking Models论文
    Selenium基础:自动化你的网页交互
    远程网络控制需要考虑的问题
    MinGW——检查版本的命令
    人工神经网络案例分析题,人工神经网络算法实例
    HTML~
    UE4游戏保存
    为什么有的测试员路越走越窄?原因在这里
    HSRP协议(思科私有)/VRRP协议(公有)
  • 原文地址:https://blog.csdn.net/a18792721831/article/details/139377732