7.从volatile去看可见性问题

我们绝大部分人应该都听说过volatile,但是它起到啥样的作用呢?我们先看个例子,在这个例子中,改变主线程的stop的值,但是在另外一个线程中stop的值还是之前的值,也就是说这个值不可见,而加了volatile这个关键字,这个线程的值就变的可见,这就是volatile的作用!

我们学知识的时候,切记不能浅尝则止,我们要理清楚知识的源头,那为啥会有这个关键字呢?

在明白这个问题之前,我们先来聊聊硬件的发展,众所周知,我们电脑CPU-内存-硬盘的速度是呈指数级下滑的,CPU是一个非常珍贵的资源,我们会尽可能的提高CPU的利用率,于是人们就想到了三种方式:1)引入高速缓存 2)引入线程或者进程 3)指令优化,我们今天具体来聊第一种,第二种第三种都很好理解!我们引入高速缓存来平衡CPU和内存之间的差异!现在多核心的CPU的架构如下


这样的架构肯定提高了CPU的利用率,但是也引发了数据不一致的问题!由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程,看到同一份内存的缓存值不一样就会存在缓存不一致的问题!为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供了两种解决办法1)总线锁 2)缓存锁,我们分别讲下!

总线锁,顾名思义,是锁住整个总线!简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的!

缓存锁,为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI 等。最常见的就是 MESI 协议。接下来给大家简单讲解一下 MESI!

MESI 表示缓存行的四种状态,分别是M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致,E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改,S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致,I(Invalid) 表示缓存已经失效,在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作!

对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据!CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写,使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果


那这个MESI会不会有啥问题呢?答案:是!就是各个 CPU 缓存行的状态是通过消息传递来进行的。如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态,这样就会影响效率,具体如下图

那我们应该如何处理这个问题呢?我们应该很清晰地看到上面的处理方式其实是同步,那我们可不可以异步处理呢,于是我们就引入了一个storebuffer(内存缓冲),当我对变量进行写入的时候,我就把这个写入的值放在storebuffer中,到这我们就算写成功了!接着,我们再发送一个失效的消息给其他的CPU,当前的CPU就可以继续执行相关命令了,等到其他CPU给出ack信号的时候,我们再把storebuff的值写入cache中,最后再从cache同步到主内存!

那这个storebuffer这种异步处理的方式会不会有啥问题呢?直觉告诉我们应该会存在问题,如下图

这个问题本质上是CPU的乱序执行导致的指令重排序,进而引发指令的可见性问题

到这里,硬件的开发人员就蒙了,不知道该如何处理,因为这个情况取决于用户(软件),于是在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flush store bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。

CPU 层面的内存屏障:什么是内存屏障?从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。

X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障),具体的作用如下

Store Memory Barrier(写屏障): 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的

Load Memory Barrier(读屏障): 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的

Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

具体的体现如下

加入内存屏障

到这里,知识的源头就搞清楚了,内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性,但是这个屏障怎么来加呢?回到最开始我们讲 volatile 关键字的代码,这个关键字会生成一个 Lock 的汇编指令,这个指令其实就相当于实现了一种内存屏障,在我读之前,强制把之前写的刷入内存,并且从内存中读!

最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容