随着硬件技术的飞速发展,多核处理器已经成为计算设备的标配,这使得开发人员需要掌握并发编程的知识和技巧,以充分发挥多核处理器的潜力。然而并发编程并非易事,它涉及到许多复杂的概念和原理。为了更好地理解并发编程的内在机制,需要深入研究内存模型及其在并发编程中的应用。本文将主要以 Java 内存模型来探讨并发编程中 BUG 的源头和处理这些问题的底层实现原理,助你更好地把握并发编程的内在机制。
随着硬件技术的飞速发展,多核处理器已经成为计算设备的标配,这使得开发人员需要掌握并发编程的知识和技巧,以充分发挥多核处理器的潜力。然而并发编程并非易事,它涉及到许多复杂的概念和原理。为了更好地理解并发编程的内在机制,需要深入研究内存模型及其在并发编程中的应用。本文将主要以 Java 内存模型来探讨并发编程中 BUG 的源头和处理这些问题的底层实现原理,助你更好地把握并发编程的内在机制。
private int a, b;
private int x, y;
public void test() {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 2;
y = a;
});
// ...start启动线程,join等待线程
assert x == 2;
assert y == 1;
}
首先我们先看一段代码,这里定义了两个共享变量 x 和 y,在两个线程中分别对 x 和 y 赋值,当同时开启两个线程并等待线程执行完成,最终结果是否是共享变量 x 等于 2 并且 y 等于 1 呢?答案是未可知,即共享变量 x 和 y 可能存在多种执行结果。可以看到在并发编程中,常常会遇到一些与预期不符的结果,导致程序逻辑的失败。这样的异常问题,会让开发人员感到困惑。但是如果细细探究这些问题的根源,发现是有迹可循的。
这个问题的原因主要是两点:一是处理器和内存对共享变量的处理的速度差异。二是编译优化和处理器优化造成代码指令重排序。前者导致可见性问题,后者导致有序性问题。
如上图所示,由于处理器和内存的速度差距太大。为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作。基于局部性原理,处理器在读取内存数据时,是一块块地读取,每一小块数据也叫缓存行(cache line)。当处理器操作完数据,也不直接写回内存,而且先写入缓存中,并将当前缓存标记为脏(dirty)。等到当前缓存被替换时,才将数据写回内存。这个过程叫写回策略(write-back)。
同时为了提高效率,处理器使用写缓存区(store buffer)临时保存向内存写入的数据。写缓冲区可以保证指令流水线的持续运行,同时合并写缓冲区中对同一内存地址的多次写,减少内存总线的占用。但是由于缓冲区的数据并非及时写回内存,且写缓冲区仅对自己的处理器可见,其他处理器无法感知当前共享变量已经变更。处理器的读写顺序与内存实际操作的读写顺序可能存在不一致。
现在再回来看上面代码,那么可以得到四种结果:
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 缓存。
对如下共享变量:
// instance是volatile变量
volatile Singlenton instance = new Singlenton();
转换成汇编代码,如下:
0x01a3de1d: movb 5 0 x 0, 0 x 1104800(% esi);
0x01a3de24: lock addl $ 0 x 0,(% esp);
可以看到 volatile 修饰的共享变量会多出第二行汇编变量,并且多了一个 LOCK 指令。LOCK 前缀的指令在多核处理器会引发两件事:
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
上述的操作是通过总线嗅探和总线仲裁来实现。而基于总线嗅探和总线仲裁,现代处理器逐渐形成了各种缓存一致性协议,例如 MESI 协议。
总之操作系统便是基于上述实现,从底层来保证共享变量在并发情况下的线程安全。而对开发人员,只需要在恰当时候加上 volatile 关键字就可以。
除了 volatile,也可以使用 synchronized 关键字来保证可见性。关于 volatile 和 synchronized 的具体实现,会在下篇文章详细阐述。
前面讲到通过缓存一致性协议,来保障共享变量的可见性。那么是否还有其他情况,导致对共享变量操作不符合预期结果。可以看下面的代码:
private int a, b;
private int x, y;
public void test() {
Thread t1 = new Thread(() -> {
x = b;
a = 1;
});
Thread t2 = new Thread(() -> {
y = a;
b = 2;
});
// ...start启动线程,join等待线程
assert x == 2;
assert y == 1;
}
假设将线程 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。
可见代码的重排序也会影响到程序最终结果。
代码和指令的重排序的主要原因有三个,分别为编译器的重排序,处理器的乱序执行,以及内存系统的重排序。后面两点是处理器优化。
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序需要遵守两点:
1)数据依赖性:如果两个操作之间存在数据依赖,那么编译器和处理器不能调整它们的顺序。
// 写后读
a = 1;
b = a;
// 写后写
a = 1;
a = 2;
// 读后写
a = b;
b = 1;
上面 3 种情况,编译器和处理器不能调整它们的顺序,否则将会造成程序语义的改变。
2)as-if-serial 语义:即给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
a = 1;
b = 2;
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 字段读操作之后的内存访问被重排序至其之前。
// 变量a,b通过volatile修饰
private volatile int a, b;
private int x, y;
public void test() {
Thread t1 = new Thread(() -> {
a = 1;
// 编译器插入storeload内存屏障指令
// 1)禁止代码和指令重排序
// 2)强制刷新变量a的最新值到内存
x = b;
// 1)强制从内存中读取变量b的最新值
});
Thread t2 = new Thread(() -> {
b = 2;
// 编译器插入storeload内存屏障指令
// 1)禁止代码和指令重排序
// 2)强制刷新变量b的最新值到内存
y = a;
// 1)强制从内存中读取变量a的最新值
});
// ...start启动线程,join等待线程
assert x == 2;
assert y == 1;
}
可以看到通过 volatile 修饰的变量通过 LOCK 指令和内存屏障,实现共享变量的可见性和避免代码和指令的重排序,最终保障了程序在多线程情况下的正常执行。
private int count = 0;
public void test() {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
count += 1;
}
});
ts.add(t);
}
// ...start启动线程,join等待线程
assert count == 100 * 10000;
}
对于 Java 这样的高级语言,一条语句最终会被转换成多条 CPU 指令完成,例如上面代码中的 count+=1,至少需要三条 CPU 指令:
1)指令 1:把变量 count 从内存加载到 CPU 的寄存器;
2)指令 2:在寄存器中执行+1 操作;
3)指令 3:将结果写入内存(缓存机制导致可能写入的是处理器缓存而不是内存)。
那么假设有两个线程 A 和 B,同时执行 count+=1,可能存在如下情况:
1)线程 A 从内存加载 count 并执行 count+=1,同时线程 B 从内存加载 count 并执行 count+=1,并同时写回内存。那么这时结果是:count = 1。
2)线程 A 从内存加载 count 并执行 count+=1,并将 count 结果写回内存。线程 B 再从内存加载 count 并执行 count+=1。那么这时结果是:count = 2。
可以看到如果要 count 结果正确,要保证 count 读取,操作,写入三个过程不被中断。这个过程,可以称之为原子操作。原子 (atomic)本意是“不能被进一步分割的最小粒子”,而原子操作 (atomicoperation) 意为“不可被中断的一个或一系列操作”。
处理器主要使用基于对缓存加锁或总线加锁的方式来实现原子操作:
1)总线加锁:通过 LOCK#信号锁住总线 BUS,使当前处理器独享内存空间。但是此时其他处理器都不能访问内存其他地址,效率低。
2)缓存加锁:缓存一致性协议(MESI)。强制当前处理器缓存行失效,并从内存读取其他处理器回写的数据。当有些无法被缓存或跨域多个缓存行的数据,依然需要使用总线锁。
对于以上两个机制,处理器底层提供了很多指令来实现,其中最重要的是 CMPXCHG 指令。但 CMPXCHG 只在单核处理器下有效,多核处理器依然要加上 LOCK 前缀(LOCK CMPXCHG)。利用 CMPXCHG 指令可以通过循环 CAS 方式来实现原子操作。
// 判断当前机器是否是多核处理器
int mp = os::is MP();
_asm {
mov edx, dest
mov ecx, exchange value
mov eax, compare_value
// 这里需要先进行判断是否为多核处理器
LOCK IF MP(mp)
// 如果是多核处理器就会在这行指令前加Lock标记
cmpxchg dword ptr [edx],ecx
}
CAS 即 Compare and Swap。CAS 操作需要输入两个数值,一个旧值 (期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
Java 语言提供了大量的原子操作类,来实现对应的 CAS 操作。比如 AtomicBoolean,AtomicInteger,AtomicLong 等。
private AtomicInteger count = new AtomicInteger(0);
public void test() {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
count.incrementAndGet();
}
});
ts.add(t);
}
// ...start启动线程,join等待线程
assert count.get() == 100 * 10000;
}
CAS 虽然很高效解决了原子操作,但是 CAS 也存在一些问题,比如 ABA 问题,循环时间长开销大,只能保障一个共享变量的原子操作。关于如上问题解决和 Atomic 包介绍,会在下篇文章详细阐述。
前面说到处理器提供了一些特殊指令比如 LOCK,CMPXCHG,内存屏障等来保障多线程情况下的程序逻辑正常执行。但这依然存在几个问题:
1)处理器底层指令实现细节复杂难懂,开发人员需要付出巨大的学习成本。
2)不同的硬件和操作系统,对指令的支持和实现不一样,需要考虑跨平台的兼容性。
3)程序业务逻辑复杂多变,处理器和线程之间的数据操作依赖关系也相应更复杂。
因此高级语言会提供一种抽象的内存模型,用于描述多线程环境下的内存访问行为。 无需关心底层硬件和操作系统的具体实现细节,就可以编写出高效、可移植的并发程序。对于 Java 语言,这种内存模型便是 Java 内存模型(Java Memory Model,简称 JMM)。
Java 内存模型主要特性是提供了 volatile、synchronized、final 等同步原语,用于实现原子性、可见性和有序性。另一个重要的概念便是 happens-before 关系,用来描述并发编程中操作之间的偏序关系。除了 Java 语言,包括 golang,c++,rust 等高级语言也实现了自己的 happens-before 关系。
Java 内存模型的 happens-before 关系是用来描述两个线程操作的内存可见性。需注意这里的可见性,并不代表 A 线程的执行顺序一定早于 B 线程, 而是 A 线程对某个共享变量的操作对 B 线程可见。即 A 线程对变量 a 进行写操作,B 线程读取到是变量 a 的变更值。
Java 内存模型定义了主内存(main memory),本地内存(local memory),共享变量等抽象关系,来决定共享变量在多线程之间通信同步方式,即前面所说两个线程操作的内存可见性。其中本地内存,涵盖了缓存,写缓冲区,寄存器以及其他硬件和编译器优化等概念。
如图所示,如果线程 A 与线程 B 之间要通信的话,必须要经历下面 2 个步骤:
1)线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中
2)线程 B 到主内存中去读取线程 A 之前已更新过的共享变量
尽管定义这样的数据通信方式,但实际程序的数据依赖操作是复杂多变的。为了进一步抽象这种线程间的数据同步方式,Java 内存模型定义了下述线程间的 happens-before 关系:
1)程序顺序规则:单线程内,每个操作 happens-before 于该线程中的任意后续操作。
2)监视器锁规则:解锁操作 happens-before 之后对同一把锁的加锁操作。
3)volatile 变量规则:volatile 字段的写操作 happens-before 之后对同一字段的读操作。
4)传递性规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
5)start()规则:如果线程 A 执行操作 ThreadB.start(),那么 A 线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作。
6)join()规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 线程 A 从 ThreadB.join()操作成功返回。
与开发人员密切相关的是 1、2、3、4 四个规则。其中规则 1 满足了 as-if-serial 语义,即 Java 内存模型允许代码和指令重排序,只要不影响程序执行结果。规则 2 和 3 是通过 synchronized、volatile 关键字实现。结合规则 1、2、3 来看看规则 4 的具体使用,可以看到如下的代码,程序最终执行且得到正确结果:
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。