什么时候使用易失性与多线程?

如果有两个线程访问一个全局变量,那么许多教程说,使变量易变,以防止编译器将变量缓存在一个寄存器中,因此它没有得到正确的更新。 然而,两个线程都访问一个共享变量是通过互斥锁调用保护的东西,不是吗? 但是在这种情况下,在线程锁定和释放互斥锁之间,代码处于一个关键部分,只有一个线程可以访问变量,在这种情况下,变量不需要是可变的?

那么,在一个多线程程序中,易失性的用途是什么呢?

64914 次浏览

(编者按: 在 C + + 11 volatile是不是正确的工具,这项工作仍然有数据竞赛 UB。使用带有 std::memory_order_relaxed加载/存储的 std::atomic<bool>,可以在不使用 UB 的情况下完成此操作。在真正的实现中,它将编译成与 volatile相同的高度。我添加了更多的细节 一个答案,并且还解决了注释中的错误概念,即弱顺序内存可能是这个用例的一个问题: 所有现实世界的 CPU 都有一致的共享内存,所以 volatile将在真正的 C + + 实现中使用 为了这个。但还是不行。

注释中的一些讨论似乎在谈论其他用例,在这些用例中,需要比轻松的原子更强大的东西。这个答案已经指出,volatile不会给你排序。)


由于以下原因,易失性偶尔是有用的: 这段代码:

/* global */ bool flag = false;


while (!flag) {}

由海湾合作委员会优化,以:

if (!flag) { while (true) {} }

如果标志是由另一个线程写入的,那么这显然是不正确的。请注意,如果没有这种优化,同步机制可能会正常工作(取决于其他代码,可能需要一些内存屏障)——在1生产者-1消费者场景中不需要互斥锁。

否则,volatile 关键字就太奇怪了,无法使用——它不提供任何内存排序,保证 wrt 的易失性访问和非易失性访问,也不提供任何原子操作——也就是说,除了禁用寄存器缓存之外,编译器没有提供任何关于 volatile 关键字的帮助。

简短快速的回答 : volatile对于平台无关的多线程应用程序编程来说(几乎)是无用的。它不提供任何同步,不创建内存隔离,也不确保操作的执行顺序。它不会使操作成为原子操作。它不会神奇地使您的代码成为线程安全的。volatile可能是所有 C + + 中最容易被误解的工具。有关 volatile的更多信息,请参见 这个这个这个

另一方面,volatile确实有一些不那么明显的用途。它的使用方式与使用 const帮助编译器向您显示在以非保护方式访问某些共享资源时可能出错的地方非常相似。这种用法由 Alexandrescu 在 这篇文章中讨论。然而,这基本上是在使用 C + + 类型系统,通常被视为一种发明,可以唤起未定义行为。

volatile专门用于与内存映射硬件、信号处理程序和 setjmp 机器代码指令接口时。这使得 volatile直接适用于系统级编程,而不是普通的应用程序级编程。

2003年的 C + + 标准并没有说 volatile对变量应用任何类型的获取或发布语义。事实上,标准对多线程的所有问题都保持沉默。但是,特定的平台确实对 volatile变量应用了获取和发布语义。

[ C + + 11更新]

现在的 C + + 11标准 是的在内存模型和语言中直接承认多线程,并且它提供了库设施来以平台无关的方式处理多线程。然而,volatile的语义仍然没有改变。volatile仍然不是同步机制。比雅尼·斯特劳斯特鲁普在《 TCPppl4E:

除非在直接处理的低级代码中,否则不要使用 volatile 用硬件。

不要假设 volatile在内存模型中有特殊意义 不是。它不是——如在一些后来的语言中—— a 要获得同步,请使用 atomic,a mutex或者 condition_variable

[/更新完毕]

以上这些都适用于 C + + 语言本身,正如2003年的标准(现在是2011年的标准)所定义的那样。然而,一些特定的平台确实给 volatile增加了额外的功能或限制。例如,在 MSVC 2010中(至少)获取和发布语义 适用于对 volatile变量的某些操作。返回文章页面

