【C++并发系列】第三章:volatile 能解决并发问题吗

📅 2026/6/18 21:00:08
【C++并发系列】第三章:volatile 能解决并发问题吗
博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站一段后台工作线程的代码Debug 模式下跑得好好的切到 Release 就停不下来了。排查了一圈有人跟你说加个volatile就行。你加上去果然好了。然而这种看起来“跑正常了”的现象在底层逻辑上是根本经不起推敲的。boolstopfalse;voidWorker(){while(!stop){DoOneRound();}}voidRequestStop(){stoptrue;}这段代码在 Debug 编译下表现正常是因为 Debug 模式通常不做激进的优化编译器老老实实地每次循环都从内存里读stop。一旦开了优化-O2或 Release 模式编译器发现Worker函数内部没有任何地方修改stop就把它当成了循环不变量——要么提到循环外面只读一次要么直接把while (!stop)优化成while (true)。主线程再怎么改stop工作线程也看不见了。这时候加一个volatilevolatileboolstopfalse;编译器看到volatile就不敢把stop的读取优化掉了每次循环都乖乖去内存里读一次。线程能停下来了。问题在于这种修法仅仅是触及了表面。它虽然强迫编译器保留了读取动作但完全没有在 C内存模型层面建立任何线程间的同步关系因为volatile在 C里根本就不是一个用于线程同步的工具。volatile 到底约束了谁在 C 的设计语义中volatile约束的对象其实只有一个那就是编译器。它的核心作用是告诉编译器这个变量的每一次读写都有外部可观察的意义你不能随便优化掉。具体来说当编译器遇到volatile声明后就不能做以下几件事不能把多次读取合并成一次哪怕你在循环里读一千次它也得生成一千条 load 指令。不能把多次写入合并成一次连续写两次不同的值两次都得保留。不能把volatile变量的读写跨过其他volatile访问进行重排。但注意范围——这些约束仅限于编译器层面。volatile管不了 CPU 硬件的行为。它不会插入任何内存屏障指令不会阻止 CPU 的写缓冲区延迟刷入也不会阻止 CPU 流水线对指令做乱序执行。更要命的是C标准压根没有给volatile读写定义任何线程同步的语义。如果两个线程在没有其他同步手段的情况下同时读写同一个volatile变量在 C 标准看来依然是数据竞争Data Race属于未定义行为Undefined Behavior。既然是未定义行为编译器怎么折腾这段代码都算合规——它甚至可以直接把相关逻辑优化掉标准也完全管不着。硬件寄存器才是 volatile 的主场既然volatile不管线程同步那它到底是干什么的它的设计初衷是服务于一类特殊场景变量背后的存储并不是普通内存而是硬件寄存器或者内存映射 I/OMMIO。#includecstdintvolatilestd::uint32_t*constkStatusRegisterreinterpret_castvolatilestd::uint32_t*(0x40000000);std::uint32_tReadStatus(){return*kStatusRegister;}这个地址0x40000000背后可能是一块网卡的状态寄存器。每次读它硬件可能返回不同的值比如当前有没有新数据包到达。读一次和读两次是完全不同的操作——第一次读可能会清掉硬件的中断标志第二次读才能拿到新状态。如果编译器把两次读合并成一次硬件协议就乱套了。在写入操作中也是完全相同的逻辑。当我们往一个控制寄存器连续写入0x01和0x02时这两个值可能分别代表了启动传输和设置模式两个截然不同的硬件命令。如果此时编译器自作聪明地认为反正最终值是0x02第一次写可以直接省掉那么硬件设备就会因为漏掉指令而出现严重故障。所以volatile的语义可以概括成一句话保留每一次读写动作保持 volatile 访问之间的相对顺序。 在嵌入式开发、驱动开发、信号处理这些场景下这个语义非常关键。但它跟多线程同步需要的东西完全是两码事。线程同步需要什么当两个线程需要围绕一个共享变量进行安全协作时通常需要满足三个核心条件而volatile却连一个都无法提供。第一原子性。 一个 64 位整数的读写在有些平台上并不是一条指令完成的。如果线程 A 正在写入高 32 位线程 B 同时读了整个值读到的可能是高 32 位是新的、低 32 位是旧的这种撕裂数据。volatile不保证读写的原子性。第二消除数据竞争。 C 标准规定两个线程在没有同步保护的情况下并发访问同一个非原子变量且至少一方是写操作就构成数据竞争——直接判定为未定义行为UB。volatile变量仍然是非原子变量并发读写它照样是 UB。编译器在遇到 UB 时可以做任何事情包括生成看起来完全不合理的代码。第三内存可见性和重排约束。 线程 A 在写标志位之前修改了一批普通数据线程 B 看到标志位改变后去读那批数据——这中间需要一条完整的同步链来保证数据可见。volatile既不会在标志位的写入处插入 release 屏障也不会在标志位的读取处插入 acquire 屏障。没有这条屏障链普通数据的修改有可能被 CPU 重排到标志位之后才对其他核心可见。把这三条摆出来就很清楚了volatile只解决了编译器别把这次读/写优化掉这一个问题。线程同步需要的原子性、UB 消除、跨变量的内存可见性它全都不管。volatile ready flag 的错误与修正理解了上面三条之后来看一个典型的错误用法——用volatile做数据发布volatileboolreadyfalse;intdata0;voidProducer(){data42;readytrue;}voidConsumer(){while(!ready){}Use(data);}这段代码想表达的意图很明确Producer 准备好data然后翻ready标志Consumer 等ready变成true然后读data。然而这段看似合理的代码实际上隐藏了两个非常严重的并发安全问题。第一个问题是数据竞争。ready虽然标了volatile但它仍然是一个普通的bool。Producer 写ready、Consumer 读ready同时发生没有同步保护——这在 C 标准中就是 UB。data也一样Producer 写data、Consumer 读data中间没有任何同步关系建立同样是 UB。第二个问题是内存重排。即使我们不考虑 UB比如在某些编译器和平台组合下碰巧跑对了volatile也没有在data 42和ready true之间建立任何屏障。CPU 完全有可能先执行ready true的写入写缓冲区先刷出去了后执行data 42的写入。Consumer 那边看到ready是true兴冲冲去读data读到的却是 0。正确的做法是用std::atomic#includeatomicstd::atomicboolready{false};intdata0;voidProducer(){data42;ready.store(true,std::memory_order_release);}voidConsumer(){while(!ready.load(std::memory_order_acquire)){}Use(data);}这个版本里ready是一个原子变量并发读写它不是 UB。更关键的是release和acquire在ready上建立了一条同步链Producer 的store(true, release)保证data 42不会被重排到 store 之后Consumer 的load(acquire)保证Use(data)不会被重排到 load 之前。Consumer 一旦看到ready true就能确定data已经是 42 了。这就是volatile和atomic的根本区别volatile只管编译器别优化掉读写动作atomic带着完整的同步语义——原子性、消除数据竞争、内存屏障一样不少。volatile 计数器为什么仍然丢更新另一个常见误用是多线程计数器volatileintcounter0;voidWorker(){for(inti0;i100000;i){counter;}}volatile确保编译器每次都生成真实的 load 和 store 指令不会把多次自增合并。但counter本身不是一条指令——它拆开来是三步从内存读counter到寄存器、寄存器加 1、把新值写回内存。虽然volatile保留了这三步的读写动作但它没办法把三步合成一个不可打断的操作。当两个线程同时执行counter时就完全可能出现如下的交叉执行顺序线程 A 读counter得到 0。线程 B 也读counter也得到 0。线程 A 算出 0 1 1写回 1。线程 B 也算出 0 1 1写回 1。两次自增结果只加了 1。跑两个线程各自增 100000 次最终结果远小于 200000。正确的做法是用std::atomic的读-改-写操作#includeatomicstd::atomicintcounter{0};voidWorker(){for(inti0;i100000;i){counter.fetch_add(1,std::memory_order_relaxed);}}fetch_add在硬件层面是一条原子指令x86 上是lock xadd读取、加 1、写回三步合在一起中间不会被其他核心插入。这里用relaxed内存序就够了因为我们只需要计数本身的原子性不需要用这个计数器去保护其他数据的可见性。停止标志的正确内存序选择回到最开始的停止标志场景。如果工作线程退出时不需要读取主线程在设置stop之前写入的其他数据relaxed就够了#includeatomicstd::atomicboolstop{false};voidWorker(){while(!stop.load(std::memory_order_relaxed)){DoOneRound();}}voidRequestStop(){stop.store(true,std::memory_order_relaxed);}relaxed保证了原子性和消除数据竞争但不提供跨变量的内存可见性保证。对于一个纯粹的退出通知标志这就足够了——工作线程只要最终能看到stop变成true就行不需要从stop的写入推导出其他变量的状态。但如果主线程在设置停止标志的同时还要传递业务数据呢比如主线程写好一条指令然后通知工作线程去执行#includeatomic#includestringstd::string command;std::atomicboolcommand_ready{false};voidPublishCommand(){commandreload;command_ready.store(true,std::memory_order_release);}voidWorker(){while(!command_ready.load(std::memory_order_acquire)){}Run(command);}这里command_ready不只是一个通知信号它还承担了发布command数据的职责。release保证command reload不会被重排到command_ready.store之后acquire保证Run(command)不会被重排到command_ready.load之前。这条 release-acquire 同步链确保了 Worker 在跳出循环后读到的command是完整的。长时间等待不要忙等不管是volatile的忙等还是atomic的忙等只要是空循环就意味着一个 CPU 核心在全速空转。短时间的忙等几微秒级别在低延迟场景下有时是合理的但普通业务逻辑中生产者可能需要几毫秒甚至几秒才能准备好数据。让一个核心空转这么久功耗和调度成本都不划算。正常的业务等待应该用std::condition_variable让线程挂起把 CPU 让出来#includecondition_variable#includemutexboolreadyfalse;intdata0;std::mutex mtx;std::condition_variable cv;voidProducer(){{std::lock_guardstd::mutexlock(mtx);data42;readytrue;}cv.notify_one();}voidConsumer(){std::unique_lockstd::mutexlock(mtx);cv.wait(lock,[]{returnready;});Use(data);}这个版本里ready和data都是普通变量。它们的线程安全性完全由mtx保证——lock_guard和unique_lock在获取和释放锁的边界上天然提供了完整的 acquire-release 语义。condition_variable在条件不满足时会让消费者线程挂起不占用 CPU。生产者notify_one之后消费者才会被操作系统唤醒。Java 的 volatile 和 C 的 volatile 不是一回事如果你写过 Java 或 C#可能对volatile有完全不同的印象。在 Java 里volatile字段确实是线程同步工具的一部分。JVM 在实现volatile读写时会自动插入内存屏障建立 happens-before 关系。Java 的volatile可以安全地用在双重检查锁定、状态标志等并发场景中。C的volatile完全不是这个意思。它的设计目标是硬件寄存器和信号处理跟线程同步没有任何关系。C标准里写得很清楚volatile读写不构成线程间的同步操作不建立 happens-before 关系。这两个关键字碰巧同名语义却天差地别。从 Java 转过来的开发者特别容易踩这个坑——在 Java 里养成的共享标志加 volatile的习惯搬到 C里就是在写未定义行为。C里对应 Javavolatile功能的东西是std::atomic不是volatile。代码审查中怎么处理 volatile在日常的代码审查Code Review中当我们看到volatile关键字时应当条件反射地提高警惕并多问几个问题外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传先确认它是不是在做线程同步。 如果一个volatile变量被多个线程读写用来做停止标志、就绪信号、共享计数器之类的事情那就是误用。改成std::atomic或者用std::mutex保护。再确认它是不是在合理场景下。volatile的合理用途很窄内存映射 I/O嵌入式、驱动开发、sig_atomic_t配合信号处理函数、某些平台特定的底层操作。如果代码不属于这些场景volatile大概率是误用或者历史遗留。警惕优化降级。 偶尔会有人把std::atomic改成volatile理由是atomic 太重了volatile 够用。这几乎总是错的。volatile不提供原子性不消除数据竞争不建立内存屏障。所谓够用只是在当前编译器、当前平台、当前负载下碰巧没出问题。换个编译器版本或者换个 CPU 架构bug 就来了。如果确实需要降低atomic的开销正确的做法是降低内存序比如从默认的seq_cst降到relaxed而不是把atomic整个扔掉。volatile在 C里有它的位置但那个位置在硬件边界上不在多线程同步里。把它从线程同步工具箱里拿走是理解 C并发模型的第一步。下一章进入std::atomic看看它到底在硬件层面提供了什么保证以及不同的原子操作各自解决什么问题。码字不易欢迎大家点赞关注评论谢谢