C ++ volatile关键字是否引入了内存屏障?


85

我了解这会volatile告知编译器该值可能会更改,但是为了完成此功能,编译器是否需要引入内存隔离墙以使其起作用?

据我了解,对易失对象的操作顺序不能重新排序,必须保留。这似乎意味着需要一些内存隔离,并且实际上没有解决方法。我说的对吗?


有关这个问题有一个有趣的讨论

乔纳森·韦克利(Jonathan Wakely)写道

...对不同的volatile变量的访问,只要它们出现在单独的完整表达式中,就无法重新排序...对,volatile对于线程安全性是无用的,但并非出于其给出的原因。这不是因为编译器可能会重新排列对易失性对象的访问,而是因为CPU可能会重新排列对易失性对象的访问。原子操作和内存屏障可防止编译器和CPU重新排序

大卫·施瓦茨回答的评论

从C ++标准的角度来看,在编译器执行某些操作和编译器发出导致硬件执行某些操作的指令之间没有区别。如果CPU可以重新排序对易失性的访问,则该标准不需要保留其顺序。...

... C ++标准对重新排序没有任何区别。而且您不能说CPU可以对它们进行重新排序而不会产生可观察的效果,所以没关系-C ++标准将它们的顺序定义为可观察的。如果编译器生成使平台执行标准要求的代码,则它符合平台上的C ++标准。如果该标准要求对挥发物的访问不进行重新排序,那么重新排序它们的平台将不兼容。...

我的观点是,如果C ++标准禁止编译器对不同的volatile进行重新排序,那么从理论上讲,此类访问的顺序是程序可观察到的行为的一部分,那么它还要求编译器发出禁止CPU执行的代码所以。该标准没有区分编译器做什么和编译器生成的代码使CPU做什么。

哪个产生两个问题:它们中的一个是“正确的”吗?实际的实现实际上是做什么的?


9
这主要意味着编译器不应将该变量保存在寄存器中。源代码中的每个赋值和读取都应对应于二进制代码中的内存访问。
Basile Starynkevitch 2014年


1
我怀疑这一点是,如果将值存储在内部寄存器中,则任何内存隔离都将无效。我认为您同时需要采取其他保护措施。
Galik 2014年

据我所知,volatile用于可由硬件更改的变量(通常与微控制器一起使用)。这只是意味着不能以不同的顺序读取变量,也不能对其进行优化。虽然是C,但在++中应该相同。
2014年

1
@Mast我还没有看到过一个编译器,它可以防止volatile变量的读取被CPU缓存优化。所有这些编译器都不符合标准,或者该标准并不意味着您认为的含义。(标准没有区分编译器做什么和编译器让CPU做什么。这是编译器的工作,发出的代码在运行时符合标准。)
David Schwartz 2014年

Answers:


58

除了volatile让我解释什么时候使用外,还请允许我解释一下您应该何时使用volatile

  • 在信号处理程序内部时。因为写入volatile变量几乎是标准允许您在信号处理程序中执行的唯一操作。从C ++ 11开始,您可以将其std::atomic用于此目的,但前提是该原子是无锁的。
  • setjmp 根据英特尔处理时。
  • 直接与硬件打交道时,您要确保编译器不会优化您的读取或写入。

例如:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

如果没有说明volatile符,则允许编译器完全优化循环。该volatile说明符告诉编译器它可能不会假定2个后续读取返回相同的值。

注意,volatile这与线程无关。如果存在不同的线程写入,则上面的示例将不起作用,*foo因为不涉及获取操作。

在所有其他情况下,volatile除非处理C ++ 11之前的编译器和编译器扩展(例如,msvc的/volatile:ms开关,默认情况下在X86 / I64下启用),否则,应不再将的用法视为不可移植且不需通过代码审查。


5
它比“可能不会假设2次后续读取返回相同的值”严格。即使您只读取一次和/或丢弃该值,也必须完成读取。
2014年

1
在信号处理程序中的使用setjmp是标准制定的两项保证。另一方面,目的至少在开始时就是要支持内存映射的IO。在某些处理器上可能需要围栏或围栏。
James Kanze 2014年

@philipxy除非没人知道“读”的含义。例如,没有人相信必须完成对内存的实际读取-我所知没有编译器会尝试在volatile访问时绕过CPU缓存。
David Schwartz 2014年

