编译器是否有时会缓存声明为volatile的变量?

13

据我所知,编译器从不优化声明为volatile的变量。但是,我有一个声明如下的数组。

volatile long array[8];

有不同的线程读取和写入这个数组。数组的一个元素只会被一个线程修改,而可以被任何其他线程读取。但是,在某些情况下,我注意到即使我从一个线程中修改了一个元素,读取它的线程也没有注意到更改。它仍然继续读取相同的旧值,好像编译器已经把它缓存到某个地方了。但是,编译器原则上不应该缓存一个volatile变量,对吗?那么为什么会发生这种情况呢?

注意:我不是使用volatile进行线程同步,请不要给我提供使用锁或原子变量等答案。我知道volatile、原子变量和互斥锁之间的区别。还要注意的是,架构是具有主动缓存一致性的x86。而且在另一个线程修改变量后,我已经长时间读取过这个变量。即使经过很长时间后,读取线程仍然看不到修改后的值。


3
volatile 不适用于线程,你需要使用互斥锁(mutex)。 - Vaughn Cato
4
据我所知,在C++中,volatile只影响编译器优化,而不影响可能发生的CPU重排序。 - Tudor
3
顺便说一下,你的注释并不影响我的答案,也可能不影响其他人的答案。你可能认为你正在使用 volatile 进行线程同步,但如果你期望它在不同线程中引入读写关系,那么实际上你确实在进行线程同步,因为根据定义,这就是线程同步的作用。 - Steve Jessop
2
@Eric:它明确说明了读者何时会看到更新:“我在其他线程修改变量后足够长的时间内读取变量”。假设测试代码正在执行其预期的操作,这是用户1018562和他的实现之间关于“足够长”的参数争论。他说有一个限制,实现显然没有。标准不会干涉这个争论,因为代码存在数据竞争,所以它对编译器“原则上不应该缓存易失性变量”是否有任何发言权。 - Steve Jessop
2
我会尝试使用互斥锁。如果这解决了问题,那么你可以预期它是一个缓存问题。如果这没有解决问题,那么你可以在其他地方寻找问题所在。 - Vaughn Cato
显示剩余16条评论
10个回答

7

但编译器原则上不应缓存易失性变量,对吗?

不,原则上编译器必须每次读写变量时都读/写变量的地址。

[编辑:至少,在实现认为该地址的值是“可观察”的点之前,它必须这样做。如Dietmar在他的答案中指出的那样,实现可能声明普通内存“无法被观察”。这将使使用调试器、mprotect或标准范围之外的其他内容的人感到惊讶,但原则上它是符合规范的。]

在不考虑线程的C++03中,当运行在一个线程中时,“访问地址”的定义由实现定义。像这样的细节称为“内存模型”。例如,Pthreads允许每个线程缓存整个内存,包括易失性变量。我IRC,MSVC提供了一个保证,即适当大小的易失性变量是原子的,并且它将避免缓存(相反,它将刷新到所有核心的单个一致缓存)。它提供这种保证的原因是因为在英特尔上这是相对便宜的——Windows只关心基于英特尔的架构,而Posix关注更奇特的东西。

C++11为线程定义了一个内存模型,并且它说这是数据竞争(即,volatile 不保证读取在一个线程中与写入在另一个线程中按顺序排列)。两个访问可以按特定顺序排序、无序排序(标准可能会说“不确定的顺序”,我记不清了)或根本不排序。根本不排序是不好的——如果两个未排序的访问中的任何一个是写入,则行为是未定义的。

关键在于“然后”这个暗示,即“我从一个线程中修改一个元素,然后在读取它的线程中没有注意到这个修改”。你假设操作是按顺序排列的,但事实并非如此。就读线程而言,除非使用某种形式的同步,否则另一个线程中的写入尚未发生。实际上,情况比这更糟——你可能会认为从我刚才写的内容来看,只有操作顺序是未指定的,但实际上,具有数据竞争的程序的行为是未定义的。


