Dav*_*ton 121 c++ concurrency multithreading atomic volatile
如果有两个线程访问全局变量,那么许多教程都说使变量volatile变为阻止编译器将变量缓存在寄存器中,从而无法正确更新.但是,访问共享变量的两个线程是通过互斥锁来调用保护的东西不是吗?但是在这种情况下,在线程锁定和释放互斥锁之间,代码处于一个关键部分,只有那个线程可以访问变量,在这种情况下变量不需要是volatile?
那么多线程程序中volatile的用途/目的是什么?
Joh*_*ing 160
简短快速回答: volatile对于平台无关的多线程应用程序编程(几乎)无用.它不提供任何同步,它不会创建内存栅栏,也不会确保操作的执行顺序.它不会使操作成为原子.它不会使您的代码神奇地保证线程安全. volatile可能是所有C++中最容易被误解的设施.有关详细信息,请参阅此,此和此内容volatile
另一方面,volatile确实有一些使用可能不那么明显.它的使用方式可以const用来帮助编译器显示您在以非受保护的方式访问某些共享资源时可能出错的位置.Alexandrescu在本文中讨论了这种用法.但是,这基本上是以一种经常被视为设计的方式使用C++类型系统,并且可以引起未定义的行为.
volatile专门用于与内存映射硬件,信号处理程序和setjmp机器代码指令连接时使用.这volatile直接适用于系统级编程,而不是普通的应用程序级编程.
2003 C++标准并没有说volatile在变量上应用任何类型的Acquire或Release语义.实际上,标准对多线程的所有问题都完全保持沉默.但是,特定平台会对volatile变量应用Acquire和Release语义.
C++ 11标准现在直接在内存模型和语言中承认多线程,它提供了以独立于平台的方式处理它的库设施.但是语义volatile仍然没有改变. volatile仍然不是同步机制.Bjarne Stroustrup在TCPPPL4E中说得很多:
volatile除了直接处理硬件的低级代码外,请勿使用.不要假设
volatile在内存模型中有特殊含义.它不是.它不像在某些后来的语言中那样是一种同步机制.要获得同步,请使用atomic,amutex或acondition_variable.
以上都适用于C++语言本身,如2003标准(现在的2011标准)所定义.但是,某些特定平台会增加额外的功能或限制volatile.例如,在MSVC 2010中(至少)Acquire和Release语义确实适用于volatile变量的某些操作. 来自MSDN:
优化时,编译器必须维护对volatile对象的引用以及对其他全局对象的引用之间的顺序.特别是,
对volatile对象的写入(volatile write)具有Release语义; 对在指令序列中写入易失性对象之前发生的全局或静态对象的引用将在编译二进制文件中的易失性写入之前发生.
读取volatile对象(volatile read)具有Acquire语义; 在读取编译二进制文件中的易失性读取之后,将在读取指令序列中的易失性存储器之后发生对全局或静态对象的引用.
但是,您可能会注意到,如果您遵循上述链接,则在评论中存在一些争论,即在这种情况下获取/释放语义是否实际适用.
zeu*_*xcg 30
由于以下原因,易失性偶尔会有用:此代码:
/* global */ bool flag = false;
while (!flag) {}
Run Code Online (Sandbox Code Playgroud)
由gcc优化为:
if (!flag) { while (true) {} }
Run Code Online (Sandbox Code Playgroud)
如果该标志由另一个线程写入,那么这显然是不正确的.请注意,如果没有此优化,同步机制可能会起作用(取决于其他代码可能需要一些内存障碍) - 1个生产者 - 1个消费者场景中不需要互斥锁.
否则,volatile关键字太奇怪了,无法使用 - 它不提供任何内存排序保证兼具易失性和非易失性访问,也不提供任何原子操作 - 即除了禁用的寄存器缓存外,你不会从编译器中获得volatile关键字的帮助.
Pet*_*des 30
volatile用于线程,仅用于 MMIO但是 TL:DR,它mo_relaxed在具有一致缓存(即一切)的硬件上确实像原子一样“工作” ;阻止编译器将变量保存在寄存器中就足够了。 atomic不需要内存屏障来创建原子性或线程间可见性,只需让当前线程在操作之前/之后等待以在该线程对不同变量的访问之间创建排序。 mo_relaxed不需要任何障碍,只需加载、存储或 RMW。
对于在 C++11 之前的糟糕日子中使用volatile(以及用于障碍的内联汇编)滚动你自己的原子,是使某些事情工作的唯一好方法。但这取决于很多关于实现如何工作的假设,并且从未得到任何标准的保证。std::atomicvolatile
例如,Linux 内核仍然使用它自己的手动原子和volatile,但只支持少数特定的 C 实现(GNU C、clang,也许还有 ICC)。部分原因是因为 GNU C 扩展和内联 asm 语法和语义,但也因为它取决于关于编译器如何工作的一些假设。
对于新项目来说,这几乎总是错误的选择;您可以使用std::atomic(with std::memory_order_relaxed) 使编译器发出与volatile. std::atomic与mo_relaxed过时volatile的线程目的。(除了可能在某些编译器上解决错过的优化错误atomic<double>。)
std::atomic主流编译器(如 gcc 和 clang)的内部实现不仅仅在volatile内部使用;编译器直接公开原子加载、存储和 RMW 内置函数。(例如,在“普通”对象上运行的GNU C__atomic内置函数。)
也就是说,由于 CPU 的工作方式(连贯缓存)和关于应该如何工作的共享假设,因此volatile在实践中可用于诸如exit_now真实 CPU 上所有(?)现有 C++ 实现上的标志之类的事情volatile。但其他的不多,也不推荐。 此答案的目的是解释现有 CPU 和 C++ 实现的实际工作方式。如果您不关心这一点,您只需要知道std::atomicmo_relaxed 已过时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;
Run Code Online (Sandbox Code Playgroud)
在没有 volatile 或 atomic的情况下,无数据竞争 UB 的 as-if 规则和假设允许编译器将其优化为仅检查一次标志的 asm,然后再进入(或不)无限循环。这正是真实编译器在现实生活中发生的事情。(并且通常优化掉大部分,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
}
Run Code Online (Sandbox Code Playgroud)
多线程程序卡在优化模式但在 -O0 中正常运行是一个例子(带有 GCC 的 asm 输出的描述),这是在 x86-64 上 GCC 是如何发生的。还有MCU 编程 - C++ O2 优化在循环时中断在electronics.SE 上的时显示了另一个示例。
我们通常希望CSE 和提升从循环中加载的积极优化,包括全局变量。
在 C++11 之前,volatile bool exit_now是使这项工作按预期工作的一种方法(在普通的 C++ 实现上)。但是在 C++11 中,数据竞争 UB 仍然适用,volatile因此实际上并不能保证ISO 标准可以在任何地方工作,即使假设硬件一致缓存也是如此。
请注意,对于较宽的类型,volatile不能保证不会撕裂。我在这里忽略了这种区别,bool因为它在正常实现中不是问题。但这也是部分原因volatile仍然受制于数据竞争 UB 而不是等同于放松原子的部分原因。
请注意,“按预期”并不意味着线程正在exit_now等待另一个线程实际退出。或者甚至exit_now=true在继续此线程中的后续操作之前,它甚至等待易失性存储甚至全局可见。(atomic<bool>默认情况下mo_seq_cst,它至少会在任何以后的 seq_cst 加载之前等待。在许多 ISA 上,您只会在存储后获得一个完整的障碍)。
“继续运行”或“立即退出”标志应std::atomic<bool> flag与mo_relaxed
使用
flag.store(true, std::memory_order_relaxed)while( !flag.load(std::memory_order_relaxed) ) { ... }将为您提供与您获得的完全相同的 asm(没有昂贵的障碍说明) volatile flag.
除了无撕裂之外,atomic还使您能够在没有 UB 的情况下在一个线程中存储并在另一个线程中加载,因此编译器无法将负载提升到循环之外。(没有数据竞争 UB 的假设允许我们对非原子非易失性对象进行积极的优化。)这个特性与纯加载和纯存储的特性atomic<T>几乎相同volatile。
atomic<T>也将 make+=等转化为原子 RMW 操作(比原子加载到临时、操作和单独的原子存储中要昂贵得多。如果您不想要原子 RMW,请使用本地临时代码编写代码)。
使用seq_cst您从 获得的默认排序while(!flag),它还添加了排序保证 wrt。非原子访问,以及其他原子访问。
(理论上,ISO C ++标准不排除原子能公司的编译时优化,但实际上编译器不会因为没有办法控制的情况下,不会好的。有一些情况下甚至volatile atomic<T>可能不如果编译器确实优化,则对原子的优化有足够的控制,所以现在编译器没有。请参阅为什么编译器不合并冗余 std::atomic 写入? 请注意,wg21/p0062 建议不要volatile atomic在当前代码中使用以防止优化原子。)
volatile 在真正的 CPU 上确实适用于这个(但仍然不使用它)即使使用弱排序的内存模型(非 x86)。但实际上不要使用它,而是使用atomic<T>with mo_relaxed!本节的重点是解决对真实 CPU 工作方式的误解,而不是证明volatile. 如果您正在编写无锁代码,您可能会关心性能。了解缓存和线程间通信的成本对于良好的性能通常很重要。
真正的 CPU 具有一致的缓存/共享内存:在一个内核的存储变得全局可见后,其他内核无法加载过时的值。 (另请参阅Myths Programmers Being about CPU Caches,其中讨论了一些 Java volatiles,相当于 C++atomic<T> Being具有 seq_cst 内存顺序的。)
当我说load 时,我的意思是一条访问内存的 asm 指令。这就是一个volatile访问确保,并且是不一样的东西左值到右值非原子/非易失性C ++变量的转换。(例如local_tmp = flag或while(!flag))。
您唯一需要克服的是编译时优化,在第一次检查后根本不会重新加载。每次迭代的任何负载+检查就足够了,无需任何排序。如果此线程和主线程之间没有同步,那么谈论存储发生的确切时间或负载的顺序是没有意义的。循环中的其他操作。只有当它对该线程可见时才重要。当您看到 exit_now 标志设置时,您退出。典型的 x86 Xeon 上的内核间延迟在不同的物理内核之间可能大约为40ns。
我看不出有任何方法可以远程高效,仅使用纯 ISO C++ 而无需程序员在源代码中进行显式刷新。
理论上,您可以在与此不同的机器上实现 C++ 实现,需要编译器生成的显式刷新以使其他内核上的其他线程可见。(或者读取不使用可能过时的副本)。C++ 标准并没有让这成为不可能,但是 C++ 的内存模型是围绕在一致的共享内存机器上高效而设计的。例如,C++ 标准甚至谈到了“读-读一致性”、“写-读一致性”等。标准中的一个注释甚至指出了与硬件的连接:
http://eel.is/c++draft/intro.races#19
[注意:前面的四个一致性要求有效地禁止编译器将原子操作重新排序为单个对象,即使这两个操作都是宽松加载。这有效地使大多数硬件提供的缓存一致性保证可用于 C++ 原子操作。— 尾注 ]
没有机制让release存储只刷新自身和一些选择的地址范围:它必须同步所有内容,因为如果他们的获取加载看到这个释放存储(形成一个release-sequence 在线程之间建立了一个happens-before 关系,保证早期由写线程完成的非原子操作现在可以安全读取。除非它在发布存储之后对它们进行了进一步的写入......)否则编译器会是真的聪明的证明,只有少数的高速缓存行需要冲洗。
相关:我的回答是 mov + mfence 在 NUMA上是否安全?详细介绍了没有一致共享内存的 x86 系统是不存在的。还相关:加载和存储在 ARM 上重新排序以获取更多关于加载/存储到相同的信息位置的更多信息。
这里是我想用非相干共享存储集群,但他们不是单一系统映像机器。每个一致性域都运行一个单独的内核,因此您不能在其上运行单个 C++ 程序的线程。相反,您运行程序的单独实例(每个实例都有自己的地址空间:一个实例中的指针在另一个实例中无效)。
为了让它们通过显式刷新相互通信,您通常会使用 MPI 或其他消息传递 API 使程序指定哪些地址范围需要刷新。
std::thread跨越缓存一致性边界运行:存在一些非对称 ARM 芯片,它们具有共享的物理地址空间,但没有内部可共享的缓存域。所以不连贯。(例如,注释线程A8 内核和 Cortex-M3,如 TI Sitara AM335x)。
但是不同的内核会在这些内核上运行,而不是可以在两个内核上运行线程的单个系统映像。我不知道有任何 C++ 实现std::thread在没有一致缓存的情况下跨 CPU 内核运行线程。
特别是对于 ARM,GCC 和 clang 生成代码假设所有线程都在同一个内部可共享域中运行。事实上,ARMv7 ISA手册说
此架构 (ARMv7) 的编写预期所有使用相同操作系统或管理程序的处理器都在同一个内部共享共享域中
因此,不同域之间的非连贯共享内存只是为了在不同内核下的不同进程之间进行通信的共享内存区域的显式系统特定使用。
另请参阅有关在该编译器中使用(内部共享屏障)与(系统)内存屏障的代码生成的CoreCLR讨论。dmb ishdmb sy
我断言其他任何其他 ISA 的 C++ 实现都不会std::thread跨具有非一致性缓存的内核运行。 我没有证据表明不存在这样的实现,但这似乎不太可能。除非您针对以这种方式工作的特定奇特硬件,否则您对性能的考虑应该假设所有线程之间具有类似 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,但不要那样做。)
另请参见C/C++11 到处理器的映射
有趣的事实:在 x86 上,每个 asm 存储都是一个释放存储,因为 x86 内存模型基本上是 seq-cst 加上存储缓冲区(带有存储转发)。
半相关的 re:存储缓冲区、全局可见性和一致性:C++11 保证很少。大多数真正的 ISA(PowerPC 除外)确实保证所有线程都可以就另外两个线程出现两个存储的顺序达成一致。(在正式的计算机架构内存模型术语中,它们是“多副本原子”)。
另一个误解是需要的存储栅栏汇编指令刷新存储缓冲区其他处理器看到我们的店在所有。实际上,存储缓冲区总是试图尽可能快地耗尽自己(提交到 L1d 缓存),否则它会填满并停止执行。一个完整的屏障/栅栏的作用是停止当前线程,直到存储缓冲区被耗尽,所以我们后面的加载出现在我们之前的存储之后的全局顺序中。
(X86,强烈下令ASM存储模式意味着volatile在x86最终可能让你更接近mo_acq_rel,不同的是编译时与非原子变量仍可能发生重新排序,但大多数非x86已经弱有序内存模型,以便volatile和relaxed大约为在mo_relaxed允许的情况下弱。)
| 归档时间: |
|
| 查看次数: |
45050 次 |
| 最近记录: |