go底层系列-mutex原理剖析

go底层系列-mutex原理剖析

前言

  • 互斥锁是并发程序中对共享资源进行访问控制的主要手段
  • Go语言提供了非常简单易用的Mutex
    • Mutex为一结构体类型
      • 对外暴露两个方法Lock()和Unlock()
      • 分别用于加锁和解锁
    • Mutex使用起来非常方便,但其内部实现却复杂得多
    • 这包括Mutex的几种状态
  • Mutex重复解锁引起panic

Mutex数据结构

Mutex结构体

在这里插入图片描述

  • Mutex.state表示互斥锁的状态

    • 比如是否被锁定
  • Mutex.sema表示信号量

    • 协程阻塞等待该信号量
      • 解锁的协程释放信号量从而唤醒等待信号量的协程
  • Mutex.state是32位的整型变量

    • 内部实现时把该变量分成四份
      • 用于记录Mutex的四种状态

下图展示Mutex的内存布局:

在这里插入图片描述

  • Locked: 表示该Mutex是否已被锁定

    • 0:没有锁定
    • 1:已被锁定
  • Woken: 表示是否有协程已被唤醒

    • 0:没有协程唤醒
    • 1:已有协程唤醒,正在加锁过程中
  • Starving:表示该Mutex是否处于饥饿状态

    • 0:没有饥饿
    • 1:饥饿状态
      • 说明有协程阻塞了超过1ms
  • Waiter: 表示阻塞等待锁的协程个数

    • 协程解锁时根据此值来判断是否需要释放信号量
  • 协程之间抢锁实际上是抢给Locked赋值的权利

    • 能给Locked域置1,就说明抢锁成功
    • 抢不到的话就阻塞等待 Mutex.sema信号量
      • 一旦持有锁的协程解锁,等待的协程会依次被唤醒
  • Woken和Starving主要用于控制协程间的抢锁过程

Mutex方法
  • Mutext对外提供两个方法,实际上也只有这两个方法:
    • Lock() : 加锁方法
    • Unlock(): 解锁方法
  • 下面我们分析一下加锁和解锁的过程
    • 加锁分成功和失败两种情况
      • 成功的话直接获取锁
      • 失败后当前协程被阻塞
    • 同样,解锁时跟据是否有阻塞协程也有两种处理

加解锁过程

简单加锁

假定当前只有一个协程在加锁,没有其他协程干扰,那么过程如下图所示:

在这里插入图片描述

  • 加锁过程会去判断Locked标志位是否为0
    • 如果是0则把Locked位置1,代表加锁成功
  • 从上图可见,加锁成功后
    • 只是Locked位置1,其他状态位没发生变化
加锁被阻塞

假定加锁时,锁已被其他协程占用了,此时加锁过程如下图所示:

在这里插入图片描述

  • 从上图可看到
    • 当协程B对一个已被占用的锁再次加锁时
      • Waiter计数器增加了1
      • 此时协程B将被阻塞
      • 直到 Locked值变为0后才会被唤醒
简单解锁

假定解锁时,没有其他协程阻塞,此时解锁过程如下图所示:

在这里插入图片描述

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

假定解锁时,有1个或多个协程阻塞,此时解锁过程如下图所示:

在这里插入图片描述

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

自旋过程

  • 加锁时,如果当前Locked位为1
    • 说明该锁当前由其他协程持有
  • 尝试加锁的协程并不是马上转入阻塞
    • 而是会持续的探测Locked位是否变为0
    • 这个过程即为自旋过程
  • 自旋时间很短,但如果在自旋过程中发现锁已被释放
    • 那么协程可以立即获取锁
  • 此时即便有协程被唤醒也无法获取锁,只能再次阻塞
  • 自旋的好处是
    • 当加锁失败时不必立即转入阻塞
    • 有一定机会获取到锁
      • 这样可以避免协程的切换
什么是自旋?
  • 自旋对应于CPU的”PAUSE”指令
    • CPU对该指令什么都不做,相当于CPU空转
    • 对程序而言相当于sleep了一小段时间,时间非常短
    • 当前实现是30个时钟周期
  • 自旋过程中会持续探测Locked是否变为0
    • 连续两次探测间隔就是执行这些PAUSE指令
    • 它不同于sleep
    • 不需要将协程转为睡眠状态
