永远不要用volatile保证多线程安全

本文揭示了C/C++中volatile关键字的常见误解,指出它并非用于解决多线程竞争,而是用于标记受不可控因素影响的变量。正确处理多线程同步应使用原子操作或锁,如C++11的std::atomic。滥用volatile可能导致程序错误和性能隐患。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

永远不要用volatile保证多线程安全

C/C++多线程编程中不要使用volatile。(注:这里的意思指的是指望volatile解决多线程竞争问题是有很大风险的,除非所用的环境系统不可靠才会为了保险加上volatile,或者是从极限效率考虑来实现很底层的接口。这要求编写者对程序逻辑走向很清楚才行,不然就会出错)

C++11标准中明确指出解决多线程的数据竞争问题应该使用原子操作或者互斥锁。

C和C++中的volatile并不是用来解决多线程竞争问题的,而是用来修饰一些因为程序不可控因素导致变化的变量,比如访问底层硬件设备的变量,以提醒编译器不要对该变量的访问擅自进行优化。

多线程场景下可以参考《Programming with POSIX threads》的作者Dave Butenhof对Why don’t I need to declare shared variables VOLATILE?这个问题的解释:https://www.lambdacs.com/cpt/FAQ.html%23Q56

简单的来说,对访问共享数据的代码块加锁,已经足够保证数据访问的同步性,再加volatile完全是多此一举。

如果光对共享变量使用volatile修饰而在可能存在竞争的操作中不加锁或使用原子操作对解决多线程竞争没有任何卵用,因为volatile并不能保证操作的原子性,在读取、写入变量的过程中仍然可能被其他线程打断导致意外结果发生。

C++中的多线程与volatile是两个正交的问题。任何妄图用volatile解决任何多线程相关问题的行为,哪怕乍看上去好用的,在某个平台的某个编译器某个runtime上好用的,本质上都是在玩UB,俗称自寻死路。如果你觉得你的某个黑魔法优化很帅,很extreme performance,先想想你的应用有没有这么高的流量和性能需求让你牺牲correctness来追求extreme performance。

除了内存映射 IO 和信号处理函数中使用 volatile std::sig_atomic_t 定义变量以外的任何场合使用 volatile 都是错误的。

https://www.kernel.org/doc/Documentation/volatile-considered-harmful.txt

C 和 C++ 的 volatile 关键字为什么给编程者造成了如此大的误解?

C 和 C++ 的 volatile 关键字为什么给编程者造成了如此大的误解?直至今日,还有C和C++程序员把volatile当作解决多线程同步问题的方式,把volatile变量当作原子变量使用,甚至C++标准库都要特别支持这些给对象加volatile后使用的用法,比如std::atomic的volatile版本成员函数。回答者中依然有执迷不悟的。究竟是什么造成了这种误解?而且对人的影响如此之深?

只知道是不让编译器优化,怎么就扯到同步互斥了 ,身边也没有人这样用啊。volatile 是为了不让编译器对变量访问进行优化,读取变量直接去访问内存,而不是从cache中查找,我记得是这样的。

阻止编译器优化

谢邀。对这个关键字的理解我不想去讨论。。。因为他本身有很多语义是不可移植的。

不过 如果你看到谁的代码里插入有asm volatile ("" : : : "memory")这种的话, 多数情况可以用volatile去小小的优化下。

asm volatile ("" : : : "memory")使用了gcc内嵌汇编加上一个内存破坏性描述符,他表示

  1. 这条语句后面的语句不能被编译器优化到前面去,前面的不能被优化到后面去。这是所有内嵌汇编都具有的效果。
  2. 所在函数不得被inline,不管开多高的优化等级, 这也是所有内嵌汇编都具有的效果。但是这一点目前也渐渐被gcc新版本打脸了。
  3. 重点,这条语句之后,函数返回前的所有流程(也就是包括了循环语句 跳转到这条语句前面去的场景),对所有地址的访问(包括局部变量地址)都必须去内存里去load,而不能使用这条语句前寄存器缓存的结果(当然,某些寄存器的结果编译器还是认为还是有效的,例如堆栈寄存器,不然代码没法走下去了)。

就靠着以上3点,很多开源库里都写着把它当“编译期内存屏障“来用。

http://www.boost.org/doc/libs/1_55_0/boost/atomic/detail/gcc-x86.hpp

https://zh.wikipedia.org/wiki/%E5%86%85%E5%AD%98%E5%B1%8F%E9%9A%9C

