【JAVA 基础】volatile 的进一步思考
【JAVA 基础】volatile 的进一步思考
在深入理解 volatile 关键字的过程中,出现了一些之前没有了解到的知识点,而这些知识点有影响着我对 volatile 的认知,下面就对这些知识点做一些梳理
MESI 是什么
为了解决速度不匹配问题,计算机中多处使用到了缓存。为了解决 CPU 和内存的速度不匹配问题,出现了高速缓存。在多核 CPU 的计算机中,每个 CPU 有自己单独的高速缓存。如果只是读或者在单核的情况下,缓存中的数据不会出现不一致的情况。只有当出现了写,才有可能出现缓存不一致问题。本质上不是 CPU 的核数不唯一造成的。而是多个缓存同时缓存了共享数据造成的。那么针对这个因为 CPU 造成的问题,CPU 厂商自己做了一层在 CPU 缓存硬件级别的解决方案。这个就是 MESI,虽然他们各个高速缓存之间是独立的,但是他们公用一条总线。每个缓存都在不停地嗅探总线中的任何一个操作。一个缓存通过总线去读取写入内存中的数据,其他缓存都会同步的使自己缓存中的数据失效或修改成其他状态。但是如果只是在自己的缓存中修改了,没有同步到内存,这个时候怎么办呢?其实可以提前一步,在 CPU 通知缓存修改的时候,也就是缓存修改之前就通过总线通知其他缓存,让他们修改状态。
详情请参考:https://www.infoq.cn/article/cache-coherency-primer
都已经有了 MESI,volatile 还有什么用呢?
MESI 只是保证了高速缓存这一块的一致性,但是从内存到 CPU 之间还有其他的缓存。同样会出现可见性问题,而且不是所有的硬件体系架构都支持 MESI。java 的 volatile 关键字的作用可以认为是让所有缓存(例如寄存器)都失效(认为是不可靠的),针对 volatile 修饰的共享变量,读写都直接操作主内存。这样就从根本上杜绝了不一致问题。
volatile 为什么要禁止重排序呢
- 重排序一定要保证在单线程情况下,不影响最终的执行结果。
首先重排序在 java 编译期间会发生重排序,根据 as-if-serial 语义,会把指令重排序。重排序的目的是为了提升效率,他会尽可能的把对于同一变量的操作放到一起,减少缓存交互次数。
其次处理器也会做重排序,目前的处理器都才用了指令级并行技术,比如处理器的加法器和除法器器是可以同时执行的。所以同一时刻加法指令和除法指令是可以并行执行,但是两个加法指令需要顺序执行。为了有效的利用 CPU,硬件级别也会做重排序。
重排序在单线程的情况下不会出现问题,但是在多线程的情况下会有问题。比如说双重检查锁来实现的单例模式。
public class Singleton {
private volatile static Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
// 这里主要原因是synchronized是一个重量操作,这样可以减少锁操作
if (singleton == null) {
// 如果不加锁的话,就会出现线程1判断了为null以后,时间片切换了,然后线程2来判断也为null的情况
synchronized (Singleton.class) {
// 如果不判断的话,当线程1和线程2同时过来,线程1获取了锁资源,线程2挂起到等待队列,等线程1执行完释放锁以后,线程2也会创建
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这里加上 volatile 的作用是 singleton = new Singleton (); 不是一个原子操作。他可能出现重排序。比如先申请了内存,然后把内存地址就返回给变量。但这个时候是没有实例化的,等另外一个线程过来发现 singleton 不为 null,然后就返回使用了。但这个时候对象还没有实例化完成,就会出现问题。这就是重排序造成的问题。volatile 的作用就是禁止重排序。
volatile 如果做到禁止重排序呢?其实就是使用内存屏障,处理器提供了读写屏障用于让用户指定执行顺序
参考
https://blog.csdn.net/aigoogle/article/details/40793947
https://www.jianshu.com/p/35e4504d42e4
.
本文是 RAD Game Tools 程序员 Fabian “ryg” Giesen 在其博客上发表的《Cache coherency primer》一文的翻译,经作者许可分享至 InfoQ 中文站。该系列共有两篇,本文系第一篇。
我计划写一些关于多核场景下数据组织的文章。写了第一篇,但我很快意识到有大量的基础知识我首先需要讲一下。在本文中,我就尝试阐述这些知识。
缓存(Cache)
本文是关于 CPU 缓存的快速入门。我假设你已经有了基本概念,但你可能不熟悉其中的一些细节。(如果你已经熟悉了,你可以忽略这部分。)
在现代的 CPU(大多数)上,所有的内存访问都需要通过层层的缓存来进行。也有些例外,比如,对映射成内存地址的 I/O 口、写合并(Write-combined)内存,这些访问至少会绕开这个流程的一部分。但这两者都是罕见的场景(意味着绝大多数的用户态代码都不会遇到这两种情况),所以在本文中,我将忽略这两者。
CPU 的读 / 写(以及取指令)单元正常情况下甚至都不能直接访问内存 —— 这是物理结构决定的;CPU 都没有管脚直接连到内存。相反,CPU 和一级缓存(L1 Cache)通讯,而一级缓存才能和内存通讯。大约二十年前,一级缓存可以直接和内存传输数据。如今,更多级别的缓存加入到设计中,一级缓存已经不能直接和内存通讯了,它和二级缓存通讯 —— 而二级缓存才能和内存通讯。或者还可能有三级缓存。你明白这个意思就行。
缓存是分 “段”(line)的,一个段对应一块存储空间,大小是 32(较早的 ARM、90 年代 /2000 年代早期的 x86 和 PowerPC)、64(较新的 ARM 和 x86)或 128(较新的 Power ISA 机器)字节。每个缓存段知道自己对应什么范围的物理内存地址,并且在本文中,我不打算区分物理上的缓存段和它所代表的内存,这听起来有点草率,但是为了方便起见,还是请熟悉这种提法。具体地说,当我提到 “缓存段” 的时候,我就是指一段和缓存大小对齐的内存,不关心里面的内容是否真正被缓存进去(就是说保存在任何级别的缓存中)了。
当 CPU 看到一条读内存的指令时,它会把内存地址传递给一级数据缓存(或可戏称为 L1D$,因为英语中 “缓存(cache)” 和 “现金(cash)” 的发音相同)。一级数据缓存会检查它是否有这个内存地址对应的缓存段。如果没有,它会把整个缓存段从内存(或者从更高一级的缓存,如果有的话)中加载进来。是的,一次加载整个缓存段,这是基于这样一个假设:内存访问倾向于本地化(localized),如果我们当前需要某个地址的数据,那么很可能我们马上要访问它的邻近地址。一旦缓存段被加载到缓存中,读指令就可以正常进行读取。
如果我们只处理读操作,那么事情会很简单,因为所有级别的缓存都遵守以下规律,我称之为:
基本定律:在任意时刻,任意级别缓存中的缓存段的内容,等同于它对应的内存中的内容。
一旦我们允许写操作,事情就变得复杂一点了。这里有两种基本的写模式:直写(write-through)和回写(write-back)。直写更简单一点:我们透过本级缓存,直接把数据写到下一级缓存(或直接到内存)中,如果对应的段被缓存了,我们同时更新缓存中的内容(甚至直接丢弃),就这么简单。这也遵守前面的定律:缓存中的段永远和它对应的内存内容匹配。
回写模式就有点复杂了。缓存不会立即把写操作传递到下一级,而是仅修改本级缓存中的数据,并且把对应的缓存段标记为 “脏” 段。脏段会触发回写,也就是把里面的内容写到对应的内存或下一级缓存中。回写后,脏段又变 “干净” 了。当一个脏段被丢弃的时候,总是先要进行一次回写。回写所遵循的规律有点不同。
回写定律:当所有的脏段被回写后,任意级别缓存中的缓存段的内容,等同于它对应的内存中的内容。
换句话说,回写模式的定律中,我们去掉了 “在任意时刻” 这个修饰语,代之以弱化一点的条件:要么缓存段的内容和内存一致(如果缓存段是干净的话),要么缓存段中的内容最终要回写到内存中(对于脏缓存段来说)。
直接模式更简单,但是回写模式有它的优势:它能过滤掉对同一地址的反复写操作,并且,如果大多数缓存段都在回写模式下工作,那么系统经常可以一下子写一大片内存,而不是分成小块来写,前者的效率更高。
有些(大多数是比较老的)CPU 只使用直写模式,有些只使用回写模式,还有一些,一级缓存使用直写而二级缓存使用回写。这样做虽然在一级和二级缓存之间产生了不必要的数据流量,但二级缓存和更低级缓存或内存之间依然保留了回写的优势。我想说的是,这里涉及到一系列的取舍问题,且不同的设计有不同的解决方案。没有人规定各级缓存的大小必须一致。举个例子,我们会看到有 CPU 的一级缓存是 32 字节,而二级缓存却有 128 字节。
为了简化问题,我省略了一些内容:缓存关联性(cache associativity),缓存组(cache sets),使用分配写(write-allocate)还是非分配写(上面我描述的直写是和分配写相结合的,而回写是和非分配写相结合的),非对齐的访问(unaligned access),基于虚拟地址的缓存。如果你感兴趣,所有这些内容都可以去查查资料,但我不准备在这里讲了。
一致性协议(Coherency protocols)
只要系统只有一个 CPU 核在工作,一切都没问题。如果有多个核,每个核又都有自己的缓存,那么我们就遇到问题了:如果某个 CPU 缓存段中对应的内存内容被另外一个 CPU 偷偷改了,会发生什么?
好吧,答案很简单:什么也不会发生。这很糟糕。因为如果一个 CPU 缓存了某块内存,那么在其他 CPU 修改这块内存的时候,我们希望得到通知。我们拥有多组缓存的时候,真的需要它们保持同步。或者说,系统的内存在各个 CPU 之间无法做到与生俱来的同步,我们实际上是需要一个大家都能遵守的方法来达到同步的目的。
注意,这个问题的根源是我们拥有多组缓存,而不是多个 CPU 核。我们也可以这样解决问题,让多个 CPU 核共用一组缓存:也就是说只有一块一级缓存,所有处理器都必须共用它。在每一个指令周期,只有一个幸运的 CPU 能通过一级缓存做内存操作,运行它的指令。
这本身没问题。唯一的问题就是太慢了,因为这下处理器的时间都花在排队等待使用一级缓存了(并且处理器会做大量的这种操作,至少每个读写指令都要做一次)。我指出这一点是因为它表明了问题不是由多核引起的,而是由多缓存引起的。我们知道了只有一组缓存也能工作,只是太慢了,接下来最好就是能做到:使用多组缓存,但使它们的行为看起来就像只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的。就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。
缓存一致性协议有多种,但是你日常处理的大多数计算机设备使用的都属于 “窥探(snooping)” 协议,这也是我这里要讲的。(还有一种叫 “基于目录的(directory-based)” 协议,这种协议的延迟性较大,但是在拥有很多个处理器的系统中,它有更好的可扩展性。)
“窥探” 背后的基本思想是,所有内存传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(arbitrate):同一个指令周期中,只有一个缓存可以读写内存。窥探协议的思想是,缓存不仅仅在做内存传输的时候才和总线打交道,而是不停地在窥探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其他处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其他处理器马上就知道这块内存在它们自己的缓存中对应的段已经失效。
在直写模式下,这是很直接的,因为写操作一旦发生,它的效果马上会被 “公布” 出去。但是如果混着回写模式,就有问题了。因为有可能在写指令执行过后很久,数据才会被真正回写到物理内存中 —— 在这段时间内,其他处理器的缓存也可能会傻乎乎地去写同一块内存地址,导致冲突。在回写模型中,简单把内存写操作的信息广播给其他处理器是不够的,我们需要做的是,在修改本地缓存 _ 之前 _,就要告知其他处理器。搞懂了细节,就找到了处理回写模式这个问题的最简单方案,我们通常叫做 MESI 协议(译者注:MESI 是 Modified、Exclusive、Shared、Invalid 的首字母缩写,代表四种缓存状态,下面的译文中可能会以单个字母指代相应的状态)。
MESI 以及衍生协议
本节叫做 “MESI 以及衍生协议”,是因为 MESI 衍生了一系列紧密相关的一致性协议。我们先从原生的 MESI 协议开始:MESI 是四种缓存段状态的首字母缩写,任何多核系统中的缓存段都处于这四种状态之一。我将以相反的顺序逐个讲解,因为这个顺序更合理:
- 失效(Invalid)缓存段,要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。
- 共享(Shared)缓存段,它是和主内存内容保持一致的一份拷贝,在这种状态下的缓存段只能被读取,不能被写入。多组缓存可以同时拥有针对同一内存地址的共享缓存段,这就是名称的由来。
- 独占(Exclusive)缓存段,和 S 状态一样,也是和主内存内容保持一致的一份拷贝。区别在于,如果一个处理器持有了某个 E 状态的缓存段,那其他处理器就不能同时持有它,所以叫 “独占”。这意味着,如果其他处理器原本也持有同一缓存段,那么它会马上变成 “失效” 状态。
- 已修改(Modified)缓存段,属于脏段,它们已经被所属的处理器修改了。如果一个段处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成失效状态,这个规律和 E 状态一样。此外,已修改缓存段如果被丢弃或标记为失效,那么先要把它的内容回写到内存中 —— 这和回写模式下常规的脏段处理方式一样。
如果把以上这些状态和单核系统中回写模式的缓存做对比,你会发现 I、S 和 M 状态已经有对应的概念:失效 / 未载入、干净以及脏的缓存段。所以这里的新知识只有 E 状态,代表独占式访问。这个状态解决了 “在我们开始修改某块内存之前,我们需要告诉其他处理器” 这一问题:只有当缓存段处于 E 或 M 状态时,处理器才能去写它,也就是说只有这两种状态下,处理器是独占这个缓存段的。当处理器想写某个缓存段时,如果它没有独占权,它必须先发送一条 “我要独占权” 的请求给总线,这会通知其他处理器,把它们拥有的同一缓存段的拷贝失效(如果它们有的话)。只有在获得独占权后,处理器才能开始修改数据 —— 并且此时,这个处理器知道,这个缓存段只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。
反之,如果有其他处理器想读取这个缓存段(我们马上能知道,因为我们一直在窥探总线),独占或已修改的缓存段必须先回到 “共享” 状态。如果是已修改的缓存段,那么还要先把内容回写到内存中。
MESI 协议是一个合适的状态机,既能处理来自本地处理器的请求,也能把信息广播到总线上。我不打算讲更多关于状态图的细节以及不同的状态转换类型。如果你感兴趣的话,可以在关于硬件架构的书中找到更多的深度内容,但对于本文来说,讲这些东西有点过了。作为一个软件开发者,你只要理解以下两点,就大有可为:
第一,在多核系统中,读取某个缓存段,实际上会牵涉到和其他处理器的通讯,并且可能导致它们发生内存传输。写某个缓存段需要多个步骤:在你写任何东西之前,你首先要获得独占权,以及所请求的缓存段的当前内容的拷贝(所谓的 “带权限获取的读(Read For Ownership)” 请求)。
第二,尽管我们为了一致性问题做了额外的工作,但是最终结果还是非常有保证的。即它遵守以下定理,我称之为:
MESI 定律:在所有的脏缓存段(M 状态)被回写后,任意缓存级别的所有缓存段中的内容,和它们对应的内存中的内容一致。此外,在任意时刻,当某个位置的内存被一个处理器加载入独占缓存段时(E 状态),那它就不会再出现在其他任何处理器的缓存中。
注意,这其实就是我们已经讲过的回写定律加上独占规则而已。我认为 MESI 协议或多核系统的存在根本没有弱化我们现有的内存模型。
好了,至此我们(粗略)讲了原生 MESI 协议(以及使用它的 CPU,比如 ARM)。其他处理器使用 MESI 扩展后的变种。常见的扩展包括 “O”(Owned)状态,它和 E 状态类似,也是保证缓存间一致性的手段,但它直接共享脏段的内容,而不需要先把它们回写到内存中(“脏段共享”),由此产生了 MOSEI 协议。还有 MERSI 和 MESIF,这两个名字代表同一种思想,即指定某个处理器专门处理针对某个缓存段的读操作。当多个处理器同时拥有某个 S 状态的缓存段的时候,只有被指定的那个处理器(对应的缓存段为 R 或 F 状态)才能对读操作做出回应,而不是每个处理器都能这么做。这种设计可以降低总线的数据流量。当然你可以同时加入 R/F 状态和 O 状态,或者更多的状态。这些都属于优化,没有一种会改变基本定律,也没有一种会改变 MESI 协议所确保的结果。
我不是这方面的专家,很有可能有系统在使用其他协议,这些协议并不能完全保证一致性,不过如果有,我没有注意到它们,或者没有看到有什么流行的处理器在使用它们。所以为了达到我们的目的,我们真的就可以假设一致性协议能保证缓存的一致性。不是基本一致,不是 “写入一会儿后才能保持一致”—— 而是完全的一致。从这个层面上说,除非硬件有问题,内存的状态总是一致的。用技术术语来说,MESI 以及它的衍生协议,至少在原理上,提供了完整的顺序一致性(sequential consistency),在 C++ 11 的内存模型中,这是最强的一种确保内存顺序的模型。这也引出了问题,为什么我们需要弱一点的内存模型,以及 “什么时候会用到它们”?
内存模型
不同的体系结构提供不同的内存模型。到本文写作的时候为止,ARM 和 POWER 体系结构的机器拥有相对较弱的内存模型:这类 CPU 在读写指令重排序(reordering)方面有相当大的自由度,这种重排序有可能会改变程序在多核环境下的语义。通过 “内存屏障(memory barrier)”,程序可以对此加以限制:“重排序操作不允许越过这条边界”。相反,x86 则拥有较强的内存模型。
我不打算在这里深入到内存模型的细节中,这很容易陷入堆砌技术术语中,而且也超出了本文的范围。但是我想说一点关于 “他们如何发生” 的内容 —— 也就是,弱内存模型如何保证正确性(相比较于 MESI 协议给缓存带来的顺序一致性),以及为什么。当然,一切都归结于性能。
规则是这样的:如果满足下面的条件,你就可以得到完全的顺序一致性:第一,缓存一收到总线事件,就可以在当前指令周期中迅速做出响应。第二,处理器如实地按程序的顺序,把内存操作指令送到缓存,并且等前一条执行完后才能发送下一条。当然,实际上现代处理器一般都无法满足以上条件:
- 缓存不会及时响应总线事件。如果总线上发来一条消息,要使某个缓存段失效,但是如果此时缓存正在处理其他事情(比如和 CPU 传输数据),那这个消息可能无法在当前的指令周期中得到处理,而会进入所谓的 “失效队列(invalidation queue)”,这个消息等在队列中直到缓存有空为止。
- 处理器一般不会严格按照程序的顺序向缓存发送内存操作指令。当然,有乱序执行(Out-of-Order execution)功能的处理器肯定是这样的。顺序执行(in-order execution)的处理器有时候也无法完全保证内存操作的顺序(比如想要的内存不在缓存中时,CPU 就不能为了载入缓存而停止工作)。
- 写操作尤其特殊,因为它分为两阶段操作:在写之前我们先要得到缓存段的独占权。如果我们当前没有独占权,我们先要和其他处理器协商,这也需要一些时间。同理,在这种场景下让处理器闲着无所事事是一种资源浪费。实际上,写操作首先发起获得独占权的请求,然后就进入所谓的由 “写缓冲(store buffer)” 组成的队列(有些地方使用 “写缓冲” 指代整个队列,我这里使用它指代队列的一条入口)。写操作在队列中等待,直到缓存准备好处理它,此时写缓冲就被 “清空(drained)” 了,缓冲区被回收用于处理新的写操作。
这些特性意味着,默认情况下,读操作有可能会读到过时的数据(如果对应失效请求还等在队列中没执行),写操作真正完成的时间有可能比它们在代码中的位置晚,一旦牵涉到乱序执行,一切都变得模棱两可。回到内存模型,本质上只有两大阵营:
在弱内存模型的体系结构中,处理器为了开发者能写出正确的代码而做的工作是最小化的,指令重排序和各种缓冲的步骤都是被正式允许的,也就是说没有任何保证。如果你需要确保某种结果,你需要自己插入合适的内存屏障 —— 它能防止重排序,并且等待队列中的操作全部完成。
使用强一点的内存模型的体系结构则会在内部做很多记录工作。比如,x86 会跟踪所有在等待中的内存操作,这些操作都还没有完全完成(称为 “退休(retired)”)。它会把它们的信息保存在芯片内部的 MOB(“memory ordering buffer”,内存排序缓冲)。x86 作为部分支持乱序执行的体系结构,在出问题的时候能把尚未 “退休” 的指令撤销掉 —— 比如发生页错误(page fault),或者分支预测失败(branch mispredict)的时候。我已经在我以前的文章 “好奇地说” 中提到过一些细节,以及和内存子系统的一些交互。主旨是 x86 处理器会主动地监控外部事件(比如缓存失效),有些已经执行完的操作会因为这些事件而被撤销,但不算 “退休”。这就是说,x86 知道自己的内存模型应该是什么样子的,当发生了一件和这个模型冲突的事,处理器会回退到上一个与内存模型兼容的状态。这就是我在以前另一篇文章中提到的 “清除内存排序机(memory ordering machine clear)”。最后的结果是,x86 处理器为内存操作提供了很强的一致性保证 —— 虽然没有达到完美的顺序一致性。
无论如何,一篇文章讲这么多已经够了。我把它放在我的博客上。我的想法是将来的文章只要引用它就行了。我们看效果吧。感谢阅读!
查看参考原文: http://fgiesen.wordpress.com/2014/07/07/cache-coherency/
缓存一致性入门
我打算写一些关于多核场景的数据组织的文章。我开始写第一篇文章,但很快意识到我需要首先介绍一些基础知识。在这篇文章中,我将尝试这样做。
缓存
这是有关 CPU 高速缓存的快速入门读物。我假设您知道基本概念,但您可能不熟悉一些细节。 (如果是,请随意跳过本节。)
在现代 CPU 中,(几乎)所有内存访问都经过高速缓存层次结构;内存映射 IO 和写组合内存有一些例外,它们至少绕过了该过程的部分内容,但这两种情况都是极端情况(从某种意义上说,绝大多数用户模式代码永远不会看到其中任何一个),因此我将在这篇文章中忽略它们。
CPU 核心的加载 / 存储(和指令获取)单元通常甚至无法直接访问内存 —— 这在物理上是不可能的;必要的电线不存在!相反,他们与应该处理它的 L1 缓存进行对话。
大约 20 年前,L1 缓存确实可以直接与内存通信。此时,通常会涉及更多的缓存级别;这意味着 L1 缓存不再直接与内存对话,而是与 L2 缓存对话,而 L2 缓存又与内存对话。
或者也许是 L3 缓存。你明白了。
缓存被组织成 “行”,对应于 32(较旧的 ARM、90 年代 / 2000 年代早期的 x86/PowerPC)、64(较新的 ARM 和 x86)或 128(较新的 Power ISA 机器)字节内存的对齐块。
每个缓存行都知道它对应的物理内存地址范围,在本文中我不会区分物理缓存行和它代表的内存 —— 这是草率的,但常规用法,所以最好习惯它。
特别是,我所说的 “缓存行” 是指内存中适当对齐的字节组,无论这些字节当前是否被缓存(即存在于任何缓存级别中)。
当 CPU 核心看到内存加载指令时,它会将地址传递到 L1数据缓存(或 “L1D$”,在 “缓存” 上播放,发音与 “现金” 相同)。 L1D$ 检查是否包含相应的缓存行。
如果没有,则从内存(或下一个更深的缓存级别,如果存在)引入整个缓存行 - 是的,整个缓存行;假设内存访问是本地化的,所以如果我们正在查看内存中的某个字节,我们可能很快就会访问它的邻居。
一旦 L1D$ 中存在高速缓存行,加载指令就可以继续执行其内存读取。
只要我们处理只读访问,一切都非常简单,因为所有缓存级别都遵循我所说的
基本不变量:任何缓存级别中存在的所有缓存行的内容始终与内存中相应地址处的值相同。
一旦我们允许存储,即内存写入,事情就会变得有点复杂。这里有两种基本方法:直写式和回写式。直写是更容易的一种:我们只需将存储传递到下一级缓存(或内存)。
如果我们缓存了相应的行,我们就会更新我们的副本(或者甚至可能只是丢弃它),但仅此而已。这保留了与之前相同的不变性:如果缓存行存在于缓存中,则其内容始终与内存匹配。
回写有点棘手。缓存不会立即传递写入。相反,此类修改会本地应用于缓存数据,并且相应的缓存行被标记为 “脏”。
脏缓存线可以触发写回,此时它们的内容被写回内存或下一个缓存级别。回写后,脏缓存行再次 “干净”。
当脏高速缓存行被逐出时(通常是为高速缓存中的其他内容腾出空间),它总是需要首先执行回写。回写式缓存的不变量略有不同。
回写不变式:写回所有脏高速缓存行后,任何高速缓存级别中存在的所有高速缓存行的内容与内存中相应地址处的值相同。
换句话说,在回写式缓存中,我们丢失了 “始终” 限定符,并将其替换为更弱的条件:要么缓存内容与内存匹配(对于所有干净的缓存行都是如此),要么它们包含最终需要的值写回内存(对于脏缓存行)。
直写式缓存更简单,但回写式有一些优点:它可以过滤对同一位置的重复写入,并且如果大部分缓存行在回写时发生更改,它可以发出一个大的内存事务,而不是几个小的内存事务。的,效率更高。
一些(大多数是较旧的)CPU 到处都使用直写式缓存;有些到处都使用回写式缓存;有些有更简单的直写式 L1$,并由回写式 L2$ 支持。
这可能会在 L1$ 和 L2$ 之间产生冗余流量,但会获得传输到较低缓存级别或内存的回写优势。我的观点是,这里存在一整套权衡,不同的设计使用不同的解决方案。
也不要求所有级别的缓存行大小都相同 - 例如,CPU 在 L1$ 中具有 32 字节行,但在 L2$ 中具有 128 字节行,这并非闻所未闻。
为了简单起见,本节中省略了:缓存关联性 / 集;是否进行写分配(我描述了不带写分配的直写和带回写,这是最常见的用法);未对齐的访问;虚拟寻址缓存。
如果你有兴趣的话,这些都是你可以查找的内容,但我不会在这里深入探讨。
一致性协议
只要系统中只有该单个 CPU 核心,这一切就可以正常工作。添加更多核心,每个核心都有自己的缓存,我们遇到一个问题:如果其他核心修改了我们缓存之一中的数据,会发生什么?
嗯,答案很简单:什么也没有发生。这很糟糕,因为我们希望当其他人修改我们拥有缓存副本的内存时发生一些事情。一旦我们拥有多个缓存,我们确实需要保持它们同步,否则我们就没有真正的 “共享内存” 系统,更像是 “内存中内容的共享总体概念” 系统。
请注意,问题实际上是我们有多个缓存,而不是我们有多个核心。我们可以通过在所有核心之间共享所有缓存来解决整个问题:只有一个 L1$,所有处理器都必须共享它。
每个周期,L1$ 都会选择一个幸运的核心,该核心可以在本周期执行内存操作,并运行它。
这很好用。唯一的问题是它也很慢,因为核心现在大部分时间都花在排队等待 L1$ 请求的下一轮(处理器会执行很多这样的操作,每个加载 / 存储指令至少执行一个)。我指出这一点是因为它表明问题实际上与其说是多核问题,不如说是多缓存问题。我们知道一组缓存可以工作,但是当这太慢时,下一个最好的办法就是拥有多个缓存,然后让它们表现得就像只有一个缓存一样。这就是缓存一致性协议的用途:顾名思义,它们确保多个缓存的内容保持一致。
一致性协议有多种类型,但您日常处理的大多数计算设备都属于 “窥探” 协议的类别,这就是我将在这里介绍的内容。
(主要的替代方案是基于目录的系统,延迟较高,但可以更好地扩展到具有大量内核的系统)。
窥探背后的基本思想是,所有内存事务都发生在所有内核都可见的共享总线上:缓存本身是独立的,但内存本身是共享资源,并且内存访问需要仲裁:只有一个缓存可以读取在任何给定周期内将数据从内存中取出或写回内存。
现在,窥探协议的想法是,当缓存想要自己进行内存事务时,它们不仅仅与总线交互;而是与总线交互。相反,每个缓存不断监听总线流量以跟踪其他缓存正在做什么。
因此,如果一个缓存想要代表其核心读取或写入内存,所有其他核心都会注意到,这使它们能够保持缓存同步。
一旦一个核心写入内存位置,其他核心就知道它们对应的缓存行的副本现在已过时,因此无效。
对于直写式缓存,这相当简单,因为写入一发生就会 “发布”。
但是,如果混合中存在回写式缓存,则这不起作用,因为物理回写到内存可能会在核心执行相应存储后很长一段时间内发生 - 并且在中间时间,其他核心及其缓存并不明智,它们本身可能会尝试写入同一位置,从而导致冲突。
因此,对于回写模型,仅在写入发生时广播写入内存是不够的;如果我们想避免冲突,我们需要在开始更改本地副本中的任何内容之前告诉其他核心我们的写入意图。弄清楚细节,最适合写回式缓存的最简单的解决方案就是通常所说的 MESI 协议。
梅西和朋友们
这一部分被称为 “MESI 和朋友”,因为 MESI 催生了一整套密切相关的一致性协议。让我们从最初的开始:MESI 是多核系统中任意多个核心的缓存行可以处于的四种状态的缩写。
我将以相反的顺序介绍它们,因为这是解释它们的更好顺序:
- Invalid 行是缓存中不存在的缓存行,或者已知其内容已过时的缓存行。出于缓存的目的,这些将被忽略。一旦缓存行失效,就好像它一开始就不在缓存中一样。
- 共享行是主内存内容的干净副本。处于共享状态的缓存行可用于读取,但不能写入。允许多个缓存同时在 “共享” 状态下拥有同一内存位置的副本,因此得名。
- 独占行也是主存内容的干净副本,就像 S 状态一样。不同之处在于,当一个核心在 E 状态下保持一条线路时,其他核心不能同时保持该线路,因此是 “独占” 的。
也就是说,同一行在所有其他核的缓存中必须处于 I 状态。 - 修改后的线路脏了;它们已被本地修改。如果一条线路处于 M 状态,则对于所有其他内核,它也必须处于 I 状态,与 E 相同。
此外,修改后的缓存行在被逐出或失效时需要写回内存,与回写缓存中的常规脏状态相同。
如果将此与上面单核情况下的回写式缓存的表示进行比较,您将看到 I、S 和 M 状态已经具有其等效项:分别为无效 / 不存在、干净和脏缓存行。
所以新的是 E 状态,表示独占访问。这种状态解决了 “我们需要在开始修改内存之前告诉其他核心” 的问题:每个核心只有在其缓存将其保持在 E 或 M 状态(即它们是独占的)时才可以写入缓存行。
如果核心在想要写入时没有对缓存行的独占访问权,则它首先需要向总线发送 “我想要独占访问” 请求。这告诉所有其他核心使该缓存行的副本无效(如果有的话)。
只有在授予独占访问权限后,核心才可以开始修改数据,此时,核心知道该缓存行的唯一副本位于其自己的缓存中,因此不会出现任何冲突。
相反,一旦其他核心想要从该缓存行读取(我们立即获知,因为我们正在监听总线),独占和修改的缓存行必须恢复到 “共享”(S)状态。
在修改缓存行的情况下,这还涉及首先将其数据写回内存。
MESI 协议是一个适当的状态机,它响应来自本地核心的请求和总线上的消息。
我不会详细介绍完整的状态图以及不同的转换类型;如果您关心的话,您可以在有关硬件架构的书籍中找到更深入的信息,但对于我们的目的来说,这有点过分了。
作为一名软件开发人员,您将只了解两件事:
首先,在多核系统中,获取对缓存行的读访问涉及与其他核心的通信,并可能导致它们执行内存事务。
写入缓存行是一个多步骤的过程:在写入任何内容之前,您首先需要获取缓存行的独占所有权及其现有内容的副本(所谓的 “读取所有权” 请求)。
其次,虽然我们必须做一些额外的体操,但最终的结果实际上确实提供了一些相当有力的保证。也就是说,它遵循我所说的
MESI 不变量:写回所有脏(M 状态)高速缓存行后,任何高速缓存级别中存在的所有高速缓存行的内容与内存中相应地址处的值相同。此外,在任何时候,当某个内存位置由一个核心独占缓存(处于 E 或 M 状态)时,它不会出现在任何其他核心的缓存中。
请注意,这实际上只是我们已经看到的回写不变量,并添加了额外的排他性规则。我的观点是 MESI 或多核的存在并不一定会削弱我们的内存模型。
好的,所以(非常粗略地)涵盖了普通的 MESI(因此也涵盖了使用它的 CPU,例如 ARM)。其他处理器使用扩展变体。
流行的扩展包括类似于 “E” 的 “O”(拥有)状态,允许共享脏缓存线,而不必先将它们写回内存(“脏共享”),从而产生 MOESI 和 MERSI/MESIF,它们是不同的同一想法的名称,即使一个核心成为对给定缓存行的读取请求的指定响应者。
当多个核心将高速缓存行保持在共享状态时,只有指定的响应者(将高速缓存行保持在 “R” 或 “F” 状态)响应读取请求,而不是所有将高速缓存行保持在 S 状态。这减少了公交车流量。
当然,您可以添加 R/F 状态和 O 状态,或者变得更奇特。所有这些都是优化,但它们都没有改变协议提供的基本不变量或保证。
我不是这个主题的专家,而且很可能正在使用其他协议,它们只能提供明显较弱的保证,但如果是这样,我不知道它们,也不知道有任何使用它们的流行 CPU 内核。
因此,出于我们的目的,我们确实可以假设一致性协议使缓存保持一致。不是大部分连贯,不是 “连贯,除了变化后的短暂窗口”—— 正确连贯。
在这个级别上,除非出现硬件故障,否则对于内存的当前状态应该是什么总是达成一致的。从技术角度来说,MESI 及其所有变体原则上无论如何都可以提供完全顺序一致性,这是 C++11 内存模型中指定的最强内存排序保证。这就引出了一个问题,为什么我们的记忆模型较弱,以及 “它们发生在哪里”?
内存型号
不同的架构提供不同的内存模型。
截至撰写本文时,ARM 和 POWER 架构机器的内存模型相对 “较弱”:CPU 核心在重新排序加载和存储操作方面有相当大的余地,这可能会改变多核上下文中程序的语义,同时 “内存程序可以使用屏障指令来指定约束:“不要跨这条线重新排序内存操作”。
相比之下,x86 具有相当强大的内存模型。
我不会在这里详细讨论内存模型;它很快就会变得非常技术性,并且超出了本文的范围。
但我确实想谈谈 “它们是如何发生的”—— 也就是说,弱化的保证(与我们可以从 MESI 等获得的完全顺序一致性相比)从何而来,以及为什么。和往常一样,这一切都归结为性能。
所以事情是这样的:如果 a) 缓存在收到总线事件的同一周期立即响应总线事件,并且 b) 核心按程序顺序尽职尽责地将每个内存操作发送到缓存,并等待,那么您确实会获得完全的顺序一致性以便在发送下一个之前完成它。
当然,在实践中,现代 CPU 通常不会执行以下操作:
- 缓存不会立即响应总线事件。如果触发缓存行失效的总线消息到达时缓存正忙于执行其他操作(例如,将数据发送到核心),则该周期可能不会得到处理。
相反,它会进入所谓的 “失效队列”,在那里停留一段时间,直到缓存有时间处理它。 - 一般来说,内核不会按照严格的程序顺序将内存操作发送到缓存;对于无序执行的核心来说肯定是这种情况,但即使在其他情况下,有序核心也可能对内存操作具有较弱的排序保证(例如,确保单个缓存未命中不会立即使整个核心磨到停止)。
- 特别是,存储很特殊,因为它们是一个两阶段操作:我们首先需要获得缓存行的独占所有权,然后存储才能通过。如果我们还没有独占所有权,我们需要与其他核心对话,这需要一段时间。
同样,在发生这种情况时让核心闲置并摆弄拇指并不是对执行资源的良好利用。
相反,发生的情况是,存储开始获取独占所有权的过程,然后进入所谓的 “存储缓冲区” 队列(有些人将整个队列称为 “存储缓冲区”,但我将使用指代条目的术语)。
它们在此队列中停留一段时间,直到缓存准备好实际执行存储操作,此时相应的存储缓冲区被 “耗尽” 并且可以回收以保存新的待处理存储。
所有这些事情的含义是,默认情况下,加载可以获取过时的数据(如果相应的失效请求位于失效队列中),存储实际上完成的时间晚于其在代码中的位置所建议的时间,并且一切都变得更加模糊当涉及乱序执行时。
回到内存模型,本质上有两个阵营:
具有弱内存模型的架构在核心中执行最少的必要工作,以允许软件开发人员编写正确的代码。指令重新排序和各种缓冲阶段得到正式允许;没有任何保证。
如果需要保证,则需要插入适当的内存屏障 —— 这将防止在需要时重新排序和排出待处理操作的队列。
具有更强内存模型的架构在内部进行更多的簿记工作。
例如,x86 处理器在称为 MOB(“内存排序缓冲区”)的芯片内部数据结构中跟踪所有尚未完全完成(“退休”)的待处理内存操作。
作为乱序基础设施的一部分,如果出现问题(例如页面错误或分支错误预测等异常),x86 内核可以回滚非退役操作。
我在之前的文章 “推测性地说” 中介绍了一些细节,以及与内存子系统的一些交互。其要点是 x86 处理器主动监视外部事件(例如缓存失效),这些事件将使某些已执行但尚未停用的操作的结果追溯性失效。
也就是说,x86 处理器知道它们的内存模型是什么,当发生与该模型不一致的事件时,机器状态会回滚到上次仍然符合内存模型规则的时间。
这是我在之前的另一篇文章中介绍的 “内存排序机清除”。最终结果是 x86 处理器为所有内存操作提供了非常强有力的保证 —— 尽管不是完全顺序一致性。
因此,较弱的内存模型可以实现更简单(并且可能功耗更低)的内核。更强的内存模型使内核(及其内存子系统)的设计更加复杂,但更容易编写代码。
理论上,较弱的模型允许更多的调度自由度并且可能更快;实际上,x86 似乎在内存操作的性能方面做得很好,至少目前是这样。因此,到目前为止,我很难判定谁是明确的赢家。
当然,作为一名软件开发人员,当我能够获得更强大的 x86 内存模型时,我很乐意采用它。
反正。对于一篇文章来说就足够了。现在我已经将所有这些写在我的博客上,我的想法是以后的帖子可以参考它。我们会看看情况如何。谢谢阅读!
线程安全的单例模式常见写法是双重检查加锁。代码如下:
class Singleton{
private volatile static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){ // 1
synchronized(Singleton.class){ // 2
if(singleton == null){ // 3
singleton = new Singleton(); // 4
}
}
}
return singleton;
}
}
双重检查加锁的单例模式代码上就比较复杂,尤其体现在 getInstance
方法上,包括两次检查 singleton
是否是 null
,一次加锁,singleton
用关键字 volatile
修饰。为什么写一个单例如此复杂呢?
首先是懒汉模式,实例的初始化延迟到 getInstance
方法中,为了保证只会生成一个实例,要先判断 singleton
是否已经初始化,如果已经初始化了,就返回 singleton
,没有的话就创建对象。这就是 1 的作用。如果是单线程的情况,这样就够用了。
在多线程的情况下,只有 1,没有 2,3,就可能导致创建多个实例。例如,线程 A 和线程 B 调用 getInstance
方法,线程 A 先判断了 1,然后时间片结束了,切换到线程 B,线程 B 判断 1,然后创建了 singleton。时间片有切会线程 A,线程 A 创建实例。这样就线程 A 和线程 B 就分别创建了一个实例了。破坏了单例的结构。
为了解决这个问题,加了 synchronized
保证只有一个线程进入临界区。那只有 2,没有 3,可以吗?还是考虑和前面一模一样的场景,这次线程 A 和线程 B 都判断了 1 了,进入 2,线程 A 先进入临界区,线程 B 发现线程 A 进入了临界区,就挂在了 Singleton.class
等等待队列中,等待线程 A 执行完成。线程 A 继续执行,创建了一个 singleton
实例。退出了临界区。然后线程 B 被唤醒,进入临界区,又创建了一个 singleton
实例。结果又创建了两个 singleton
实例。
所以 3 的作用很明显了。在上面例子中,如果线程 B 发现实例已经被创建了 (singleton
不等于 null
),就直接退出临界区了。那 1 和 3 的作用似乎有点重合了,1 似乎就不是必须了。2,3 确实就足够保证单例了。但是加锁是比较消耗资源的,1 就是为了减少资源的消耗。
最后,这么看来 1,2,3,4 就足以保证单例了。那为什么需要加 volatile 呢?volatile 就牵扯到指令重排序的问题了。
要理解为什么要加 volatile,首先要理解 new Singleton()
做了什么。new 一个对象有几个步骤。1. 看 class 对象是否加载,如果没有就先加载 class 对象,2. 分配内存空间,初始化实例,3. 调用构造函数,4. 返回地址给引用。而 cpu 为了优化程序,可能会进行指令重排序,打乱这 3,4 这几个步骤,导致实例内存还没分配,就被使用了。
再用线程 A 和线程 B 举例。线程 A 执行到 new Singleton()
,开始初始化实例对象,由于存在指令重排序,这次 new 操作,先把引用赋值了,还没有执行构造函数。这时时间片结束了,切换到线程 B 执行,线程 B 调用 new Singleton()
方法,发现引用不等于 null
,就直接返回引用地址了,然后线程 B 执行了一些操作,就可能导致线程 B 使用了还没有被初始化的变量。
加了 volatile 之后,就保证 new
不会被指令重排序。
至此,这就是一个完整的懒汉模式 —> 线程安全的 -> 双重检查加锁单例模式。
推荐另一种单例模式的写法:
class Singleton{
private Singleton(){}
private static class LazySomethineHolder{
public static Singleton singleton = new Singleton();
}
public static Singleton getInstance(){
return LazySomethineHolder.singleton;
}
}