锁的本质 (锁的核心和原理)

b级和c级锁的本质区别,锁的内部结构

Linux 对锁的实现

书接上文,我们来看看它到底是个什么锁~!注意锁的实现方式有很多种futext是信号量锁实现方式,而mutex的核心实现机制是采用自旋等待和yield。

mutex

这里直接越过C++的类库封装,单枪匹马直入Linux内核mutex的核心实现,据我阅读源码的时候linux源码的master分支版本是5.8。

b级和c级锁的本质区别,锁的内部结构

b级和c级锁的本质区别,锁的内部结构

来看看快速尝试获取锁是怎么实现的:

b级和c级锁的本质区别,锁的内部结构

atomic_long_try_cmpxchg_acquire的底层实现如下:

b级和c级锁的本质区别,锁的内部结构

来,慢车道实现源码太长太难读,我有尝试过展开这个篇幅跟大家说,可是篇幅实在是太大了涉及现代操作系统进程调度算法实现,信号量机制,进程中断等等,所以我给大家画了张图,感兴趣的可以去细读源码:

b级和c级锁的本质区别,锁的内部结构

futex

Futex这个机制是在2002年由IBM公司提出,在Linux内核2.5.7随后在2.6.x系列稳定版内核中出现,它的动机就是尽可能的减少内核态的参与,在用户空间进行线程的同步操作,这是因为它觉得大部分情况下锁并没有被争用,这时候线程去获取一个空闲的锁,代价是非常小的,原子交换一下就完事了,假如操作失败了则代表发生了竞争,此时可以先自旋一段时间,尽管是用户态,一般来说就算自旋也能很快获取到,因为通常来说临界区的代码都是比较短的能够很快执行完毕,如果自旋了一段时间还没有获取到该锁的话,也不浪费CPU资源了,直接进入"睡眠"吧,这就是系统内核态帮助的地方这就是futex实现的地方(进程(线程)调度)。其实直白来说对于用户态来说就是维护了一个内核队列,使不满足条件的入列并进行睡眠,等待用户态发出唤醒等待的信息。

那么问题来了为什么有mutex还要有futex?

首先我们考虑一下场景:短期锁定的优先使用自旋锁,开销成本小,但如果是长期就适得其反,所以长期加锁优先使用信号量,比如需要睡眠以及调度因为它可以避免无谓的互斥操作,但其实这块的内容说到最后这些内核的数据结构我们在开发中不可能直接操作这些数据结构,我们只能通过调用glibc接口的接口进行调用,你可以理解为GLIBC是linux系统中最底层的api。

原子操作

Atomic译为原子性,何为原子性?Atom(原子)这个概念是从古希腊语ἄτομος译为不可切分而转化来的,它寓意着一个整体,不可分割,在程序上要具有原子的这一特性,表示我这一个或多个操作是一个整体是具有不可被切分的这一原子特性的,从某种意义上可以这么说,"要么全执行,要么全不执行",这就是原子性。

那么我建议大家停一停思考一下,在我们软件层这一层,我们的原子性通过什么来实现的呢?答案是"锁",我们常见的锁有"乐观"、"悲观",对于不断重试的自旋锁我们可以称为是乐观锁的一种实现,对于阻塞等待唤醒的又可被称之为悲观锁的一种实现,这是锁实现的机制也不匮乏有乐观悲观结合实现的,从性质上来说锁,又可以再划分为互斥锁(独占)和共享锁,互斥锁不用介绍了吧,上面说的都是,对于共享锁最典型的例子就是读写锁的读锁。

这些锁的实现,无非就是运用了几个机制一个是硬件层面的CAS或原子交换(atomic exchange 也有个别名test and set instruction) 或是将进程(线程)调度的机制,将它挂起,等待唤醒,当操作系统接受到唤醒信号时则会将挂起进程(线程)进行唤醒恢复执行。

接下来,我们了解2条汇编指令分别为CAS操作和Atomic Exchange。

CAS

x86平台:cmpxchg

arm v8平台:cas

为了便于理解我通过x86平台的指令进行叙述。

有三个步骤:

read

compare

write

操作便是进行比较并交换,它有三个参数target_address,expected_value,new_value,大体意思就是target_adress位置的值和expected_value相等,则写入new_value,抛出个疑问,什么也不做的情况下CAS是原子性的吗?其实答案是不,CAS指令可能不是原子性的,它有三步操作分别为 读、比较、写,如果需要cmpxchg保证原子性,需要使用lock前缀(看CPU平台),但根据最新的Intel开发者白皮书来看在p6以后的处理器的缓存行操作可能不会再发出任何lock信号,所以cmpxchg不加lock前缀也没事了,全部由缓存一致性协议完成,但还是需要注意它无法避免ABA这个问题,其实这个问题也不是CAS指令自身的缺点它本身就是个比较和交换,反倒是我们将CAS用到了某些地方才导致出现了ABA这个问题。

