已经有 MESI 协议,为什么还需要 volatile 关键字?

已经,mesi,协议,为什么,需要,volatile,关键字 · 浏览次数 : 1066

小编点评

**顺序一致性内存模型** 顺序一致性内存模型是 Java 中并发编程中常用的内存模型,它与强顺序一致性模型相比,允许线程看到的结果可能不完全一致,但保证执行结果与强顺序执行结果一致。 **特性** * **局部执行顺序一致性:**线程看到的执行顺序与编码顺序相同。 * **全局执行顺序不一致性:**线程看到的执行顺序可能与编码顺序不同。 **内存屏障** 内存屏障是一种特殊的指令,它可以强制线程执行指令在内存中执行顺序之前或之后执行其他指令。它通常用于解决顺序一致性问题。 **与 volatile 关键字的关系** volatile 关键字类似于内存屏障,但它更简单易用。 volatile 关键字可以用于标记变量的内存可见性,并确保其在每次访问之前被加载到内存中。 **示例** ```java // 使用内存屏障 synchronized (sync) { for (int i = 0; i < 10; i++) { if (i == 5) { System.out.println("线程 A 完成"); sync.notify(); } } } ``` **总结** 顺序一致性内存模型是一种允许线程看到的结果可能不完全一致,但保证执行结果与强顺序执行结果一致的内存模型。内存屏障是一种特殊的指令,它可以强制线程执行指令在内存中执行顺序之前或之后执行其他指令。

正文

本文已收录到  GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 进 Android 面试交流群。

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了 CPU 的缓存一致性问题,分为纵向的 Cache 与内存的一致性问题以及横向的多个核心 Cache 的一致性问题。我们也讨论了 MESI 协议通过写传播和事务串行化实现缓存一致性。

不知道你是不是跟我一样,在学习 MESI 协议的时候,自然地产生了一个疑问:在不考虑写缓冲区和失效队列的影响下,在硬件层面已经实现了缓存一致性,那么在 Java 语言层面为什么还需要定义 volatile 关键字呢?是多此一举吗?今天我们将围绕这些问题展开。


学习路线图:


1. 回顾 MESI 缓存一致性协议

由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存,CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。其中 L1 / L2 缓存是核心独占的,而 L3 缓存是多核心共享的。

在 CPU Cache 的三级缓存中,会存在 2 个缓存一致性问题:

  • 纵向 - Cache 与内存的一致性问题:通过写直达或写回策略解决;
  • 横向 - 多核心 Cache 的一致性问题:通过 MESI 等缓存一致性协议解决。

MESI 协议能够满足写传播和事务串行化 2 点特性,通过 “已修改、独占、共享、已失效” 4 个状态实现了 CPU Cache 的一致性;

现代 CPU 为了提高并行度,会在增加写缓冲区 & 失效队列将 MESI 协议的请求异步化,这其实是一种处理器级别的指令重排,会破坏了 CPU Cache 的一致性。

Cache 不一致问题

MESI 协议在线模拟

网站地址:https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESI.htm

现在,我们的问题是:既然 CPU 已经实现了 MESI 协议,为什么 Java 语言层面还需要定义 volatile 关键字呢?岂不是多此一举?你可能会说因为写缓冲区和失效队列破坏了 Cache 一致性。好,那不考虑这个因素的话,还需要定义 volatile 关键字吗?

其实,MESI 解决数据一致性(Data Conherence)问题,而 volatile 解决顺序一致性(Sequential Consistency)问题。 WC,这两个不一样吗?


2. 数据一致性 vs 顺序一致性

2.1 数据一致性

数据一致性讨论的是同一份数据在多个副本之间的一致性问题, 你也可以理解为多个副本的状态一致性问题。例如内存与多核心 Cache 副本之间的一致性,或者数据在主从数据库之间的一致性。

当我们从 CPU 缓存一致性问题开始,逐渐讨论到 Cache 到内存的写直达和写回策略,再讨论到 MESI 等缓存一致性协议,从始至终我们讨论的都是 CPU 缓存的 “数据一致性” 问题,只是为了简便我们从没有刻意强调 “数据” 的概念。

