不要再误解C++ volatile了

李肖遥 2021-08-31 08:30

关注、星标公众号,直达精彩内容

来源:https://liam.page/2018/01/18/volatile-in-C-and-Cpp/

作者:Liam Huang

最近在讨论多线程编程中的一个可能的 false sharing 问题时,有人提出加 volatile 可能可以解决问题。这种错误的认识荼毒多年,促使我写下这篇文章。


约定


Volatile 这个话题,涉及到计算机科学多个领域多个层次的诸多细节。仅靠一篇博客,很难穷尽这些细节。因此,若不对讨论范围做一些约定,很容易就有诸多漏洞。到时误人子弟,就不好了。以下是一些基本的约定:


1. 这篇博文讨论的 volatile 关键字,是 C 和 C++ 语言中的关键字。Java 等语言中,也有 volatile 关键字。但它们和 C/C++ 里的 volatile 不完全相同,不在这篇博文的讨论范围内。


2. 这篇博文讨论的 volatile 关键字,是限定在 C/C++ 标准之下的。这也就是说,我们讨论的内容应该是与平台无关的,同时也是与编译器扩展无关的。


3. 相应的,这篇文章讨论的「标准」指的是 C/C++ 的标准,而不是其他什么东西。


4. 我们希望编写的代码是 (1) 符合标准的,(2) 性能良好的,(3) 可移植的。这里 (1) 保证了代码执行结果的正确性,(2) 保证了高效性,(3) 体现了平台无关性(以及编译器扩展等的无关性)。


含义


单词 volatile 的含义


在谈及 C/C++ 中的 volatile 关键字时,总有人会拿 volatile 这个英文单词的中文解释说事。他们把 volatile 翻译作「易变的」。但事实上,对于翻译来说,很多时候目标语言很难找到一个词能够反映源语言中单词的全部含义和细节。此处「易变的」就无法做到这一点。


Volatile 的意思,若要详细理解,还是应该查阅权威的英英字典。在柯林斯高阶学习词典中,volatile 是这样解释的:

A situation that is volatile is likely to change suddenly and unexpectedly.

这里对 volatile 的解释有三个精髓的形容词和副词,体现了 volatile 的含义。


1. likely:可能的。这意味着被 volatile 形容的对象「有可能也有可能不」发生改变,因此我们不能对这样的对象的状态做出任何假设。


2. suddenly:突然地。这意味着被 volatile 形容的对象可能发生瞬时改变。


3. unexpectedly:不可预期地。这与 likely 相互呼应,意味着被 volatile 形容的对象可能以各种不可预期的方式和时间发生更改。


因此,volatile 其实就是告诉我们,被它修饰的对象出现任何情况都不要奇怪,我们不能对它们做任何假设。


程序中 volatile 的含义


对于程序员来说,程序本身的任何行为都必须是可预期的。那么,在程序当中,什么才叫 volatile 呢?这个问题的答案也很简单:程序可能受到程序之外的因素影响。


考虑以下 C/C++ 代码。

volatile int *p = /* ... */;int a, b;a = *p;b = *p;

若忽略 volatile,那么 p 就只是一个「指向 int 类型的指针」。这样一来,a = *p; 和 b = *p; 两句,就只需要从内存中读取一次就够了。因为从内存中读取一次之后,CPU 的寄存器中就已经有了这个值;把这个值直接复用就可以了。这样一来,编译器就会做优化,把两次访存的操作优化成一次。这样做是基于一个假设:我们在代码里没有改变 p 指向内存地址的值,那么这个值就一定不会发生改变。

此处说的「读取内存」,包括了读取 CPU 缓存和读取计算机主存。

然而,由于 MMIP(Memory mapped I/O)的存在,这个假设不一定是真的。例如说,假设 p 指向的内存是一个硬件设备。这样一来,从 p 指向的内存读取数据可能伴随着可观测的副作用:硬件状态的修改。此时,代码的原意可能是将硬件设备返回的连续两个 int 分别保存在 a 和 b 当中。这种情况下,编译器的优化就会导致程序行为不符合预期了。


