《一文讲清多线程和多线程同步》读后感

最近读了一篇美团技术文章写多线程和多线程同步的文章,想起自己两年前面试时死磕多线程并发,当时有一些底层的东西一直模模糊糊,那时候也没有大模型的加持,所以也无处求解,现在结合这篇文章和大模型的回答,对当时模糊的一些概念进行整理,让自己更加明晰。

疑问分析

java的多线程之间同步我们都知道可以通过sychronized锁或reentrantLock来实现,这种算是悲观锁的实现方式,乐观锁的实现方式可以利用原子的比较并交换命令CAS来实现,但细究底层实现,想要实现原子的对锁标识的比较、修改、写回操作,都离不开类似CAS的指令,比如TestAndFetch之类,这是在之前看操作系统导论的时候学到的。
那么CAS指令是怎么实现的呢,一般书里或文章里会说这是由硬件来保证的,具体怎么实现,往往语焉不详。

MESI缓存一致性协议

看CAS实现时,经常绕不开多核缓存一致性问题,由于CPU每个核都有自己独有的L1、L2级缓存,所以会存在不同核之间数据一致性的问题,比如一个数据同时被加载到核A和核B中,两个核都进行了修改,然后再写回到内存,就会造成并发的数据问题,这也是经典的多线程执行i++,最终数据不对的问题。
而MESI协议被用来同步不同核和内存之间的数据,是用来解决缓存一致性问题的。比如一个共享的数据为S状态,有一个核想要修改的话,可以将其它核的数据置为I无效状态,自己保持E的独占状态,然后进行修改,改为M状态,那这样的话,是不是说i++非原子操作的问题,在MESI协议下就不会出现了呢,这个是困扰我很久的问题。
我们常说i++为什么会出错,究其原因是分了三步:

  1. 数据加载到寄存器中
  2. 执行加1的操作
  3. 写回到内存

这并不是原子操作,所以在并发的情况下,就会出现问题,比如核A和核B同时读取了i=0,然后同样执行i+1,这样写回的值就为1,而不是2。
为什么存在MESI协议还出现这样的问题,一方面是MESI协议为了效率,并不是强一致性的,里面引入了Store Buffer和Invalid Queue,这样提高了效率,但整体是弱一致性的,另一方面是MESI协议更多地是为了解决不同核之间缓存一致性设计的,并不是为原子性设计的,像刚才的情况,如果数据都已经加载到了寄存器里,这种不再读取的情况,即便核A在执行modify的时候,是否会让核B寄存器中的数据也失效呢,这里感觉不会,可能会出现同时写回的情况,MESI协议只是最终一致性协议,无法保证原子性。

Store Buffer与写屏障

Store Buffer的作用这里美团文章写的和网上搜到的还不一样,感觉网上说的似乎更靠谱一些,感觉这种问题自己很难验证是学习的一个难点。
作用:

  1. 美团文章的说法是,对应的数据还在内存中,加载到缓存中需要时间,所以先放到Store Buffer里,等数据加载到缓存行再进行写入
  2. AI搜到的主流回答是由于写入缓存之前需要将其它核的缓存信息invalid,需要等待其它核的invalid ack,先放store buffer里是要等其它核的响应。

这里先不去探究哪种更科学,引入Store Buffer的目的是为了将写回缓存这个过程异步化,提升效率,这样会导致写入乱序的问题。比如下面的CPU0中的b = 2先于 a = 1被CPU1看到,CPU1中的程序执行就会出错。


image.png

对于这种情况,就需要引入写屏障来解决,写屏障会给Store Buffer里悬而未决的项进行mark标记,直到里面这些数据都进入到缓存后,后面的写入才会进入到缓存,保证后续的写入不会先于写屏障前的写入先被其它核看到。


image.png

对了,为防止当前核因为数据修改保存到了Store Buffer里,导致后续读取从缓存里取到旧值,使用了Store Buffer Forwarding的技术,读取数据时,先看下store buffer里是否有数据还未写入到缓存,这种算是保证单核不会读取不一致的手段。

Invalid Queue与读屏障

当一个变量加载到多个core的Cache,则这个Cache Line处于Shared状态,如果Core1要修改这个变量,则需要通过发送核间消息Invalidate来通知其他Core把对应的Cache Line置为Invalid,当其他Core都Invalid这个CacheLine后,则本Core获得该变量的独占权,这个时候就可以修改它了。
但其他Core并不会将对应的Cache Line置为Invalid,因为这样会比较影响效率,一方面影响自己Core的命令执行,另一方面这样修改的Core也会等比较久,因此就引入了Invalid Queue,这样收到Invalid消息,就先放到Invalid Queue里,直接返回Invalid Queue,等后续再进行处理。
Invalid Queue同样引入了读乱序的问题,有数据已经被invalid,但读的时候还是读了缓存里的旧数据,这样也是会引起错误的,为了解决这种错误,可以使用读屏障,读屏障会对invalid queue中的数据项做标记。当一个load操作发生的时候,之前的rmb()所有标记的invalidate命令必须全部执行完成,然后才可以让随后的load发生,这样可以保证读操作的global memory order。
之前出现问题的代码加上写屏障和读屏障以后就能正常执行了。


image.png

CAS命令的实现

在cpu层面cas命令的实现,不同的架构不同:

  1. x86架构 :使用cmpxchg指令(Compare and Exchange)
  2. ARM架构 :使用LDREX和STREX指令(Load Exclusive 和 Store Exclusive)

以常见的x86架构为例,在多核环境下通常会在cmpxchg指令前加上lock前缀,lock前缀的作用如下:

  1. 强制原子性 :确保cmpxchg指令的读-改-写操作不会被其他CPU核心打断。
  2. 触发总线锁或缓存锁 :
  • 如果目标内存地址未被缓存,则触发总线锁。
  • 如果目标内存地址已被缓存,则触发缓存锁。

所以这也是从cpu底层来说CAS命令的实现原理了。

Java Volatile命令实现

volatile 的核心作用之一是保证变量的可见性 ,即当一个线程修改了 volatile 变量时,其他线程能够立即看到最新的值。
volatile 的另一个重要作用是禁止指令重排序 ,以保证代码的执行顺序符合预期。
这里Volatile具体的实现方式感觉不同的文章说的都不太一致,这里暂时先以《java 并发编程的艺术》为准,按这里说的是JMM为了实现Volatile的可见性和禁止指令重排序,会插入下面这些内存屏障。

  1. 在每个volatile写操作之前插入一个StoreStore屏障
  2. 在每个volatile写操作之后插入一个StoreLoad屏障
  3. 在每个volatile读操作之后插入一个LoadLoad屏障
  4. 在每个volatile读操作之后插入一个LoadStore屏障
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容