找回密码
 立即注册
首页 业界区 业界 深入理解并发编程艺术之内存模型

深入理解并发编程艺术之内存模型

呵烘稿 前天 21:59
1.jpeg

随着硬件技术的飞速发展,多核处理器已经成为计算设备的标配,这使得开发人员需要掌握并发编程的知识和技巧,以充分发挥多核处理器的潜力。然而并发编程并非易事,它涉及到许多复杂的概念和原理。为了更好地理解并发编程的内在机制,需要深入研究内存模型及其在并发编程中的应用。本文将主要以 Java 内存模型来探讨并发编程中 BUG 的源头和处理这些问题的底层实现原理,助你更好地把握并发编程的内在机制。
 
并发编程问题-可见性和有序性
  1.     private int a, b;
  2.     private int x, y;
  3.     public void test() {
  4.         Thread t1 = new Thread(() -> {
  5.             a = 1;
  6.             x = b;
  7.         });
  8.         Thread t2 = new Thread(() -> {
  9.             b = 2;
  10.             y = a;
  11.         });
  12.         // ...start启动线程,join等待线程
  13.         assert x == 2;
  14.         assert y == 1;
  15.     }
复制代码
首先我们先看一段代码,这里定义了两个共享变量 x 和 y,在两个线程中分别对 x 和 y 赋值,当同时开启两个线程并等待线程执行完成,最终结果是否是共享变量 x 等于 2 并且 y 等于 1 呢?答案是未可知,即共享变量 x 和 y 可能存在多种执行结果。可以看到在并发编程中,常常会遇到一些与预期不符的结果,导致程序逻辑的失败。这样的异常问题,会让开发人员感到困惑。但是如果细细探究这些问题的根源,发现是有迹可循的。
这个问题的原因主要是两点:一是处理器和内存对共享变量的处理的速度差异。二是编译优化和处理器优化造成代码指令重排序。前者导致可见性问题,后者导致有序性问题。
处理器缓存导致的可见性问题

2.png

如上图所示,由于处理器和内存的速度差距太大。为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作。基于局部性原理,处理器在读取内存数据时,是一块块地读取,每一小块数据也叫缓存行(cache line)。当处理器操作完数据,也不直接写回内存,而且先写入缓存中,并将当前缓存标记为脏(dirty)。等到当前缓存被替换时,才将数据写回内存。这个过程叫写回策略(write-back)。
同时为了提高效率,处理器使用写缓存区(store buffer)临时保存向内存写入的数据。写缓冲区可以保证指令流水线的持续运行,同时合并写缓冲区中对同一内存地址的多次写,减少内存总线的占用。但是由于缓冲区的数据并非及时写回内存,且写缓冲区仅对自己的处理器可见,其他处理器无法感知当前共享变量已经变更。处理器的读写顺序与内存实际操作的读写顺序可能存在不一致。
3.jpeg
现在再回来看上面代码,那么可以得到四种结果:
1)假设处理器 A 对变量 a 赋值,但没及时回写内存。处理器 B 对变量 b 赋值,且及时回写内存。处理器 A 从内存中读到变量 b 最新值。那么这时结果是:x 等于 2,y 等于 0。
2)假设处理器 A 对变量 a 赋值,且及时回写内存。处理器 B 从内存中读到变量 a 最新值。处理器 B 对变量 b 赋值,但没及时回写内存。那么这时结果是:x 等于 0,y 等于 1。
3)假设处理器 A 和 B,都没及时回写变量 a 和 b 值到内存。那么这时结果是:x 等于 0,y 等于 0。
4)假设处理器 A 和 B,都及时回写变量 a 和 b 值到内存,且从内存中读到变量 a 和 b 的最新值。那么这时结果是:x 等于 2,y 等于 1。
从上面可发现除了第四种情况,其他三种情况都存在对共享变量的操作不可见。所谓可见性,便是当一个线程对某个共享变量的操作,另外一个线程立即可见这个共享变量的变更。
而从上面推论可以发现,要达到可见性,需要处理器及时回写共享变量最新值到内存,也需要其他处理器及时从内存中读取到共享变量最新值。
因此也可以说只要满足上述两个条件。那么就可以保证对共享变量的操作,在并发情况下是线程安全的。在 Java 语言中,是通过 volatile 关键字实现。volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。
对如下共享变量:
  1.   // instance是volatile变量
  2.   volatile Singlenton instance = new Singlenton();
复制代码
转换成汇编代码,如下:
  1. 0x01a3de1d: movb 5 0 x 0, 0 x 1104800(% esi);
  2. 0x01a3de24: lock addl $ 0 x 0,(% esp);
复制代码
可以看到 volatile 修饰的共享变量会多出第二行汇编变量,并且多了一个 LOCK 指令。LOCK 前缀的指令在多核处理器会引发两件事:
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
上述的操作是通过总线嗅探和总线仲裁来实现。而基于总线嗅探和总线仲裁,现代处理器逐渐形成了各种缓存一致性协议,例如 MESI 协议。
4.jpeg
总之操作系统便是基于上述实现,从底层来保证共享变量在并发情况下的线程安全。而对开发人员,只需要在恰当时候加上 volatile 关键字就可以。
除了 volatile,也可以使用 synchronized 关键字来保证可见性。关于 volatile 和 synchronized 的具体实现,会在下篇文章详细阐述。
编译优化导致的有序性问题