@JamesKanze:不是。关于信号处理程序的标准说,在信号处理过程中,仅易失性std :: sig_atomic_t和无锁原子对象具有已定义的值。但是它也说,访问易失性对象是可观察到的副作用。
2014年

1
@DavidSchwartz一些编译器体系结构对将标准指定的访问序列映射到实际效果,而工作程序访问volatile以获取这些效果。一些这样的对没有映射或琐碎无用的映射的事实与实现的质量有关,而与当前的观点无关。
philipxy 2014年

25

C ++ volatile关键字是否引入了内存屏障?

不需要使用符合规范的C ++编译器来引入内存屏障。您的特定编译器可能会;将您的问题定向到编译器的作者。

C ++中的“ volatile”功能与线程无关。请记住,“易失性”的目的是禁用编译器优化,以便不会优化因外部条件而变化的寄存器的读取。是否由于不同的条件而在不同CPU上由不同线程写入的内存地址正在更改的寄存器?再次。如果某些编译器作者选择将由不同线程在不同CPU上写入的内存地址视为由于外在条件而导致寄存器更改,那是他们的职责。他们不需要这样做。也不要求它们(即使确实引入了内存隔离),例如,确保每个线程都看到一个一致的 易失性读写的顺序。

实际上,对于C / C ++中的线程,volatile几乎没有用。最佳做法是避免这种情况。

此外:内存栅栏是特定处理器体系结构的实现细节。在C#中,其中挥发性明确地设计用于多线程,该规范并没有说半挡片将被引入,因为该计划可能会在不具有在首位围栏的架构上运行。相反,该规范再次对编译器,运行时和CPU避免进行哪些优化提供了某些(极其薄弱的)保证,以对某些副作用的排序方式施加某些(极其薄弱的)约束。实际上,通过使用半栅栏消除了这些优化,但这是实现细节,将来可能会发生变化。

您关心与多线程相关的任何语言中volatile的语义的事实表明您正在考虑跨线程共享内存。考虑根本不这样做。它使您的程序更难以理解,并且更有可能包含细微的,无法重现的错误。


19
“ volatile在C / C ++中几乎没有用。” 一点也不!您具有以用户模式-桌面为中心的世界观...但是大多数C和C ++代码在嵌入式系统上运行,在嵌入式系统中,内存映射I / O非常需要volatile。
Ben Voigt 2014年

12
保留易失性访问的原因不仅仅是因为外部条件可能会更改内存位置。完全访问本身可以触发进一步的操作。例如,读操作先进先出FIFO或清除中断标志是很常见的。
Ben Voigt 2014年

3
@BenVoigt:我无意有效地处理线程问题。
埃里克·利珀特

4
@DavidSchwartz该标准显然不能保证内存映射IO的工作方式。但是内存映射的IO是为什么将volatile其引入C标准的原因。但是,由于该标准无法指定“访问”时实际发生的事情,因此它说“构成对具有volatile限定类型的对象的访问的构成是实现定义的”。如今,太多的实现无法提供有用的访问定义,恕我直言,IMHO违反了该标准的精神,即使它符合该标准。
James Kanze 2014年

8
这种编辑是绝对的改进,但是您的解释仍然过于侧重于“内存可能会被外源更改”。 volatile语义要强于此,编译器必须生成每个请求的访问权限(1.9 / 8,1.9 / 12),而不仅仅是保证最终检测到外部变化(1.10 / 27)。在内存映射I / O的世界中,内存读取可以具有任意关联的逻辑,例如属性获取器。您不会根据您为规则规定的规则优化对属性获取器的调用,volatile标准也不允许这样做。
Ben Voigt 2014年

13

David忽略的事实是,C ++标准指定了仅在特定情况下交互的多个线程的行为,而其他所有行为均导致未定义的行为。如果您不使用原子变量,则涉及至少一次写入的竞争条件是不确定的。

因此,编译器完全有权放弃任何同步指令,因为您的CPU只会注意到程序中由于缺少同步而表现出不确定行为的差异。


5
很好地解释了,谢谢。只要程序没有未定义的行为,该标准仅将对volatile的访问顺序定义为可观察到的。
Jonathan Wakely 2014年

4
如果程序有数据争用,则该标准对程序的可观察行为没有要求。不应期望编译器通过使用显式屏障或原子操作来防止易失性访问增加障碍,以防止程序中存在数据争用,这是程序员的工作。
Jonathan Wakely 2014年