优化时,编译器必须维护引用之间的顺序 以及对其他全局对象的引用 特别是,

对易失性对象的写操作(易失性写操作)具有发布语义; 对全局对象或静态对象的引用,该对象在写入 指令序列中的可变对象将在此之前出现 编译后的二进制文件中的可变写入。

易失性对象的读取(易失性读取)具有获取语义; 读取之后发生的全局对象或静态对象的引用 指令序列中的易失性存储器将在那之后发生 已编译二进制文件中的可变读数。

但是,您可能会注意到这样一个事实: 如果您点击上面的链接,那么在评论中会有一些关于是否在这种情况下应用获取/发布语义 事实上的争论。

你需要不稳定的和可能的锁定。

Volatile 告诉优化器值可以异步更改,因此

volatile bool flag = false;


while (!flag) {
/*do something*/
}

将在每次循环中读取标志。

如果您关闭优化或使每个变量易变,程序的行为将相同,但更慢。它的意思是“我知道你可能刚刚读过它,知道它说了什么,但是如果我说读,那就读吧。”。

锁定是程序的一部分。所以,顺便说一下,如果您要实现信号量,那么除了其他事情之外,它们必须是易变的。(不要尝试,这很难,可能需要一个小的汇编程序或新的原子的东西,它已经完成了。)

#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std;


bool checkValue = false;


int main()
{
std::thread writer([&](){
sleep(2);
checkValue = true;
std::cout << "Value of checkValue set to " << checkValue << std::endl;
});


std::thread reader([&](){
while(!checkValue);
});


writer.join();
reader.join();
}

有一次,一个采访者也认为易失性是无用的,他和我争论说,优化不会引起任何问题,而且是指不同的内核有不同的缓存线路,诸如此类(他真的不明白他到底指的是什么)。但是这段代码在 g + + (g + +-O3 thread.cpp-lpthread)上用 -O3编译时,显示了未定义的行为。基本上,如果在 while 检查之前设置了这个值,那么它就可以正常工作,如果不能,那么它就会进入一个循环,而不用费心去获取这个值(实际上这个值已经被另一个线程修改过了)。基本上,我相信 checkValue 的值只被提取到寄存器中一次,并且在最高级别的优化下永远不会被再次检查。如果在提取之前它被设置为 true,那么它就可以正常工作,如果不能正常工作,那么它就进入一个循环。如果我错了,请纠正我。

在 C + + 11中,不要使用 volatile进行线程处理,只用于 MMIO

但是 TL: DR,它的“工作”有点像原子 mo_relaxed在具有相干缓存(即所有内容)的硬件上的工作; 它足以停止编译器在寄存器中保存 vars。atomic不需要内存屏障来创建原子性或线程间可见性,只需要让当前线程在一个操作之前/之后等待,以便在这个线程访问不同变量之间创建顺序。mo_relaxed从来不需要任何障碍,只需要加载,存储,或 RMW。

用于使用 volatile滚动自己的原子(以及用于屏障的内联式原子) 在 C + + 11之前的糟糕的旧时代,volatile是让一些东西工作的唯一好方法。但是,它依赖于许多关于实现如何工作的假设,而且从未得到任何标准的保证。

例如,Linux 内核在 volatile中仍然使用自己的手工原子,但是只支持少数特定的 C 实现(GNU C、 clang,也许还有 ICC)。部分原因是由于 GNU C 扩展和内联提示语法和语义,但也因为它取决于一些关于编译器如何工作的假设。

对于新项目来说,这几乎总是错误的选择; 您可以使用 std::atomic(使用 std::memory_order_relaxed)使编译器发出与使用 volatile相同的高效机器代码。带有 ABC4的 ABC0废弃了用于穿线的 volatile(可能不包括 在某些编译器上使用 atomic<double>解决缺少优化的错误)

std::atomic在主流编译器上的内部实现(如 gcc 和 clang)只在内部使用 volatile; 编译器直接公开原子加载、存储和 RMW 内置函数。(例如,GNU C __atomic内建操作“普通”对象。)