1
@user1018562:关键在于数据竞争是未定义行为。其成为UB的一个动机与非一致性缓存有关,但一旦发生,行为可能是任何事情,因为优化器可能依赖于在转换代码时没有数据竞争。要求您的代码没有数据竞争的目的是允许编译器执行对具有数据竞争的代码来说是不正确的转换。 - Steve Jessop
1
@SteveJessop:人们过于强调“未定义行为”。行为从来没有被规定为未定义。它不仅仅是没有定义。区别在于C标准可能不定义行为,但它并不阻止另一个规范来定义它。你不能从C没有定义行为这一事实中得出结论,认为我们不能期望特定的硬件以某种方式运作。如果硬件在短时间内传播了更改,并且发生了更改,而在经过一段时间后没有观察到更改,则存在错误。 - Eric Postpischil
1
@SteveJessop:不是关于内存屏障的问题。问题在于汇编是否包含C抽象机器访问对象的加载指令。这是C标准所要求的。 - Eric Postpischil
...但这样一种哲学思想尚未深入人心。有很多平台可以“免费”提供一些关于无序访问的松散保证(例如,每次读取都会产生一些已经被写入的值),而且有许多算法即使像这样非常宽松的保证也足以确保正确性,特别是与一个有效限制执行次数或缓存更新量的指令相结合时。添加足以防止未定义行为的同步可能更加昂贵... - supercat
与其拥有一些代码,不特别关心它是否获得了旧数据或新数据,只要任何新数据最终都会显示出来,这样做更好。 - supercat
显示剩余6条评论

4

C

volatile的作用:

  • 保证变量的值是最新的,如果该变量来自外部源(硬件寄存器、中断、不同线程、回调函数等)被修改。
  • 阻止所有对变量读写访问的优化。
  • 防止危险的优化错误发生在多个线程/中断/回调函数之间共享的变量上,当编译器没有意识到程序调用线程/中断/回调时。(这在各种可疑的嵌入式系统编译器中特别常见,当你遇到这个bug时很难追踪。)

volatile并不能:

  • 保证原子访问或任何形式的线程安全。
  • 用作互斥量/信号量/守卫/临界区的替代品。不能用于线程同步。

volatile可能做也可能不做:

  • 它可能由编译器实现为提供内存屏障,以保护多核环境中的指令缓存/指令管道/指令重排序问题。除非编译器文档明确说明,否则不要假定volatile会为您执行此操作。

我没有将易失变量用作原子变量、内存屏障或线程同步。 - pythonic

3

使用 volatile 只能保证在使用变量的值时重新读取它。它不能保证不同层次结构中存在的不同值/表示是一致的。

如果要具有这样的保障,您需要使用 C11 和 C++1 中与原子访问和内存屏障相关的新实用程序。许多编译器已经以扩展形式实现了这些功能。例如,gcc 家族(clang、icc 等)有以前缀 __sync 开头的内建函数来实现这些操作。


我认为使用__sync可以确保原子操作,但不能防止竞争条件。 - Genís
确保原子操作避免竞争条件,这正是它们的定义。但它们也保证数据的一致性。 - Jens Gustedt
抱歉,我应该说原子操作不能确保线程之间的正确同步。 - Genís
硬件保证了缓存一致性(除了存储缓冲区以外)。编译器为 atomic<int> 发出的汇编代码并不会做任何事情来确保其他线程可以看到你的存储。这总是发生的。一个 seq-cst 存储将使当前线程等待直到其他加载/存储完成,然后再继续进行其他加载/存储操作。 - Peter Cordes

2
Volatile关键字仅保证编译器不会将此变量用作寄存器。因此,对该变量的每次访问都将读取内存位置。现在,我假设您的架构中有多个处理器之间的高速缓存一致性。因此,如果一个处理器写入并且另一个处理器读取它,则在正常情况下应该是可见的。但是,您应该考虑边界情况。假设变量在一个处理器核心的流水线中,并且其他处理器正在尝试读取它,假设它已经被写入,那么就存在问题。因此,共享变量应该通过锁进行保护,或者使用正确的屏障机制进行保护。

另外,我在想编译时启用了一些优化级别,编译器是否会删除这个语句?这只是一个想法。查看正在发生的事情的方法之一是使用某些实用程序转储汇编代码。 - Raj

2
volatile的语义是由实现定义的。如果编译器知道在执行某段代码时中断会被禁用,并且知道在目标平台上除了通过中断处理程序观察某些存储区域之外没有其他手段,那么它可以像缓存普通变量一样注册缓存volatile限定的变量在这样的存储区域中,前提是它记录了这种行为。
请注意,“可观察”的行为方面可能在某种程度上由实现定义。如果实现文档说明它不适用于使用主RAM访问来触发所需外部可见动作的硬件,则在该实现中对主RAM的访问将不被视为“可观察”。如果硬件能够物理观察到这些访问,而无论是否实际上看到任何这样的访问都没有关系,那么该实现将与能够观察这些访问的硬件兼容。然而,如果这些访问是必需的,例如如果这些访问被视为“可观察”,则编译器将不会声称兼容性,因此不会对任何事情做出承诺。

