MENU

浅谈"内存屏障"-开发者角度

May 5, 2023 • Read: 1453 • 学习记录

浅谈"内存屏障"-开发者角度

阅读本系列带着几个问题:

  1. 了解 volatile 如何保证可见性的?
  2. CAS 是如何保证对同一个地址操作的原子性?
  3. volatile 如何避免指令重排序的?
  4. MESI 协议与内存屏障有何种关联?

0、前言

推荐一篇有趣的文章:不想当作家的程序员写不出 Redis

从不同角度来理解内存屏障,谈论“内存屏障”共有三篇文章,分别从开发角度、语言角度和硬件角度。

本文主要是从开发角度了解与内存屏障(memory barrier)的关联。

1、内存屏障(memory barrier)基础知识

1.1 什么是内存屏障?

这里只是简单了解下概念,后续会更加详细解释内存屏障是什么

内存屏障是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候,严格按照一定的顺序来执行。内存屏障的作用是防止 CPU 对内存的乱序访问,从而保证共享数据在多线程并行执行下的可见性。内存屏障有不同的类型,例如读内存屏障、写内存屏障、全功能内存屏障等,它们分别约束了不同的内存操作顺序。

1.2 为什么要有内存屏障?

单例模式经典的 dobule check lock 错误写法:

public class Singleton {
    private static Singleton instance = null;
    
    public static Singleton getInstance() {
        if (instance == null) { // a
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

上面这段代码,初看没有问题,但是在并发模型下,可能会出错,因为 instance = new Singleton(); 不是一个原子操作,实际上包含三个动作:

memory = allocate(); // 1:分配对象的内存空间

ctorInstance(memory); // 2:初始化对象

instance = memory; // 3:设置 instance 指向刚分配的内存地址

上面的操作 2 依赖操作 1,但是操作 3 不依赖于操作 2,但是为了加快执行效率,JVM 是可以针对他们进行指令的重排序。经过重排序后假设如下:

memory = allocate(); // 1:分配对象的内存空间

instance = memory; // 3:设置 instance 指向刚分配的内存地址

ctorInstance(memory); // 2:初始化对象

由于重排序影响,instance 已经指向了 memory,这时在多线程的情况下,线程 A 执行到了操作 3,线程 B 执行到了操作 a,发现不为空,就直接返回 instance 继续执行,由于还未初始化对象,线程 B 可能发生执行出错。

内存屏障在这里的作用

// 正确方式
private static volatile Singleton instance = null;

从开发者角度看,使用 volatile 保证了获取到正确的数据。在 Java 中可以利用 volatile 关键字防止重排序,而防止重排序与 内存屏障 有关。

这里的 instance = memory; 赋值操作是针对 volatile 类型的变量,这样就可以保证操作 1、操作 2 一定在操作 3 前面执行完成。

volatile 还有另一个作用:可见性,但这里 synchronzied 关键字就能保证可见性。

1.3 为什么指令需要重排序?

程序到执行结果的转换

1hct3lPrSOVUQo0AsE6AsTQ.png

出自《C++ and Beyond 2012: Herb Sutter — atomic<> Weapons》

由上图可知,从程序源代码到具体的执行中间多了很多步骤,类似于在 Java 中也一样,其中每一层的转换,保证在 single thread 有一样的结果,这个由 JVM 的JSR-133规范中定义了 as-if-serial 语义来保证。但是在 multi-thread 情况下,需要开发者自己处理有 data race(数据争用) 的部分。

比如下面两个计算:

a = b + c;
d = e + f;

编译后,产生的汇编语句如下:

load R1, b
load R2, c
add R3, R1, R2  # (1)
store a, R3
load R4, e  # (2)
load R5, f  # (3)
add R6, R4, R5
store d, R6
  • R1 ~ R6 是 register。
  • a ~ f 表示变量的内存中的位置。
  • load/add/store 第一个参数是目的地。

由于 CPU 访问 cache 和访问主存的时间相差很大(差了百倍以上,也可以看看这篇文章),

image-20230421164809369.png

加上 CPU 指令执行的流水线技术分支预测技术等,所以会尽可能优化执行的指令顺序。

例如:由于 cpu 的流水线技术,上面代码的 (2) 和 (3) 两条指令 可能会移动到指令 (1) 之前,可以尽可能减少访问主存的时间,缩短整体执行时间。

参考文章

  1. CPU扫盲-CPU如何执行指令以及流水线技术 - 知乎
  2. Latency Numbers Every Programmer Should Know
  3. CPU内部各个部件的时延大概是多少?(皮秒,纳秒)?- 知乎
  4. 以 double-checked locking 為例,了解 memory barrier 的作用以及thread 之間何時會同步資料
  5. 浅论Lock 与X86 Cache 一致性
  6. Java内存模型-volatile的内存语义 - 玉树临枫 - 博客园
  7. 如何理解 JAVA 中的 volatile 关键字 - 腾讯云开发者社区-腾讯云
  8. 软件角度:簡介 C++11 atomic 和 memory order
Last Modified: May 11, 2023