Volative 在实践中是可用的(但不要这样做)

也就是说,volatile在实践中可用于诸如 all (?)上的 exit_now标志之类的事情实际 CPU 上现有的 C + + 实现,因为 CPU 是如何工作的(一致性缓存) ,以及关于 volatile应该如何工作的共享假设。除此之外就没什么了,这是 没有推荐的。这个答案的目的是解释现有的 CPU 和 C + + 实现实际上是如何工作的。如果您不关心这一点,那么您只需要知道,使用 mo _ rest 的 ABC3在线程方面取代了 volatile

(ISO C + + 标准对此模糊不清,只是说 volatile访问应该严格按照 C + + 抽象机的规则进行评估,而不是优化掉。假设真正的实现使用机器的内存地址空间来建模 C + + 地址空间,这意味着 volatile读取和分配必须编译为加载/存储指令来访问内存中的对象表示。)


正如另一个答案所指出的,exit_now标志是线程间通信的一个简单例子,它不需要任何同步 : 它不会发布数组内容是否已经准备好或类似的内容。只是一个被另一个线程中未经优化的远程加载及时发现的存储。

    // global
bool exit_now = false;


// in one thread
while (!exit_now) { do_stuff; }


// in another thread, or signal handler in this thread
exit_now = true;

在进入(或不进入)一个无限循环之前,没有可变的或原子的 似是而非的规则和没有数据竞赛的假设 UB 允许编译器优化到只检查一次标志的高度。这正是真正的编译器在现实生活中会发生的情况。(并且通常会优化掉大部分 do_stuff,因为循环永远不会退出,所以如果我们进入循环,任何以后可能使用结果的代码都是不可到达的)。

 // Optimizing compilers transform the loop into asm like this
if (!exit_now) {        // check once before entering loop
while(1) do_stuff;  // infinite loop
}

多线程程序停留在优化模式,但正常运行在-O0 就是一个例子(描述了 GCC 的 asm 输出) ,说明在 x86-64上 GCC 是如何发生这种情况的。SE 上的 单片机编程-C + + O2优化在循环时中断也显示了另一个例子。

我们通常 想要积极的优化,CSE 和提升负载出循环,包括全局变量。

在 C + + 11之前,volatile bool exit_now是按照预期(在正常的 C + + 实现中)实现此功能的一种方式。但是在 C + + 11中,数据竞赛 UB 仍然适用于 volatile,所以它实际上并不是按照 ISO 标准在任何地方工作的 保证,即使假设 HW 相干缓存。

注意,对于更宽的类型,volatile不能保证不会撕裂。我在这里忽略了 bool的区别,因为它在正常实现中是不存在问题的。但这也是为什么 volatile仍然受制于数据竞赛 UB,而不是相当于放松的原子。

请注意,“按预期”并不意味着执行 exit_now的线程等待另一个线程实际退出。甚至在继续后面的线程操作之前,它甚至会等待易失性 exit_now=true存储在全局可见。(使用默认 mo_seq_cstatomic<bool>至少会让它等待以后的 seq _ cst 加载。在许多情况下,你只是得到一个完整的障碍后,商店)。

C + + 11提供了一种非 UB 的编译方法

“继续运行”或“立即退出”标志应该使用 std::atomic<bool> flagmo_relaxed

吸毒

  • flag.store(true, std::memory_order_relaxed)
  • while( !flag.load(std::memory_order_relaxed) ) { ... }

将给你完全相同的高潮(没有昂贵的障碍指示) ,你会得到从 volatile flag

除了不破坏外,atomic还提供了在一个线程中存储和在另一个线程中加载而不用 UB 的能力,因此编译器不能将负载提升到循环之外。(没有数据竞争的 UB 假设允许我们对非原子的非易失性对象进行积极的优化。)atomic<T>的这个特性与 volatile对纯负载和纯存储所做的几乎相同。