为什么您认为我忽略了这一点?您认为我论点的哪一部分无效?我100%同意编译器完全有权放弃任何同步。
David Schwartz

2
这是完全错误的,或者至少忽略了本质。 volatile与线程无关;它的最初目的是支持内存映射的IO。至少在某些处理器上,支持内存映射的IO需要隔离墙。(编译器不执行此操作,但这是另一个问题。)
James Kanze 2014年

@JamesKanzevolatile与线程有很多关系:volatile处理可在编译器不知道可访问的情况下访问的内存,并且该内存涵盖了特定CPU上线程之间共享数据的许多实际用法。
curiousguy18年

12

首先,C ++标准不保证正确排序非原子的读/写所需的内存屏障。建议使用volatile变量与MMIO,信号处理等配合使用。在大多数实现中,volatile对于多线程而言没有用,因此通常不建议使用。

关于易失性访问的实现,这是编译器的选择。

文章,描述了GCC的行为表明,您不能使用挥发性对象作为内存屏障命令写入非易失性存储器的序列。

关于icc行为,我发现此消息源还告诉我们volatile不能保证对内存访问进行排序。

Microsoft VS2013编译器具有不同的行为。本文档说明了volatile如何强制执行Release / Acquire语义,并使volatile对象能够在多线程应用程序的锁/释放中使用。

需要考虑的另一个方面是同一编译器可能具有不同的行为。取决于目标硬件体系结构的变化。这个职位有关MSVS 2013编译器中明确规定挥发性为ARM平台编译的细节。

所以我的回答是:

C ++ volatile关键字是否引入了内存屏障?

将是:不保证,可能不能保证,但是某些编译器可以做到。您不应该依赖事实。


2
它不会阻止优化,而只是阻止编译器改变负载和存储超出某些限制。
Dietrich Epp 2014年

不清楚你在说什么。您是说在某些未指定的编译器上碰巧会volatile阻止编译器重新排序加载/存储吗?还是您说的是C ++标准要求这样做?如果是后者,您能否回应我对原始问题中引用的相反观点的论点?
David Schwartz 2014年

@DavidSchwartz该标准防止通过volatile左值对访问(从任何来源)进行重新排序。但是,由于它将“访问”的定义留给了实现,因此,如果实现不关心,这对我们没有多大帮助。
詹姆斯·坎泽

我认为MSC编译器的某些版本中那样实施围栏语义volatile,但在从视觉工作室2012年的编译器生成的代码没有围栏
詹姆斯观世

@JamesKanze从根本上讲,它的唯一可移植行为volatile是标准明确规定的行为。(setjmp,信号,等等。)
大卫·施瓦茨

7

据我所知,编译器仅在Itanium体系结构上插入了内存屏障。

volatile实际上,关键字最适合用于异步更改,例如信号处理程序和内存映射的寄存器。它通常是用于多线程编程的错误工具。


1
有点。当以ARM以外的体系结构为目标并且使用/ volatile:ms开关(默认设置)时,“编译器”(msvc)会插入内存屏障。请参阅msdn.microsoft.com/en-us/library/12a04hfd.aspx。据我所知,其他编译器不会在可变变量上插入隔离墙。除非直接与硬件,信号处理程序或不符合c ++ 11的编译器打交道,否则应避免使用volatile。
Stefan

@Stefan号volatile对于许多从未使用过的硬件非常有用。每当您希望实现生成紧密遵循C / C ++代码的CPU代码时,请使用volatile
curiousguy18年

7

这取决于哪个编译器是“编译器”。自2005年以来,Visual C ++就开始使用Visual C ++。但是Standard不需要它,因此其他一些编译器则不需要。


VC ++ 2012似乎没有插入篱笆:int volatile i; int main() { return i; }生成带有正好两条指令的主体:mov eax, i; ret 0;
James Kanze 2014年

@JamesKanze:确切是哪个版本?并且您是否使用任何非默认的编译选项?我依赖文档(第一个受影响的版本)(最新版本),这些文档肯定提到了获取和发布语义。
Ben Voigt 2014年

cl /help说版本18.00.21005.1。它所在的目录是C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC。命令窗口的标题为VS2013。因此,关于版本...我使用的唯一选项是/c /O2 /Fa。(如果没有/O2,它还会建立本地堆栈框架。但是仍然没有围栏指令。)
James Kanze 2014年