总结来说,被 volatile 修饰的变量,在对其进行读写操作时,会引发一些可观测的副作用。而这些可观测的副作用,是由程序之外的因素决定的。


关键字 volatile 的含义


CPP reference 网站是对 C 和 C++ 语言标准的整理。因此,绝大多数时候,我们可以通过这个网站对语言标准进行查询。关于 volatile 关键字,有 C 语言标准和 C++ 语言标准可查。这里摘录两份标准对 volatile 访问的描述。

C 语言:Every access (both read and write) made through an lvalue expression of volatile-qualified type is considered an observable side effect for the purpose of optimization and is evaluated strictly according to the rules of the abstract machine (that is, all writes are completed at some time before the next sequence point). This means that within a single thread of execution, a volatile access cannot be optimized out or reordered relative to another visible side effect that is separated by a sequence point from the volatile access.
C++ 语言:Every access (read or write operation, member function call, etc.) made through a glvalue expression of volatile-qualified type is treated as a visible side-effect for the purposes of optimization (that is, within a single thread of execution, volatile accesses cannot be optimized out or reordered with another visible side effect that is sequenced-before or sequenced-after the volatile access. This makes volatile objects suitable for communication with a signal handler, but not with another thread of execution, see std::memory_order). Any attempt to refer to a volatile object through a non-volatile glvalue (e.g. through a reference or pointer to non-volatile type) results in undefined behavior.

这里首先解释两组概念:值类型和序列点(执行序列)。


值类型指的是左值(lvalue)右值(rvalue)这些概念。关于左值和右值,前作有过介绍。简单的理解,左值可以出现在赋值等号的左边,使用时取的是作为对象的身份;右值不可以出现在赋值等号的左边,使用时取的是对象的值。除了 lvalue 和 rvalue,C++ 还定义了其他的值类型。其中,xvalue 大体可以理解为返回右值引用的函数调用或表达式,而 glvalue 则是 lvalue 和 xvalue 之和。


序列点则是 C/C++ 中讨论执行顺序时会提到的概念。对于 C/C++ 的表达式来说,执行表达式有两种类型的动作:(1) 计算某个值、(2) 副作用(例如访问 volatile 对象,原子同步,修改文件等)。因此,如果在两个表达式 E1 和 E2 中间有一个序列点,或者在 C++ 中 E1 于序列中在 E2 之前,则 E1 的求值动作和副作用都会在 E2 的求值动作和副作用之前。关于序列点和序列顺序规则,可以参考:这里和这里。


因此我们讲,在 C/C++ 中,对 volatile 对象的访问,有编译器优化上的副作用:


1. 不允许被优化消失(optimized out);


2. 于序列上在另一个对 volatile 对象的访问之前。


这里提及的「不允许被优化」表示对 volatile 变量的访问,编译器不能做任何假设和推理,都必须按部就班地与「内存」进行交互。因此,上述例中「复用寄存器中的值」就是不允许的。


需要注意的是,无论是 C 还是 C++ 的标准,对于 volatile 访问的序列性,都有单线程执行的前提。其中 C++ 标准特别提及,这个顺序性在多线程环境里不一定成立。


volatile 与多线程


volatile 可以解决多线程中的某些问题,这一错误认识荼毒多年。例如,在知乎「volatile」话题下的介绍就是「多线程开发中保持可见性的关键字」。为了拨乱反正,这里先给出结论(注意这些结论都基于本文第一节提出的约定之上):


1. volatile 不能解决多线程中的问题。


2. 按照 Hans Boehm & Nick Maclaren 的总结,volatile 只在三种场合下是合适的。


2.1 和信号处理(signal handler)相关的场合;


2.2 和内存映射硬件(memory mapped hardware)相关的场合;


2.3 和非本地跳转(setjmp 和 longjmp)相关的场合。


以下我们尝试来用 volatile 关键字解决多线程同步的一个基本问题:happens-before。


首先我们考虑这样一段(伪)代码。

