锁:是系统提供的一中访问内存临界区的一种手段(也被成称为互斥量)

首先

在进入话题之前先问大家一个问题:

  1. 大家对锁的第一印象是什么?
  2. 对锁有什么看法?

大家可以稍微思考一下。

我的印象

不知道大家的想法是怎么样的,我先说下自己的看法吧!我觉得:它给我的是一种很稳重,可靠的感觉。因为很多很多事情只要用了它都是十拿九稳的。但是!我很烦它,恨不得永远都不要用到它,因为它会让我的代码凌乱不堪后期很难维护。

背景

在开发一个公司项目的时候,有个场景是用户在访问页面要切换线路,因为切换线路和用户访问页面时并行的,这就涉及到了数据争抢的问题。想要解决这方面的问题就不得不使用锁机制了。锁其实有两个特性一个是原子性另一个是可见性,前者会影响性能,后者可能会导致数据出现错误。性能受到影响我还能接受,但是要兼顾可见性就得加很多额外得锁。在这个问题上我纠结了很久很久。

正因为上面的纠结,才有了今天我们要聊的话题:"锁"

内存重排

内存重排分为两种1.CPU重排 2.编译器重排,它们可以根据其对代码的分析结果,一定程度上打乱代码的执行顺序,以达到减少程序指令数和最大化提高 CPU 利用率的目的

内存重排的原理

wb.png
正常情况下在线程1中(2)需要等待(1)执行完后才会执行。但是(1)是一个写操作会比较的耗时,而且(2)和(1)它们根本不会相互干扰,(2)根本没有理由要等待(1)执行结束。这两个操作完全是可以并行执行的。那系统实现的呢?

三级缓存

v2-8a5029f37f602d38fedc18ff18a2034f_1440w.jpg
这就要引入三级缓存的概念了,虽然内存的读写速度已经很快了,但是对于cpu而言内存还是个弟弟。为了减少对CPU性能的影响,就在 CPU 内部引入了 CPU Cache的概念,也称高速缓存。CPU Cache 通常分为大小不等的三级缓存,分别是 L1 Cache、L2 Cache 和 L3 Cache。其中L3是多个核心共享的。越靠近 CPU 核心的缓存其访问速度越快。

store buffer(存储缓冲区)

wb-cores.png
回到之前的例子,其实在cpu缓存还是一个结构叫做store buffer(存储缓冲区),它是位于CPU上的所以读写速度特别快,甚至高于L1 cache。

wb-wb.png
所以我们只要将(1)写入到store buffer上,就可以立刻的去执行(2)了,没必要等待(1)对其他线程可见了再执行。至于store buffer 里面的(1)会在未来的某个时间被批量的刷到cpu cache中,从而被其他线程可见。Store buffer相当于把写的耗时隐藏了起来。也就达到了我们之前所说(2)不用等待(1)执行完毕的目的

wb-local.png
Store buffer 对于单线程来说几乎是完美无瑕的。
举个例子:(1)对A的修改会先被写到store buffer中,然后(2)去读取A的值它会先从store buffer 中查询一遍如果有值就直接使用这个值,下面的步骤全部都省了,整个流程奇快无比,简直完美。

wb-tso0.png
但是在多线程的情况下,就会出现很神奇的情况了。看右边的例子首先在内存中A和B的值都是0,这是先执行(1)和(3)他们都将数据写到了store buffer中并没有直接写到缓存中。然后再执行(2)和(4)它们先从自己的store buffer中寻找对应的值不出以为都会找不到,然后就会从内存去获取然而内存这两个值都还是0,打印出来的结果自然也就是0,0了。

这就是为什么会出现可见性问题的直接原因了。

那为什么锁(屏障技术)能解决这个问题呢?

不急~ 不急~

我们接着往下查资料 :)

CPU缓存得刷新机制

cpu-arch.png
上面提到一个线程在修改一个值时会先把它写到他自己得store buffer里。但是store buffer是各CPU独享的,所以在写入其实他会发送一条read-invalid消息(又涉及到某些协议不在深入)给其他的线程,因为需要确保所有其它缓存了当前内存地址的 CPU 的 cache line 都被失效掉。其他核心在收到消息后会将本地的cache标记位失效,还得涉及了 cacheline 的修改和 store buffer 的处理,需要等待。为了消除这个等待,CPU 会把这些 失效的消息放在一个叫做invalidate queue 的队列里,并在之后尽快处理。

