美文网首页
Go - sync.Mutex

Go - sync.Mutex

作者: kyo1992 | 来源:发表于2021-05-15 10:08 被阅读0次

    设计目的

    保证多个 Goroutine 在访问同一片内存时不会出现竞争条件等问题

    Locker接口

    Locker 的接口定义了锁同步原语的方法集:

    type Locker interface {
        Lock()
        Unlock()
    }
    

    Mutex 以及读写锁 RWMutex 都实现了 Locker 接口

    Mutex

    简单来说,互斥锁 Mutex 就提供两个方法 Lock 和 Unlock:进入临界区之前调用 Lock 方法,退出临界区的时候调用 Unlock 方法:

      func(m *Mutex)Lock()
      func(m *Mutex)Unlock()
    

    当一个 goroutine 通过调用 Lock 方法获得了这个锁的拥有权后, 其它请求锁的 goroutine 就会阻塞在 Lock 方法的调用上,直到锁被释放并且自己获取到了这个锁的拥有权。

    Mutex架构演进

    “初版”的 Mutex 使用一个 flag 来表示锁是否被持有,实现比较简单;后来照顾到新来的 goroutine,所以会让新的 goroutine 也尽可能地先获取到锁,这是第二个阶段,我把它叫作“给新人机会”;那么,接下来就是第三阶段“多给些机会”,照顾新来的和被唤醒的 goroutine;但是这样会带来饥饿问题,所以目前又加入了饥饿的解决方案,也就是第四阶段“解决饥饿”。


    初版

    // 互斥锁的结构,包含两个字段 
    type Mutex struct {
       key int32 // 锁是否被持有的标识 
       sema int32 // 信号量专用,用以阻塞/唤醒goroutine 
    }
    

    Mutex 结构体包含两个字段:

    • 字段 key:是一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个排外锁已经被持有;
    • 字段 sema:是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。

    初版的 Mutex 利用 CAS 原子操作,对 key 这个标志量进行设置。key 不仅仅标识了锁是否被 goroutine 所持有,还记录了当前持有和等待获取锁的 goroutine 的数量。

    Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的 goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今。

    所以,我们在使用 Mutex 的时候,必须要保证 goroutine 尽可能不去释放自己未持有的锁,一定要遵循“谁申请,谁释放”的原则。在真实的实践中,我们使用互斥锁的时候,很少在一个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。

    初版的 Mutex 实现有一个问题:请求锁的 goroutine 会排队等待获取互斥锁。虽然这貌似很公平,但是从性能上来看,却不是最优的。因为如果我们能够把锁交给正在占用 CPU 时间片的 goroutine 的话,那就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。

    第二版

       type Mutex struct {
            state int32
            sema  uint32
        }
    
        const (
            mutexLocked = 1 << iota // mutex is locked
            mutexWoken
            mutexWaiterShift = iota
         )
    

    虽然 Mutex 结构体还是包含两个字段,但是第一个字段已经改成了 state,它的含义也不一样了。



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


    相对于初版的设计,这次的改动主要就是,新来的 goroutine 也有机会先获取到锁,甚至一个 goroutine 可能连续获取到锁,打破了先来先得的逻辑。但是,代码复杂度也显而易见。

    这一版的 Mutex 已经给新来请求锁的 goroutine 一些机会,让它参与竞争,没有空闲的锁或者竞争失败才加入到等待队列中。

    第三版

    如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不到锁,它们就会通过自旋(spin,通过循环不断尝试,spin 的逻辑是在runtime 实现的)的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑。

    对于临界区代码执行非常短的场景来说,这是一个非常好的优化。因为临界区的代码耗时很短,锁很快就能释放,而抢夺锁的 goroutine 不用通过休眠唤醒方式等待调度,直接 spin 几次,可能就获得了锁。

    第四版 解决饥饿问题

    因为新来的 goroutine 也参与竞争,有可能每次都会被新来的 goroutine 抢到获取锁的机会,在极端情况下,等待中的 goroutine 可能会一直获取不到锁,这就是饥饿问题。


    改进目的是尽可能地让等待较长的 goroutine 更有机会获取到锁

    跟之前的实现相比,当前的 Mutex 最重要的变化,就是增加饥饿模式。
    将饥饿模式的最大等待时间阈值设置成了 1 毫秒,这就意味着,一旦等待者等待的时间超过了这个阈值,Mutex 的处理就有可能进入饥饿模式,优先让等待者先获取到锁,新来的goroutine主动让一下,给老goroutine机会。

    通过加入饥饿模式,可以避免把机会全都留给新来的 goroutine,保证了请求锁的 goroutine 获取锁的公平性,对于我们使用锁的业务代码来说,不会有业务一直等待锁不被处理。

    正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine由于陷入等待无法获取锁而造成的高延时

    当前版本加锁解锁流程

    加锁
    1. 如果互斥锁未加锁,则通过位mutexLocked加锁;
    2. 如果互斥锁处于加锁状态并且处于普通模式下工作,会进入自旋,等待锁的释放;
    3. 如果当前goroutine等待锁的时间超过了1ms,就会将互斥锁切换到饥饿模式,饥饿模式下,新来的goroutine直接加入等待队列尾部;
    4. 互斥锁通过信号量将尝试获取锁的goroutine切换至休眠状态,等待锁的持有者唤醒;
    5. 如果当前goroutine是互斥锁上最后一个等待协程或者等待的时间小于1ms,那么它会将互斥锁切换为正常模式.
    解锁
    1. 当互斥锁已经被解锁,直接抛出异常.
    2. 当互斥锁处于饥饿模式下, 将锁的所有权交给队列中下一个等待者.
    3. 当互斥锁处于普通模式下,如果没有goroutine等待锁的释放或者已经有被唤醒的goroutine获得了锁,会直接返回; 其他情况下会通过信号量唤醒对应的goroutine.

    常见的 4 种错误场景

    1. Lock/Unlock 不是成对出现

    Lock/Unlock 没有成对出现,就意味着会出现死锁的情况,或者是因为 Unlock 一个未加锁的 Mutex 而导致 panic。

    常见的有三种情况:

    1. 代码中有太多的 if-else 分支,可能在某个分支中漏写了 Unlock;
    2. 在重构的时候把 Unlock 给删除了;
    3. Unlock 误写成了 Lock。

    在这种情况下,锁被获取之后,就不会被释放了,这也就意味着,其它的 goroutine 永远都没机会获取到锁。

    2. Copy 已使用的 Mutex

    第二种误用是 Copy 已使用的 Mutex。在正式分析这个错误之前,我先交代一个小知识点,那就是 Package sync 的同步原语在使用后是不能复制的。我们知道 Mutex 是最常用的一个同步原语,那它也是不能复制的。为什么呢?

    原因在于,Mutex 是一个有状态的对象,它的 state 字段记录这个锁的状态。如果你要复制一个已经加锁的 Mutex 给一个新的变量,那么新的刚初始化的变量居然被加锁了,这显然不符合你的期望,因为你期望的是一个零值的 Mutex。关键是在并发环境下,你根本不知道要复制的 Mutex 状态是什么,因为要复制的 Mutex 是由其它 goroutine 并发访问的,状态可能总是在变化。

    如果要作为函数参数传递,Mutex只能以引用形式传递。

    3. 死锁

    两个或两个以上的进程(或线程,goroutine)在执行过程中,因争夺共享资源而处于一种互相等待的状态,如果没有外部干涉,它们都将无法推进下去,此时,我们称系统处于死锁状态或系统产生了死锁。

    如果想避免死锁,只要破坏这四个条件中的一个或者几个,就可以了。

    1. 互斥: 至少一个资源是被排他性独享的,其他线程必须处于等待状态,直到资源被释放。持有和等待:goroutine 持有一个资源,并且还在请求其它 goroutine
    2. 持有的资源,也就是咱们常说的“吃着碗里,看着锅里”的意思。
    3. 不可剥夺:资源只能由持有它的 goroutine 来释放。
    4. 环路等待:一般来说,存在一组等待进程,P={P1,P2,…,PN},P1 等待 P2 持有的资源,P2 等待 P3 持有的资源,依此类推,最后是 PN 等待 P1 持有的资源,这就形成了一个环路等待的死结。

    相关文章

      网友评论

          本文标题:Go - sync.Mutex

          本文链接:https://www.haomeiwen.com/subject/tkzxjltx.html