自旋条件
  • 加锁时程序会自动判断是否可以自旋
    • 无限制的自旋将会给CPU带来巨大压力
    • 所以判断是否可以自旋就很重要了
  • 自旋必须满足以下所有条件:
    • 自旋次数要足够小,通常为4
      • 即自旋最多4次
    • CPU核数要大于1
      • 否则自旋没有意义
      • 因为此时不可能有其他协程释放锁
    • 协程调度机制中的Process数量要大于1
      • 比如使用GOMAXPROCS()将处理器设置为1就不能启用自旋
    • 协程调度机制中的可运行队列必须为空
      • 否则会延迟协程调度
  • 可见,自旋的条件是很苛刻的
    • 总而言之就是不忙的时候才会启用自旋
自旋的优势
  • 自旋的优势是
    • 更充分的利用CPU,尽量避免协程切换
      • 因为当前申请加锁的协程拥有CPU
      • 如果经过短时间的自旋可以获得锁
      • 当前协程可以继续运行,不必进入阻塞状态
自旋的问题
  • 如果自旋过程中获得锁
    • 那么之前被阻塞的协程将无法获得锁
    • 如果加锁的协程特别多,每次都通过自旋获得锁
      • 那么之前被阻塞的进程将很难获得锁,从而进入饥饿状态
    • 为了避免协程长时间无法获取锁
      • 自1.8版本以来增加了一个状态,即Mutex的Starving状态
      • 这个状态下不会自旋
        • 一旦有协程释放锁
        • 那么一定会唤醒一个协程并成功加锁

Mutex模式

  • 前面分析加锁和解锁过程中只关注了Waiter和Locked位的变化
  • 现在我们看一下Starving位的作用
  • 每个Mutex都有两个模式
    • Normal
    • Starving
normal模式
  • 默认情况下,Mutex的模式为normal,该模式下
    • 协程如果加锁不成功
      • 不会立即转入阻塞排队
      • 而是判断是否满足自旋的条件
        • 如果满足则会启动自旋过程
        • 尝试抢锁
starvation模式
  • 自旋过程中能抢到锁
    • 一定意味着同一时刻有协程释放了锁
  • 释放锁时如果发现有阻塞等待的协程
    • 还会释放一个信号量来唤醒一个等待协程
      • 被唤醒的协程得到CPU后开始运行
    • 此时发现锁已被抢占了,自己只好再次阻塞
      • 不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间
      • 如果超过1ms的话
        • 将Mutex标记为”饥饿”模式
        • 然后再阻塞
      • 处于饥饿模式下
        • 不会启动自旋过程
        • 也即一旦有协程释放了锁,那么一定会唤醒协程
        • 被唤醒的协程将会成功获取锁
        • 同时也会把等待计数减1

woken状态

  • Woken状态用于加锁和解锁过程的通信
    • 举个例子:同一时刻,两个协程一个在加锁,一个在解锁
      • 加锁的协程可能在自旋过程中
        • 此时把Woken标记为1
        • 用于通知解锁协程不必释放信号量了
        • 好比在说:你只管解锁好了,不必释放信号量,我马上就拿到锁了

为什么重复解锁要panic

  • 可能你会想
    • 为什么Go不能实现得更健壮些,多次执行Unlock()也不要panic?
  • 仔细想想Unlock的逻辑就可以理解,这实际上很难做到
    • Unlock过程分为将Locked置为0
    • 然后判断Waiter值
      • 如果值>0,则释放信号量
    • 如果多次Unlock()
      • 那么可能每次都释放一个信号量
        • 这样会唤醒多个协程
      • 多个协程唤醒后会继续在Lock()的逻辑里抢锁
        • 势必会增加Lock()实现的复杂度
        • 也会引起不必要的协程切换。

编程Tips

  • 使用defer避免死锁
    • 加锁后立即使用defer对其解锁
    • 可以有效的避免死锁
  • 加锁和解锁应该成对出现
    • 加锁和解锁最好出现在同一个层次的代码块中
      • 比如同一个函数
    • 重复解锁会引起panic
      • 应避免这种操作的可能性

rwmutex

前言

  • 所谓读写锁RWMutex

    • 完整的表述应该是读写互斥锁

    • 可以说是Mutex的一个改进版

    • 在某些场景下可以发挥更加灵活的控制能力

      • 比如:读取数据频率远远大于写数据频率的场景
      • 例如,程序中写操作少而读操作多
    • 简单的说,如果执行过程是1次写然后N次读的话

      • 使用Mutex,这个过程将是串行的

        • 因为即便N次读操作互相之间并不影响
        • 但也都需要持有Mutex后才可以操作
      • 如果使用读写锁

        • 多个读操作可以同时持有锁

          • 并发能力将大大提升
  • 实现读写锁需要解决如下几个问题:

    • 写锁需要阻塞写锁:
      • 一个协程拥有写锁时,其他协程写锁定需要阻塞
    • 写锁需要阻塞读锁:
      • 一个协程拥有写锁时,其他协程读锁定需要阻塞
    • 读锁需要阻塞写锁:
      • 一个协程拥有读锁时,其他协程写锁定需要阻塞
    • 读锁不能阻塞读锁:
      • 一个协程拥有读锁时,其他协程也可以拥有读锁