@JamesKanze:我对体系结构更感兴趣,例如“针对x64的Microsoft(R)C / C ++优化编译器版本18.00.30723”也许没有围栏,因为x86和x64在其内存模型中首先具有相当强的缓存一致性保证?
Ben Voigt

也许。我真的不知道 我在中执行此操作的事实main,因此编译器可以看到整个程序,并且知道在我的线程之前没有其他线程,或者至少没有其他对变量的访问(因此可能没有缓存问题)可能会影响此也是,但是以某种方式,我对此表示怀疑。
James Kanze 2014年

5

这主要来自内存,并且基于C ++ 11之前的版本,没有线程。但是,在参与了关于提交中线程的讨论之后,我可以说委员会从来没有一种意图volatile可用于线程之间的同步。微软提出了这个建议,但是这个提议没有实现。

的关键规范volatile是,与IO一样,访问volatile表示“可观察到的行为”。以相同的方式,编译器无法重新排序或删除特定的IO,也无法重新排序或删除对volatile对象的访问(或更正确地说,是通过具有volatile限定类型的左值表达式进行的访问)。实际上,volatile的最初意图是支持内存映射的IO。但是,“问题”是由实现定义的,它构成“易失性访问”。许多编译器都将其实现为“定义已执行了一条读或写内存的指令”。如果实现指定的话,这是一个合法的,尽管无用的定义。(我尚未找到任何编译器的实际规范。

可以说(这是我接受的一个论点),这违反了标准的意图,因为除非硬件将地址识别为内存映射的IO,并禁止任何重新排序等,否则您甚至不能对内存映射的IO使用volatile,至少在Sparc或Intel架构上。即便如此,我看过的所有编译器(Sun CC,g ++和MSC)都不会输出任何围栏或操作员指令。(大约在Microsoft建议扩展其规则的时间 volatile,我认为他们的一些编译器已实现了他们的建议,并且确实发出了针对易失性访问的隔离指令。我尚未验证最近的编译器的功能,但是如果依赖于它,也不会感到惊讶在某些编译器选项上。我检查的版本(我认为是VS6.0)不过没有发出围栏。)


为什么只说编译器不能重新排序或删除对易失对象的访问?当然,如果访问是可观察到的行为,那么防止CPU,写发布缓冲区,内存控制器以及其他所有东西也不能重新排序无疑也同样重要。
David Schwartz 2014年

@DavidSchwartz因为这就是标准所说的。当然,从实际的角度来看,我已经验证过的编译器所做的事情完全没有用,但是标准的鼬鼠一词足以说明问题,以便他们仍然可以声明符合性(或者如果他们实际记录了,则可以这样做)。
James Kanze 2014年

1
@DavidSchwartz:对于外围设备的专用(或互斥)内存映射I / O,volatile语义就足够了。通常,此类外设将其内存区域报告为不可缓存,这有助于在硬件级别重新排序。
Ben Voigt 2014年

@BenVoigt我对此有些疑惑:处理器以某种方式“知道”它正在处理的地址是内存映射IO的想法。据我所知,Sparcs对此没有任何支持,因此仍然会使Sparc上的Sun CC和g ++无法用于内存映射的IO。(当我调查时,我主要对Sparc感兴趣。)
James Kanze 2014年

@JamesKanze:通过我的少量搜索,看来Sparc具有专用的地址范围,用于不可缓存的内存“备用视图”。只要您的易失性访问指向ASI_REAL_IO地址空间的那一部分,我认为您就可以了。(Altera NIOS使用类似的技术,其中地址的高位控制MMU旁路;我敢肯定还有其他问题)
Ben Voigt 2014年

5

不必。易失性不是同步原语。它只是禁用优化,即您在线程内以与抽象机规定的顺序相同的顺序获得可预测的读取和写入序列。但是不同线程中的读写首先没有顺序,谈论保留或不保留顺序是没有意义的。可以通过同步原语来建立广告之间的顺序,如果没有它们,您将获得UB。

关于内存障碍的一些解释。典型的CPU具有多个级别的内存访问。有一条内存管道,几级缓存,然后是RAM等。

孟巴指令冲洗管道。它们不会更改读取和写入的执行顺序,而只是强制在给定时刻执行出色的读取和写入操作。它对多线程程序很有用,但在其他方面则没有太大用处。

缓存通常在CPU之间自动保持一致。如果要确保缓存与RAM同步,则需要刷新缓存。它与孟巴非常不同。


1
所以您是说C ++标准说volatile只是禁用编译器优化?那没有任何意义。至少原则上,编译器可以执行的任何优化都可以由CPU很好地完成。因此,如果该标准说它只是禁用了编译器优化,那将意味着它不会提供任何可依赖于可移植代码的行为。但这显然是不正确的,因为可移植代码可以依赖于其关于setjmp和信号的行为。
David Schwartz 2014年

1
@DavidSchwartz不,该标准没有规定。禁用优化只是实现该标准的常用方法。该标准要求可观察到的行为按照抽象机要求的相同顺序发生。当抽象机不需要任何顺序时,实现可以自由使用任何顺序或根本不需要任何顺序。除非应用其他同步,否则不对访问不同线程中的易失性变量进行排序。
n。代词

1
@DavidSchwartz我为措词不准确表示歉意。该标准不要求禁用优化。它根本没有优化的概念。相反,它指定的行为实际上要求编译器以某种可观察的读写顺序符合标准的方式禁用某些优化。
n。代词

1
除非它不需要这样做,因为该标准允许实现根据需要定义“可观察的读写顺序”。如果实现选择定义可观察的序列,使得必须禁用优化,则可以这样做。如果没有,那就没有。当且仅当实现方式选择将序列提供给您时,您才能获得可预测的读写序列。
David Schwartz 2014年

1
不,实现需要定义什么构成单个访问。这种访问的顺序由抽象机规定。实现必须保留顺序。该标准明确指出:“易失性是实现的一种暗示,可以避免涉及对象的积极优化”,尽管这是非规范性的部分,但目的很明确。
n。代词

4

volatile且仅在必要时,编译器需要在访问周围引入内存围墙,以使用该特定平台上volatile的标准工作(setjmp,信号处理程序等)中指定的用途。

请注意,某些编译器的确超出了C ++标准的要求,以使其volatile在这些平台上更强大或更有用。可移植代码不应依赖于volatileC ++标准中未指定的内容。


2

我总是在中断服务程序中使用易失性,例如ISR(通常是汇编代码)会修改某些内存位置,而在中断上下文之外运行的高级代码会通过指向volatile的指针来访问该内存位置。

我这样做是针对RAM以及内存映射的IO。

根据此处的讨论,看来这仍然是volatile的有效用法,但与多个线程或CPU没有任何关系。如果微控制器的编译器“知道”没有其他访问权限(例如,所有内容都在芯片上,没有高速缓存且只有一个内核),我认为根本就没有隐含存储器保护,编译器只是需要防止某些优化。

随着我们在执行目标代码的“系统”中投入更多的东西,几乎所有的赌注都消失了,至少这就是我阅读本讨论的方式。编译器如何涵盖所有基础?


0

我认为,关于易失性和指令重新排序的困惑源于CPU的两种重新排序概念:

  1. 乱序执行。
  2. 其他CPU所见的存储器读/写顺序(在某种意义上重新排序,即每个CPU可能会看到不同的顺序)。

易失性会影响假设单线程执行(包括中断)的编译器生成代码的方式。它并不暗含关于内存屏障指令的任何信息,但是它使编译器无法执行与内存访问相关的某些优化。
一个典型的示例是从内存中重新获取值,而不是使用缓存在寄存器中的值。

乱序执行

如果最终结果可能在原始代码中发生,CPU可以无序/推测地执行指令。CPU可以执行编译器中不允许的转换,因为编译器只能执行在所有情况下都正确的转换。相反,CPU可以检查这些优化的有效性,如果结果不正确,则可以退出。

其他CPU看到的存储器读/写顺序

指令序列的最终结果(有效顺序)必须与编译器生成的代码的语义一致。但是,CPU选择的实际执行顺序可能会有所不同。在其他CPU中看到的有效顺序(每个CPU都有不同的视图)可能会受到内存屏障的限制。
我不确定有效顺序和实际顺序会有所不同,因为我不知道内存障碍可以在多大程度上阻止CPU执行乱序执行。

资料来源:


0

当我浏览用于3D图形和游戏引擎开发的在线可下载视频教程时,使用现代OpenGL。我们确实volatile在其中一个类中使用过。可以在此处找到教程网站volatile,而在Shader Engine系列视频98中可以找到使用关键字的视频。这些作品不是我自己的作品,但已获认可Marek A. Krzeminski, MASc,这是视频下载页面的摘录。

“由于我们现在可以在多个线程中运行我们的游戏,因此在线程之间正确同步数据非常重要。在此视频中,我演示了如何创建易失性锁定类以确保易失性变量正确同步……”

而且,如果您订阅了他的网站并可以访问此视频中的他的视频,那么他将引用有关with编程使用的本文Volatilemultithreading

这是上面链接的文章:http : //www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volatile:多线程程序员的最好朋友

作者:Andrei Alexandrescu,2001年2月1日

设计volatile关键字是为了防止编译器优化,该优化可能在存在某些异步事件的情况下使代码不正确。

我不想破坏您的心情,但是本专栏讨论了多线程编程这个令人恐惧的话题。如果像Generic的上一期文章所述,异常安全编程很困难,那么与多线程编程相比,这是小孩子的玩法。

众所周知,使用多个线程的程序通常很难编写,证明正确,调试,维护和驯服。不正确的多线程程序可能会运行数年而不会出现故障,但由于已满足某些关键的计时条件,因此只能意外运行amok。

不用说,编写多线程代码的程序员需要她所能获得的所有帮助。本专栏重点讨论竞争条件(多线程程序中常见的故障源),并为您提供有关如何避免它们的见解和工具,而且令人惊讶的是,让编译器努力工作以帮助您解决这一问题。

只是一个小关键字

尽管C和C ++标准在线程方面都非常沉默,但它们确实以volatile关键字的形式对多线程做了一些让步。

就像它最著名的对应const一样,volatile是类型修饰符。它旨在与在不同线程中访问和修改的变量结合使用。基本上,没有volatile,要么编写多线程程序变得不可能,要么编译器浪费了巨大的优化机会。请按要求进行解释。

考虑以下代码:

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

Gadget :: Wait的目的是每秒检查一下flag_成员变量,并在另一个线程将该变量设置为true时返回。至少这就是程序员的意图,但是,Wait,等待是不正确的。

假设编译器发现Sleep(1000)是对外部库的调用,它无法修改成员变量flag_。然后,编译器得出结论,可以将flag_缓存在寄存器中并使用该寄存器,而不用访问较慢的板载内存。这是对单线程代码的出色优化,但是在这种情况下,它会损害正确性:在调用Wait for a Gadget对象之后,尽管另一个线程调用Wakeup,Wait将永远循环。这是因为flag_的更改不会反映在缓存flag_的寄存器中。优化太...乐观了。

在寄存器中缓存变量是非常有价值的优化,大多数情况下都会应用,因此浪费它是可惜的。C和C ++为您提供了显式禁用此类缓存的机会。如果在变量上使用volatile修饰符,则编译器不会在寄存器中缓存该变量-每次访问都将访问该变量的实际内存位置。因此,要使小工具的“等待/唤醒”组合起作用,您要做的就是适当地限制flag_的资格:

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

关于volatile的基本原理和用法的大多数解释都在这里停止,并建议您对在多个线程中使用的原始类型进行volatile限定。但是,由于volatile是C ++出色的类型系统的一部分,因此您可以做更多的事情。

对用户定义类型使用volatile

您不仅可以volatile限定基本类型,还可以对用户定义类型进行volatile限定。在这种情况下,volatile以类似于const的方式修改类型。(您也可以同时将const和volatile应用于同一类型。)

与const不同,volatile区分原始类型和用户定义类型。也就是说,与类不同,基本类型在经过volatile限定时仍支持其所有操作(加法,乘法,赋值等)。例如,可以将非易失性int分配给volatile int,但是不能将非易失性对象分配给volatile对象。

让我们在示例中说明volatile如何对用户定义的类型起作用。

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

如果您认为volatile对对象不是那么有用,请准备一些惊喜。

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

从非限定类型到易失对应类型的转换是微不足道的。但是,就像使用const一样,您不能使旅行从不稳定转变为不合格。您必须使用强制转换:

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

易失性合格的类仅允许访问其接口的子集,该子集在类实现者的控制下。用户只有使用const_cast才能获得对该类型的界面的完全访问权限。另外,就像常量一样,易失性从类传播到其成员(例如,volatileGadget.name_和volatileGadget.state_是易失性变量)。

易失性,临界区和竞争条件

互斥锁是多线程程序中最简单,最常用的同步设备。互斥锁公开“获取”和“释放”原语。在某个线程中调用Acquire之后,任何其他调用Acquire的线程都会阻塞。稍后,当该线程调用Release时,恰好一个被Acquire调用阻塞的线程将被释放。换句话说,对于给定的互斥锁,只有一个线程可以在调用Acquire和调用Release之间获得处理器时间。在对Acquire的调用与对Release的调用之间的执行代码称为关键部分。(Windows术语有点令人困惑,因为它称互斥体本身为关键部分,而“互斥体”实际上是进程间互斥体。如果将它们称为线程互斥体和进程互斥体,那就太好了。)

互斥体用于保护数据免于竞争条件。根据定义,当更多线程对数据的影响取决于线程的调度方式时,就会发生竞争状态。当两个或多个线程竞争使用同一数据时,出现竞争条件。由于线程可以在任意时间相互中断,因此数据可能会被破坏或解释不正确。因此,更改和有时对数据的访问必须由关键部分仔细保护。在面向对象的编程中,这通常意味着您将互斥锁作为成员变量存储在类中,并在访问该类的状态时使用它。

有经验的多线程程序员可能会打哈欠阅读上面的两段内容,但是它们的目的是提供知识性的锻炼,因为现在我们将与易失性连接链接。为此,我们在C ++类型的世界和线程语义的世界之间画出了一条平行线。

  • 在关键部分之外,任何线程都可以随时中断其他任何线程。没有控制,因此从多个线程可访问的变量是易失的。这与volatile的初衷是一致的-防止编译器一次不经意地缓存多个线程使用的值。
  • 在由互斥锁定义的关键部分内,只有一个线程可以访问。因此,在关键部分内部,执行代码具有单线程语义。受控变量不再是volatile —您可以删除volatile限定符。

简而言之,线程之间共享的数据在概念上在关键部分外是易失的,而在关键部分内是非易失性的。

您可以通过锁定互斥锁来输入关键部分。您可以通过应用const_cast从类型中删除volatile限定符。如果我们设法将这两个操作放在一起,我们将在C ++的类型系统和应用程序的线程语义之间建立连接。我们可以让编译器为我们检查竞争条件。

锁定点

我们需要一个收集互斥量获取和const_cast的工具。让我们开发一个LockingPtr类模板,使用一个易失对象obj和一个互斥体mtx对其进行初始化。在其生命周期内,LockingPtr保持获取mtx。此外,LockingPtr还提供对易失性剥离的obj的访问。通过operator->和operator *以智能指针方式提供访问。const_cast在LockingPtr内部执行。强制转换在语义上是有效的,因为LockingPtr在其生命周期内保留获取的互斥量。

首先,让我们定义LockingPtr将与之一起工作的Mutex类的框架:

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

要使用LockingPtr,需要使用操作系统的本机数据结构和原始函数来实现Mutex。

LockingPtr使用受控变量的类型进行模板化。例如,如果要控制窗口小部件,则可以使用用volatile窗口小部件类型的变量初始化的LockingPtr。

LockingPtr的定义非常简单。LockingPtr实现了一个简单的智能指针。它仅专注于收集const_cast和关键部分。

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

尽管LockingPtr简单,但它在编写正确的多线程代码方面非常有用。您应该将线程之间共享的对象定义为volatile,并且永远不要将const_cast与它们一起使用-始终使用LockingPtr自动对象。让我们用一个例子来说明。

假设您有两个共享矢量对象的线程:

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

在线程函数内部,您只需使用LockingPtr即可控制对buffer_成员变量的访问:

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

该代码非常容易编写和理解-每当需要使用buffer_时,都必须创建一个指向它的LockingPtr。完成后,您就可以访问vector的整个界面。

令人高兴的是,如果您犯了一个错误,编译器会指出:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

在应用const_cast或使用LockingPtr之前,您无法访问buffer_的任何功能。区别在于LockingPtr提供了将const_cast应用于易失变量的有序方法。

LockingPtr表现出色。如果只需要调用一个函数,则可以创建一个未命名的临时LockingPtr对象并直接使用它:

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

返回原始类型

我们看到了volatile如何很好地保护对象免受不受控制的访问,以及LockingPtr如何提供一种简单有效的编写线程安全代码的方法。现在让我们回到原始类型,它们被volatile区别对待。

让我们考虑一个示例,其中多个线程共享一个int类型的变量。

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { —ctr_; }
private:
    int ctr_;
};

