合并写(write combining)

  • CPU用大量技术抵消内存访问的延迟

  • 读写内存期间,CPU能执行成百上千条

  • 多级SRAM缓存是减小这种延迟带来的影响的主要手段

    • SMP用消息传递协议来实现缓存之间一致性

  • 现代的CPU实在是太快了,

    • 即使用了缓存,有时也无法跟上CPU速度

  • 为进一步减小延迟,一些鲜为人知的缓冲区派上了用场

  • 本文探讨

  • “合并写存储缓冲区(write combining store buffers)”,

  • 及如何写出有效利用它们的代码。

  • CPU缓存是非链式结构的hash map

  • 每个桶(bucket)通常64字节

  • 这就是一个“缓存行(cache line)”。

  • 缓存行是内存交换的实际单位

  • CPU需要访问的地址hash后的行尚不在缓存中,那缓存中对应位置的缓存行会被清除,以便载入新行。

  • 如果有两个地址,hash到同一缓存行,

    • 那么新的值会覆盖老的值。

  • CPU执行store时

  • 它尝试将数据写到离CPU最近的L1缓存。

  • 如果此时出现缓存未命中,CPU会访问下一级缓存。

  • 此时

    • 无论是英特尔还是其它厂商的CPU

    • 都会用“合并写(write combining)”的技术

  • 在请求L2缓存行的所有权尚未完成时,

  • 待存储的数据被写到处理器自身的众多跟缓存行一样大小的存储缓冲区之一

  • 这些芯片上的缓冲区允许

    • CPU在缓存子系统准备好接收和处理数据时

    • 继续执行指令

  • 当数据不在任何其它级别的缓存中时,将获得最大优势

  • 当后续的写操作要修改相同的缓存行时,这些缓冲区变得非常有趣

  • 在将后续的写操作提交到L2缓存之前,可进行缓冲区写合并

  • 这些64字节的缓冲区维护了一个64位的字段,

    • 每更新一个字节就会设置对应的位,

    • 来表示将缓冲区交换到外部缓存时哪些数据是有效的。

解决内存屏障
  • 内存屏障是硬件之上、OS或JVM之下,对并发作出的最后一层支持。

  • 再向下是是硬件提供的支持;

  • 向上是操作系统或JVM对内存屏障作出的各种封装。

  • 内存屏障是一种标准,各厂商可用不同实现

  • 本文仅帮助理解JVM提供的并发机制。

  • 首先,从volatile的语义引出可见性与重排序问题;

  • 接下来,阐述问题的产生原理,了解为什么需要内存屏障;

  • 然后,浅谈内存屏障的标准、厂商对内存屏障的支持,并以volatile为例讨论内存屏障如何解决这些问题;

  • 最后介绍JVM在内存屏障之上作出的几个封装。

  • 为助理解,会讨论硬件架构层面的一些基本原理(特别是CPU架构),

volatile规则
  • volatile关键字可参考猴子刚开博客时的文章添加链接描述。

  • volatile变量规则描述volatile变量的偏序语义;

  • 这里从volatile变量规则的角度来讲解,顺便做个复习。

  • volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。

  • volatile变量规则只是一种标准,

    • 要求JVM实现保证volatile变量的偏序语义。

    • 结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:

  • 保持可见性

  • 禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)

  • 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。

  • 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

  • 后文,如果仅涉及可见性,则指明“可见性”;如果二者均涉及,则以“偏序”代称。重排序一定会带来可见性问题,因此,不会出现单独讨论重排序的场景。

正确姿势

之前的文章多次涉及volatile变量规则的用法。

简单的仅利用volatile变量规则对volatile变量本身的可见性保证:

面试中单例模式有几种写法?:“饱汉 - 变种 3”在DCL的基础上,使用volatile修饰单例,以保证单例的可见性。

复杂的利用volatile变量规则(结合了程序顺序规则、传递性)保证变量本身及周围其他变量的偏序:

源码|并发一枝花之ReentrantLock与AQS(1):lock、unlock:exclusiveOwnerThread借助于volatile变量state保证其相对于state的偏序。

源码|并发一枝花之CopyOnWriteArrayList:CopyOnWriteArrayList借助于volatile变量array,对外提供偏序语义

