从 volatile 到 TSO - 内存可见性问题浅析

​ 最近在看《深入理解 Linux 内核》,讲到内核同步的时候提到了编译器屏障 __asm__volatile__("" ::: "memory") ,这行内联汇编的语句作用如下:

1)asm 用于指示编译器在此插入汇编语句
2)volatile 用于告诉编译器,严禁将此处的汇编语句与其它的语句重组合优化。即:原原本本按原来的样子处理这这里的汇编。
3) memory 强制 gcc 编译器假设 RAM 所有内存单元均被汇编指令修改,这样 CPU 中的通用寄存器中的数据将作废

​ 这个语义与 C 中的定义是等价的,但是在 Java 中,volatile 还有另外一层加强的语义:

声明成 volatile 的字段,Java 线程模型能确保所有线程看到这个变量的值是一致的。

Volatile 语义分析

Java 中的 volatile 死循环例子

​ 在 Java 里提到 volatile,经常会拿出下面这个例子来证明可见性问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TestVolatile {
// public static volatile boolean run = true;
public static boolean run = true;

public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
whileLoop();
}
}).start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
run = false;
}
public static void whileLoop() {
while (run) {
}
}

}

run 字段不加 volatile 修饰,这段程序会死循环,增加之后就会正常退出,并以此证明 Java 中增强的 volatile 语义。但是这个证明方法是错误的。

C 语言等价代码

​ 以下是等价的 C 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include<pthread.h>
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
#include <unistd.h>

int stop = 1;

void *func(void *arg) {
while (stop) {
}
int *p = malloc(sizeof(int));
*p = 666;
return p;
}

int main() {
pthread_t t1;
int err = pthread_create(&t1, NULL, func, NULL);
if (err != 0) {
printf("Thread create failed:%s\n", strerror(errno));

} else {
printf("Thread create success\n");
}
sleep(1);
printf("Change stop flag: %d \n", stop);
stop = 0;
printf("Change stop flag finish: %d \n", stop);
void *p = NULL;
pthread_join(t1, &p);
printf("stop: code=%d\n", *(int *) p);
return EXIT_SUCCESS;
}
O2 编译死循环

使用 O2 编译代码:

➜ gcc main.c -lpthread -o test_volatile && ./test_volatile -O2

➜ ./test_volatile
Thread create success
Change stop flag: 1
Change stop flag finish: 0

出现了与 Java 代码一样的效果,这个死循环无法退出了。

关闭编译优化

接下来我们关掉编译优化:

➜ gcc main.c -lpthread -o test_volatile && ./test_volatile

➜ ./test_volatile
Thread create success
Change stop flag: 1
Change stop flag finish: 0
stop: code=666

代码正常退出。

使用 volatile + O2 编译

然后我们将 int stop -> volatile int stop,依然使用 O2,输出如下,循环正常推出。

➜ gcc main.c -lpthread -o test_volatile && ./test_volatile -O2

➜ ./test_volatile
Thread create success
Change stop flag: 1
Change stop flag finish: 0
stop: code=666

​ 由此可见,C 语言等价代码中循环能否正常退出,更多是一种编译器的行为。下面我们分析一下编译出来的具体代码。

分析编译结果

分析 C 编译结果

​ 同时使用 O2 编译一份有无 volatile 的可执行文件,使用 objdump -d 反汇编,我们只关注 func 方法,左边是有 volatile,右边没有:

c_objdump_diff.png

紫色框的就是 while 循环的逻辑所在之处,先逐行分析左边的相关指令:

test %eax, %eax 将两个操作数做 与 运算,并根据结果设置相关标志位 ZF
jne 1278 如果标志位 ZF 不等于 0,跳转到 1278,即下一条要解析的指令地址。
mov 0x2dea(%rip),%eax # 4068 <stop> 这条指令其实是取内存中的 stop 字段的值。

显而易见,右边的指令对比左边的区别就是 jne 跳转指令不去取内存中 stop 的值。而这,就是循环能否正常停止的关键。