如果要从不同的线程调用Increment和Decrement,则上面的片段有问题。首先,ctr_必须是易失的。其次,即使是看似原子的操作(例如++ ctr_)实际上也是一个三阶段操作。内存本身没有算术功能。当增加一个变量时,处理器:

  • 在寄存器中读取该变量
  • 递增寄存器中的值
  • 将结果写回内存

此三步操作称为RMW(读-修改-写)。在RMW操作的“修改”部分期间,大多数处理器会释放内存总线,以使其他处理器可以访问内存。

如果那时另一个处理器对同一变量执行RMW操作,则我们处于竞争状态:第二个写入将覆盖第一个写入的效果。

为了避免这种情况,您可以再次依赖LockingPtr:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

现在该代码是正确的,但是与SyncBuf的代码相比,其质量较差。为什么?因为使用Counter,如果您错误地直接访问ctr_(未锁定它),编译器将不会发出警告。如果ctr_是易失性的,则编译器将编译++ ctr_,尽管生成的代码根本不正确。编译器不再是您的盟友,只有您的关注可以帮助您避免出现竞争状况。

那你该怎么办 只需封装在高层结构中使用的原始数据,然后在这些结构中使用volatile。矛盾的是,尽管最初这是volatile的使用意图,但直接将volatile与内置函数一起使用更糟!