Atomic Exchange

x86平台:xchg

arm v8平台:无直接支持,需要通过ldrex和strex组合实现

为了便于理解我通过x86平台的指令进行叙述。

有二个步骤:

read

swap

xchg有2个参数,A,B,背后操作很简单就是将A,B 2个位置的值进行交换,抛出个疑问,它是原子性的吗?没错它是原子性的,因为在Intel白皮书中有说xchg就算不使用lock前缀,它还是会加上lock前缀的,但根据最新的Intel开发者白皮书来看在p6以后的处理器的缓存行操作可能不会再发出任何lock信号,全部由缓存一致性协议完成。

x86平台对锁&原子操作的支持

首先我们要知道CPU层面和软件层面,面对的锁和解决的问题不是一样的。

CPU层上的锁,主要解决的是多个核心并发访问/修改同一块内存的问题。

应用层的锁,由于有了“进程/线程“的概念的抽象,解决的是多个进程或多个线程并发访问同一块内存的问题,比起CPU的层级来说,应用层的锁还可以借用操作系统的资源来完成操作。

CPU层上的锁(以Intel系列的为例),在P6微架构之前,锁的实现基本都是直接锁总线,由仲裁器来选择一个Core独占总线,其它的Core再此时都是不可以通过总线和内存进行交互,来达到原子性的手段,P6之后多核的出现,引入了开始使用了Ringbus + MESIF 协议的方式,它主要是保障多核缓存一致性的协议,我这里简要的说一下,就是Core之间的Cache都通过Ringbus连接到一起,每个Core都维护自己的Cache状态。

下图大家过一下就好,MESI的定义是通过侦听共享总线进行工作,但现代CPU并不是这样做的,而是通过对每个缓存行的状态操作是通过消息和snoop过滤器(可以理解为缓存目录通常会在LLC中维护一份), Intel和amd都有各自的协议,Intel制定了MESIF(多BB一句Intel在奔腾的时候是使用的MESI),AMD制定了MOESI,但是MESI可以说是基石通过它可以让我们快速的理解这些东西。

MESI有限状态机图如下:

b级和c级锁的本质区别,锁的内部结构

这些图其实并不是非常好理解,接下来我图文生动一波使大家更容易理解。

如果对于内存中的一份数据在多个核中都有Cache Line,那么状态就为S(Shared)。

b级和c级锁的本质区别,锁的内部结构

如果只有一个那么就是E(Exclusive)。

b级和c级锁的本质区别,锁的内部结构

一旦某个Core修改了数据,那么状态就会变成M(Modified),其它Core通过Ringbus感知到这个修改, 把自己的Cache状态变成I(Invalid)。

b级和c级锁的本质区别,锁的内部结构

然后从标记为M的Cache Line中读过来,同时这个数据会被原子的回写到内存,所有Core的该Cache Line的状态变为S,说来说去其实Ringbus就类似于给Cache Line做了一套总线,从而避免了真的锁总线,但!该锁总线的时候还是得锁,比如数据都没在Cache中,手动滑稽~最后补充一点,MESI状态是标识在缓存行上的,图太小了实在没地体现缓存行去塞状态。

MESIF有限状态机图如下:

b级和c级锁的本质区别,锁的内部结构

PrRd:处理器(核心)发起对某个缓存的读取请求。

PrWr:处理器发起对某个缓存的写请求。

BusRd:有处理器请求读取缓存块。会向内存发出读请求,要求获得该块的最新副本。如果另一个处理器的缓存拥有这个缓存的最新副本,它就会将这个块发布到总线上(FlushOpt),并取消启动处理器的内存读取请求,然后会被刷新到主内存。

BusRdX:表示有一个没有持有缓存的处理器,正在请求对缓存块的独占访问权,写入并获得最新的副本。其他处理器缓存会窥探这个事务,并使缓存块副本无效,对主内存的请求是为了获得这个块的最新副本。如果另一个缓存拥有这个块的最新副本,它就会把这个块发布到总线上(FlushOpt),并取消发起处理器的内存请求(写入并获取)。

BusUpgr:表示已经持有缓存的副本的处理器,正在重新请求写入缓存Flush: 表示处理器请求将缓存块写回(flush)主内存。

FlushOpt:表示处理器已经将缓存发布到总线上,以便在缓存到缓存的传输中提供给其他处理器。

来我们聊聊相比对MESI对了一个F,这个F表示Forwarding,为什么要有这个F呢?想象如果当前某个核里的缓存行状态为S,那么就意味着多个核有这个数据(假设在Core 1、2、3上),Core4没这个数据要读取这个缓存行该由谁来应答呢?如果每个持有该缓存行的核都来应答就会造成冗余的数据传输,所以Intel改进了做了一个F状态,如果需要拷贝S状态的数据,需要将一个核具有的S状态转换为F状态,由F状态的核负责应答转发。