可见性与重排序
  • 内存屏障的存在就是为了解决这些问题。

  • 什么是可见性?什么是重排序?为什么会有这些问题?

  • 可见性的定义常见于各种并发场景中,

    • 多线程为例:当一个线程修改了线程共享变量,其它线程能立即得知这个修改。

  • 性能角度考虑,没必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需最后一次同步即可,在这之前的同步都浪费。

  • 因此,实际的可见性定义要弱一些,只需要保证:

    • 当一个线程修改了线程共享变量,其它线程在使用前,能得到最新的修改值。

  • 可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,

    • `不保证任意时刻,存储的数据都是一致的(强一致)。

  • 下文讨论“缓存可见性”问题,部分文章也会称为“缓存一致性”问题。

问题来源
  • 一个最简单的可见性问题来自计算机内部的缓存架构:

  • 缓存大大缩小高速CPU与低速内存之间的差距。

  • 以三层缓存架构为例:

    • L1 最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有一个L1 Cache。

    • L2 容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache。

    • L3 最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。

  • 准确地说,每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache。

  • 单核时代的一切都是那么完美。

  • 然而,多核时代出现了可见性问题。

  • 一个badcase如下:

  • Core0与Core1命中了内存中的同一个地址,那么各自的L1 Cache会缓存同一份数据的副本。

  • 最开始,Core0与Core1都在友善的读取这份数据。

  • 突然,Core0修改了这份数据,使得两份缓存中的数据不同了,更确切的说,Core1 L1 Cache中的数据失效了。

  • 单核时代只有Core0,Core0修改Core0读,没什么问题;

  • 但是,现在Core0修改后,Core1并不知道数据已经失效,继续傻用,轻则数据计算错误,重则导致死循环、程序崩溃等。

  • 实际的可见性问题还要扩展到两方向:

  • 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。

    • 例如,寄存器就相当于CPU与L1 Cache之间的缓存。

  • 各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,

    • 但显然在CPU级别的缓存可见性问题面前,一切都失效了。

  • 以上只是最简单的可见性问题,不涉及重排序等。

  • 重排序也会导致可见性问题;同时,缓存上的可见性也会引起一些看似重排序导致的问题。

重排序
  • 重排序没有严格的定义。整体上可分为两种:

  • 真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。

  • 伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了。

  • 重排序是单核时代优秀的优化手段,有足够多的措施保证其在单核下的正确性。

  • 多核时代,如果工作线程间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。

  • 然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

内存屏障

  • 论并发编程中最基础的一项技术:

  • 内存屏障或栅栏,

  • 即让一个CPU处理单元中的内存状态对其它处理单元可见

  • CPU用很多技术来实现一个目标:CPU执行单元的速度要远超主存访问速度。

  • “Write Combing”中已介绍了其中的一项。

  • CPU避免内存访问延迟最常见的技术是将指令管道化,

  • 然后尽量重排这些管道的执行以最大化利用缓存,

    • 从而把因为缓存未命中引起的延迟降到最小。

  • 当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。

  • 在一个循环里,如果循环体内没用到这个计数器,循环的计数器什么时候更新(在循环开始,中间还是最后)并不重要。

  • 编译器和CPU可自由的重排指令以最佳的利用CPU,

  • 只要下一次循环前更新该计数器即可。

  • 并且在循环执行中,这个变量可能一直存在寄存器上,并没有被推到缓存或主存,这样这个变量对其他CPU来说一直都是不可见的。

  • CPU核内部包含了多个执行单元。

  • 现代Intel CPU含6个执行单元,可组合进行算术运算,逻辑条件判断及内存操作。

  • 每个执行单元可以执行上述任务的某种组合。

  • 这些执行单元是并行执行的,这样指令也就是在并行执行。

  • 但如果站在另一个CPU角度看,这也就产生了程序顺序的另一种不确定性。

  • 当一个缓存失效发生时,现代CPU可以先假设一个内存载入的值并根据这个假设值继续执行,直到内存载入返回确切的值。

  • 代码顺序并不是真正的执行顺序,只要有空间提高性能,CPU和编译器可进行各种优化。

  • 缓存和主存的读取会用load, store和write-combining缓冲区来缓冲和重排。

  • 这些缓冲区是查找速度很快的关联队列,当一个后来发生的load需要读取上一个store的值,而该值还没有到达缓存,查找是必需的,

  • 上图描绘的是简化的现代多核CPU,

  • 上图可看出执行单元可利用本地寄存器和缓冲区来管理和缓存子系统的交互。

  • 在多线程环境里需要使用某种技术来使程序结果尽快可见。

  • 这篇文章里不涉及到 Cache Conherence 。

  • 假定事实:一旦内存数据被推送到缓存,就会有消息协议来确保所有的缓存会对所有的共享数据同步并保持一致。

  • 这个使内存数据对CPU核可见的技术被称为内存屏障或内存栅栏。

  • 内存屏障提供两功能。

  • 首先它们通过确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;

  • 其次可实现内存数据可见性,确保内存数据会同步到CPU缓存子系统。

  • 大多数的内存屏障都是复杂的。

  • 不同的CPU架构上内存屏障的实现非常不一样。

  • Intel CPU的强内存模型比DEC Alpha的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。

  • x86处理器是在多线程编程中最常见的

  • 下面用x86的架构来阐述。

Store Barrier
  • Store屏障,是x86的”sfence“指令,

    • 强制所有在store屏障指令之前的store指令,都在该store屏障指令执行前被执行,

    • 并把store缓冲区的数据都刷到CPU缓存。

  • 使得程序状态对其它CPU可见,这样其它CPU可根据需要介入。

  • 例子是Disruptor中的BatchEventProcessor。

  • 当序列Sequence被一个消费者更新时,其它(Consumers)和Producers)知道该消费者的进度,因此可采取合适的动作。

  • 所以屏障之前发生的内存更新都可见了。

Load Barrier
  • Load屏障,x86上的”ifence“指令,

  • 强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,且一直等到load缓冲区被该CPU读完才能执行之后的load指令。

  • 使得从其它CPU暴露出来的程序状态对该CPU可见,这之后CPU可以进行后续处理。

  • 例子是上面的BatchEventProcessor的sequence对象是放在屏障后被生产者或消费者使用。

Full Barrier
  • Full屏障,是x86上的”mfence“指令,复合了load和save屏障的功能。

Java内存模型
  • Java内存模型中volatile变量在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障。一个类的final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。

原子指令和Software Locks
  • 原子指令,如x86上的”lock …” 指令是一个Full Barrier,

  • 执行时会锁住内存子系统来确保执行顺序,

  • 甚至跨多个CPU。

  • Software Locks常用内存屏障或原子指令来实现变量可见性和保持程序顺序。

内存屏障的性能影响
  • 内存屏障阻碍了CPU用优化技术来降低内存操作延迟,因此带来性能损失。

  • 为达到最佳性能,最好是把要解决的问题模块化,这样处理器可按单元执行任务,然后在任务单元的边界放上所有需要的内存屏障。

    • 用这个方法可以让处理器不受限的执行一个任务单元。

  • 合理的内存屏障组合有一好处:

文章作者: 刘同学
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 刘同学的小站
后端 Linux
喜欢就支持一下吧