memory barrier(内存屏障)

有问题自然就会有解决问题的办法,于是我们的内存屏障(锁的底层实现)就出现了。

  1. 写屏障(写锁):它会把 store buffer 全部刷新掉,确保所有的写操作都被应用到 CPU 的 cache。
  2. 读屏障(读锁):会把 invalidation queue 全部刷新掉,也就确保了其它 CPU 的写入对执行刷新操作的当前这个 CPU 可见。

这些大概就是我这次调研的重点了。

从一个实际开发得疑惑出发,牵引出了这么多得东西整个过程我感觉还是挺有趣的。

但是,还没有结束!我在调研期间还发现了一些很有趣东西,我们接着往下唠。

先给大家看一张图
微信图片_20220821163450.png
这个sync.Pool源码中的一个结构体的定义,其中只有poolLocalInternal字段是有意义,那么为啥还要加pad字段呢?大家可以想一想。

好的,那就由我来和大家解释一下吧,要解释这个我们就得先了解下面的东西

Cache Line(缓存行)

16b5bf1f49fd3dfc_tplv-t2oaga2asx-zoom-in-crop-mark_3742_0_0_0.jpg
Cache line 是CPU读取加载内存的最小单位,不同级别的缓存cache line大小会有所差异。就是说cpu在加载内存的变量的时候有可能会把其他变量也加载到缓存里面,这样做的目的一个是为了提高效率不用一个一个加载内存,另一个就是空间局部性理论(当 CPU 访问一个变量时,它可能很快就会读取它旁边的变量,所以就把旁边的变量提前加载进来了, mysql其实也有类似的策略)。

微信图片_20220821162333.png
这就会带来一个问题,比如:核心1和核心2都加载了同一个地址的cache line 里面有两个变量a和b。核心1只用到a变量,核心2只用到b变量,这个时候如果核心1改变了a变量的值,核心2就会收到这个cache line失效的消息,这个时候尽管变量b没有变化,也会导致核心2重新去内存种加载变量。

false sharing(虚假分享)

这种情况其实专业术语叫做:false sharing,出现这情况就有可能导致在核心数增多时,单次操作的成本上升,导致程序整体性能下降。false sharing 虽然在部分场景可以提高我们系统的性能,但是并不适用所有场景,填充无用的变量会浪费内存典型的用空间换时间,使用还是得谨慎得。

所以答案是这样的go的开发者为了让poolLocal不会在多个核心之间频繁的失效,所以补上了pad(顾名思义),典型的用空间换时间的做法

接着我们再来看看另一个好玩的

悲观锁 & 乐观锁

顾名思义

  1. 悲观锁:它很悲观总是认为其它线程也会过来修改这个数据。为了保证数据安全,访问前总会加锁。
  2. 乐观锁:它很乐观总是认为在使用数据的过程中其它线程不会修改这个数据,故不加锁直接访问。

乐观锁实现得原理:single machine word(单机器字)CPU一次执行得最小单位,具有原子得特性

Golang里面实现乐观锁

微信图片_20220821163557.png
实现起来其实也很简单,利用single machine word的特性事先读出旧值在做出修改后先拿旧值与指针里的值做对比相等说明这段时间就没有人动过,就可以把修改后的值写入进去。如果不一样说明在此期间有别人访问过了就结束这次的逻辑重来一边,直到成功为止。

还得提一个事情乐观锁会有个ABA的问题,但是我觉得这个只会在特定的场景下会有问题,严格来说其实这个都算不上问题,大家好奇的可以去看看。

sync.Pool源码里面也有乐观锁的应用大家有空的也可以去找找。

乐观锁的优缺点

没有最好的方案,只有最合适的方案。任何方案都是有两面性的,乐观锁也不例外。
优点:无需加锁,提高了程序性能。
确定:实现麻烦,以消耗CPU为代价提高性能,极端情况下使用可能会把cpu打爆。

参考资料

https://juejin.cn/post/6844903869403643911
https://juejin.cn/post/6844903866270482445
https://www.cs.utexas.edu/~bornholt/post/memory-models.html
https://github.com/cch123/golang-notes/blob/master/memory_barrier.md