数据一致性有强弱之分:

  • 强数据一致性: 保证在任意时刻任意副本上的同一份数据都是相同的,或者允许不同,但是每次使用前都要刷新确保数据一致,所以最终还是一致。
  • 弱数据一致性: 不保证在任意时刻任意副本上的同一份数据都是相同的,也不要求使用前刷新,但是随着时间的迁移,不同副本上的同一份数据总是向趋同的方向变化,最终还是趋向一致。

例如,MESI 协议就是强数据一致性的,但引入写缓冲区或失效队列后就变成弱数据一致性,随着缓冲区和失效队列被消费,各个核心 Cache 最终还是会趋向一致状态。

2.2 顺序一致性

顺序一致性讨论的是对多个数据的多次操作顺序在整个系统上的一致性。在并发编程中,存在 3 种指令顺序:

  • 编码顺序(Progrom Order): 指源码中指令的编写顺序,是程序员视角看到的指令顺序,不一定是实际执行的顺序;
  • 执行顺序(Memory Order): 指单个线程或处理器上实际执行的指令顺序;
  • 全局执行顺序(Global Memory Order): 每个线程或处理器上看到的系统整体的指令顺序,在弱顺序一致性模型下,每个线程看到的全局执行顺序可能是不同的。

顺序一致性模型是计算机科学家提出的一种理想参考模型,为程序员描述了一个极强的全局执行顺序一致性,由 2 个特性组成:

  • 特性 1 - 执行顺序与编码顺序一致: 保证每个线程中指令的执行顺序与编码顺序一致;
  • 特性 2 - 全局执行顺序一致: 保证每个指令的结果会同步到主内存和各个线程的工作内存上,使得每个线程上看到的全局执行顺序一致。

举个例子,线程 A 和线程 B 并发执行,线程 A 执行 A1 → A2 → A3,线程 B 执行 B1 → B2 → B3。那么,在顺序一致性内存模型下,虽然程序整体执行顺序是不确定的,但是线程 A 和线程 B 总会按照 1 → 2 → 3 编码顺序执行,而且两个线程总能看到相同的全局执行顺序。

顺序一致性内存模型

2.3 弱顺序一致性(一定要理解)

虽然顺序一致性模型对程序员非常友好,但是对编译器和处理器却不见得喜闻乐见。如果程序完全按照顺序一致性模型来实现,那么处理器和编译器的很多重排序优化都要被禁止,这对程序的 “并行度” 会有影响。例如:

  • 1、重排序问题: 编译器和处理器不能重排列没有依赖关系的指令;
  • 2、内存可见性问题: CPU 不能使用写回策略,也不能使用写缓冲区和失效队列机制。其实,从内存的视角看也是指令重排问题。

所以,在 Java 虚拟机和处理器实现中,实际上使用的是弱顺序一致性模型:

  • 特性 1 - 不要求执行顺序与编码顺序一致: 不要求单线程的执行顺序与编码顺序一致,只要求执行结果与强顺序执行的结果一致,而指令是否真的按编码顺序执行并不关心。因为结果不变,从程序员的视角看程序就是按编码顺序执行的假象;
  • 特性 2 - 不要求全局执行顺序一致: 允许每个线程看到的全局执行顺序不一致,甚至允许看不到其他线程已执行指令的结果。

举个单线程的例子: 在这段计算圆面积的代码中,在弱顺序一致性模型下,指令 A 和 指令 B 可以不按编码顺序执行。因为 A 和 B 没有数据依赖,所以对最终的结果也没有影响。但是 C 对 A 和 B 都有数据依赖,所以 C 不能重排列到 A 或 B 的前面,否则会改变程序结果。

伪代码

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C(数据依赖于 A 和 B,不能重排列到前面执行)

指令重排

再举个多线程的例子: 我们在 ChangeThread 线程修改变量,在主线程观察变量的值。在弱顺序一致性模型下,允许 ChangeThread 线程 A 指令的执行结果不及时同步到主线程,在主线程看来就像没执行过 A 指令。

这个问题我们一般会理解为内存可见性问题,其实我们可以统一理解为顺序一致性问题。 主线程看不到 ChangeThread 线程 A 指令的执行结果,就好像两个线程看到的全局执行顺序不一致:ChangeThread 线程看到的全局执行顺序是:[B],而主线程看到的全局执行顺序是 []。