// global shared databool flag = false;
thread1() { flag = false; Type* value = new Type(/* parameters */); thread2(value); while (true) { if (flag == true) { apply(value); break; } } thread2.join(); if (nullptr != value) { delete value; } return;}
thread2(Type* value) { // do some evaluations value->update(/* parameters */); flag = true; return;}

这段代码将 thread1 作为主线程,等待 thread2 准备好 value。因此,thread2 在更新 value 之后将 flag 置为真,而 thread1 死循环地检测 flag。简单来说,这段代码的意图希望实现 thread2 在 thread1 使用 value 之前执行完毕这样的语义。


对多线程编程稍有了解的人应该知道,这段代码是有问题的。问题主要出在两个方面。其一,在 thread1 中,flag = false 赋值之后,在 while 死循环里,没有任何机会修改 flag 的值,因此在运行之前,编译器优化可能会将 if (flag == true) 的内容全部优化掉。其二,在 thread2 中,尽管逻辑上 update 需要发生在 flag = true 之前,但编译器和 CPU 并不知道;因此编译器优化和 CPU 乱序执行可能会使 flag = true 发生在 update 完成之前,因此 thread1 执行 apply(value) 时可能 value 还未准备好。


加一个 volatile 试试?


在错误的理解中,此时就到了 volatile 登场的时候了。


首先我们考虑这样一段(伪)代码。

// global shared datavolatile bool flag = false;  // 1.
thread1() { flag = false; Type* value = new Type(/* parameters */); thread2(value); while (true) { if (flag == true) { // 2. apply(value); break; } } thread2.join(); if (nullptr != value) { delete value; } return;}
thread2(Type* value) { // do some evaluations value->update(/* parameters */); flag = true; return;}

这里,在 (1) 处,我们将 flag 声明为 volatile-qualified。因此,在 (2) 处,由于 flag == true 是对 volatile 变量的访问,故而 if-block 不会被优化消失。然而,尽管 flag 是 volatile-qualified,但 value 并不是。因此,编译器仍有可能在优化时将 thread2 中的 update 和对 flag 的赋值交换顺序。此外,由于 volatile 禁止了编译器对 flag 的优化,这样使用 volatile 不仅无法达成目的,反而会导致性能下降。


再加一个 volatile 呢?


在错误的理解中,可能会对 value 也加以 volatile 关键字修饰;颇有些「没有什么是一个 volatile 解决不了的;如果不行,那就两个」的意思。

// global shared datavolatile bool flag = false;
thread1() { flag = false; volatile Type* value = new Type(/* parameters */); // 1. thread2(value); while (true) { if (flag == true) { apply(value); break; } } thread2.join(); if (nullptr != value) { delete value; } return;}
thread2(volatile Type* value) { // do some evaluations value->update(/* parameters */); // 2. flag = true; return;}

在上一节代码的基础上,(1) 将 value 声明为 volatile-qualified。因此 (2) 处对两个 volatile-qualified 变量进行访问时,编译器不会交换他们的顺序。看起来就万事大吉了。


然而,volatile 只作用在编译器上,但我们的代码最终是要运行在 CPU 上的。尽管编译器不会将 (2) 处换序,但 CPU 的乱序执行(out-of-order execution)已是几十年的老技术了;在 CPU 执行时,value 和 flag 的赋值仍有可能是被换序了的(store-store)。

也许有人会说,x86 和 AMD64 架构的 CPU(大多数个人机器和服务器使用这两种架构的 CPU)只允许 sotre-load 乱序,而不会发生 store-store 乱序;或者在诸如 IA64 架构的处理器上,对 volatile-qualified 变量的访问采用了专门的指令。因而,在这些条件下,这段代码是安全的。尽管如此,使用 volatile 会禁止编译器优化相关变量,从而降低性能,所以也不建议依赖 volatile 在这种情况下做线程同步。另一方面,这严重依赖具体的硬件规范,超出了本文的约定讨论范围。


到底应该怎样做?


回顾一下,我们最初遇到的问题其实需要解决两件事情。一是 flag 相关的代码块不能被轻易优化消失,二是要保证线程同步的 happens-before 语义。但本质上,设计使用 flag 本身也就是为了构建 happens-before 语义。这也就是说,两个问题,后者才是核心;如有其他不用 flag 的办法解决问题,那么 flag 就不重要。


对于当前问题,最简单的办法是使用原子操作。

// global shared datastd::atomic<bool> flag = false;  // #include <atomic>
thread1() { flag = false; Type* value = new Type(/* parameters */); thread2(value); while (true) { if (flag == true) { apply(value); break; } } thread2.join(); if (nullptr != value) { delete value; } return;}
thread2(Type* value) { // do some evaluations value->update(/* parameters */); flag = true; return;}

由于对 std::atomic<bool> 的操作是原子的,同时构建了良好的内存屏障,因此整个代码的行为在标准下是良定义的。


除此之外,还可以结合使用互斥量和条件变量。

// global shared datastd::mutex m;                   // #include <mutex>std::condition_variable cv;     // #include <condition_variable>bool flag = false;
thread1() { flag = false; Type* value = new Type(/* parameters */); thread2(value); std::unique_lock<std::mutex> lk(m); cv.wait(lk, [](){ return flag; }); apply(value); lk.unlock(); thread2.join(); if (nullptr != value) { delete value; } return;}
thread2(Type* value) { std::lock_guard<std::mutex> lk(m); // do some evaluations value->update(/* parameters */); flag = true; cv.notify_one(); return;}

这样一来,由线程之间的同步由互斥量和条件变量来保证,同时也避免了 while (true) 死循环空耗 CPU 的情况。

来源整理于网络素材,版权归原作者所有,如有侵权,请联系删除,谢谢。

‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧  END  ‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧

关注我的微信公众号,回复“加群”按规则加入技术交流群。

欢迎关注我的视频号:


点击“阅读原文”查看更多分享,欢迎点分享、收藏、点赞、在看。

李肖遥 公众号“技术让梦想更伟大”,作者:李肖遥,专注嵌入式,只推荐适合你的博文,干货,技术心得,与君共勉。
评论
  • 1月7日-10日,2025年国际消费电子产品展览会(CES 2025)盛大举行,广和通发布Fibocom AI Stack,赋智千行百业端侧应用。Fibocom AI Stack提供集高性能模组、AI工具链、高性能推理引擎、海量模型、支持与服务一体化的端侧AI解决方案,帮助智能设备快速实现AI能力商用。为适应不同端侧场景的应用,AI Stack具备海量端侧AI模型及行业端侧模型,基于不同等级算力的芯片平台或模组,Fibocom AI Stack可将TensorFlow、PyTorch、ONNX、
    物吾悟小通 2025-01-08 18:17 53浏览
  • 「他明明跟我同梯进来,为什么就是升得比我快?」许多人都有这样的疑问:明明就战绩也不比隔壁同事差,升迁之路却比别人苦。其实,之间的差异就在于「领导力」。並非必须当管理者才需要「领导力」,而是散发领导力特质的人,才更容易被晓明。许多领导力和特质,都可以通过努力和学习获得,因此就算不是天生的领导者,也能成为一个具备领导魅力的人,进而被老板看见,向你伸出升迁的橘子枝。领导力是什么?领导力是一种能力或特质,甚至可以说是一种「影响力」。好的领导者通常具备影响和鼓励他人的能力,并导引他们朝着共同的目标和愿景前
    优思学院 2025-01-08 14:54 93浏览
  • 村田是目前全球量产硅电容的领先企业,其在2016年收购了法国IPDiA头部硅电容器公司,并于2023年6月宣布投资约100亿日元将硅电容产能提升两倍。以下内容主要来自村田官网信息整理,村田高密度硅电容器采用半导体MOS工艺开发,并使用3D结构来大幅增加电极表面,因此在给定的占位面积内增加了静电容量。村田的硅技术以嵌入非结晶基板的单片结构为基础(单层MIM和多层MIM—MIM是指金属 / 绝缘体/ 金属) 村田硅电容采用先进3D拓扑结构在100um内,使开发的有效静电容量面积相当于80个
    知白 2025-01-07 15:02 155浏览
  • 职场是人生的重要战场,既是谋生之地,也是实现个人价值的平台。然而,有些思维方式却会悄无声息地拖住你的后腿,让你原地踏步甚至退步。今天,我们就来聊聊职场中最忌讳的五种思维方式,看看自己有没有中招。1. 固步自封的思维在职场中,最可怕的事情莫过于自满于现状,拒绝学习和改变。世界在不断变化,行业的趋势、技术的革新都在要求我们与时俱进。如果你总觉得自己的方法最优,或者害怕尝试新事物,那就很容易被淘汰。与其等待机会找上门,不如主动出击,保持学习和探索的心态。加入优思学院,可以帮助你快速提升自己,与行业前沿
    优思学院 2025-01-09 15:48 47浏览
  • 故障现象一辆2017款东风风神AX7车,搭载DFMA14T发动机,累计行驶里程约为13.7万km。该车冷起动后怠速运转正常,热机后怠速运转不稳,组合仪表上的发动机转速表指针上下轻微抖动。 故障诊断 用故障检测仪检测,发动机控制单元中无故障代码存储;读取发动机数据流,发现进气歧管绝对压力波动明显,有时能达到69 kPa,明显偏高,推断可能的原因有:进气系统漏气;进气歧管绝对压力传感器信号失真;发动机机械故障。首先从节气门处打烟雾,没有发现进气管周围有漏气的地方;接着拔下进气管上的两个真空
    虹科Pico汽车示波器 2025-01-08 16:51 107浏览
  • 一个真正的质量工程师(QE)必须将一件产品设计的“意图”与系统的可制造性、可服务性以及资源在现实中实现设计和产品的能力结合起来。所以,可以说,这确实是一种工程学科。我们常开玩笑说,质量工程师是工程领域里的「侦探」、「警察」或「律师」,守护神是"墨菲”,信奉的哲学就是「墨菲定律」。(注:墨菲定律是一种启发性原则,常被表述为:任何可能出错的事情最终都会出错。)做质量工程师的,有时会不受欢迎,也会被忽视,甚至可能遭遇主动或被动的阻碍,而一旦出了问题,责任往往就落在质量工程师的头上。虽然质量工程师并不负
    优思学院 2025-01-09 11:48 82浏览
  • 光伏逆变器是一种高效的能量转换设备,它能够将光伏太阳能板(PV)产生的不稳定的直流电压转换成与市电频率同步的交流电。这种转换后的电能不仅可以回馈至商用输电网络,还能供独立电网系统使用。光伏逆变器在商业光伏储能电站和家庭独立储能系统等应用领域中得到了广泛的应用。光耦合器,以其高速信号传输、出色的共模抑制比以及单向信号传输和光电隔离的特性,在光伏逆变器中扮演着至关重要的角色。它确保了系统的安全隔离、干扰的有效隔离以及通信信号的精准传输。光耦合器的使用不仅提高了系统的稳定性和安全性,而且由于其低功耗的
    晶台光耦 2025-01-09 09:58 43浏览
  • 在智能网联汽车中,各种通信技术如2G/3G/4G/5G、GNSS(全球导航卫星系统)、V2X(车联网通信)等在行业内被广泛使用。这些技术让汽车能够实现紧急呼叫、在线娱乐、导航等多种功能。EMC测试就是为了确保在复杂电磁环境下,汽车的通信系统仍然可以正常工作,保护驾乘者的安全。参考《QCT-基于LTE-V2X直连通信的车载信息交互系统技术要求及试验方法-1》标准10.5电磁兼容试验方法,下面将会从整车功能层面为大家解读V2X整车电磁兼容试验的过程。测试过程揭秘1. 设备准备为了进行电磁兼容试验,技
    北汇信息 2025-01-09 11:24 67浏览
  • 根据环洋市场咨询(Global Info Research)项目团队最新调研,预计2030年全球中空长航时无人机产值达到9009百万美元,2024-2030年期间年复合增长率CAGR为8.0%。 环洋市场咨询机构出版了的【全球中空长航时无人机行业总体规模、主要厂商及IPO上市调研报告,2025-2031】研究全球中空长航时无人机总体规模,包括产量、产值、消费量、主要生产地区、主要生产商及市场份额,同时分析中空长航时无人机市场主要驱动因素、阻碍因素、市场机遇、挑战、新产品发布等。报告从中空长航时
    GIRtina 2025-01-09 10:35 60浏览
  • 本文介绍编译Android13 ROOT权限固件的方法,触觉智能RK3562开发板演示,搭载4核A53处理器,主频高达2.0GHz;内置独立1Tops算力NPU,可应用于物联网网关、平板电脑、智能家居、教育电子、工业显示与控制等行业。关闭selinux修改此文件("+"号为修改内容)device/rockchip/common/BoardConfig.mkBOARD_BOOT_HEADER_VERSION ?= 2BOARD_MKBOOTIMG_ARGS :=BOARD_PREBUILT_DTB
    Industio_触觉智能 2025-01-08 00:06 111浏览
  • By Toradex 秦海1). 简介嵌入式平台设备基于Yocto Linux 在开发后期量产前期,为了安全以及提高启动速度等考虑,希望将 ARM 处理器平台的 Debug Console 输出关闭,本文就基于 NXP i.MX8MP ARM 处理器平台来演示相关流程。 本文所示例的平台来自于 Toradex Verdin i.MX8MP 嵌入式平台。  2. 准备a). Verdin i.MX8MP ARM核心版配合Dahlia载板并
    hai.qin_651820742 2025-01-07 14:52 118浏览
  • HDMI 2.2 规格将至,开启视听新境界2025年1月6日,HDMI Forum, Inc. 宣布即将发布HDMI规范2.2版本。新HDMI规范为规模庞大的 HDMI 生态系统带来更多选择,为创建、分发和体验理想的终端用户效果提供更先进的解决方案。新技术为电视、电影和游戏工作室等内容制作商在当前和未来提供更高质量的选择,同时实现多种分发平台。96Gbps的更高带宽和新一代 HDMI 固定比率速率传输(Fixed Rate Link)技术为各种设备应用提供更优质的音频和视频。终端用户显示器能以最
    百佳泰测试实验室 2025-01-09 17:33 55浏览
  • 在过去十年中,自动驾驶和高级驾驶辅助系统(AD/ADAS)软件与硬件的快速发展对多传感器数据采集的设计需求提出了更高的要求。然而,目前仍缺乏能够高质量集成多传感器数据采集的解决方案。康谋ADTF正是应运而生,它提供了一个广受认可和广泛引用的软件框架,包含模块化的标准化应用程序和工具,旨在为ADAS功能的开发提供一站式体验。一、ADTF的关键之处!无论是奥迪、大众、宝马还是梅赛德斯-奔驰:他们都依赖我们不断发展的ADTF来开发智能驾驶辅助解决方案,直至实现自动驾驶的目标。从新功能的最初构思到批量生
    康谋 2025-01-09 10:04 55浏览
  •  在全球能源结构加速向清洁、可再生方向转型的今天,风力发电作为一种绿色能源,已成为各国新能源发展的重要组成部分。然而,风力发电系统在复杂的环境中长时间运行,对系统的安全性、稳定性和抗干扰能力提出了极高要求。光耦(光电耦合器)作为一种电气隔离与信号传输器件,凭借其优秀的隔离保护性能和信号传输能力,已成为风力发电系统中不可或缺的关键组件。 风力发电系统对隔离与控制的需求风力发电系统中,包括发电机、变流器、变压器和控制系统等多个部分,通常工作在高压、大功率的环境中。光耦在这里扮演了
    晶台光耦 2025-01-08 16:03 84浏览
  • 在当前人工智能(AI)与物联网(IoT)的快速发展趋势下,各行各业的数字转型与自动化进程正以惊人的速度持续进行。如今企业在设计与营运技术系统时所面临的挑战不仅是技术本身,更包含硬件设施、第三方软件及配件等复杂的外部因素。然而这些系统往往讲究更精密的设计与高稳定性,哪怕是任何一个小小的问题,都可能对整体业务运作造成严重影响。 POS应用环境与客户需求以本次分享的客户个案为例,该客户是一家全球领先的信息技术服务与数字解决方案提供商,遭遇到一个由他们所开发的POS机(Point of Sal
    百佳泰测试实验室 2025-01-09 17:35 54浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