跨线程同步用 volatile?你可能误解了 C++ 标准

102 阅读6分钟

一、引言

在写多线程程序时,很多人第一反应是:能不能用 volatile 作为线程间的标志位?
例如我们常常会看到这样的代码:

volatile bool stop = false;

void worker() {
    while (!stop) {
        // 执行任务
    }
}
int main() {
    std::thread t(worker);
    // ...
    stop = true;   // 期望通知线程退出
    t.join();
}

这段代码看似合理,很多人以为 volatile 就能保证跨线程的可见性。但事实是:这在 C++ 中并不安全,标准甚至定义为未定义行为(data race)。那么,volatile 究竟能做什么?为什么不能用于线程同步?本文逐条剖析。

二、volatile 的本质语义

在 C++ 标准中,volatile 是一种 cv 限定符(cv-qualifier),和 const 同属一类。它的语义主要体现在:

1. 对 volatile 对象的访问属于可观察行为(observable behavior)

  • 访问 volatile glvalue 是程序对外的可观察行为之一。
  • 意味着编译器不得优化掉对 volatile 对象的读写,也不能合并、多余消除。

2. 硬件交互

初衷是保证内存映射寄存器或信号处理器标志位等特殊对象的访问不能被编译器省略。
假设有一个硬件寄存器,读它会返回一个不断变化的计数值(比如定时器寄存器):

#define TIMER_REG (*(volatile unsigned int*)0x10000000)

void wait_for_tick() {
    unsigned int start = TIMER_REG;   // 读取当前计数值
    while (TIMER_REG == start) {      // 等待计数器变化
        // 忙等
    }
}

为什么要 volatile?

  • TIMER_REG 的值会随着硬件计数器不断增加。
  • 如果没有 volatile,编译器可能把 TIMER_REG 缓存在寄存器里,认为它不会变。结果 while 循环永远等不到退出,硬件的变化被“优化”掉了。

👉 这个例子清晰展示了:每次访问必须真实去读硬件,否则逻辑就错了。

三、volatile 能解决的问题

1. 保证每次读写都真实发生

volatile int* p = (int*)0x12340000; // 假设是设备寄存器
*p = 1;  // 一定会生成一次真实的写
int v = *p; // 一定会生成一次真实的读

普通变量在优化下可能被缓存到寄存器,不会每次都访问内存;而 volatile 确保读写动作都发生。

2. 保持语句级顺序

volatile int a, b;
a = 1;   // 必须先发生
b = 2;   // 之后发生

这两个访问有 sequenced-before 关系,编译器不得跨语句重排。C++标准描述如下: volatile sequenced before

但要注意:如果把多个 volatile 访问放在同一个表达式里,且没有顺序规定,那标准就不保证谁先谁后。如:

int c = a++ + b++;

3. 信号处理函数中的标志位

程序示例:

#include <csignal>
#include <iostream>
#include <unistd.h>

volatile sig_atomic_t stop = 0;

void handler(int) { stop = 1; }

int main() {
    std::signal(SIGINT, handler);
    std::cout << "Running... Press Ctrl+C to stop.\n";
    while (!stop) {
        usleep(100000); 
    }
    std::cout << "Stopped.\n";
    return 0;
}

在以上示例程序中,变量stop是全局的,且在信息处理函数中进行赋值,在main函数中进行读取,若此变量不是volatile或原子变量类型,则从C++标准而言,此程序将是未定义行为(但可能在大多数平台上侥幸能正常运行)。 signal handler 大家在写信号处理函数时一定要遵守C++标准,不能完全以测试结果为正确的依据,以免跨平台、升级编译器等操作后出现问题。

四、volatile 误用的场景

初学者可能会误以为volatile能在多线程环境下安全的使用,这是错误的理解,在多线程环境中使用volatile会存在以下问题:

1. 没有原子性

C++标准中确切的提到,对 volatile变量的读写并不保证是原子操作。 volatile不保证原子性

如果两个线程同时写同一个 volatile int,仍然可能产生撕裂写(tear)或数据竞争(data race)。C++标准中规定data race是未定义行为。 data race

2. 没有可见性保证

即使一个线程写了 volatile,另一个线程也可能在缓存/寄存器中看不到更新。C++ 内存模型要求:只有 std::atomic 的原子操作才能保证可见性。

3. 没有顺序性(happens-before)

假设volatile 写之前有写操作,另一线程在看到 volatile 更新时,不保证一定也能看到前面的写。比如:

volatile bool ready = false;
int data = 0;

void producer() {
    data = 42;
    ready = true;  // volatile 写
}

void consumer() {
    while (!ready) {}
    std::cout << data;  // 可能打印 0!
}

标准认为这是 数据竞争(data race),结果未定义。consumer线程输出可能不是42。

五、正确的跨线程方式

如果想要实现线程间通信,应该使用 std::atomic。具体细节不是本文重点,不在此处展开,后续会写文章进行讲解。

六、总结

在 C++ 中,volatile 的核心作用是告诉编译器每次访问都必须实际读取或写入内存,防止访问被优化掉或缓存,从而保证抽象机器语义下的可观察行为。除了标准允许的 signal handler 异步访问标志位 这一特殊场景外,volatile 并不提供多线程同步、原子性或内存可见性,因此不适合用于普通跨线程共享变量。

简单来说,volatile 是为单线程中防止编译器优化而设计的工具,它在 signal handler 中的应用是标准认可的例外,而在现代多线程编程中,更安全可靠的做法是使用 std::atomic 或锁机制来保证共享数据的原子性和可见性。理解这一点,能帮助开发者避免误用 volatile 导致的并发陷阱,同时正确地利用它在硬件寄存器或信号处理中的作用。

🎯 技术交流与答疑

我是十多年工作经验的一线研发工程师,日常使用 C/C++ 处理高性能、底层、并发等难题。 现在我创建了一个 C/C++ 技术交流群,为大家提供免费技术答疑和经验分享,群友之间也能互帮互助、共同进步。

⚠ 群成员名额有限,招满即止! 📌 加入方式 1️⃣ 关注我的微信公众号:Hankin-Liu的技术研究室 2️⃣ 发送关键词: “C++群”,加入技术交流群,可免费解答C/C++相关技术问题。 关注我的微信公众号

一起学习成长,技术路上不孤单,互帮互助走得更快!