但是实际上很多时候根本用不着那么大粒度的“缓存无效”指令。

有时候我们明确的知道当前函数可能需要2次访问一个int全局变量 但是这个全局变量的2次读之间

  1. 没有调用任何非inline函数。
  2. 没有任何内嵌汇编语句。
  3. 可能被另外一个线程改写这个变量的值。

那么很有可能编译器在第二次访问这个变量的时候,直接使用了第一次访问的值。这就可能造成3的变化没有体现出来,一些逻辑上的问题。

等等,平常我们多线程编程的时候,加一下锁来访问这个变量,怎么就没有这个问题?对不起,加锁就让你满足了1。 不信你可以瞅瞅 绝大多数系统api的加锁操作都是函数而不是纯宏(宏里不调函数)。因为c&c++的编译链接模型,决定了调用一个函数之后,编译器就会认为

  1. 所有非局部变量都需要重新从内存中取值,
  2. 被取过地址或者引用的局部变量往往也需要重新被求值。

我们如果,把这个全局变量定义成volatile int的,或者通过一个volatile int* 指针来访问这个全局变量, 都可以让编译器放弃上面提到的那种优化。优点是:

  1. asm volatile ("" : : : "memory") 相比,他只让一个变量放弃读优化,而不是所有的全局和局部变量。
  2. 和调用一个空函数相比,他只让一个变量放弃读优化,而不是所有的全局变量或被取过地址或者引用的局部变量。
  3. 依然具有阻止编译器越过volatile 变量读操作, 去做语句顺序重排的效果,屏障效果依然有。
  4. 当然,如果说,这个函数里可能会对这个变量做100次读操作,但是只有某2次读操作之间值才可能是变化的,而函数内的局部变量很少,可能这种情况下asm volatile ("" : : : “memory”) 相比反而就可能更高效些了。
  5. 优化一定要在保证整个程序正确性之后,并且对上面这些东西很了解的前提下去做,如果你听不懂我在说什么,或者你对我说的某些话感到迷惑,那么请不要理睬我上面的所有意见。该加锁就加锁。

volatile 惯性误解

原因很多,随便瞎猜几条。

  1. 没有接受过专门教育的人容易本能地觉得内存读写就该是顺序一致的。我见过不少人连 volatile 都懒得用,靠着程序规模大,编译器优化不过来,硬是把程序给跑对了。
  2. 单核情况下没有内存一致性问题,volatile 确实管用。不排除有一些旧书就这么写了,然后一直祸害到现在。
  3. x86 的内存读写乱序情况较少,尤其是最要命的 load-load 和 store-store 乱序基本是没有的。这就导致你跟别人讲道理,别人举着程序跟你说"明明可以用你凭什么说我错了"。
  4. Java 的 volatile 确实自带一些 barrier 语义,从 Java 转到 C++ 的人可能会习惯性认为 C++ 也是这样。

上面的前三点是一脉相承的。很有可能一个人开始什么都不用,直到某天发现不行了,然后看到某个文章说可以用 volatile,试了一下果然可以,就成了 volatile 的坚定拥护者。

P.S. 根据 MS 的文档,从 VS 2005 开始 volatile 具有 acquire load 和 release store 语义,该扩展在 x86 和 x64 架构上默认开启。这种情况下用 volatile 做线程同步是正确的,但是在有 std::memory_order 和 MemoryBarrier() 的情况下,我认为利用这种编译器扩展除了给自己挖坑以外没什么好处。

x86架构的cpu的执行只有SL乱序貌似,且保证不会引入其他乱序,以前看了一张表。是只有SL乱序。

volatile 嵌入式

最初接触volatile这个关键词是做嵌入式编程的时候,有些变量可能在主函数里没有赋值但是在中断里进行了赋值,加volatile修饰可以防止编译器对其进行优化,大概如下:

volatile int count = 0;
int main(){
	while(count < 1000) // do something	
	return 0;
}
interrupt timer(){
	count++;
}

如果不加volatile很可能while会变成死循环,加volatile修饰后编译器就不会对该变量的读写进行过度的优化,比如将两条连续赋值语句优化成只有后面一条(考虑到某个变量可能对应外设的输出)。改用C++后就再也没用过volatile。

volatile就是字面意思,用于告诉编译器这个变量需要读内存。来历是应该是端口映射地址:一个地址写入和读取是无关操作

volatile in C