这是正确的,但所有主流编译器都选择使volatile意味着您所期望的,并且确实为每个volatile访问加载或存储。因此,即使您使用调试器或模拟器进行单步观察,一切也是正确的。这意味着对于足够窄以自然原子方式处理的类型,volatile碰巧可以作为使用memory_order_relaxed的自定义atomic工作。(当然,在一般情况下,这是未定义的,但您可以认为实现足够强烈地定义了volatile)。 - Peter Cordes
相关:MCU编程 - C ++ O2优化在while循环中中断详细讨论了中断处理程序中的volatileatomic差别。 - Peter Cordes
@PeterCordes:即使在单核平台上,它们也不能很好地合成互斥锁,除非由互斥锁保护的所有内容都有volatile限定符——这是“优化”C语言基本独有的要求,而且会抵消优化的好处。 - supercat
...放置事物的位置。当然,人们需要一个专为此类用途而设计的实现,但是即使启用了优化,现有的C语法也足以处理获取/释放屏障,如果编译器编写者没有决定要求使用特定于编译器的语法的话。 - supercat
当你说“现有”的时候,我想你是排除了C11吧?我们在C11的(可选支持的)stdatomic库中拥有ISO标准的acq/rel语义,因此不需要再抱怨缺乏语言支持了。我认为,通过放松的_Atomic加上atomic_signal_fence,我们可以轻松地获得所需的编译时序,而无需在某些平台上使用强于放松的原子加载/存储顺序时实际屏障指令的运行时成本。(当然,如果您不想要慢速的原子RMW,就需要避免foo++。) - Peter Cordes
显示剩余6条评论

1

对于C++:

据我所知,编译器从不优化声明为volatile的变量。

您的前提是错误的。volatile只是一个提示给编译器,并没有真正保证任何内容。编译器可以选择防止一些对volatile变量的优化,仅此而已。

volatile不是锁,请不要尝试将其用作锁。

7.1.5.1

7) 【注:volatile是一种提示实现避免侵略性优化涉及对象,因为对象的值可能会被不可检测的手段改变。有关详细语义,请参见1.9。通常,volatile的语义旨在C++中与C中相同。-注】

7
从编译器的角度来看,“volatile”关键字的要求相当强。这不像“register”或“inline”,编译器可以自由忽略它们。对于“volatile”对象的精确访问是符合实现的最低要求之一:如果编译器将其视为仅仅是一个提示,则该实现是不符合规范的。(我相信C标准的5.1.2.3条款类似,C++也是如此。)你的结论是正确的,但原因不是你提供的那个。 - user743382
@hvd 现在正在寻找报价,但我认为你是错的。 - Luchian Grigore
2
我不知道 C++,但在 C 中编译器不允许对 volatile 进行优化。C11 5.1.2.3/2 中说到 "访问 volatile 对象,...都是副作用"。5.1.2.3/4 中提到 "如果实现能够推断出表达式的一部分没有使用且没有产生必需的副作用(包括通过调用函数或访问 volatile 对象引起的任何副作用),则它可以选择不评估此表达式的该部分。" 5.1.2.3/6 中指出 "符合标准的实现至少要求:- 访问 volatile 对象必须按照抽象机器规则进行严格评估。" - Lundin
2
这个答案基本上是正确的,volatile并不意味着“不要优化”。事实上,我认为“不要优化这个变量”甚至没有一个明确定义的含义。但是,volatile的语义不仅仅是避免优化的提示,因此volatile并不保证什么都不做。访问volatile对象是可观察行为。由于hvd提到了它进行比较,C++编译器也不能忽略inlineregister:两者除了作为优化提示的次要角色外,还有定义的含义。C编译器可以忽略register,还有restrict - Steve Jessop
@SteveJessop的确,我的评论并不完全准确。与您指出的其他影响相同,C编译器也不能完全忽略register关键字:它们仍然必须记住该关键字被使用,以便诊断尝试使用&运算符获取register变量地址的无效操作。 - user743382
显示剩余11条评论

