跳转至

从汇编角度理解 volatile

先抛出结论:c 语言 volatile 关键字的作用在于提示编译器,这个变量值可能被多线程修改,要从内存读取。

假设测试:有一个全局变量 i 初始值为 0,线程 A 轮询 i 的值,当i 的值为 1 时退出;线程 B 在某一时刻改变 i 的值,然后退出。主线程等待 A 和 B 都退出后再退出。正常情况下总能让所有线程均正常结束。

测试

#include<stdlib.h>
#include<pthread.h>

int i=0;

void* threadA() {
  for(;!i;) {}
}

void* threadB() {
  i=1;
}

int main() {
  pthread_t a;
  pthread_t b;

  pthread_create(&a, NULL, threadA, NULL);
  pthread_create(&b, NULL, threadB, NULL);

  void* ret;
  pthread_join(a, &ret);
  pthread_join(b, &ret);
}

编译并重复运行,程序可能卡住无法正常结束。

$ ./no-volatile.out

带上 volatile 关键字重新编译测试

#include<stdlib.h>
#include<pthread.h>

volatile int i=0;

void* threadA() {
  for(;!i;) {}
}

void* threadB() {
  i=1;
}

int main() {
  pthread_t a;
  pthread_t b;

  pthread_create(&a, NULL, threadA, NULL);
  pthread_create(&b, NULL, threadB, NULL);

  void* ret;
  pthread_join(a, &ret);
  pthread_join(b, &ret);
}

编译并重复运行,程序总能正常结束。

$ ./volatile.out

从汇编理解原因

这是什么原因?将这两段代码分别编译成汇编文件

gcc -Os -S -o no-volatile.s no-volatile.c
gcc -Os -S -o volatile.s volatile.c

查看 no-volatile.s 文件

threadA:
.LFB6 = .
  .cfi_startproc
  la.local  $r13,i
.L2:
  ldptr.w $r12,$r13,0
  beqz  $r12,.L2
  jr  $r1
  .cfi_endproc
.LFE6:
  .size threadA, .-threadA
  .align  2
  .globl  threadB
  .type threadB, @function

可以看到

8:  线程 A 加载变量 i 的地址到 r13 寄存器
10: 读取 r13 的值所指向的内存,即从内存中读取 i 的值
11: 如果值为 0 ,跳到 .L2,然后再次读取循环,否则继续执行 12 行,退出线程。

这个程序逻辑和我们期望的是一致的,一旦 i 的值发生改变,总可以读到 i 的新值然后退出线程。

查看 volatile.s 文件

 5 threadA:
 6 .LFB6 = .
 7   .cfi_startproc
 8   la.local  $r12,i
 9   ldptr.w $r12,$r12,0
10   bnez  $r12,.L5
11 .L4:
12   b .L4
13 .L5:
14   jr  $r1
15   .cfi_endproc

查看行号对应的汇编代码

8: 加载变量 i 的地址到 r12
9: 从内存读取变量 i 的值到 r12
10: 如果 r12 不等于 0,则跳到 .L5 即退出线程,否则,继续走到 .L4。一旦程序走到 12 行,将陷入循环。不会再次读取值做比较。

在不带 volatile 的情况下,程序行为和我们预期的不一致,程序无法正常退出。

总结

这里就明白了,要从内存中读取被 volatile 修饰的变量是什么意思。总之,在并发环境下,共享变量都应该加上 volatile 修饰。否则会因为 gcc 的一些编译优化行为造成语义不一致。