分析 Java jit 编译结果

​ 由 C 的结果,我们可以大胆推测,Java 中是否能将循环停止,也是编译器的行为。

​ 首先,我们尝试设置成按模板解释器执行:

1
java -Xint TestVolatile

​ 纯解释器执行的情况下,有无 volatile 都是可以正常停下来,也就是说是 jit 的优化行为导致的循环无法停下来。我们接下去继续验证。

​ 我们直接对比一下有无 volatile jit 生成的代码的差异:(jit 生成代码反汇编的配置可以参考这篇文章。)

分别在有无 volatile 下编译运行:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMethods -Xcomp -XX:CompileCommand=compileonly,TestVolatile.whileLoop TestVolatile > not_volatile

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMethods -Xcomp -XX:CompileCommand=compileonly,TestVolatile.whileLoop TestVolatile > volatile

​ 对比一下两者反汇编的差异:

java_objdump_diff

一样是左边有 volatile,右边没有,先逐行分析左边指令:

test %eax,0x168de31a(%rip) # 0x00007f1a7f9e2000 这行指令是 Hotspot 用来实现 safepoint 的代码,先直接无视。
movzbl 0x68(%r10),%r8d 读取 run 值到 r8 寄存器。
test %r8d,%r8d 将两个操作数做 与 运算,并根据结果设置相关标志位 ZF
jne 0x00007f1a69103ce0 如果标志位 ZF 不等于 0,跳转到 3ce0,即第一条指令的位置。

显而易见,右边的指令并没有读取内存中 run 值的指令,而是直接无条件jmp跳转到 Hotspotsafepoint 代码就继续执行,根本不可能读取到 run 值的变化。

以上实验环境如下:

gcc version 8.3.0 (Debian 8.3.0-6)

java version “1.8.0_261”
Java(TM) SE Runtime Environment (build 1.8.0_261-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, mixed mode)

Java 中的 volatile

​ 由此可见,即使没有 Java 增强的 volatile 语义,所谓内存可见性导致循环无法停止的问题是不存在的,循环无法退出完全是编译器的优化行为。

​ Java 中的 volatile 一方面与 gcc 定义的 volatile 语义一致,可以提示编译器该变量每一次都需要从内存中获取(先忽略 cache-line),不允许编译器进行优化;另一方面,Java 内存模型约束 volatile 定义的字段在 CPU 层面不会进行指令重排序。

​ 在 JVM 的字节码解释器中,如果 putstatic 字节码或 putfield 字节码的变量是 Java 层面的 volatile 关键字修饰的,就会在指令执行的最后插入一道 StoreLoad 屏障:

1
__ams__ volatile("lock; addl $0, $0(%%esp)" : : : "cc", "memory")

​ 这条指令可以把 CPU 缓存的写操作刷新到主存中。

​ 再回到上面的 Java 循环的例子中,在我们禁止掉编译优化后,有无 volatile 循环都是可以正常停止下来的。区别在于有 volatile 的情况,一旦标记位修改,马上就会刷新到主存,另一个线程就可以读取到最新的数值。没有 volatile 的情况,则必须等 CPU 把修改的值刷新到主存后,另外一个线程才能读取到最新的数值。

​ 这种写操作的延迟刷新,在我们的例子里可能只是会让循环停止产生延迟,但是在某些场景,会出现令人迷惑的情景: A线程 写操作在前,但是随后做读取操作的 B线程 依然读取到了旧值,我们从外部观察,就像是发生了 读写重排序,这个重排序并不是 CPU 真的对指令进行重排,而是由于缓存写导致的重排。

TSO 模型

​ 为什么需要有 lock 指令才能确保写操作对其他线程马上可见呢?这是因为主存的写速度与 CPU 相比实在是太慢了,必须增加一层缓存(StoreBuffer),通过 lock 执行可以刷新 StoreBuffer。相关的模型叫:TSO,可以直接参考这篇文章:

x86-TSO : 适用于x86体系架构并发编程的内存模型