跳转至

从汇编角度理解 volatile 关键字

1. 什么是 volatile 关键字

volatile 是 C/C++ 中的一个关键字,用于告诉编译器: - 该变量的值可能会被程序之外的因素修改(如硬件、其他线程等) - 编译器不应对该变量进行某些优化(如缓存到寄存器) - 每次使用该变量时都应该从内存中重新读取

2. 问题现象

假设有以下场景: - 全局变量 i 初始值为 0 - 线程 A 轮询 i 的值,当 i 为 1 时退出 - 线程 B 在某一时刻将 i 的值改为 1,然后退出 - 主线程等待 A 和 B 都退出后再退出

2.1 不带 volatile 的代码

#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);
}

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

2.2 带 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);
}

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

3. 从汇编角度分析

3.1 生成汇编代码

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

3.2 分析不带 volatile 的汇编

threadA:
.LFB6 = .
  .cfi_startproc
  la.local  $r13,i
.L2:
  ldptr.w $r12,$r13,0
  beqz  $r12,.L2
  jr  $r1
  .cfi_endproc

分析: 1. 线程 A 加载变量 i 的地址到 r13 寄存器 2. 进入循环,从内存中读取 i 的值到 r12 寄存器 3. 如果值为 0,跳回 .L2 继续循环 4. 否则退出线程

3.3 分析带 volatile 的汇编

threadA:
.LFB6 = .
  .cfi_startproc
  la.local  $r12,i
  ldptr.w $r12,$r12,0
  bnez  $r12,.L5
.L4:
  b .L4
.L5:
  jr  $r1
  .cfi_endproc

分析: 1. 线程 A 加载变量 i 的地址到 r12 寄存器 2. 从内存中读取 i 的值到 r12 寄存器 3. 如果值不为 0,跳转到 .L5 退出线程 4. 否则进入 .L4 死循环,不再重新读取内存中的值

4. 为什么会出现这种情况?

编译器在优化时,会假设变量的值只会在当前线程内被修改。对于不带 volatile 的变量,编译器可能会:

  1. 寄存器缓存:将变量的值缓存到寄存器中,避免每次都从内存读取
  2. 循环优化:如果编译器认为循环条件不会改变,可能会将循环优化为死循环
  3. 代码重排序:调整代码执行顺序以提高性能

在多线程环境中,这些优化可能导致线程无法看到其他线程对变量的修改,从而出现意想不到的行为。

5. volatile 的作用

volatile 关键字告诉编译器:

  1. 禁止寄存器缓存:每次使用变量时都必须从内存中读取
  2. 禁止循环优化:确保循环条件会被重新评估
  3. 禁止代码重排序:保证代码执行顺序与源代码一致

6. 适用场景

volatile 关键字主要适用于以下场景:

  1. 多线程共享变量:多个线程同时访问的变量
  2. 硬件寄存器访问:直接与硬件交互的内存映射寄存器
  3. 信号处理:在信号处理函数中修改的变量
  4. 中断处理:在中断处理函数中修改的变量

7. 注意事项

  1. volatile 不是线程安全的:它只是保证变量的可见性,不保证原子性
  2. volatile 不能替代锁:对于复合操作(如 i++),仍然需要使用锁或原子操作
  3. 过度使用 volatile:会降低程序性能,只在必要时使用

8. 代码优化建议

8.1 正确使用 volatile

// 正确:多线程共享的标志变量
volatile bool should_exit = false;

// 错误:复合操作仍需要锁
volatile int counter = 0;
// counter++ 不是原子操作,需要使用锁

8.2 结合其他同步机制

// 结合互斥锁使用
volatile int shared_data = 0;
pthread_mutex_t mutex;

void* thread_func(void* arg) {
  // 读取共享数据
  int value = shared_data;

  // 修改共享数据(需要加锁)
  pthread_mutex_lock(&mutex);
  shared_data = new_value;
  pthread_mutex_unlock(&mutex);

  return NULL;
}

9. 总结

volatile 关键字是 C/C++ 中用于确保变量可见性的重要工具,它告诉编译器不要对变量进行某些优化,确保每次使用变量时都从内存中读取最新值。

在多线程环境中,正确使用 volatile 可以避免因编译器优化导致的问题,但它不能替代锁或其他同步机制来保证原子性。

理解 volatile 的工作原理,特别是从汇编层面理解其对编译器优化的影响,有助于我们在编写并发程序时避免常见的陷阱。