MESIF我说完了,相信IQ爆表的大家都能看得懂,接着来看看RingBus到底是个什么玩意。

b级和c级锁的本质区别,锁的内部结构

再来一个学术一点的图。

b级和c级锁的本质区别,锁的内部结构

说一个历史Intel公司从Sandy Bridge系列开始就在微架构设计方面全面采用RingBus,但是在Skylake-X家族的系列产品开始用Mesh,不出意外应该是用于解决多核(非常非常多的核)之间的共享、扩展等等的问题,毕竟核越多RingBus延迟可能会越高,但是有意思的是在Ice Lake 系列开始没错是十代又换回了RingBus,反复摩擦把我搞晕了,我粗浅的在这里下一个结论,针对于个人PC核数并不够多,换成Mesh的收益并不大,所以就又不改了,未来的趋势 应该是面向个人PC机器的U核数不爆炸增长,会继续沿用RingBus,而面向服务器的U应该会继续采用Mesh。

先用一个案例来告诉大家RingBus在做什么?

CAS

假如有2个核都在针对同一地址进行CAS操作,你觉得是在改内存还是在改每个核自己的缓存行中的数据呢?答案是后者(补充:因为2个核都有这个数据所以状态肯定是S),要想成功修改就必须将S转换为M,那这2个以谁为准呢?谁改的是对的呢?答案是2个核都会向RingBus发送BusRdX那么RingBus会根据它的总线仲裁协议来判定谁能够完成这个操作,我假定判断成功的为赢家、失败的为输家,赢家完成操作,输家需要接受结果将值改为I,读赢家的值然后回到起点CAS。

再用一个案例来叙述Ring Bus的工作机制核相关原理。

还要由一个前置知识就是LLC(末级缓存,通常可以理解为L3)中会有一个缓存目录这个目录包含了缓存的状态、标识等信息。

假如:Core1 读取缓存,Core2 修改同一缓存。

Core1要读缓存,因为数据没在Core中假设读L1和L2都未命中缓存,会在向环形总线发送读请求,然后会在LLC找到对应的slice,然后检查它的状态如果为S,响应就直接带着数据通过环形总线返回,反正如果不为S如果为M或E 就会产生一个消息让写回Core2的缓存行,然后数据通过环形总线返回,然后状态都变更为S。

案例

前面铺了很长的知识,现在我们将这些知识串起来,看一个案例synchronized时在Java -> JVM -> OS -> Hard Device 发生了什么。

Synchronized

b级和c级锁的本质区别,锁的内部结构

利用javap反汇编字节码。

b级和c级锁的本质区别,锁的内部结构

假设我们现在线程是A、B、C,是顺序获取到锁并执行。

线程A很简单,直接获取到锁,monitorenter方法都不会进入因为在进入之前会做一系列的尝试这时候线程B、C的逻辑,怎么样呢?一系列尝试失败进入monitorenter,经过上文的铺垫,以及代码示例,线程A需要执行2秒,假定B、C都会走向重量级锁,进入操作系统的futex的等待队列挂起,等待锁释放的信号发起,那么我们看硬件层面上的竞争,在已知A、B、C是顺序获取的情况下,现在我们清楚的知道在JVM层面上锁的获取是通过CAS去修改对象头,而在硬件层面上则是多个线程可能多个核或是一个核修改同一块内存区域的原子性,我这里假设A\B\C都跑在不同的核上运行CAS指令,他们之前通过RingBus进行仲裁谁能够修改成功,其它的回到原点CAS,因为Compare关系修改失败,然后A线程执行完毕,执行字节码monitorexit,由于此时已经是重量级锁了,所以会执行ObjectMonitor::exit,默认策略下,会讲cxq中的元素顺序插入到EntryList中唤醒第一个线程,如果B\C按照上述逻辑执行的话,那么B线程必定是比C线程后发生锁竞争,具体可以通过QMode调整,所以这里有一个细节点,默认情况下对于等待的线程,是后来的线程先获得锁。

总结

透过Hotspot的Synchronized再到Linux操作系统及x86平台的实现,相信大家对锁会有一个较为系统化的认知,通过认知Hotspot的实现,只要你的临界区够短,它在尽它所有可能不让你的线程挂起哪怕竞争非常激烈也是有可能逃过被挂起的命运,通过了解系统层面上对于应用程序锁的支撑,我们能够在系统调优时不会像个无头苍蝇一样到处乱撞因为我们知道它背后运行机制能够去分析它并解决它,硬件层面上的知识提升了最根本的认知让我们了解软件与硬件层面面对原子性这个问题时所面临的不同问题,分享就到这,希望大家在学习一个东西的时候不要浅尝辄止,要能知其然知其所以然。