可见性示例程序

public class VisibilityTest {
    public static void main(String[] args) {
        ChangeThread thread = new ChangeThread();
        thread.start();
        while (true) {
            if (thread.flag) { // B
                System.out.println("Finished");
                return;
            }
        }
    }

    public static class ChangeThread extends Thread {
        private boolean flag = false;

        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true; // A
            System.out.println("Change flag = " + flag);
        }
    }
}

程序输出

Change flag = true
// 无限等待

前面你说到编译器和处理器的重排序,为什么指令可以重排序,为什么重排序可以提升性能,重排序不会出错吗?


3. 什么是指令重排序?

3.1 重排序类型

从源码到指令执行一共有 3 种级别重排序:

  • 1、编译器重排序: 例如将循环内重复调用的操作提前到循环外执行;
  • 2、处理器系统重排序: 例如指令并行技术将多条指令重叠执行,或者使用分支预测技术提前执行分支的指令,并把计算结果放到重排列缓冲区(Reorder Buffer)的硬件缓存中,当程序真的进入分支后直接使用缓存中的结算结果;
  • 3、存储器系统重排序: 例如写缓冲区和失效队列机制,即是可见性问题,从内存的角度也是指令重排问题。

指令重排序类型

3.2 什么是数据依赖性?

编译器和处理器在重排序时,会遵循数据依赖性原则,不会试图改变存在数据依赖关系的指令顺序。如果两个操作都是访问同一个数据,并且其中一个是写操作,那么这两个操作就存在数据依赖性。此时一旦改变顺序,程序最终的执行结果一定会发生改变。

数据依赖性分为 3 种类型::

数据依赖性 描述 示例
写后读 写一个数据,再读这个数据 a = 1; // 写
b = a; // 读
写后写 写一个数据,再写这个数据 a = 1; // 写
a = 2; // 写
读后写 读一个数据,再写这个数据 b = a; // 读
a = 1; // 写

3.3 指令重排序安全吗?

需要注意的是:数据依赖性原则只对单个处理器或单个线程有效,因此即使在单个线程或处理器上遵循数据依赖性原则,在多处理器或者多线程中依然有可能改变程序的执行结果。

举例说明吧。

例子 1 - 写缓冲区和失效队列的重排序: 如果是在一个处理器上执行 “写后读”,处理器不会重排这两个操作的顺序;但如果是在一个处理器上写,之后在另一个处理器上读,就有可能重排序。关于写缓冲区和失效队列引起的重排序问题,上一篇文章已经解释过,不再重复。

写缓冲区造成指令重排

例子 2 - 未同步的多线程程序中的指令重排: 在未同步的两个线程 A 和 线程 B 上分别执行这两段程序,程序的预期结果应该是 4,但实际的结果可能是 0

线程 A

a = 2; // A1
flag = true; // A2

线程 B

while (flag) { // B1
    return a * a; // B2
}

情况 1:由于 A1 和 A2 没有数据依赖性,所以编译器或处理器可能会重排序 A1 和 A2 的顺序。在 A2 将 flag 改为 true 后,B1 读取到 flag 条件为真,并且进入分支计算 B2 结果,但 A1 还未写入,计算结果是 0。此时,程序的运行结果就被重排列破坏了。

情况 2:另一种可能,由于 B1 和 B2 没有数据依赖性,CPU 可能用分支预测技术提前执行 B2,但 A1 还未写入,计算结果还是 0。此时,程序的运行结果就被重排列破坏了。

多线程的数据依赖性不被考虑

小结一下: 重排序在单线程程序下是安全的(与预期一致),但在多线程程序下是不安全的。


4. 回答最初的问题

到这里,虽然我们的讨论还未结束,但已经足够回答标题的问题:“已经有 MESI 协议,为什么还需要 volatile 关键字?”

即使不考虑写缓冲区或失效队列,MESI 也只是解决数据一致性问题,并不能解决顺序一致性问题。在实际的计算机系统中,为了提高程序的性能,Java 虚拟机和处理器会使用弱顺序一致性模型。

在单线程程序下,弱顺序一致性与强顺序一致性的执行结果完全相同。但在多线程程序下,重排序问题和可见性问题会导致各个线程看到的全局执行顺序不一致,使得程序的执行结果与预期不一致。