volatile在c中早已存在,它存在的意义是向编译器表明该变量保存的值可能会被外部改变,所以请不要缓存或者优化该变量的上下文,做嵌入式写驱动应该会经常用到这个关键字,比如一个按钮在嵌入式程序中对应的就是一个变量,如果程序优化掉了该变量,那这个按钮也就失去存在的意义了,这是c的原初关键字之一

这个东西基本是为了操作外设寄存器而存在的,和线程安全没毛线关系

多核CPU volatile

volatile在单核机器上确实有这个功能的,可能以前的cpu是单核的吧。volatile的意思是要重新从内存中读取这个变量,单核时,如果有显示指令重新从内存load,那么数据一定是对的。但是在多核时,这个数据可能就是错的,就算显式的有指令从内存load,也可能cpu0(随便取的一个cpu)只是从cache中load这个数据,如果不久前cpu1恰好修改了这个数据,那么现在cpu0拿到这个数据就是错的,而且就算cpu0确实是到内存中去拿这个数据,也可能出错,因为cpu1修改的数据可能还没写回内存。真多核机上,多线程环境下,要原子的操作某线程间共享内存,要在指令前加lock前缀的,具体原理我也不是很清楚,好像是锁总线,然后在cpu间广播这个修改(不对请指正)。所以原子操作对cache的破坏是非常大的,尽量使用线程本地变量,锁争用时会造成大量cachemiss应该是这个原因,锁争用时,多个线程同时cas一个共享变量,多次造成cpu间的数据广播/cache miss。最后c/cpp中的原子操作,在linux上gcc提供了 __sync*的一系列函数

x86

volatile在x86和x64上确实可以起到内存屏障的作用,因为这两个平台的cpu都是强内存模型,volatile同时可以组织编译器做指令重排。另外一些基本类型例如整型和指针在这两个平台上的操作本身就是原子化的难道我没说清楚?因为x86本身是强内存模型,所以本身就可以保证acqire和release语意,acquire对应loadstore和storestore内存屏障,release对应loadload和loadstore内存屏障,加上volatile可以阻止编译器优化,所以volatile在x86上确实可以正确实现内存屏障啊懒得解释了,祭出preshing大神文章:

  • Acquire and Release Semantics https://link.zhihu.com/?target=http%3A//preshing.com/20120913/acquire-and-release-semantics/
  • 不管放出多少篇文章都没人看的样子:https://gcc.gnu.org/ml/gcc/2003-04/msg01180.html

register

与volatile修饰符对应的修饰符是register。

一个函数在执行时会使用多个局部变量,包括形参。这些变量使用频度差异较大,编译器会按照使用频度来规划,将常用变量放到闲置寄存器里,使得运算速度得到很大的提高。不常用的那些就放内存里,每次用到就去内存里拿。一个值存放在寄存器和内存里,访问速度的差别可达数百倍甚至上千倍。可见将变量放在寄存器里的意义还是很大的。

但对于可能被抢占的任务,一个变量放在寄存器里就成了问题。因为被抢占后,同一个变量在内存里和寄存器里的值可能会不一样。我这里用了抢占这个词,包括抢占式多任务操作系统里的多进程或多线程调度,也包括嵌入式开发里的中断,当然其实底层都是中断。对非抢占式多任务则不存在这个问题,比如协程。

volatile的含义就是明确告诉编译器,这个变量在每次访问时,都走内存,而不要用寄存器来缓存。这样在抢占式多任务里,就能确保每次拿到最新的值。而register的意思则相反,告诉编译器这个变量尽可能放到寄存器里,以提高速度。当然如果寄存器不够用,那就还是放内存里。

实际使用中,如果一个变量仅在函数内使用,或作为值传递的形参,那么适合使用register修饰符。而如果是全局变量,且可能被多人任务读写,则应该明确的使用volatile。比如中断中访问的全局变量,就应该用volatile。

操作系统课程没仔细看的人,可能难以理解为何寄存器与内存里的值会不同。如下说一下中断发生时,CPU的动作。

中断发生时,CPU会立即把当前所有寄存器的值存入任务的自己的内存区域里。空出所有寄存器,交给中断去做事。中断处理函数返回后,CPU再把需要唤醒的任务的内存区里寄存器部分内容一一载入到寄存器里,并跳转到上次中断的地址,传入PC指针。这样任务就可以继续执行了。这里的关键就是中断结束后恢复到寄存器的值是从任务私有内存区载入的,而不是从原始变量载入的。所以中断期间对变量的修改就无法立即反应到寄存器里。

抢占式多任务的具体实现也是利用了中断。在中断处理函数中协调任务优先级和调用,原理同上。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值