读写锁数据结构

类型定义

在这里插入图片描述

  • 由以上数据结构可见
    • 读写锁内部仍有一个互斥锁
      • 用于将两个写操作隔离开来
    • 其他的几个都用于隔离读操作和写 操作
接口定义
  • RLock():读锁定
  • RUnlock():解除读锁定
  • Lock(): 写锁定
    • 与Mutex完全一致
  • Unlock():解除写锁定
    • 与Mutex完全一致

Lock()实现逻辑
  • 写锁定操作需要做两件事:
    • 获取互斥锁
    • 阻塞等待所有读操作结束(如果有的话)
  • 所以 func (rw RWMutex) Lock() 接口实现流程如下图所示:

在这里插入图片描述


Unlock()实现逻辑
  • 解除写锁定要做两件事:
    • 唤醒因读锁定而被阻塞的协程(如果有的话)
    • 解除互斥锁

所以 func (rw RWMutex) Unlock() 接口实现流程如下图所示:

在这里插入图片描述


RLock()实现逻辑
  • 读锁定需要做两件事:
    • 增加读操作计数
      • 即readerCount++
    • 阻塞等待写操作结束(如果有的话)
  • 所以 func (rw RWMutex) RLock() 接口实现流程如下图所示:

在这里插入图片描述


RUnlock()实现逻辑
  • 解除读锁定需要做两件事:
    • 减少读操作计数
      • 即readerCount—
    • 唤醒等待写操作的协程(如果有的话)
  • 所以 func (rw RWMutex) RUnlock() 接口实现流程如下图所示:
    在这里插入图片描述

场景分析

上面我们简单看了下4个接口实现原理,接下来我们看一下是如何解决前面提到的几个问题的。

写操作是如何阻止写操作的
  • 读写锁包含一个互斥锁(Mutex)
    • 写锁定必须要先获取该互斥锁
  • 如果互斥锁已被协程A获取(或者协程A在阻塞等待读结束)
    • 那么协程B只能阻塞等待该互斥锁。
  • 所以
  • 写操作依赖互斥锁阻止其他的写操作
写操作是如何阻止读操作的
  • 这个是读写锁实现中最精华的技巧
  • 我们知道RWMutex.readerCount是个整型值
    • 用于表示读者数量
    • 不考虑写操作的情况下,每次读锁定将该值+1, 每次解除读锁定将该值-1
      • 所以readerCount取值为[0, N],N为读者个数
      • 实际上最大可支持2^30个并发读者
    • 当写锁定进行时
      • 先将readerCount减去2^30
      • 从而readerCount变成了负值
      • 此时再有读锁定到来时检测到 readerCount为负值
      • 便知道有写操作在进行
      • 只好阻塞等待
    • 而真实的读操作个数并不会丢失
      • 只需要将 readerCount加上2^30即可获得
  • 所以,写操作将readerCount变成负值来阻止读操作的

读操作是如何阻止写操作的
  • 读锁定会先将RWMutext.readerCount加1
    • 此时写操作到来时发现读者数量不为0
    • 会阻塞等待所有读操作结束
  • 所以,读操作通过readerCount来将来阻止写操作的

为什么写锁定不会被饿死
  • 我们知道,写操作要等待读操作结束后才可以获得锁
    • 写操作等待期间可能还有新的读操作持续到来
    • 如果写操作等待所有读操作结束,很可能被饿死
  • 然而,通过RWMutex.readerWait可完美解决这个问题
    • 写操作到来时
      • 把RWMutex.readerCount值拷贝到RWMutex.readerWait中
    • 用于标记排在写操作前面的读者个数
      • 前面的读操作结束后
        • 除了会递减RWMutex.readerCount
        • 还会递减RWMutex.readerWait值
        • 当 RWMutex.readerWait值变为0时唤醒写操作
    • 所以,写操作就相当于把一段连续的读操作划分成两部分
      • 前面的读操作结束后唤醒写操作
      • 写操作结束后唤醒后 面的读操作
  • 如下图所示:
    在这里插入图片描述
  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值