atomic<T>还使 +=等变成原子 RMW 操作(明显比原子加载成一个临时的、操作的、然后单独存储的原子贵得多)。如果你不想要一个原子 RMW,编写你的代码与本地临时)。

使用从 while(!flag)获得的默认 seq_cst排序,它还添加了排序保证 wrt 非原子访问和其他原子访问。

(理论上,ISO C + + 标准并不排除原子的编译时优化。但是在实践中,编译器 不要是因为没有办法控制什么时候不好。在有些情况下,如果编译器确实进行了优化,那么即使是 volatile atomic<T>也可能不足以控制原子的优化,所以目前编译器不能。请参阅 为什么编译器不合并冗余的标准: : 原子写?注意 wg21/p0062建议不要在当前代码中使用 volatile atomic来防止对原子进行优化。)


volatile实际上可以在真正的 CPU 上实现这一点(但仍然不使用它)

即使使用弱序内存模型(非 x86) 。但是不要真正使用它,而是使用 atomic<T>mo_relaxed! !本节的重点是解决有关实际 CPU 如何工作的误解,而不是为 volatile辩护。如果您正在编写无锁代码,那么您可能会关心性能。理解缓存和线程间通信的成本对于良好的性能通常是非常重要的。

真正的 CPU 具有一致的缓存/共享内存: 在一个内核的存储变得全局可见之后,没有其他内核可以使用过期的 装弹值。 (另请参阅 程序员相信 CPU 缓存,其中谈到了一些 Java 挥发量,相当于具有 seq _ cst 内存顺序的 C + + atomic<T>。)

我说的 装弹是指访问内存的 asm 指令。这就是 volatile访问所确保的,而且 没有与非原子/非易失性 C + + 变量的 lvalue-to-rvalue 转换是相同的。(例如 local_tmp = flagwhile(!flag))。

您唯一需要克服的是在第一次检查之后根本不重新加载的编译时优化。每次迭代的任何加载 + 检查都是足够的,不需要任何排序。如果这个线程和主线程之间没有同步,那么讨论存储到底是什么时候发生的,或者讨论循环中其他操作的加载顺序都是没有意义的。只有 当它对这条线可见的时候才是最重要的。当您看到 exit _ now 标志设置时,您退出。典型的 x86至强上的内核间延迟可以是 在不同的物理核心之间大概有40ns


理论上: C + + 线程在硬件上没有连贯的缓存

我看不出有任何办法可以远程高效地使用纯 ISO C + + ,而不需要程序员在源代码中进行显式的刷新。

理论上,你可以在一台不是这样的机器上使用 C + + 实现,需要编译器生成的显式刷新来使其他核心上的线程可见。(或者读取时不要使用可能过时的副本)。C + + 标准并没有使这一点变得不可能,但是 C + + 的内存模型是围绕在一致的共享内存机器上提高效率而设计的。例如,C + + 标准甚至谈到了“读-读一致性”、“写-读一致性”等等。标准中的一个注释甚至指出了与硬件的连接:

Http://eel.is/c++draft/intro.races#19

注意: 前面的四个一致性要求有效地禁止编译器将原子操作重新排序到单个对象,即使这两个操作都是放松负载。这有效地使大多数硬件提供的缓存一致性保证可用于 C + + 原子操作。ー尾注]

对于 release存储来说,没有一种机制可以只刷新它自己和一些选择的地址范围: 它必须同步所有内容,因为如果它们的获取加载看到了这个发布存储,它就不知道其他线程可能想要读取什么(形成一个发布序列,在线程之间建立一个发生之前的关系,保证写线程执行的早期非原子操作现在可以安全地读取。除非它在发布存储之后再写到它们... ...)或者编译器必须是 真的智能的,以证明只有少数缓存线路需要刷新。

相关内容: 我在 在 NUMA 上 mov + mfence 安全吗?上的回答详细讨论了没有连贯共享内存的 x86系统是不存在的。也相关: 在 ARM 上加载和存储重新订购,了解更多关于加载/存储到 一样位置的信息。