为了纠正弱顺序一致性的影响,编译器和处理器都提供了 “内存屏障指令” 来保证程序关键节点的执行顺序能够与程序员的预期一致。在高级语言中,我们不会直接使用内存屏障,而是使用更高级的语法,即 synchronized、volatile、final、CAS 等语法。

那么,什么是内存屏障?synchronized、volatile、final、CAS 等语法和内存屏障有什么关联,这个问题我们在下一篇文章展开讨论,请关注。


参考资料

与已经有 MESI 协议,为什么还需要 volatile 关键字?相似的内容:

已经有 MESI 协议,为什么还需要 volatile 关键字?

本文已收录到 GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 进 Android 面试交流群。 前言 大家好,我是小彭。 在上一篇文章里,我们聊到了 CPU 的缓存一致性问题,分为纵向的 Cache 与内存的一

12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?

本文已收录到 GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 进 Android 面试交流群。 前言 大家好,我是小彭。 在上一篇文章里,我们聊到了 CPU 的三级缓存结构,提到 CPU 缓存就一定会聊到 CP

本地已经有项目需要的所有依赖,但是maven总是会去网上下载

情况说明本地已经有项目需要的所有依赖,但是maven总是会去网上下载,因为网络不好等原因,一直下载失败,但是本地明明就已经有依赖了。maven的settings配置 maven已经配置成自己下载的,至于仓库,没有改变路径,但是仓库的settings也改了。maven的settings已经配置成本地仓

[转帖]bcc入门

学习bcc已经有一段时间,稍微总结一下已知的一些内容。 1. 安装bcc bcc/INSTALL.md at master · iovisor/bcc · GitHubBCC - Tools for BPF-based Linux IO analysis, networking, monitorin

[转帖]Rocksdb的优劣及应用场景分析

研究Rocksdb已经有七个月的时间了,这期间阅读了它的大部分代码,对底层存储引擎进行了适配,同时也做了大量的测试。在正式研究之前由于对其在本地存储引擎这个江湖地位的膜拜,把它想象的很完美,深入摸索之后才发现现实很骨感,光鲜背后都有不为人知的辛酸苦辣。同时这也给幻想追求完美技术的我打了一针清醒剂,任

我的OpenAI库发布了!!!

chatGPT正式发布已经有段时间了,这段时间我也深度体验了chatGPT的魅力。 OpenAI除了提供网页版的chatGPT,还通过api的形式提供了很多其它服务,包括文字纠错、图片生成、音频转换等等。 作为程序员,即使有现成的openai库,但还是免不了想自己造轮子,所以就有这个openai库。

聊聊GLM-4-9B开源模型的微调loss计算

概述 Github官方地址:GLM-4 网上已经有很多关于微调的文章,介绍各种方式下的使用,这里不会赘述。我个人比较关心的是微调时的loss计算逻辑,这点在很多的文章都不会有相关的描述,因为大多数人都是关心如何使用之类的应用层,而不是其具体的底层逻辑,当然咱也说不清太底层的计算。 可了解其它loss

Mac装机必备软件2023

码农一枚,Mac作为生产力工具已经有10多年了。 用Mac的原因除了系统清爽,逼格高之外,最主要还是因为作为一个资深全栈,要做Apple相关开发,必须用MacOS系统。😅 与Windows不同,MacOS上流行使用的软件很多都很小众,作者也不是大厂,但有很多却很实用,这里介绍几款我常用的免费软件。

【译】基于XAML的跨平台框架对比分析

多年来,基于XAML的UI框架已经有了很大的发展。下面的图表是最好的说明。这些框架主要包含:支持跨平台应用的Avalonia UI, Uno Platform和 .NET MAUI。事实上,除了Avalonia UI之外,对跨平台XAML的需求是其发展的主要驱动力。如果微软早点推出一个类似Flutt

Android 架构模式如何选择

Android架构模式飞速演进,目前已经有MVC、MVP、MVVM、MVI。到底哪一个才是自己业务场景最需要的,不深入理解的话是无法进行选择的。这篇文章就针对这些架构模式逐一解读。重点会介绍Compose为什么要结合MVI进行使用。希望知其然,然后找到适合自己业务的架构模式。