1
< p>在C++中,volatile关键字与并发完全没有任何关系!它用于让编译器防止使用先前的值,即每次在代码中访问volatile值时,编译器将生成访问volatile值的代码。主要目的是像内存映射I/O这样的东西。然而,使用volatile对于CPU读取正常内存时没有任何影响:如果CPU没有理由相信内存中的值已更改,例如因为没有同步指令,它可以只使用来自其缓存的值。要在线程之间进行通信,您需要一些同步,例如std::atomic<T>、锁定std::mutex等。< /p>

它还可以防止编译器优化掉你“没有使用”的变量。 - Derek
是的,volatile 对线程来说是无关紧要的。但是什么构成了 volatile 的“访问”是由实现定义的。它不是与代码一一对应的。 - philipxy
缓存是一致的,因此(除了存储缓冲区)所有核心共享内存的相同视图。线程无法无限期地从“volatile”中重新读取相同的过时值。C++可能可以在需要显式刷新才能使数据全局可见的系统上实现,但这将非常昂贵。这个问题被标记为x86,但这也适用于像PowerPC和ARM这样的弱序ISA。 - Peter Cordes
只有在需要让线程等待直到存储/加载已经全局可见才需要使用内存屏障;asm存储本身已经尽可能快地变得全局可见,从存储缓冲区提交到L1d。而 volatile 的意思是编译器会发出 asm 实际存储的指令。 - Peter Cordes

1
volatile 只会影响它所在的变量。在这个例子中,是一个指针。你的代码:volatile long array[8],数组的第一个元素的指针是 volatile 的,而不是它的内容。(任何类型的对象都一样)。
你可以按照以下方式进行调整: 如何在C++中声明使用malloc创建的数组为volatile

0

C++通过易失(volatile)lvalues进行访问,而C通过易失对象进行访问是“抽象”可观察的-尽管在实践中C行为遵循的是C++标准而不是C标准。非正式地,volatile声明告诉每个线程值可能会以某种方式更改,而不考虑任何线程中的文本。在具有线程的标准下,除了在同步关键区域的同步函数调用之前的共享变量之外,不存在另一个线程的写入导致对象的更改的概念,无论易失或非易失,共享与否。对于线程共享对象,volatile是不相关的。

如果你的代码没有正确同步你正在谈论的线程,那么你的一个线程读取另一个线程写入的内容就会发生未定义的行为。因此编译器可以生成任何它想要的代码。如果你的代码已经正确同步,那么其他线程的写入只会在线程同步调用时发生。你不需要volatile来实现这一点。

附言

标准规定:“对于具有易失性限定类型的对象的访问是实现定义的。”因此,您不能仅仅假设每个易失性左值的解引用都有读取访问权限,或者每个赋值操作都有写入访问权限。
此外,“抽象”的“可观察”volatile访问如何“实际”显现也是实现定义的。因此,编译器可能不会为硬件访问生成代码,以对应于定义的抽象访问。例如,只有具有静态存储期和外部链接的对象,编译时使用特定的链接标志链接到特殊的硬件位置,才能从程序文本之外更改,以便忽略其他对象的volatile

-1
然而,在某些情况下,我注意到即使我从一个线程修改了一个元素,读取它的线程也没有注意到这个变化。它继续读取相同的旧值,好像编译器在某个地方缓存了它。
这不是因为编译器在某个地方缓存了它,而是因为读取线程从其CPU核心的缓存中读取,这可能与写入线程的缓存不同。为确保跨CPU核心传播值的更改,您需要使用适当的内存栅栏,在C++中您既不能也不需要使用volatile来实现这一点。

但是在像x86这样具有主动缓存一致性的处理器中,核心的缓存应该在这种情况下进行更新,也就是说,每当核心A写入内存X时,如果核心B尝试从X读取,则其对应于X的缓存将被更新。 - pythonic
@pythonic:没错,x86像所有正常的CPU(ARM / PowerPC / MIPS / SPARC / ...)一样具有一致的缓存。如果您使用volatile,其他内核将很快(在微秒内:“如果我不使用栅栏,一个内核需要多长时间才能看到另一个内核的写入? ”)注意到来自另一个核的存储。您只需要栅栏来对读取中的负载/存储进行排序,或者使编写者在执行其他操作之前等待存储变为全局可见。 - Peter Cordes
@pythonic:如果你所描述的情况持续很长时间,那么你要么做错了什么,要么是描述有误。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接