精华内容
下载资源
问答
  • 2022-04-06 18:15:19





    一、内存屏障



    内存屏障 , 又称为 " 屏障指令 " , 用于保证 " 编译器 “” CPU “ 访问内存时 , 保证 按照顺序执行 , 即 ” 内存屏障 之前 “ 的指令 与 ” 内存屏障 之后 " 的指令 不会犹豫 编译器 和 CPU 优化导致 顺序混乱 ;


    " 指令 " 优化主要分 2 2 2 种 :

    • ① 编译器优化 : 为了 提高程序执行性能 , 编译器会在 不影响 程序逻辑的前提下 , 对程序指令进行优化 , 主要操作是 调整程序指令的执行顺序 ;
    • ② CPU 执行优化 : 该优化是为了 提高 " 流水线 " 性能 , 但是 CPU 执行优化会导致 指令乱序执行 , 后面的指令先于前面的指令执行 , 导致 寄存器中的值冲突 ;

    Linux 内核支持的 3 3 3 种内核屏障 :

    • ① 编译器屏障
    • ② 处理器内存屏障
    • ③ 内存映射 I/O 写屏障 , 全称 Memory Mapping I/O , 简称 MMIO , 目前已经被弃用 ;




    二、编译器屏障



    " 编译器屏障 “ 针对 ” 编译器优化 " ;


    编译器优化 是 为了 提高程序执行性能 ,

    编译器会在编译代码时 ,

    不影响 程序逻辑的前提下 , 对程序指令进行重排 , 主要操作是 调整程序指令的执行顺序 ;

    优化后的结果 , 可能 不符合软件开发想要开发的需求 ;





    三、处理器内存屏障



    " 处理器内存屏障 “ 针对 ” CPU " 之间的内存访问乱序CPU 访问外设乱序 问题 ;


    为了 提高 " 流水线 " 性能 , 新式处理器可以采用 " 超标量 体系结构 “” 乱序执行 " 技术 , 可以在 一个时钟周期并行执行多条指令 ;

    但是 CPU 执行优化会导致 指令乱序执行 , 后面的指令先于前面的指令执行 , 导致 寄存器中的值冲突 ;

    CPU 执行优化总结 :

    • 顺序取指令 ,
    • 乱序执行 ,
    • 执行结果顺序提交 ;
    更多相关内容
  • 如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~ 本篇文章参考了大量文章,文档以及论文,但是这块东西真的很繁杂,我的水平有限,可能理解的也不到位,如有异议...

    个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~

    本篇文章参考了大量文章,文档以及论文,但是这块东西真的很繁杂,我的水平有限,可能理解的也不到位,如有异议欢迎留言提出。本系列会不断更新,结合大家的问题以及这里的错误和疏漏,欢迎大家留言

    如果你喜欢单篇版,请访问:全网最硬核 Java 新内存模型解析与实验单篇版(不断更新QA中)
    如果你喜欢这个拆分的版本,这里是目录:

    内存屏障,CPU 与内存模型相关:

    x86 CPU 相关资料:

    ARM CPU 相关资料:

    各种一致性的理解:

    Aleskey 大神的 JMM 讲解:

    相信很多 Java 开发,都使用了 Java 的各种并发同步机制,例如 volatile,synchronized 以及 Lock 等等。也有很多人读过 JSR 第十七章 Threads and Locks(地址:https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html),其中包括同步、Wait/Notify、Sleep & Yield 以及内存模型等等做了很多规范讲解。但是也相信大多数人和我一样,第一次读的时候,感觉就是在看热闹,看完了只是知道他是这么规定的,但是为啥要这么规定,不这么规定会怎么样,并没有很清晰的认识。同时,结合 Hotspot 的实现,以及针对 Hotspot 的源码的解读,我们甚至还会发现,由于 javac 的静态代码编译优化以及 C1、C2 的 JIT 编译优化,导致最后代码的表现与我们的从规范上理解出代码可能的表现是不太一致的。并且,这种不一致,导致我们在学习 Java 内存模型(JMM,Java Memory Model),理解 Java 内存模型设计的时候,如果想通过实际的代码去试,结果是与自己本来可能正确的理解被带偏了,导致误解。
    我本人也是不断地尝试理解 Java 内存模型,重读 JLS 以及各路大神的分析。这个系列,会梳理我个人在阅读这些规范以及分析还有通过 jcstress 做的一些实验而得出的一些理解,希望对于大家对 Java 9 之后的 Java 内存模型以及 API 抽象的理解有所帮助。但是,还是强调一点,内存模型的设计,出发点是让大家可以不用关心底层而抽象出来的一些设计,涉及的东西很多,我的水平有限,可能理解的也不到位,我会尽量把每一个论点的论据以及参考都摆出来,请大家不要完全相信这里的所有观点,如果有任何异议欢迎带着具体的实例反驳并留言

    5. 内存屏障

    5.1. 为何需要内存屏障

    内存屏障(Memory Barrier),也有叫内存栅栏(Memory Fence),还有的资料直接为了简便,就叫 membar,这些其实意思是一样的。内存屏障主要为了解决指令乱序带来了结果与预期不一致的问题,通过加入内存屏障防止指令乱序(或者称为重排序,reordering)。

    那么为什么会有指令乱序呢?主要是因为 CPU 乱序(CPU乱序还包括 CPU 内存乱序以及 CPU 指令乱序)以及编译器乱序。内存屏障可以用于防止这些乱序。如果内存屏障对于编译器和 CPU 都生效,那么一般称为硬件内存屏障,如果只对编译器生效,那么一般被称为软件内存屏障。我们这里主要关注 CPU 带来的乱序,对于编译器的重排序我们会在最后简要介绍下。

    5.2. CPU 内存乱序相关

    我们从 CPU 高速缓存以及缓存一致性协议出发,开始分析为何 CPU 中会有乱序。我们这里假设一种简易的 CPU 模型请大家一定记住,实际的 CPU 要比这里列举的简易 CPU 模型复杂的多

    5.2.1. 简易 CPU 模型 - CPU 高速缓存的出发点 - 减少 CPU Stall

    我们在这里会看到,现代的 CPU 的很多设计,一切以减少 CPU Stall 出发。什么是 CPU Stall 呢?举一个简单的例子,假设 CPU 需要直接读取内存中的数据(忽略其他的结构,例如 CPU 缓存,总线与总线事件等等):

    image

    CPU 发出读取请求,在内存响应之前,CPU 需要一直等待,无法处理其他的事情。这一段 CPU 就是处于 Stall 状态。如果 CPU 一直直接从内存中读取,CPU 直接访问内存消耗时间很长,可能需要几百个指令周期,也就是每次访问都会有几百个指令周期内 CPU 处于 Stall 状态什么也干不了,这样效率会很低。一般需要引入若干个高速缓存(Cache)来减少 Stall:高速缓存即与处理器紧挨着的小型存储器,位于处理器和内存之间。

    我们这里不关心多级高速缓存,以及是否存在多个 CPU 共用某一缓存的情况,我们就简单认为是下面这个架构:
    image
    当需要读取一个地址的值时,访问高速缓存看是否存在:存在代表命中(hit),直接读取。不存在被称为缺失(miss)。同样的,如果需要写一个值到一个地址,这个地址在缓存中存在也就不需要访问内存了。大部分程序都表现出较高的局部性(locality):

    • 如果处理器读或写一个内存地址,那么它很可能很快还会读或写同一个地址
    • 如果处理器读或写一个内存地址,那么它很可能很快还会读或写附近的地址

    针对局部性,高速缓存一般会一次操作不止一个字,而是一组临近的字,称为缓存行

    但是呢,由于告诉缓存的存在,就给更新内存带来了麻烦:当一个 CPU 需要更新一块缓存行对应内存的时候,它需要将其他 CPU 缓存中这块内存的缓存行也置为失效。为了维持每个 CPU 的缓存数据一致性,引入了缓存一致性协议(Cache Coherence Protocols)

    5.2.2. 简易 CPU 模型 - 一种简单的缓存一致性协议(实际的 CPU 用的要比这个复杂) - MESI

    现代的缓存一致性的协议以及算法非常复杂,缓存行可能会有数十种不同的状态。这里我们并不需要研究这种复杂的算法,我们这里引入一个最经典最简单的缓存一致性协议即 4 状态 MESI 协议(再次强调,实际的 CPU 用的协议要比这个复杂,MESI 其实本身有些问题解决不了),MESI 其实指的就是缓存行的四个状态:

    • Modified:缓存行被修改,最终一定会被写回入主存,在此之前其他处理器不能再缓存这个缓存行。
    • Exclusive:缓存行还未被修改,但是其他的处理器不能将这个缓存行载入缓存
    • Shared:缓存行未被修改,其他处理器可以加载这个缓存行到缓存
    • Invalid:缓存行中没有有意义的数据

    根据我们前面的 CPU 缓存结构图中所示,假设所有 CPU 都共用在同一个总线上,则会有如下这些信息在总线上发送:

    1. Read:这个事件包含要读取的缓存行的物理地址。
    2. Read Response:包含前面的读取事件请求的数据,数据来源可能是内存或者是其他高速缓存,例如,如果请求的数据在其他缓存处于 modified 状态的话,那么必须从这个缓存读取缓存行数据作为 Read Response
    3. Invalidate:这个事件包含要过期掉的缓存行的物理地址。其他的高速缓存必须移除这个缓存行并且响应 Invalidate Acknowledge 消息。
    4. Invalidate Acknowledge:收到 Invalidate 消息移除掉对应的缓存行之后,回复 Invalidate Acknowledge 消息。
    5. Read Invalidate:是 Read 消息还有 Invalidate 消息的组合,包含要读取的缓存行的物理地址。既读取这个缓存行并且需要 Read Response 消息响应,同时发给其他的高速缓存,移除这个缓存行并且响应 Invalidate Acknowledge 消息。
    6. Writeback:这个消息包含要更新的内存地址以及数据。同时,这个消息也允许状态为 modified 的缓存行被剔除,以给其他数据腾出空间。

    缓存行状态转移与事件的关系:

    image

    这里只是列出这个图,我们不会深入去讲的,因为 MESI 是一个非常精简的协议,具体实现的时候会有很多额外的问题 MESI 无法解决,如果详细的去讲,会把读者绕进去,读者会思考在某个极限情况下这个协议要怎么做才能保证正确,但是 MESI 实际上解决不了这些。在实际的实现中,CPU 一致性协议要比 MESI 复杂的多得多,但是一般都是基于 MESI 扩展的

    举一个简单的 MESI 的例子:
    image
    1.CPU A 发送 Read 从地址 a 读取数据,收到 Read Response 将数据存入他的高速缓存并将对应的缓存行置为 Exclusive

    2.CPU B 发送 Read 从地址 a 读取数据,CPU A 检测到地址冲突,CPU A 响应 Read Response 返回缓存中包含 a 地址的缓存行数据,之后,地址 a 的数据对应的缓存行被 A 和 B 以 Shared 状态装入缓存

    image
    3.CPU B 对于 a 马上要进行写操作,发送 Invalidate,等待 CPU A 的 Invalidate Acknowledge 响应之后,状态修改为 Exclusive。CPU A 收到 Invalidate 之后,将 a 所在的缓存行状态置为 Invalid 失效

    4.CPU B 修改数据存储到包含地址 a 的缓存行上,缓存行状态置为 modified

    5.这时候 CPU A 又需要 a 数据,发送 Read 从地址 a 读取数据,CPU B 检测到地址冲突,CPU B 响应 Read Response 返回缓存中包含 a 地址的缓存行数据,之后,地址 a 的数据对应的缓存行被 A 和 B 以 Shared 状态装入缓存

    我们这里可以看到,MESI 协议中,发送 Invalidate 消息需要当前 CPU 等待其他 CPU 的 Invalidate Acknowledge,也就是这里有 CPU Stall。为了避免这个 Stall,引入了 Store Buffer

    5.2.3. 简易 CPU 模型 - 避免等待 Invalidate Response 的 Stall - Store Buffer

    为了避免这种 Stall,在 CPU 与 CPU 缓存之间添加 Store Buffer,如下图所示:
    image

    有了 Store Buffer,CPU 在发送 Invalidate 消息的时候,不用等待 Invalidate Acknowledge 的返回,将修改的数据直接放入 Store Buffer。如果收到了所有的 Invalidate Acknowledge 再从 Store Buffer 放入 CPU 的高速缓存的对应缓存行中。但是加入的这个 Store Buffer 又带来了新的问题:

    假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0,a 现在位于 CPU A 的缓存行中,b 现在位于 CPU B 的缓存行中:

    假设 CPU B 要执行下面的代码:

    image
    我们肯定是期望最后 b 会等于 2 的。但是真的会如我们所愿么?我们来详细看下下面这个运行步骤:

    image

    1.CPU B 执行 a = 1:

    (1)由于 CPU B 缓存中没有 a,并且要修改,所以发布 Read Invalidate 消息(因为是要先把包含 a 的整个缓存行读取后才能更新,所以发的是 Read Invalidate,而不只是 Invalidate)。

    (2)CPU B 将 a 的修改(a=1)放入 Storage Buffer

    (3)CPU A 收到 Read Invalidate 消息,将 a 所在的缓存行标记为 Invalid 并清除出缓存,并响应 Read Response(a=0) 和 Invalidate Acknowlegde

    image
    2.CPU B 执行 b = a + 1:

    (1)CPU B 收到来自于 CPU A 的 Read Response,这时候这里面 a 还是等于 0。

    (2)CPU B 将 a + 1 的结果(0+1=1)存入缓存中已经包含的 b。

    3.CPU B 执行 assert(b == 2) 失败

    这个错误的原因主要是我们在加载到缓存的时候没考虑从 store buffer 最新的值,所以我们可以加上一步,在加载到缓存的时候从 store buffer 读取最新的值。这样,就能保证上面我们看到的结果 b 最后是 2:

    image

    5.2.4. 简易 CPU 模型 - 避免 Store Buffer 带来的乱序执行 - 内存屏障

    我们下面再来看一个示例:假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0。假设 CPU A (缓存行里面包含 b,这个缓存行状态是 Exclusive)执行:

    image

    假设 CPU B 执行:

    image

    如果一切按照程序顺序预期执行,那么我们期望 CPU B 执行 assert(a == 1) 是成功的,但是我们来看下面这种执行流程:
    image
    1.CPU A 执行 a = 1:

    (1)CPU A 缓存里面没有 a,并且要修改,所以发布 Read Invalidate 消息。

    (2)CPU A 将 a 的修改(a=1)放入 Storage Buffer

    2.CPU B 执行 while (b == 0) continue:

    (1)CPU B 缓存里面没有 b,发布 Read 消息。
    image
    3.CPU A 执行 b = 1:

    (1)CPU A 缓存行里面有 b,并且状态是 Exclusive,直接更新缓存行。

    (2)之后,CPU A 收到了来自于 CPU B 的关于 b 的 Read 消息。

    (3)CPU A 响应缓存中的 b = 1,发送 Read Response 消息,并且缓存行状态修改为 Shared

    (4)CPU B 收到 Read Response 消息,将 b 放入缓存

    (5)CPU B 代码可以退出循环了,因为 CPU B 看到 b 此时为 1

    4.CPU B 执行 assert(a == 1),但是由于 a 的更改还没更新,所以失败了。

    像这种乱序,CPU 一般是无法自动控制的,但是一般会提供内存屏障指令,告诉 CPU 防止乱序,例如:

    image
    smp_mb() 会让 CPU 将 Store Buffer 中的内容刷入缓存。加入这个内存屏障指令后,执行流程变成:

    image
    1.CPU A 执行 a = 1:

    (1)CPU A 缓存里面没有 a,并且要修改,所以发布 Read Invalidate 消息。

    (2)CPU A 将 a 的修改(a=1)放入 Storage Buffer

    2.CPU B 执行 while (b == 0) continue:

    (1)CPU B 缓存里面没有 b,发布 Read 消息。
    image
    3.CPU B 执行 smp_mb():

    (1)CPU B 将当前 Store Buffer 的所有条目打上标记(目前这里只有 a,就是对 a 打上标记)

    4.CPU A 执行 b = 1:

    (1)CPU A 缓存行里面有 b,并且状态是 Exclusive,但是由于 Store Buffer 中有标记的条目 a,不直接更新缓存行,而是放入 Store Buffer(与 a 不同,没有标记)。并发出 Invalidate 消息。

    (2)之后,CPU A 收到了来自于 CPU B 的关于 b 的 Read 消息。

    (3)CPU A 响应缓存中的 b = 0,发送 Read Response 消息,并且缓存行状态修改为 Shared

    (4)CPU B 收到 Read Response 消息,将 b 放入缓存

    (5)CPU B 代码不断循环,因为 CPU B 看到 b 还是 0

    (6)CPU A 收到前面对于 a 的 “Read Invalidate” 相关的消息响应,将 Store Buffer 中打好标记的 a 条目刷入缓存,这个缓存行状态为 modified。

    (7)CPU B 收到 CPU A 发的 Invalidate b 的消息,将 b 的缓存行失效,回复 Invalidate Acknowledge

    (8)CPU A 收到 Invalidate Acknowledge,将 b 从 Store Buffer 刷入缓存。

    (9)由于 CPU B 不断读取 b,但是 b 已经不在缓存中了,所以发送 Read 消息。

    (10)CPU A 收到 CPU B 的 Read 消息,设置 b 的缓存行状态为 shared,返回缓存中 b = 1 的 Read Response

    (11)CPU B 收到 Read Response,得知 b = 1,放入缓存行,状态为 shared

    5.CPU B 得知 b = 1,退出 while (b == 0) continue 循环

    6.CPU B 执行 assert(a == 1)(这个比较简单,就不画图了):
    (1)CPU B 缓存中没有 a,发出 Read 消息。
    (2)CPU A 从缓存中读取 a = 1,响应 Read Response
    (3)CPU B 执行 assert(a == 1) 成功

    Store Buffer 一般都会比较小,如果 Store Buffer 满了,那么还是会发生 Stall 的问题。我们期望 Store Buffer 能比较快的刷入 CPU 缓存,这是在收到对应的 Invalidate Acknowledge 之后进行的。但是,其他的 CPU 可能在忙,没发很快应对收到的 Invalidate 消息并响应 Invalidate Acknowledge,这样可能造成 Store Buffer 满了导致 CPU Stall 的发生。所以,可以引入每个 CPU 的 Invalidate queue 来缓存要处理的 Invalidate 消息。

    5.2.5. 简易 CPU 模型 - 解耦 CPU 的 Invalidate 与 Store Buffer - Invalidate Queues

    加入 Invalidate Queues 之后,CPU 结构如下所示:
    image

    有了 Invalidate Queue,CPU 可以将 Invalidate 放入这个队列之后立刻将 Store Buffer 中的对应数据刷入 CPU 缓存。同时,CPU 在想主动发某个缓存行的 Invalidate 消息之前,必须检查自己的 Invalidate Queue 中是否有相同的缓存行的 Invalidate 消息。如果有,必须等处理完自己的 Invalidate Queue 中的对应消息再发。

    同样的,Invalidate Queue 也带来了乱序执行。

    5.2.6. 简易 CPU 模型 - 由于 Invalidate Queues 带来的进一步乱序 - 需要内存屏障

    假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0。假设 CPU A (缓存行里面包含 a(shared), b(Exclusive))执行:

    image
    CPU B(缓存行里面包含 a(shared))执行:

    image

    image
    1.CPU A 执行 a = 1:

    (1)CPU A 缓存里面有 a(shared),CPU A 将 a 的修改(a=1)放入 Store Buffer,发送 Invalidate 消息。

    2.CPU B 执行 while (b == 0) continue:

    (1)CPU B 缓存里面没有 b,发布 Read 消息。

    (2)CPU B 收到 CPU A 的 Invalidate 消息,放入 Invalidate Queue 之后立刻返回。

    (3)CPU A 收到 Invalidate 消息的响应,将 Store Buffer 中的缓存行刷入 CPU 缓存

    3.CPU A 执行 smp_mb():

    (1)因为 CPU A 已经把 Store Buffer 中的缓存行刷入 CPU 缓存,所以这里直接通过

    image
    4.CPU A 执行 b = 1:

    (1)因为 CPU A 本身包含 b 的缓存行 (Exclusive),直接更新缓存行即可。

    (2)CPU A 收到 CPU B 之前发的 Read 消息,将 b 的缓存行状态更新为 Shared,之后发送 Read Response 包含 b 的最新值

    (3)CPU B 收到 Read Response, b 的值为 1

    5.CPU B 退出循环,开始执行 assert(a == 1)

    (1)由于目前关于 a 的 Invalidate 消息还在 Invalidate queue 中没有处理,所以 CPU B 看到的还是 a = 0,assert 失败

    所以,我们针对这种乱序,在 CPU B 执行的代码中也加入内存屏障,这里内存屏障不仅等待 CPU 刷完所有的 Store Buffer,还要等待 CPU 的 Invalidate Queue 全部处理完。加入内存屏障,CPU B 执行的代码是:

    image

    这样,在前面的第 5 步,CPU B 退出循环,执行 assert(a == 1) 之前需要等待 Invalidate queue 处理完:
    (1)处理 Invalidate 消息,将 b 置为 Invalid
    (2)继续代码,执行 assert(a == 1),这时候缓存内不存在 b,需要发 Read 消息,这样就能看到 b 的最新值 1 了,assert 成功。

    5.2.7. 简易 CPU 模型 - 更细粒度的内存屏障

    我们前面提到,在我们前面提到的 CPU 模型中,smp_mb() 这个内存屏障指令,做了两件事:等待 CPU 刷完所有的 Store Buffer,等待 CPU 的 Invalidate Queue 全部处理完。但是,对于我们这里 CPU A 与 CPU B 执行的代码中的内存屏障,并不是每次都要这两个操作同时存在:

    image

    所以,一般 CPU 还会抽象出更细粒度的内存屏障指令,我们这里管等待 CPU 刷完所有的 Store Buffer 的指令叫做写内存屏障(Write Memory Buffer),等待 CPU 的 Invalidate Queue 全部处理完的指令叫做读内存屏障(Read Memory Buffer)。

    5.2.8. 简易 CPU 模型 - 总结

    我们这里通过一个简单的 CPU 架构出发,层层递进,讲述了一些简易的 CPU 结构以及为何会需要内存屏障,可以总结为下面这个简单思路流程图:

    1. CPU 每次直接访问内存太慢,会让 CPU 一直处于 Stall 等待。为了减少 CPU Stall,加入了 CPU 缓存
    2. CPU 缓存带来了多 CPU 间的缓存不一致性,所以通过 MESI 这种简易的 CPU 缓存一致性协议协调不同 CPU 之间的缓存一致性
    3. 对于 MESI 协议中的一些机制进行优化,进一步减少 CPU Stall:
    4. 通过将更新放入 Store Buffer,让更新发出的 Invalidate 消息不用 CPU Stall 等待 Invalidate Response。
    5. Store Buffer 带来了指令(代码)乱序,需要内存屏障指令,强制当前 CPU Stall 等待刷完所有 Store Buffer 中的内容。这个内存屏障指令一般称为写屏障。
    6. 为了加快 Store Buffer 刷入缓存,增加 Invalidate Queue,

    5.3. CPU 指令乱序相关

    CPU 指令的执行,也可能会乱序,我们这里只说一种比较常见的 - 指令并行化。

    5.3.1. 增加 CPU 执行效率 - CPU 流水线模式(CPU Pipeline)

    现代 CPU 在执行指令时,是以指令流水线的模式来运行的。因为 CPU 内部也有不同的组件,我们可以将执行一条指令分成不同阶段,不同的阶段涉及的组件不同,这样伪解耦可以让每个组件独立的执行,不用等待一个指令完全执行完再处理下一个指令。

    一般分为如下几个阶段:取指(Instrcution Fetch,IF)、译码(Instruction Decode,ID)、执行(Execute,EXE)、存取(Memory,MEM)、写回(Write-Back, WB)
    image

    5.3.2. 进一步降低 CPU Stall - CPU 乱序流水线(Out of order execution Pipeline)

    由于指令的数据是否就绪也是不确定的,比如下面这个例子:

    image

    倘若数据 a 没有就绪,还没有载入到寄存器,那么我们其实没必要 Stall 等待加载 a,可以先执行 c = 1; 由此,我们可以将程序中,可以并行的指令提取出来同时安排执行,CPU 乱序流水线(Out of order execution Pipeline)就是基于这种思路:

    image

    如图所示,CPU 的执行阶段分为:

    1. Instructions Fetch:批量拉取一批指令,进行指令分析,分析其中的循环以及依赖,分支预测等等
    2. Instruction Decode:指令译码,与前面的流水线模式大同小异
    3. Reservation stations:需要操作数输入的指令,如果输入就绪,就进入 Functoinal Unit (FU) 处理,如果没有没有就绪就监听 Bypass network,数据就绪发回信号到 Reservation stations,让指令进图 FU 处理。
    4. Functional Unit:处理指令
    5. Reorder Buffer:会将指令按照原有程序的顺序保存,这些指令会在被 dispatched 后添加到列表的一端,而当他们完成执行后,从列表的另一端移除。通过这种方式,指令会按他们 dispatch 的顺序完成。

    这样的结构设计下,可以保证写入 Store Buffer 的顺序,与原始的指令顺序一样。但是加载数据,以及计算,是并行执行的。前面我们已经知道了在我们的简易 CPU 架构里面,有着多 CPU 缓存 MESI, Store Buffer 以及 Invalidate Queue 导致读取不到最新的值,这里的乱序并行加载以及处理更加剧了这一点。并且,结构设计下,仅能保证检测出同一个线程下的指令之间的互相依赖,保证这样的互相依赖之间的指令执行顺序是对的,但是多线程程序之间的指令依赖,CPU 批量取指令以及分支预测是无法感知的。所以还是会有乱序。这种乱序,同样可以通过前面的内存屏障避免

    5.4. 实际的 CPU

    实际的 CPU 多种多样,有着不同的 CPU 结构设计以及不同的 CPU 缓存一致性协议,就会有不同种类的乱序,如果每种单独来看,就太复杂了。所以,大家通过一种标准来抽象描述不同的 CPU 的乱序现象(即第一个操作为 M,第二个操作为 N,这两个操作是否会乱序,是不是很像 Doug Lea 对于 JMM 的描述,其实 Java 内存模型也是参考这个设计的),参考下面这个表格:
    image

    我们先来说一下每一列的意思:

    1. Loads Reordered After Loads:第一个操作是读取,第二个也是读取,是否会乱序。
    2. Loads Reordered After Stores:第一个操作是读取,第二个是写入,是否会乱序。
    3. Stores Reordered After Stores:第一个操作是写入,第二个也是写入,是否会乱序。
    4. Stores Reordered After Loads:第一个操作是写入,第二个是读取,是否会乱序。
    5. Atomic Instructions Reordered With Loads:两个操作是原子操作(一组操作,同时发生,例如同时修改两个字这种指令)与读取,这两个互相是否会乱序。
    6. Atomic Instructions Reordered With Stores:两个操作是原子操作(一组操作,同时发生,例如同时修改两个字这种指令)与写入,这两个互相是否会乱序。
    7. Dependent Loads Reordered:如果一个读取依赖另一个读取的结果,是否会乱序。
    8. Incoherent Instruction Cache/Pipeline:是否会有指令乱序执行。

    举一个例子来看即我们自己的 PC 上面常用的 x86 结构,在这种结构下,仅仅会发生 Stores Reordered After Loads 以及 Incoherent Instruction Cache/Pipeline。其实后面要提到的 LoadLoad,LoadStore,StoreLoad,StoreStore 这四个 Java 中的内存屏障,为啥在 x86 的环境下其实只需要实现 StoreLoad,其实就是这个原因。

    5.5. 编译器乱序

    除了 CPU 乱序以外,在软件层面还有编译器优化重排序导致的,其实编译器优化的一些思路与上面说的 CPU 的指令流水线优化其实有些类似。比如编译器也会分析你的代码,对相互不依赖的语句进行优化。对于相互没有依赖的语句,就可以随意的进行重排了。但是同样的,编译器也是只能从单线程的角度去考虑以及分析,并不知道你程序在多线程环境下的依赖以及联系。再举一个简单的例子,假设没有任何 CPU 乱序的环境下,有两个变量 x = 0,y = 0,线程 1 执行:

    image

    线程 2 执行:

    image

    那么线程 2 是可能 assert 失败的,因为编译器可能会让 x = 1y = 1 之间乱序。

    编译器乱序,可以通过增加不同操作系统上的编译器屏障语句进行避免。例如线程一执行:

    image

    这样就不会出现 x = 1y = 1 之间乱序的情况。

    同时,我们在实际使用的时候,一般内存屏障指的是硬件内存屏障,即通过硬件 CPU 指令实现的内存屏障,这种硬件内存屏障一般也会隐式地带上编译器屏障。编译器屏障一般被称为软件内存屏障,仅仅是控制编译器软件层面的屏障,举一个例子即 C++ 中的 volaile,它与 Java 中的 volatile 不一样, C++ 中的 volatile 仅仅是禁止编译器重排即有编译器屏障,但是无法避免 CPU 乱序。

    以上,我们就基本搞清楚了乱序的来源,以及内存屏障的作用。接下来,我们即将步入正题,开始我们的 Java 9+ 内存模型之旅。在这之前,再说一件需要注意的事情:为什么最好不要自己写代码验证 JMM 的一些结论,而是使用专业的框架去测试

    微信搜索“我的编程喵”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
    image
    我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

    • 知乎:https://www.zhihu.com/people/zhxhash
    • B 站:https://space.bilibili.com/31359187
    展开全文
  • 一、处理器内存屏障、 二、Linux 内核处理器内存屏障





    一、处理器内存屏障



    " 处理器内存屏障 “ 针对 ” CPU " 之间的内存访问乱序CPU 访问外设乱序 问题 ;


    为了 提高 " 流水线 " 性能 , 新式处理器可以采用 " 超标量 体系结构 “” 乱序执行 " 技术 , 可以在 一个时钟周期并行执行多条指令 ;

    但是 CPU 执行优化会导致 指令乱序执行 , 后面的指令先于前面的指令执行 , 导致 寄存器中的值冲突 ;

    CPU 执行优化总结 :

    • 顺序取指令 ,
    • 乱序执行 ,
    • 执行结果顺序提交 ;




    二、Linux 内核处理器内存屏障



    Linux 内核中有 8 8 8 种 " 处理器内存屏障 " ;


    内存屏障 有 4 4 4 种类型 ,

    • ① 通用内存屏障
    • ② 写内存屏障
    • ③ 读内存屏障
    • ④ 数据依赖屏障

    每种类型的 内存屏障 又分为

    • ① 强制性内存屏障
    • ② SMP 内存屏障

    两种类型 ;


    因此将上面 8 8 8 种 " 处理器内存屏障 " 列成表格如下 :

    内存屏障类型强制性内存屏障SMP 内存屏障
    ① 通用内存屏障mb()smp_mb()
    ② 写内存屏障wmb()smp_wmb()
    ③ 读内存屏障rmb()smp_rmb()
    ④ 数据依赖屏障read_barrier_depends()smp_read_barrier_depends()

    如果使用 " 处理器内存屏障 " , 其隐含着同时使用 " 编译器优化屏障 " ; ( 数据依赖屏障 除外 ) ;

    展开全文
  • CPU乱序发射与内存屏障 在提出问题之前,先看一段简单的代码。 #include <stdio.h> #include <pthread.h> int x = 0, y = 0; void* mythread1(void*) { while (y == 0) { } printf("x=%d\n",x); } ...

    CPU乱序发射与内存屏障


    在提出问题之前,先看一段简单的代码。

    #include <stdio.h>
    #include <pthread.h>
    int x = 0, y = 0;
    void* mythread1(void*)
    {
    
        while (y == 0)
        {
        }
        printf("x=%d\n",x);
    }
    
    void* mythread2(void*)
    {
        x = 1;
        y = 1;
    }
    
    int main()
    {
        int ret = 0;
        pthread_t id1, id2;
        ret = pthread_create(&id1, NULL, mythread1, NULL);
        if (ret)
        {
            printf("create pthread error!\n")return -1;
        }
        ret = pthread_create(&id2, NULL, mythread2, NULL);
        if (ret)
        {
            printf("create pthread error!\n");
            return -1;
        }
        pthread_join(id1, NULL);
        pthread_join(id2, NULL);
        return 0;
    }
    

    阅读代码可知程序的输出为x=1,因为在对y赋值之前已对x赋值,所以当线程1跳出循环时x=1是不奇怪的。实际运行这段代码时,程序的输出也确实为1,即使测试了10000次,也没出现其他结果。但能出现正确结果真的是因为代码中赋值顺序吗?
    实际上并不是,程序能输出正确结果仅仅是因为线程分配到的时间片过长&&线程需要执行的时间过短
    对于线程2而言,执行时间大约等于访问两次内存的时间,约等于50ns,而线程分配的时间片为ms级别。所以程序并没有出现异常的结果。但是相信程序的执行顺序与代码的编写顺序一致是一种危险的观点。实际上如果两个语句之间不存在数据相关,由于编译器的乱序优化和CPU的乱序执行,语句的执行顺序是不确定。CPU乱序执行只保证局部的正确性(数据相关的语句一定按原顺序执行)

    a = x + y;	//语句1
    c = a + b;	//语句2
    // 语句2一定在语句1之前执行
    m = p + q;	//语句3
    k = i + j;	//语句4
    //语句3不一定在语句4之前执行
    

    设想一个极端情况,在执行语句3时,访问变量p时cache未命中且发生缺页,此时语句4大概率在语句3之前执行完毕。为使语句执行顺序与代码顺序保持一致,有两种解决思路。
    一:增加冗余的数据相关

    m = p + q;	
    k = i + j + m;	
    k -= m;
    

    这种方法是我瞎想的,不知道会不会被编译器优化掉,(__) 嘻嘻……
    二:增加内存屏障
    Barrier函数可以在代码中设置屏障,这个屏障可以阻挡编译器的优化,也可以阻挡处理器的优化。

    对于编译器来说,设置任何一个屏障都可以保证:

    编译器的乱序优化不会跨越屏障,即屏障前后的代码不会乱序;
    
    在屏障后所有对变量或者地址的操作,都会重新从内存中取值(相当于刷新寄存器中的变量副本)。
    

    而对于处理器来说,根据不同的屏障有不同的表现(以下仅仅列举3种最简单的屏障):

    读屏障rmb()
    处理器对读屏障前后的取数指令(LOAD)能保证有序,但是不一定能保证其他算术指令或者是写指令的有序。对于读指令的执行完成时间也不能保证,即它不能保证在屏障之前的读指令一定都执行完成,只能保证屏障之前的读指令一定能在屏障之后的读指令之前完成。
    
    写屏障wmb()
    处理器对屏障前后的写指令(STORE)能保证有序,但是不一定能保证其他算术指令或者是读指令的有序。对于写指令的执行完成时间也不能保证,即它不能保证在屏障之前的写指令一定都执行完成,只能保证屏障之前的写指令一定能在屏障之后的写指令之前完成。
    
    通用内存屏障mb()
    处理器保障只有屏障之前的访存操作(包括读写)都完成以后才会执行屏障之后的访存操作。即可以保障读写之间的有序(但是同样无法保证指令完成的时 间)。这种屏障对处理器的执行单元效率产生的负面影响要比单纯用读屏障或者写屏障来的大。比如对于PowerPC来说这种通用屏障通常是使用sync指令实现的,在这种情况下处理器会丢弃所有预取的指令并清空流水线。所以频繁使用内存屏障会降低处理器执行单元的效率。
    

    对于驱动开发者来说,一些对设备寄存器的操作,通常是必须保证有序的。在绝大部分情况下,一般都是写操作。对于有序的写操作,必须设置写屏障(wmb)。

    当程序出现随机性的问题时,在随机的部分找原因

    参考博客:CPU乱序执行

    展开全文
  • 内存屏障简介

    万次阅读 多人点赞 2020-06-22 15:34:57
    为了限制性能下降,CPU被设计成在从内存 中获取数据的同时,可以执行其他指令和内存引用。这明显会导致指令 和内存引用乱序执行,并导致严重的混乱 仅仅在两个CPU之间或者CPU与... 缓存同步顺序(导致可见性问题) ...
  • 实际上,编译器屏障几乎是空闲的,因为它只防止编译器可能重排指令 下表给出了内核中所有体系结构提供的完整的内存和编译器屏障方法 注意,对于不同体系结构,屏障的实际效果差别很大。例如,如果一个体系结构不执行...
  • 新式处理器采用超标量系统结构和乱序执行技术,能够在一个周期内并行执行多条指令顺序取指令,乱序执行,顺序提交执行结果 多处理器系统当中,硬件工程师使用存储缓冲区,缓存一致性实现高校性能,引入
  • 文章目录1 内存屏障1.1 编译器屏障1.2 处理器内存屏障1.3 MMIO写屏障1.4 隐含内存屏障 重要:本系列文章内容摘自<Linux内核深度解析>基于ARM64架构的Linux4.x内核一书,作者余华兵。系列文章主要用于记录...
  • 写线程和读线程通过配对使用释放屏障和获取屏障,使得上述内存操作提交顺序与程序顺序不一致并不会对有序性产生影响。 java虚拟机(JIT编译器)会在volatile变量写操作之后插入一个StoreLoad屏障。该屏障不仅禁止该...
  • 内存屏障需要解决我们前面提到的两个问题,一个是编译器的优化乱序和CPU的执行乱序,我们可以分别使用优化屏障和内存屏障这两个机制来解决从CPU层面来了解一下什么是内存屏障CPU的乱序执行,本质还是,由于在多CPU的...
  • 屏障(Full Memory Barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。 这些概念听起来可能有点模糊,我们通过将上面的例子改写一下来说明: package com.zwx.concurrent; public...
  • 一、抽象的内存访问模型 ...同样,只要不影响程序的结果,编译器可以以它喜欢的任何顺序安排指令。 因此,上图中,一个CPU执行内存操作的结果能被系统的其它部分感知到,因为这些操作穿过了CPU与系统其它...
  • 屏障(Full Memory Barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。 这些概念听起来可能有点模糊,我们通过将上面的例子改写一下来说明: package com.zwx.concurrent; public...
  • 顺序屏障 收藏

    2013-12-19 20:35:01
    当处理多处理器之间或硬件设备之间的同步问题时,有时需要在程序代码中以指定的顺序...但是编译器和处理器为了提高效率,可能对读和写重新排序。   所有可能重新排序和写的处理器提供了及其指令来确保顺序要求。同
  • 写这个变量的时候,JMM会把本地内存变量刷新到主内存当中 实现:添加Volatile关键字会在汇编代码中多处一个kock前指令,也就是内存屏障 四.Synchronized关键字 (1)synchronized是Java中的关键字,是一种同步锁。...
  • 内存屏障简略介绍

    2022-06-06 17:01:43
    内存屏障
  • 深入浅出Linux内核中的内存屏障

    千次阅读 2021-01-16 17:17:35
    在这个抽象 CPU 中,内存 操作的顺序是松散的,CPU 假定进程间不依靠内存直接通信,在不改变程序执行 结果 的推测下由自己方便的顺序执行内存访问操作。 例如,考虑下面的执行过程: { A = 1 b = 2} 这有 24 中内存...
  • 浅析c程序中的屏障

    2020-03-12 10:51:12
    目录1 编译屏障1.1 为什么需要编译屏障1.2 编译屏障会造成哪些影响2 内存屏障2.1 为什么需要内存屏障2.2 内存屏障会造成哪些影响3 总结参考文献 1 编译屏障 1.1 为什么需要编译屏障 众所周知,从c源文件到可执行程序...
  • 在应用层,关于锁的使用大家应该都很熟悉了,...代码经过编译器生成的指令并不一定都是按着我们原先的想法来生成的,可能经过优化等情况进行了指令的重排,然而这些重排在执行后的结果应当是一致的。其实及时编译器...
  • 锁,原子操作,volatile,内存屏障,死锁的排查检测与避免
  • 顺序屏障

    千次阅读 2008-12-22 15:56:00
    当处理多处理器之间或硬件设备之间的同步问题时,有时需要在程序代码中以指定的顺序发出读内存(读入)...但是编译器和处理器为了提高效率,可能对读和写重新排序。 所有可能重新排序和写的处理器提供了及其指令来确保
  • 我们知道java在运行的时候有两个地方可能用到重排序,一个是编译器编译的的时候,一个是处理器运行的时候。 那么我们就应该问问为啥要用指令重排序呢? 生活类比 我们从生活中举个例子,假设你有一箱红纸,现在要...
  • 揭开内存屏障的面纱

    千次阅读 2019-01-30 19:16:37
    内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的...
  • 首先是现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。编译期指令重排是通过调整代码中的指令顺序,在不改变代码语义的前提下,对变量访问进行优化。从而尽可能的减少对寄存器的读取和存储,并...
  • 64位是分为两个32位分两次赋值的,如果多线程赋值无法保证顺序正常 说明 1.基本类型,引用类型的指定,引用是原子的操作; 2.但是long与double的指定,引用是分割的; 3.要在线程间共享long与...
  • LINUX内核内存屏障

    千次阅读 2013-05-12 01:28:37
    =================  LINUX内核内存屏障  ================= By: David Howells  Paul E. McKenney 译: kouu 出处: Linux内核文档 -- Documentati
  • 所以简单说,查询多选择AVL树,查询更新次数差不多选红黑树 AVL树顺序插入和删除时有20%左右的性能优势,红黑树随机操作15%左右优势,现实应用当然一般都是随机情况,所以红黑树得到了更广泛的应用 索引为B+树 ...
  • 是一类同步屏障指令,它使得CPU或编译器在对内存进行操作的时候,严格按照一定的顺序来执行, 也就是说在memory barrier之前的指令和memory barrier之后的指令不会由于系统优化等原因而导致乱序 内存屏障指令(大...
  • 如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~ 本篇文章参考了大量文章,文档以及论文,但是这块东西真的很繁杂,我的水平有限,可能理解的也不到位,如有异议...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 3,237
精华内容 1,294
关键字:

编译器屏障影响顺序提交