中,我认为集群具有非相干的共享内存,但它们不是单系统映像机器。每个一致性域都运行一个单独的内核,因此不能通过它运行单个 C + + 程序的线程。相反,您可以运行程序的单独实例(每个实例都有自己的地址空间: 一个实例中的指针在另一个实例中无效)。

为了让它们通过显式刷新彼此通信,通常需要使用 MPI 或其他消息传递 API 来使程序指定哪些地址范围需要刷新。


真正的硬件不会跨缓存一致性边界运行 std::thread:

一些非对称的 ARM 芯片存在,共享物理地址空间,但 没有内部共享缓存域。太不连贯了。(例如 评论帖子和 A8核心以及类似 TI Sitara AM335x 的 Cortex-M3)。

但是不同的内核会在这些内核上运行,没有一个系统映像可以在两个内核之间运行线程。我不知道有任何 C + + 实现在没有一致缓存的情况下跨 CPU 核运行 std::thread线程。

对于 ARM 而言,假设所有线程都在同一个内部共享域中运行,GCC 和 clang 会生成代码。事实上,ARMv7 ISA 手册说

这个体系结构(ARMv7)的编写预期是,所有使用相同操作系统或管理程序的处理器都处于相同的内部共享共享域中

因此,不同域之间的非一致性共享内存只是显式的系统特定使用的共享内存区域,用于在不同内核下的不同进程之间进行通信。

另请参阅 这个 CoreCLR关于在编译器中使用 dmb ish(内部共享屏障)与 dmb sy(系统)内存屏障的代码生成的讨论。

我断言,没有任何其他 ISA 的 C + + 实现在具有非一致性缓存的核之间运行 std::thread。我没有证据证明不存在这样的实现,但似乎不太可能。除非您的目标是某个特定的异国情况的 HW,否则您对性能的考虑应该假设所有线程之间具有类似 MESI 的缓存一致性。(尽管如此,最好以保证正确性的方式使用 atomic<T>!)


相干缓存使其变得简单

但是 在具有连贯缓存的多核系统上实现发布存储仅仅意味着为这个线程的存储命令提交到缓存中,而不执行任何显式的刷新。(https://preshing.com/20120913/acquire-and-release-semantics/https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/)。(获取加载意味着对另一个核心中的缓存进行排序访问)。

内存屏障指令只是阻塞当前线程的加载和/或存储,直到存储缓冲区消耗殆尽; 这种情况总是以尽可能快的速度自行发生。 (或者对于 LoadLoad/LoadStore 屏障,阻塞直到以前的负载完成。)(内存屏障是否确保缓存一致性已经完成?解决了这个误解)。因此,如果不需要排序,只需要在其他线程中提示可见性,那么使用 mo_relaxed就可以了。(volatile也是,但不要这样做。)

另见: rel = “ noReferrer”> C/C + + 11到处理器的映射

有趣的事实: 在 x86上,每个 asm 存储都是一个发布存储,因为 x86内存模型基本上是 seq-cst 加上一个存储缓冲区(带存储转发)。


半相关的 re: 存储缓冲区、全局可见性和一致性: C + + 11保证的很少。大多数真正的 ISA (PowerPC 除外)确保所有线程可以按照另外两个线程的两个存储区的出现顺序达成一致。(在正式的计算机体系结构内存模型术语中,它们是“多拷贝原子”)。

另一个误解是,需要内存隔离指令来刷新存储缓冲区,以便其他核查看我们的存储 完全没有。实际上,存储缓冲区总是试图尽可能快地清空自身(提交到 L1d 缓存) ,否则它将填满并延迟执行。完整的屏障/围栏所做的是 暂停当前线程,直到存储缓冲区被耗尽,因此我们以后的加载在我们以前的存储之后以全局顺序出现。

(x86的强有序内存模型意味着 x86上的 volatile最终可能使您更接近于 mo_acq_rel,除了非原子变量的编译时重新排序仍然可能发生。但是大多数非 x86的内存模型都是弱序的,所以 volatilerelaxedmo_relaxed允许的范围内是最弱的。)