易失的成员函数

到目前为止,我们已经有了聚合易失数据成员的类。现在让我们考虑设计类,这些类又将成为更大对象的一部分并在线程之间共享。在这里可变成员函数可以提供很大帮助。

在设计类时,只对那些线程安全的成员函数进行volatile限定。您必须假设外部代码会随时从任何代码中调用volatile函数。不要忘记:volatile等于免费的多线程代码,没有关键部分;非易失性等于单线程方案或在关键部分内。

例如,您定义了一个Widget类,它以两种变体来实现操作:一种是线程安全的,另一种是不受保护的快速线程。

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

注意使用重载。现在,Widget的用户可以使用统一语法来调用Operation,以实现易失性对象并获得线程安全性,或者使用常规语法并获得速度。用户必须谨慎定义共享的Widget对象为volatile。

在实现易失性成员函数时,通常的第一步是使用LockingPtr锁定它。然后,通过使用非易失性同级完成工作:

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

概要

在编写多线程程序时,可以使用volatile来发挥自己的优势。您必须遵守以下规则:

  • 将所有共享对象定义为易失性。
  • 不要将volatile直接用于原始类型。
  • 定义共享类时,请使用易失成员函数来表示线程安全性。

如果这样做,并且使用简单的通用组件LockingPtr,则可以编写线程安全的代码,而不必担心争用条件,因为编译器会为您担心,并会认真指出错误之处。

