如果有两个线程访问全局变量,那么许多教程都说要使变量可变,以防止编译器将变量缓存在寄存器中,从而导致无法正确更新。但是,两个都访问共享变量的线程需要通过互斥锁进行保护,不是吗?但是在那种情况下,在线程锁定和释放互斥锁之间,代码处于关键部分,其中只有一个线程可以访问该变量,在这种情况下,变量不需要是可变的?
因此,多线程程序中volatile的用途/目的是什么?
如果有两个线程访问全局变量,那么许多教程都说要使变量可变,以防止编译器将变量缓存在寄存器中,从而导致无法正确更新。但是,两个都访问共享变量的线程需要通过互斥锁进行保护,不是吗?但是在那种情况下,在线程锁定和释放互斥锁之间,代码处于关键部分,其中只有一个线程可以访问该变量,在这种情况下,变量不需要是可变的?
因此,多线程程序中volatile的用途/目的是什么?
Answers:
简短的回答: volatile
对于平台无关的多线程应用程序编程,(几乎)没有用。它不提供任何同步,不创建内存隔离,也不能确保操作的执行顺序。它不会使操作原子化。它不会使您的代码神奇地线程安全。 volatile
可能是所有C ++中最容易被误解的工具。有关更多信息,请参见此,此和此。volatile
另一方面,volatile
确实有一些用途可能不太明显。它的使用方式const
与帮助编译器向您显示以非保护方式访问某些共享资源时可能出错的方式相同。Alexandrescu在本文中讨论了这种用法。但是,这基本上是在使用C ++类型系统,这种方式通常被视为一种发明,并且会引起未定义的行为。
volatile
是专门用于与内存映射的硬件,信号处理程序和setjmp机器代码指令进行接口连接时使用的。这使得它volatile
直接适用于系统级编程,而不是普通的应用程序级编程。
2003 C ++标准没有说volatile
对变量应用任何类型的获取或发布语义。实际上,该标准在多线程的所有问题上都是完全沉默的。但是,特定的平台确实将Acquire和Release语义应用于volatile
变量。
现在,C ++ 11标准确实在内存模型和内存中直接确认了多线程,并且它提供了以平台无关的方式处理它的库工具。但是,的语义volatile
仍然没有改变。 volatile
仍然不是同步机制。Bjarne Stroustrup在TCPPPL4E中说了很多:
volatile
除非在直接处理硬件的低级代码中使用,否则请勿使用。不要以为
volatile
在内存模型中有特殊含义。它不是。与某些后来的语言一样,它不是一种同步机制。为了得到同步,使用atomic
,一个mutex
,或condition_variable
。
以上所有内容均适用2003标准(以及现在的2011标准)定义的C ++语言本身。但是,某些特定平台的确会增加其他功能或限制volatile
。例如,至少在MSVC 2010中,“获取”和“发布”语义确实适用于对volatile
变量的某些操作。 从MSDN:
优化时,编译器必须保持对易失性对象的引用以及对其他全局对象的引用之间的顺序。特别是,
写入易失性对象(易失性写入)具有Release语义;对全局或静态对象的引用发生在指令序列中对易失性对象的写操作之前,将在已编译二进制文件中的易失性写操作之前进行。
易失性对象的读取(易失性读取)具有Acquire语义;在指令序列中读取易失性存储器之后对全局或静态对象的引用将在编译后的二进制文件中的易失性读取之后发生。
但是,您可能会注意到以下事实:如果您遵循上面的链接,则注释中会涉及到在这种情况下是否实际应用获取/释放语义的争论。
volatile
它就可以编写多线程程序,是因为您站在曾经volatile
实现线程库的人们的肩膀上。
volatile
实际功能。@约翰所说的是正确的,故事的结尾。就此而言,它与应用程序代码与库代码无关,或者与“普通”与“神似的无所不能的程序员”无关。volatile
对于线程之间的同步是不必要且无用的。线程库不能用volatile
; 来实现;无论如何,它都必须依赖于特定于平台的细节,而当您依靠这些细节时,就不再需要volatile
。
(编者注:在C ++ 11 volatile
中不是适合此工作的工具,并且仍然具有数据争用UB。std::atomic<bool>
与std::memory_order_relaxed
加载/存储一起使用可在没有UB的情况下执行此操作。在实际实现中,它将编译为与.asm相同的asm volatile
。答案与更多的细节,也解决了误解在评论认为弱有序存储可能是该用例的一个问题:所有现实世界的CPU具有连贯共享内存中,因此volatile
将努力为这对真正的C ++实现,但还是不要。不做。
评论中的某些讨论似乎是在谈论其他用例,在这些用例中,您将需要比松弛原子更强大的功能。该答案已经指出,volatile
您无法订购。)
由于以下原因,挥发性有时会有用:此代码:
/* global */ bool flag = false;
while (!flag) {}
由gcc优化以:
if (!flag) { while (true) {} }
如果该标志是由另一个线程写入的,那显然是不正确的。请注意,如果没有这种优化,则同步机制可能会起作用(取决于其他代码,可能需要一些内存障碍)-在1个生产者-1个使用者场景中不需要互斥体。
否则,volatile关键字太怪异而无法使用-它不提供任何易失性和非易失性访问的内存顺序保证,并且不提供任何原子操作-即,除了禁用的寄存器缓存外,使用volatile关键字的编译器没有任何帮助。
volatile
不会阻止对内存访问进行重新排序。volatile
访问不会相对于彼此重新排序,但是它们不能保证对非volatile
对象的重新排序,因此,它们基本上也无用。
volatile
。
while (work_left) { do_piece_of_work(); if (cancel) break;}
如果在循环中对取消进行了重新排序,我有一段类似的代码:如果主线程想要终止,它将设置其他线程的标志,但是不会...
volatile
用于线程,仅用于MMIO但是TL:DR,mo_relaxed
在具有相干缓存(即所有内容)的硬件上,确实可以像原子一样“工作” ;停止编译器将var保存在寄存器中就足够了。 atomic
不需要内存障碍来创建原子性或线程间可见性,只需使当前线程在操作之前/之后等待,以创建该线程对不同变量的访问之间的排序。 mo_relaxed
永远不需要任何障碍,只需加载,存储或RMW。
对于滚你自己的原子能公司volatile
(和内联汇编的障碍)在坏日子C ++ 11之前std::atomic
,volatile
是得到一些东西的工作唯一的好办法。但这取决于有关实现方式工作的许多假设,并且从来没有任何标准可以保证。
例如,Linux内核仍使用带有的自己的原子原子volatile
,但仅支持一些特定的C实现(GNU C,clang以及ICC)。部分原因是由于GNU C扩展以及内联asm语法和语义,还因为它取决于有关编译器如何工作的一些假设。
对于新项目而言,几乎总是错误的选择。您可以使用std::atomic
(与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
所有(?)现有C ++实现上的标志之类的事情volatile
。但是没有太多其他内容,因此不建议使用。 这个答案的目的是解释现有的CPU和C ++实现实际上是如何工作的。如果您对此不关心,则只需知道std::atomic
使用mo_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;
如果没有volatile或atomic,则as-if规则和无数据争用UB的假定使编译器可以将其优化为只进入标志一次的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
}
多线程程序停留在优化模式下,但通常在-O0上运行,这是一个示例(说明了GCC的asm输出),它说明了x86-64上的GCC会如何发生。此外,MCU编程-C ++ O2优化在电子设备上循环时中断.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>
还会将+=
诸如此类的事情变成原子RMW操作(比将原子加载到临时存储器,操作和一个单独的原子存储器要昂贵得多。如果您不希望使用原子RMW,请使用本地临时存储器编写代码)。
使用seq_cst
您从中获得的默认订购while(!flag)
,它还会添加订购保证wrt。非原子访问,以及其他原子访问。
(理论上,ISO C ++标准不排除原子的编译时优化。但是实际上,编译器并不这样做,因为没有办法控制何时不可行。在某些情况下,甚至volatile atomic<T>
可能无法超过原子能如果编译器做了优化的优化足够的控制,所以现在编译器不会见你为什么不编译器合并冗余的std ::原子写? 需要注意的是WG21 / p0062建议不要使用volatile atomic
在当前代码防范的优化原子。)
volatile
确实可以在真正的CPU上工作(但是仍然不使用它)即使使用弱排序的内存模型(非x86)。但实际上并不使用它,使用atomic<T>
与mo_relaxed
替代!本节的重点是要解决有关实际CPU工作方式的误解,而不是要证明其合理性volatile
。如果您正在编写无锁代码,那么您可能会关心性能。通常,了解缓存和线程间通信的成本对于获得良好的性能很重要。
实际的CPU具有一致的缓存/共享内存:一个核心的存储全局可见后,其他任何核心都无法加载陈旧的值。 (另请参见神话程序员相信CPU缓存,其中讨论了有关Java volatile的某些知识,等效于atomic<T>
具有seq_cst内存顺序的C ++ 。)
当我说load时,我的意思是访问内存的asm指令。这就是一个volatile
访问确保,并且是不一样的东西左值到右值非原子/非易失性C ++变量的转换。(例如local_tmp = flag
或while(!flag)
)。
您唯一需要克服的就是编译时优化,它在第一次检查后根本不会重新加载。每次迭代中的任何load + check都足够,无需任何排序。如果该线程与主线程之间没有同步,那么谈论确切的存储时间或加载顺序就没有意义。循环中的其他操作。仅当此线程可见时才重要。当您看到设置了exit_now标志时,您将退出。在典型的x86 Xeon上,不同物理内核之间的内核间延迟可能约为40ns。
我看不到有什么办法可以实现远程高效,仅使用纯ISO C ++而不要求程序员在源代码中进行显式刷新。
从理论上讲,您可以在不是这样的机器上实现C ++实现,这需要编译器生成的显式刷新来使事物对其他内核上的其他线程可见。(或者使读取不使用过时的副本)。C ++标准并没有做到这一点,但是C ++的内存模型是围绕在相干共享内存计算机上高效而设计的。例如,C ++标准甚至谈到“读-读一致性”,“写-读一致性”等。该标准中的一个注释甚至指出了与硬件的连接:
http://eel.is/c++draft/intro.races#19
[注意:前面的四个一致性要求有效地禁止了编译器对单个对象的原子操作重新排序,即使两个操作都是宽松的负载也是如此。这有效地使大多数硬件提供的缓存一致性可用于C ++原子操作。—尾注]
没有一种机制可以让release
存储仅刷新自身并选择一些地址范围:它必须同步所有内容,因为如果它们的获取负载看到了这个发布存储,它将不知道其他线程可能想要读取什么内容(形成一个释放序列可以在线程之间建立事前发生的关系,从而保证写入线程完成的较早的非原子操作现在可以安全读取。除非在释放存储之后对其进行了进一步的写操作...)否则编译器将拥有是真正聪明的证明,只有少数的高速缓存行需要冲洗。
相关:我对mov + mfence在NUMA上安全吗?详细介绍了没有连贯的共享内存的x86系统的不存在。还相关:在ARM上进行加载和存储重新排序,以获取有关到相同位置的加载/存储的更多信息。
这里是我想用非相干共享存储集群,但他们不是单一系统映像机器。每个一致性域都运行一个单独的内核,因此您不能跨该内核运行单个C ++程序的线程。而是运行程序的单独实例(每个实例都有其自己的地址空间:一个实例中的指针在另一个实例中无效)。
为了使它们通过显式刷新相互通信,通常需要使用MPI或其他消息传递API来使程序指定需要刷新的地址范围。
std::thread
跨缓存一致性边界运行:存在一些非对称ARM芯片,具有共享的物理地址空间,但不具有内部共享的缓存域。所以不连贯。(例如,注释线程为A8核和TI Sitara AM335x之类的Cortex-M3)。
但是不同的内核将在这些内核上运行,而不是一个可以跨两个内核运行线程的系统映像。我不知道有任何C ++实现std::thread
在没有一致性缓存的情况下跨CPU内核运行线程。
专门针对ARM,GCC和clang生成代码,并假设所有线程都在同一内部可共享域中运行。实际上,ARMv7 ISA手册说
编写此体系结构(ARMv7)时,希望所有使用相同操作系统或虚拟机管理程序的处理器都位于同一内部共享域中
因此,对于不同内核下不同进程之间的通信,单独域之间的非一致性共享内存仅是明确使用共享内存区域的系统特定用途。
另请参阅有关在该编译器中使用(内部可共享屏障)与(系统)内存屏障进行代码生成的CoreCLR讨论。dmb ish
dmb 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/)。(获取负载意味着命令访问另一个核心中的缓存)。
内存屏障指令仅阻塞当前线程的加载和/或存储,直到存储缓冲区耗尽为止。总是尽可能快地自行发生。 (内存屏障是否可确保高速缓存一致性已完成?解决了这个误解)。因此,如果您不需要订购,则只需在其他线程中快速查看即可mo_relaxed
。(,也是如此volatile
,但不要那样做。)
有趣的事实:在x86上,每个asm存储都是一个发布存储,因为x86内存模型基本上是seq-cst加一个存储缓冲区(带有存储转发)。
半相关的re:存储缓冲区,全局可见性和一致性:C ++ 11几乎不能保证。大多数真正的ISA(PowerPC除外)的确保证所有线程都能通过其他两个线程就两个存储出现的顺序达成一致。(在正式的计算机体系结构内存模型术语中,它们是“多副本原子”)。
另一个误解是需要的存储栅栏汇编指令刷新存储缓冲区其他处理器看到我们的店在所有。实际上,存储缓冲区总是试图尽快耗尽自身(提交到L1d缓存),否则它将填满并暂停执行。完整的屏障/栅栏所做的是使当前线程停止运行,直到耗尽存储缓冲区为止,因此,我们的较晚负载将在较早的存储之后以全局顺序显示。
(x86的有序asm内存模型意味着volatile
x86可能最终使您更接近mo_acq_rel
,除了使用非原子变量的编译时重排序仍然可能发生。但是大多数非x86的有弱有序的内存模型,volatile
并且relaxed
大约弱点mo_relaxed
。)
atomic
可能导致不同的线程对cache中的同一变量具有不同的值,因此我写了这篇文章。/ facepalm。在高速缓存中,否,在CPU 寄存器中,是(带有非原子变量);CPU使用一致的缓存。我希望关于SO的其他问题没有引起人们对atomic
CPU工作原理的误解的充分解释。(因为出于性能原因,这是一件有用的事情,并且还有助于解释为什么按原样编写ISO C ++原子规则。)
#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();
}
曾经也认为volatile是无用的访问者曾与我争辩说,Optimization不会引起任何问题,并且指的是具有单独的缓存行以及所有其他内容的不同内核(并不能真正理解他所指的是什么)。但是,这段代码在g ++(g ++ -O3 thread.cpp -lpthread)上用-O3编译时,显示了未定义的行为。基本上,如果在while检查之前设置该值,则它可以正常工作;否则,它会进入循环而不会费心去获取该值(该值实际上已由另一个线程更改)。基本上,我相信checkValue的值只会被提取一次到寄存器中,而不会在最高级别的优化下再次被检查。如果在获取之前将其设置为true,则它将正常工作,否则将进入循环。如果有错,请纠正我。
volatile
?是的,此代码是UB,但也包含UB volatile
。
您需要易失性并且可能需要锁定。
volatile告诉优化器,该值可以异步更改,因此
volatile bool flag = false;
while (!flag) {
/*do something*/
}
每次循环都会读取标志。
如果关闭优化或使每个变量都可变,则程序的行为将相同,但速度较慢。volatile只是意味着“我知道您可能已经读了它,并且知道它说了什么,但是如果我说了,请先阅读。
锁定是程序的一部分。因此,顺便说一句,如果要实现信号量,那么它们必须是易变的。(不要尝试,这很难,可能会需要一些汇编程序或新的原子材料,并且它已经完成了。)
volatile
即使在这种情况下也不是很有用。但是繁忙的等待是一种偶尔有用的技术。
volatile
“不重新排序”的意思。您希望它意味着这些存储将按程序顺序在全局(对其他线程)可见。这就是atomic<T>
用memory_order_release
或seq_cst
给你。但是,这volatile
只能保证您没有编译时重新排序:每次访问都将按程序顺序显示在asm中。对于设备驱动程序很有用。对于与当前内核/线程上的中断处理程序,调试器或信号处理程序进行交互很有用,但对于与其他内核进行交互则无效。
volatile
在实践中,足以keep_running
像您在此处那样检查标志:真正的CPU始终具有不需要手动刷新的一致缓存。但是,没有任何理由建议volatile
过atomic<T>
用mo_relaxed
; 您将获得相同的asm。