前面讲到通过缓存一致性协议,来保障共享变量的可见性。那么是否还有其他情况,导致对共享变量操作不符合预期结果。可以看下面的代码:
  1.     private int a, b;
  2.     private int x, y;
  3.     public void test() {
  4.         Thread t1 = new Thread(() -> {
  5.             x = b;
  6.             a = 1;
  7.         });
  8.         Thread t2 = new Thread(() -> {
  9.             y = a;
  10.             b = 2;
  11.         });
  12.         // ...start启动线程,join等待线程
  13.         assert x == 2;
  14.         assert y == 1;
  15.     }
复制代码
假设将线程 t1 的代码块从 a = 1;x = b;改成 x = b;a = 1; 。将线程 t2 的代码块从 b = 2;y = a;改成 y = a;b = 2;。
对于线程 t1 和 t2 自己来说,代码的重排序,不会影响当前线程执行。但是在多线程并发执行下,会出现如下情况:
1)假设处理器 A 先将变量 b=0 赋值给 x,再将变量 a 赋值 1。处理器 B 先将变量 a=0 赋值给 y,再将变量 b 赋值 2。那么这时结果是:x 等于 0,y 等于 0。
可见代码的重排序也会影响到程序最终结果。
代码和指令的重排序的主要原因有三个,分别为编译器的重排序,处理器的乱序执行,以及内存系统的重排序。后面两点是处理器优化。
5.jpeg
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序需要遵守两点:
1)数据依赖性:如果两个操作之间存在数据依赖,那么编译器和处理器不能调整它们的顺序。
  1. // 写后读
  2. a = 1;
  3. b = a;
  4. // 写后写
  5. a = 1;
  6. a = 2;
  7. // 读后写
  8. a = b;
  9. b = 1;
复制代码
上面 3 种情况,编译器和处理器不能调整它们的顺序,否则将会造成程序语义的改变。
2)as-if-serial 语义:即给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
  1. a = 1;
  2. b = 2;
  3. c = a * b;
复制代码
如上对变量 a 的赋值和对变量 b 的赋值,不存在数据依赖关系。因此对变量 a 和 b 重排序不会影响变量 c 的结果。
但数据依赖性和 as-if-serial 语义只保证单个处理器中执行的指令序列和单个线程中执行的操作,并不考虑多处理器和多线程之间的数据依赖情况。因此在多线程程序中,对存在数据依赖的操作重排序,可能会改变程序的执行结果。因此要避免程序的错误的执行,便是需要禁止这种编译和处理器优化导致的重排序。
这种方式叫做内存屏障(memory barriers)。内存屏障是一组处理器指令,用户实现对内存操作的顺序限制。以我们日常接触的 X86_64 架构来说,读读(loadload)、读写(loadstore)以及写写(storestore)内存屏障是空操作(no-op),只有写读(storeload)内存屏障会被替换成具体指令。
在 Java 语言中,内存屏障通过 volatile 关键字实现,禁止被它修饰的变量发生指令重排序操作:
1)不允许 volatile 字段写操作之前的内存访问被重排序至其之后。
2)不允许 volatile 字段读操作之后的内存访问被重排序至其之前。
  1.     //  变量a,b通过volatile修饰
  2.     private volatile int a, b;
  3.     private int x, y;
  4.     public void test() {
  5.         Thread t1 = new Thread(() -> {
  6.             a = 1;
  7.             // 编译器插入storeload内存屏障指令
  8.             // 1)禁止代码和指令重排序
  9.             // 2)强制刷新变量a的最新值到内存
  10.             x = b;
  11.             // 1)强制从内存中读取变量b的最新值
  12.         });
  13.         Thread t2 = new Thread(() -> {
  14.             b = 2;
  15.             // 编译器插入storeload内存屏障指令
  16.             // 1)禁止代码和指令重排序
  17.             // 2)强制刷新变量b的最新值到内存
  18.             y = a;
  19.             // 1)强制从内存中读取变量a的最新值
  20.         });
  21.         // ...start启动线程,join等待线程
  22.         assert x == 2;
  23.         assert y == 1;
  24.     }
复制代码
可以看到通过 volatile 修饰的变量通过 LOCK 指令和内存屏障,实现共享变量的可见性和避免代码和指令的重排序,最终保障了程序在多线程情况下的正常执行。
 
并发编程问题-原子性
  1.     private int count = 0;
  2.     public void test() {
  3.         List<Thread> ts = new ArrayList<>();
  4.         for (int i = 0; i < 100; i++) {
  5.             Thread t = new Thread(() -> {
  6.                 for (int j = 0; j < 10000; j++) {
  7.                     count += 1;
  8.                 }
  9.             });
  10.             ts.add(t);
  11.         }
  12.         // ...start启动线程,join等待线程
  13.         assert count == 100 * 10000;
  14.     }
复制代码
内存模型综述

在本文中,我们对 Java 内存模型进行了全面的概述。Java 内存模型是 Java 虚拟机规范的一部分,为 Java 开发人员提供了一种抽象的内存模型,用于描述多线程环境下的内存访问行为。
jJava 内存模型关注并发编程中的原子性、可见性和有序性问题,并提供了一系列同步原语(如 volatile、synchronized 等)来实现这些原则。此外,还定义 happens-before 关系,用于描述操作之间的偏序关系,从而确保内存访问的正确性和一致性。
Java 内存模型的主要优势在于它为并发编程提供了基础,简化了复杂性。屏蔽不同处理器差异性,在不同的处理器平台之上呈现了一致的内存模型,并允许一定程度的性能优化。这些优势使得 Java 开发人员可以更容易地编写出正确、高效、可移植的并发程序。
了解 Java 内存模型的原理和实践对于编写高质量的 Java 并发程序至关重要。希望本文能为您提供有关 Java 内存模型的有用信息,帮助您更好地理解并发编程的内在机制,以及在实际项目中选择合适的同步原语和策略。
 
作者:ehtan

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册