我参与的两个项目都使用volatile和LockingPtr产生了很大的效果。该代码是干净且易于理解的。我记得有几个死锁,但是我更喜欢死锁而不是竞争条件,因为它们很容易调试。比赛条件几乎没有问题。但是那时你永远不知道。

致谢

非常感谢James Kanze和Sorin Jianu提出了有见地的想法。


Andrei Alexandrescu是总部位于华盛顿州西雅图的RealNetworks Inc.(www.realnetworks.com)的开发经理,并着有《现代C ++设计》一书。可以通过www.moderncppdesign.com与他联系。Andrei还是C ++研讨会(www.gotw.ca/cpp_seminar)的特色讲师之一。

本文可能有点过时,但确实可以很好地了解如何使用volatile修饰符以及多线程编程,以帮助保持事件异步,同时让编译器为我们检查竞争条件。这可能无法直接回答OP最初有关创建内存防护的问题,但是我选择将其发布为其他人的答案,作为在使用多线程应用程序时良好使用volatile的极好的参考。


0

关键字volatile本质上意味着读写对象应完全按照程序编写的方式执行,而不以任何方式进行优化。二进制代码应遵循C或C ++代码:读取该文件的负载,写入的存储器。

这也意味着不应期望任何读取会导致可预测的值:即使在写入相同的volatile对象之后,编译器也不应该假设与读取有关的任何事情:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatile可能是“ C是高级汇编语言”工具箱中最重要的工具

声明对象易失性是否足以确保处理异步更改的代码的行为取决于平台:不同的CPU为正常的内存读写提供不同级别的保证同步。除非您是该领域的专家,否则您可能不应该尝试编写这样的低级多线程代码。

原子原语为多线程提供了对象的高级视图,使推理代码变得容易。几乎所有程序员都应使用原子基元或提供互斥的基元,例如互斥体,读写锁,信号量或其他阻塞基元。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.