• go中高并发下的锁是如何工作的(结合源码)



    1. 锁的基础是什么?

    在go中锁的底层往往涉及两个基础构造,一是原子操作,另一个就是sima。

    原子操作

    在看原子操作的概念之前,我们体会一下原子操作究竟是什么样的。执行以下的代码:

    func add(p *int32) {
    	*p++
    }
    func main() {
    	c := int32(0)
    	for i:=0; i<1000; i++ {
    		go add(&c)
    	}
    	time.Sleep(time.Second)
    	fmt.Println(c)
    }
    // output:
    // 997
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    理论上最终的结果应该是1000,但是最终程序运行的结果却是997,问题出在哪里呢?这是因为*p++其实是*p = *p + 1,本质上执行这行代码需要先对变量进行内存读取然后操作在重新写入,如下图所示。那么当2个线程同时读取变量内容,此时虽然是两个协程都对变量进行了操作,但是最终的效果却是只有一个协程对变量增1,就会造成这样的结果。

    在这里插入图片描述
    接下来我们使用如下代码重新执行:

    func add(p *int32) {
    	atomic.AddInt32(p, delta:1)  // 使用原子操作增1
    }
    func main() {
    	c := int32(0)
    	for i:=0; i<1000; i++ {
    		go add(&c)
    	}
    	time.Sleep(time.Second)
    	fmt.Println(c)
    }
    // output:
    // 1000
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    当我们使用原子操作增1的时候,最终的运行结果是正确的,这是因为在原子操作中,有一个硬件锁来保证每次只有一个协程改变变量。
    在这里插入图片描述

    原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何切换。

    我们对原子操作做一个小总结:

    • 原子操作是一种硬件层面加锁的机制
    • 保证操作一个变量的时候,其他协程、线程无法访问
    • 只能用于简单变量的简单操作

    sema操作

    sema锁又称为信号量锁或者信号锁,每个sema锁都对应一个SemaRoot结构体如下。(在mutex和rwmutex中都含有一个uint32的数,名为Sema(ReadSem/WriteSem))。

    type semaRoot struct {
    	lock mutex
    	treap *sudog  // 是一个平衡二叉的根节点,用于协程排队,内部有一个成员是协程。
    	nwait uint32
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    在这里插入图片描述


    当表面的uint32这个数大于0时,此时该值表示可以有几个协程获取这个锁。

    我们将底层如何利用这个数字来获取锁和释放锁的函数的调用过程展示如下,获取锁的过程:
    在这里插入图片描述

    释放锁的过程:
    在这里插入图片描述
    总结下来就是:

    • 获取锁:uint32减一,获取成功
    • 释放锁:uint32加一,释放成功

    当表面这个uint32数等于0的时候

    • 获取信号所的结构体,将当前协程放进平衡二叉树上排队,执行gopark函数将协程休眠。
    • 如果当前有一个协程释放锁,就从树上取出一个协程并唤醒。
    • 此时(uint32=0)sema锁就退化成了一个休眠队列

    2. 互斥锁

    互斥锁如何工作

    在Mutex的结构体中,有一个state结构体,其成员如下表所示,还有一个成员是值为0的sema(其实是一个休眠队列)。

    成员含义
    WaiterShift等待锁的协程数量
    Starving饥饿模式
    Woken低二位,是否从睡眠中唤醒
    Locked低一位,0表示没有锁柱,1表示锁住了

    我们接下来介绍一下互斥锁在正常情况下是如何工作如下图,为抢锁过程,其中箭头方向为顺序。
    在这里插入图片描述

    只有当锁被解开的时候才会唤醒sema中休眠的协程。在底层实现代码中是通过一个for死循环来实现这个自旋,若此时还允许自旋且未抢到锁则continue,如果自旋过程中抢到锁则break,如果不允许自旋(自旋次数超过上限)就调用semacquire函数将该协程放入队列中,更新等待协程数量(state中的WaiterShift),停止循环。

    当此时加锁的协程释放锁了:

    • 将Locked置为0
    • 如果此时有协程在等待,则唤醒协程(执行semrelease函数),让其继续运行

    但是此时被释放的协程不一定会抢到锁,如果此时还有其他写成正在竞争锁,此时就可能会出现先来的协程反而比新到的协程拿到锁的时间晚,时间长了就会陷入锁饥饿,关于锁饥饿我们在下文中详细解释。

    锁饥饿了怎么办

    锁饥饿是指若是出现了严重的锁竞争,可能会出现某些协程长期抢占不到锁的情况。所以在mutex中的state中有一个饥饿的标志位starving。

    • 当前协程等待的时间超过了1ms(阈值),切换到饥饿模式
    • 饥饿模型中,不自旋,新来的写成直接sema休眠
    • 饥饿模式中,被唤醒的协程直接获取锁
    • 没有协程在队列中等待时,回到正常模式

    饥饿模式的意义一是可以提升性能,当出现严重的锁竞争时,所有协程全部去抢占锁是没有意义的,直接将协程放入休眠队列中去,避免浪费相关资源。二是保证了锁公平,不会出现旧协程比新协程获取到锁的时间晚。

    那么底层是如何进入饥饿模式的呢?当某一个协程从休眠队列中唤醒时,首先需要判断是否需要进入饥饿模式(通过判断协程等待时间是否超过了阈值),如果此时的确出现了锁饥饿,就会将mutex中的state结构体的倒数第三位置为1。

    经验,可以借助defer来进行解锁,防止函数执行出错之后无法正常解锁,使用defer保证一定可以解锁成功。

    3. 读写锁

    当出现多个协程同时只读时,我们就需要保证在读的时候不能出现被读的数据正在修改的情况,第一反应可能就是加互斥锁,但是互斥锁的加入会使得读进程不能并发进行,所以引入了读写锁来解决这种场景。

    我们先来简单看一下读写锁功能:

    • 当读协程首先执锁时,此时该锁对于读进程来说是一把共享锁,所有读协程可以进行读取,但是写协程必须等待,将其放入写协程的排队队列中去,直到读协程释放锁写协程才能获取锁。
      在这里插入图片描述

    • 当写线程首先执有锁,此时的锁就是一个互斥锁,不论是写进程还是读进程都不能进入临界区。

    在这里插入图片描述
    我们总结一下读写锁需求:

    1. 每个锁分为读锁和写锁,写锁互斥
    2. 没有加写锁时,多个协程都可以加读锁
    3. 加了写锁时,无法加读锁,读协程排队等待
    4. 加了读锁,写锁排队等待

    接下来我们看一下go中的读写锁实现:

    在这里插入图片描述

    变量成员含义
    w互斥锁为写锁,竞争了该锁不表示成功加写锁只能说有了加写锁的资格
    writerSem作为写协程队列
    readerSem作为读写成队列
    readerCount是读锁,正值:读协程数量;负值:加了写锁
    readWait写锁需要等待读锁释放的个数

    关于写锁

    1. 加写锁
      当此时没有读协程时,我们加写锁的步骤有两部,首先竞争互斥锁,然后将readCount置为-rwmutexMaxReaders,这样写协程就加锁成功了,如下图所示:
      在这里插入图片描述
      当写协程抢锁的时候发现此时有3个读协程在读数据,那么写协程还是需要先竞争互斥锁w,然后将readCount的值置为3-rwmutexMaxReader(该值有两个信息,一是负值表示此时存在写进程,其他读进程先不要来了,二是和rwmutexMaxReader的差值3表示当前有3个读协程读数据),然后将该写协程放入写协程队列中去。注意此时写锁没有加上,只有当3个读协程执行完毕,readerwait值为0时此时写锁才增加成功。

    在这里插入图片描述
    总结一下加写锁的步骤:

    1. 竞争互斥锁w,若已经加写锁则会被阻塞等待
    2. 将readCount变为负值,阻塞读锁获取
    3. 计算需要等待多少读协程释放
    4. 如果需要等待读协程释放,先放入写协程队列
    5. 当写协程开始执行时才表示写锁添加成功

    1. 释放写锁
      在释放写锁之前,读写锁中的成员状态如下,此时的互斥锁被当前正在执行的写协程执有,由于存在写锁,所有的读进程都将被加入到读进程队列当中去,如下图:
      在这里插入图片描述
      当写协程执行完毕,释放互斥锁,让处于读协程队列中的协程并发运行,如下图所示:
      在这里插入图片描述

    关于读锁

    1. 加读锁
      当readerCount的值为正值时说明没有写协程,此时如果来了一个读协程,将readcount加1即可。当readCount的值为负值时说明此时有写协程,如果来了一个读协程,将readcount加1,就将读协程放入读协程队列中。

    2. 解读锁
      当readerCount的值为正值时说明没有写协程,此时读协程执行结束,将readcount减1即可。当readCount的值为负值时说明此时有写协程,如果某一个读协程执行结束,将readcount和readwait均减1,如果readwait为0时就当前读协程需要唤醒写协程。

    在陈硕老师的书中,有多次强调尽量不要使用读写锁,因为读锁在某种意义上是一种可重入的锁,这种锁往往会掩盖代码的一些问题,不如不可重入的锁好debug。

    4. WaitGroup

    在实际需求中,往往需要一个或者一组协程需要等待另一组协程完成,这时就需要WaitGroup工具,它同样也对应一个结构体如下。

    在这里插入图片描述
    其中的关键成员是一个拥有3个uint32的数组state1,我们看一下其中的内容:
    在这里插入图片描述

    wait()

    wait方法的作用是等待所有运行的协程结束。基本的逻辑是:

    • 如果当前的count为0,直接返回
    • 如果当前新增一个协程,waiter加1,将新协程放入sema

    done()

    功能就是指当前协程执行结束,counter减一,记录一下有协程运行结束了。其中实际上运行了add(-1)


    add()

    功能是增加被等待的协程数量,就是给count加上差值。在done中我们发先add中有可能-1,那么当count为0的时候如何处理呢?当被等待协程没做完,或者没人等待,返回。如果被等待协程都做完了,且有人等待,唤醒所有sema中的协程。

    5. once

    功能其实就是整个程序运行过程中,一段代码只执行一次,一般用于一些初始化的操作。有一种思路,就是找个一个变量记录一下,没有运行之前置为0,一旦运行一次就置为1,在每次执行之前判断该值是否为1,若是1就不执行。但是当多个协程竞争一个值会带来性能问题,和前文的解决方式一样,我们可以借助mutex来实现。

    我们可以考虑使用互斥锁来进行:

    • 争取一个mutex,抢不到的陷入sema休眠
    • 抢到的执行代码,更改值并释放锁
    • 其他协程唤醒之后判断发现该值已经被修改,直接返回

    底层是一个如下的结构体:
    在这里插入图片描述

    其中相关的重要函数是do函数,底层实现如下:

    在这里插入图片描述

  • 相关阅读:
    互联网摸鱼日报(2022-11-08)
    C语言——程序解构说明
    [scratch][列表]全国青少年软件编程等级考试-四级-班级成绩处理
    github ations 入门使用
    BUUCTF 九连环 1
    Kotlin 开篇之基础语法篇
    关于Vue3 ,看这一篇文档你就会用了
    西班牙知名导演:电影产业应与NFT及社区做结合
    【微服务 SpringCloud】实用篇 · Ribbon负载均衡
    LoRa Basics无线通信技术和应用案例详解
  • 原文地址:https://blog.csdn.net/weixin_50941083/article/details/125879271