为什么volatile在多线程C或C++编程中不被认为有用?

Mic*_*and 159 c c++ multithreading volatile c++-faq

正如我最近发布的这个答案所示,我似乎对volatile多线程编程环境中的实用程序(或缺乏实用程序)感到困惑.

我的理解是这样的:每当一个变量可以在访问它的一段代码的控制流之外被改变时,该变量应该被声明为volatile.信号处理程序,I/O寄存器和由另一个线程修改的变量都构成这种情况.

所以,如果你有一个全局int foo,并且foo由一个线程读取并由另一个线程原子设置(可能使用适当的机器指令),则读取线程看到这种情况的方式与它看到由信号处理程序调整的变量或由外部硬件条件修改,因此foo应该声明volatile(或者,对于多线程情况,使用内存隔离负载访问,这可能是一个更好的解决方案).

我怎么错,哪里错了?

jal*_*alf 207

volatile多线程上下文中的问题是它不能提供我们需要的所有保证.它确实有一些我们需要的属性,但不是所有属性,所以我们不能volatile 单独依赖.

但是,我们必须用于剩余属性的原语也提供了那些原语volatile,因此它实际上是不必要的.

对于对共享数据的线程安全访问,我们需要保证:

  • 读/写实际发生(编译器不会仅将值存储在寄存器中,而是延迟更新主存储器直到很久以后)
  • 没有重新排序.假设我们使用volatile变量作为标志来指示是否准备好读取某些数据.在我们的代码中,我们只是在准备数据后设置标志,所以看起来都很好.但是如果指令被重新排序以便首先设置标志呢?

volatile确保第一点.它还保证在不同的易失性读/写之间不会发生重新排序.所有volatile内存访问都将按照指定的顺序进行.这就是我们所需要的所有内容volatile:操作I/O寄存器或内存映射硬件,但它对多线程代码没有帮助,其中volatile对象通常仅用于同步对非易失性数据的访问.这些访问仍然可以相对于那些访问重新排序volatile.

防止重新排序的解决方案是使用内存屏障,该内存屏障向编译器和CPU指示在该点上不能重新排序内存访问.在易失性变量访问周围放置这些障碍可确保即使非易失性访问也不会在易失性访问中重新排序,从而允许我们编写线程安全的代码.

但是,内存障碍还可以确保在达到屏障时执行所有挂起的读/写操作,因此它有效地为我们提供了我们自己需要的所有内容,这使得volatile不必要.我们可以完全删除volatile限定符.

从C++ 11开始,原子变量(std::atomic<T>)为我们提供了所有相关的保证.

  • @OJW:但微软的编译器重新定义了`volatile`是一个完整的内存屏障(防止重新排序).这不是标准的一部分,因此您不能在可移植代码中依赖此行为. (27认同)
  • @Skizz:在C++ 11和C11之前,线程本身始终是依赖于平台的扩展.据我所知,提供线程扩展的每个C和C++环境也提供了"内存屏障"扩展.无论如何,`volatile`对于多线程编程总是无用的.(在Visual Studio中除外,其中volatile*是内存屏障扩展.) (13认同)
  • @jbcreix:你问的是哪个"它"?易失性或记忆障碍?无论如何,答案几乎是一样的.它们都必须在编译器和CPU级别工作,因为它们描述了程序的可观察行为 - 因此它们必须确保CPU不会重新排序所有内容,从而改变它们保证的行为.但是你目前无法编写可移植的线程同步,因为内存屏障不是标准C++的一部分(所以它们不可移植),而`volatile`不够强大,无法使用. (5认同)
  • MSDN示例执行此操作,并声称指令无法通过易失性访问重新排序:http://msdn.microsoft.com/en-us/library/12a04hfd(v = vs.80).aspx (4认同)
  • @Skizz:不,这就是方程式中"编译魔术"部分的来源.必须通过*和CPU以及编译器来理解内存障碍.如果编译器理解内存屏障的语义,它就知道要避免这样的技巧(以及跨屏障重新排序读/写).幸运的是,编译器*确实*了解了内存屏障的语义,所以最后,它都可以解决.:) (4认同)
  • @SumitTrehan隐含在互斥锁中.它意味着内存屏障,因此在执行时,会指示编译器确保对其他线程可见的变量的所有写入都应该刷新到内存中. (4认同)
  • 它是否真的保证了顺序并禁止使用缓存值或仅在编译器级别这样做?如果前者是真的,你可能会编写可移植的线程同步,但我见过的所有代码都使用CPU特定的指令,并假设CPU将重新排序所有内容. (3认同)
  • @Skizz:我不确定我理解.您可以使用编译器为此目的提供的任何内在函数(或使用C++ 11等效项)指定内存屏障.这是你必须手动插入的东西; 您不能依赖编译器在您需要的地方生成内存屏障. (3认同)
  • @guardian:不,不是,数据依赖关系分析将内存屏障视为外部函数,它可能会更改曾经被别名的任何变量。(实际上,从未使用过地址的寄存器存储本地变量是绝对安全的)。即使在单线程代码中,`global_x = 5; extern_call(); cout &lt;&lt; global_x;`编译器无法将其替换为`cout &lt;&lt; 5;`,因为`extern_call()`可能已更改了该值。 (3认同)
  • @Skizz简而言之,CPU和编译器的魔力(是的,你需要两者才能工作).内存屏障是一种CPU指令,旨在解决该问题.它与内存总线交互,确保在屏障完成之前没有其他内核可以读/写内存,并且CPU知道不重新排序内存访问.它无法真正用软件模拟. (2认同)
  • @Skizz:我明白了。抱歉当时造成的混乱。然而,如果编译器没有这样的扩展,那么你*无论如何*都会不走运,因为“易失性”本身是不够的。 (2认同)
  • 根据我的理解,volatile将强制始终从内存而不是缓存中读取值.假设我们有共享变量(在线程之间),一个线程修改它,变量是非易失性的.假设我们在访问共享变量时使用互斥锁.由于共享变量是非易失性的,如何保证变量总是从内存中读取而不是从缓存中读取? (2认同)

小智 49

您也可以从Linux内核文档中考虑这一点.

C程序员经常采用volatile来表示变量可以在当前执行线程之外进行更改; 因此,当使用共享数据结构时,他们有时会在内核代码中使用它.换句话说,他们已知将易失性类型视为一种简单的原子变量,而它们则不然.在内核代码中使用volatile几乎是不正确的; 本文档描述了原因.

关于volatile的关键点是它的目的是抑制优化,这几乎不是人们真正想做的事情.在内核中,必须保护共享数据结构免受不必要的并发访问,这是一项非常不同的任务.防止意外并发的过程也将以更有效的方式避免几乎所有与优化相关的问题.

与volatile类似,内核原语使得并发访问数据安全(自旋锁,互斥锁,内存屏障等)旨在防止不必要的优化.如果使用得当,也不需要使用挥发性物质.如果仍然需要volatile,那么代码中肯定存在一个错误.在正确编写的内核代码中,volatile只能减慢速度.

考虑一个典型的内核代码块:

spin_lock(&the_lock);
do_something_on(&shared_data);
do_something_else_with(&shared_data);
spin_unlock(&the_lock);
Run Code Online (Sandbox Code Playgroud)

如果所有代码都遵循锁定规则,则在保持_lock时,shared_data的值不会意外更改.可能想要使用该数据的任何其他代码将等待锁定.自旋锁原语充当内存屏障 - 它们是明确写入的 - 这意味着数据访问不会在它们之间进行优化.所以编译器可能会认为它知道shared_data中会有什么,但是spin_lock()调用因为它充当了内存屏障,会强制它忘记它所知道的任何东西.访问该数据不会出现优化问题.

如果shared_data被声明为volatile,则仍然需要锁定.但是,当我们知道没有其他人可以使用它时,编译器也将无法优化对关键部分的shared_data的访问.在保持锁定的同时,shared_data不是易失性的.在处理共享数据时,正确的锁定会使挥发性变得不必要 - 并且可能有害.

易失性存储类最初用于内存映射I/O寄存器.在内核中,寄存器访问也应该受到锁的保护,但是也不希望编译器在关键部分内"优化"寄存器访问.但是,在内核中,I/O内存访问总是通过访问器函数完成; 通过指针直接访问I/O内存是不受欢迎的,并不适用于所有体系结构.编写这些访问器是为了防止不必要的优化,因此,再一次,不需要volatile.

另一种情况是人们可能想要使用volatile,那就是当处理器忙于等待变量的值时.执行忙碌等待的正确方法是:

while (my_variable != what_i_want)
    cpu_relax();
Run Code Online (Sandbox Code Playgroud)

cpu_relax()调用可以降低CPU功耗或对超线程双处理器的产量; 它也恰好是一个记忆障碍,因此,再一次,不必要的是挥发性的.当然,忙碌等待通常是一种反社会行为.

还有一些罕见的情况,其中volatile在内核中是有意义的:

  • 上述访问器函数可能在直接I/O内存访问确实有效的体系结构上使用volatile.本质上,每个访问者调用本身都成为一个小关键部分,并确保访问按照程序员的预期发生.

  • 更改内存但没有其他可见副作用的内联汇编代码可能会被GCC删除.将volatile关键字添加到asm语句将阻止此删除.

  • jiffies变量的特殊之处在于它每次引用时都可以有不同的值,但是可以在没有任何特殊锁定的情况下读取它.所以jiffies可能是易变的,但是这种类型的其他变量的添加是非常不受欢迎的.在这方面,Jiffies被认为是一个"愚蠢的遗产"问题(Linus的话); 修理它会比它的价值更麻烦.

  • 可能由I/O设备修改的连贯存储器中的数据结构的指针有时可能合法地是易失性的.网络适​​配器使用的环形缓冲区,其中该适配器更改指针以指示已处理哪些描述符,是此类情况的示例.

对于大多数代码,上述适用于volatile的理由均不适用.因此,volatile的使用很可能被视为一个错误,并将对代码进行额外的审查.那些想要使用volatile的开发人员应该退一步思考他们真正想要实现的目标.

  • @curiousguy:是的.另见http://gcc.gnu.org/onlinedocs/gcc-4.0.4/gcc/Extended-Asm.html. (3认同)
  • Syncopated 有一个很好的观点。这实质上意味着程序员应该知道那些“特殊功能”的内部实现,或者至少非常了解它们的行为。这引发了其他问题,例如 - 这些特殊功能是否标准化并保证在所有架构和所有编译器上以相同的方式工作?是否有可用的此类函数的列表,或者至少是否有使用代码注释向开发人员发出信号,表明相关函数保护代码不被“优化掉”的约定? (2认同)

Jer*_*ner 11

我不认为你错了 - 如果值被线程A以外的其他东西改变,那么volatile是必要的,以保证线程A将看到值的变化.据我所知,volatile基本上是一种告诉编译器"不要将此变量缓存在寄存器中,而是确保始终在每次访问时从RAM存储器中读取/写入".

困惑是因为挥发性不足以实现许多事情.特别是,现代系统使用多级缓存,现代多核CPU在运行时进行一些花哨的优化,现代编译器在编译时进行一些花哨的优化,这些都会导致各种不同的副作用出现在不同的如果您只是查看源代码,请从您期望的顺序订购.

所以挥发性很好,只要你记住,挥发性变量的"观察"变化可能不会在你认为的那个时候发生.具体来说,不要尝试使用volatile变量作为跨线程同步或排序操作的方法,因为它不能可靠地工作.

就个人而言,我对volatile标志的主要(仅限?)用作"pleaseGoAwayNow"布尔值.如果我有一个连续循环的工作线程,我将在循环的每次迭代中检查volatile布尔值,如果布尔值为真,则退出.然后,主线程可以通过将boolean设置为true来安全地清理工作线程,然后调用pthread_join()以等待工作线程消失.

  • @Jeremy,你在练习中是正确的,但理论上它仍然可以打破.在两个核心系统上,一个核心不断执行您的工作线程.另一个核心将bool设置为true.然而,并不能保证工作线程的核心会看到这种变化,即它可能永远不会停止,即使它重复检查bool.c ++ 0x,java和c#内存模型允许此行为.在实践中,这种情况永远不会发生,因为繁忙的线程最有可能在某处插入内存屏障,之后它会看到对bool的更改. (10认同)
  • 显然它只有在工作线程的例程的性质保证定期检查布尔值时才有效.volatile-bool-flag保证保留在作用域中,因为线程关闭序列总是在保存volatile-boolean的对象被销毁之前发生,并且线程关闭序列在设置bool之后调用pthread_join().pthread_join()将阻塞,直到工作线程消失.信号有自己的问题,特别是与多线程结合使用时. (6认同)
  • 采用POSIX系统,使用实时调度策略`SCHED_FIFO`,比系统中的其他进程/线程更高的静态优先级,足够的内核,应该是完全可能的.在Linux中,您可以指定实时进程可以使用100%的CPU时间.如果没有更高优先级的线程/进程并且永远不会被I/O阻塞,它们将永远不会进行上下文切换.但重点是C/C++`volatile`并不意味着强制执行正确的数据共享/同步语义.我发现寻找特殊情况来证明错误的代码有时可能无效运动. (4认同)
  • 您的布尔标志可能不安全.你如何保证工人完成它的任务,并且该标志保持在范围内直到它被读取(如果它被读取)?这是信号的工作.易失性有利于实现简单的自旋锁*如果*不涉及互斥,因为别名安全意味着编译器假定`mutex_lock`(以及其他所有库函数)可能会改变标志变量的状态. (2认同)
  • 工作线程*不保证在布尔值为真之前完成其工作 - 事实上,当bool设置为true时,它几乎肯定会在工作单元的中间.但是工作线程何时完成其工作单元并不重要,因为主线程除了阻塞内部pthread_join()之外不会做任何事情,直到工作线程退出为止.所以关闭序列是有序的 - 在pthread_join()返回之前,不会释放volatile bool(和任何其他共享数据),并且在工作线程消失之前pthread_join()不会返回. (2认同)
  • @Deft_code:为什么您确切地认为工作线程可能永远看不到更改?是因为辅助线程不读取变量还是因为主管线程不写入变量?哪个线程需要内存屏障? (2认同)

Pot*_*ter 7

volatile实现自旋锁互斥锁的基本结构是有用的(尽管不够),但是一旦你拥有它(或更高级的东西),你就不需要另一个volatile.

多线程编程的典型方法不是保护机器级别的每个共享变量,而是引入引导程序流的保护变量.而不是volatile bool my_shared_flag;你应该拥有的

pthread_mutex_t flag_guard_mutex; // contains something volatile
bool my_shared_flag;
Run Code Online (Sandbox Code Playgroud)

这不仅封装了"硬部分",而且从根本上说是必要的:C不包括实现互斥锁所必需的原子操作 ; 它只volatile需要对普通操作做出额外的保证.

现在你有这样的事情:

pthread_mutex_lock( &flag_guard_mutex );
my_local_state = my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag
my_shared_flag = ! my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );
Run Code Online (Sandbox Code Playgroud)

my_shared_flag 尽管不可缓解,但不需要易变,因为

  1. 另一个线程可以访问它.
  2. 意味着必须在某个时间(与&操作员)一起参考它.
    • (或参考包含结构)
  3. pthread_mutex_lock 是一个图书馆功能.
  4. 意味着编译器无法判断是否pthread_mutex_lock以某种方式获取该引用.
  5. 这意味着编译器必须假定pthread_mutex_lockmodifes共享的标志!
  6. 因此必须从内存重新加载变量.volatile虽然在这方面有意义,但是无关紧要.


jpa*_*cek 6

你的理解真的是错的.

volatile变量具有的属性是"读取和写入此变量是程序可感知行为的一部分".这意味着该程序可以工作(给定适当的硬件):

int volatile* reg=IO_MAPPED_REGISTER_ADDRESS;
*reg=1; // turn the fuel on
*reg=2; // ignition
*reg=3; // release
int x=*reg; // fire missiles
Run Code Online (Sandbox Code Playgroud)

问题是,这不是我们想要的线程安全的任何属性.

例如,线程安全计数器就是(linux-kernel-like代码,不知道c ++ 0x等价物):

atomic_t counter;

...
atomic_inc(&counter);
Run Code Online (Sandbox Code Playgroud)

这是原子的,没有记忆障碍.如有必要,您应该添加它们.添加volatile可能没有帮助,因为它不会将访问与附近的代码相关联(例如,将一个元素附加到计数器正在计数的列表中).当然,您不需要在程序之外看到计数器递增,并且仍然需要优化,例如.

atomic_inc(&counter);
atomic_inc(&counter);
Run Code Online (Sandbox Code Playgroud)

仍然可以优化

atomically {
  counter+=2;
}
Run Code Online (Sandbox Code Playgroud)

如果优化器足够智能(它不会改变代码的语义).


zeb*_*box 6

要使数据在并发环境中保持一致,您需要应用两个条件:

1)原子性,即如果我将一些数据读取或写入存储器,则该数据一次读取/写入,并且由于例如上下文切换而不能被中断或争用

2)一致性,即在多个并发环境之间必须看到读/写操作的顺序相同 - 是线程,机器等

volatile不符合上述要求 - 或者更具体地说,c或c ++标准关于挥发性应该如何表现不包括上述内容.

在实践中甚至更糟糕,因为一些编译器(例如intel Itanium编译器)确实尝试实现并发访问安全行为的某些元素(即通过确保内存栅栏)但是编译器实现之间没有一致性,而且标准不需要这个首先是实施.

将变量标记为volatile只会意味着每次都强制将值刷新到内存中以及从内存中刷新值,这在很多情况下会降低代码速度,因为基本上会破坏缓存性能.

c#和java AFAIK通过使volatile符合1)和2)来解决这个问题,但是对于c/c ++编译器来说同样不能这样说,所以基本上可以根据你的需要使用它.

对于一些更深入(尽管不公正)的讨论,请阅读此内容

  • +1 - 保证原子性是我失踪的另一部分.我假设加载一个int是原子的,因此挥发性阻止重新排序提供了读取端的完整解决方案.我认为这对大多数架构来说是一个不错的假设,但它并不是一种保证. (3认同)

Ton*_*roy 5

comp.programming.threads FAQ有Dave Butenhof 的经典解释:

Q56:为什么我不需要声明共享变量VOLATILE?

但是,我担心编译器和线程库都满足各自的规范.符合标准的C编译器可以将一些共享(非易失性)变量全局分配给一个寄存器,该寄存器在CPU从一个线程传递到另一个线程时被保存和恢复.每个线程都有这个共享变量的私有值,这不是我们想要的共享变量.

在某种意义上,如果编译器足够了解变量和pthread_cond_wait(或pthread_mutex_lock)函数的相应范围,则情况确实如此.实际上,大多数编译器都不会尝试通过调用外部函数来保留全局数据的寄存器副本,因为很难知道例程是否可能以某种方式访问​​数据的地址.

所以,是的,确实严格(但非常积极地)符合ANSI C的编译器可能不适用于没有volatile的多个线程.但有人最好解决它.因为任何不提供POSIX内存一致性保证的SYSTEM(即实用,内核,库和C编译器的组合)都不符合POSIX标准.期.系统不要求您在共享变量上使用volatile来获得正确的行为,因为POSIX仅要求POSIX同步功能是必需的.

因此,如果您的程序因为没有使用volatile而中断,那就是BUG.它可能不是C中的错误,也不是线程库中的错误,也不是内核中的错误.但这是一个系统错误,其中一个或多个组件必须解决它.

你不想使用volatile,因为,在它产生任何差异的任何系统上,它将比适当的非易失性变量昂贵得多.(ANSI C要求每个表达式的volatile变量都有"序列点",而POSIX仅在同步操作时需要它们 - 计算密集型线程应用程序将使用volatile看到更多的内存活动,毕竟,它是内存活动,真的让你慢下来.)

/ --- [Dave Butenhof] ----------------------- [butenhof@zko.dec.com] ---\
| 数字设备公司110 Spit Brook Rd ZKO2-3/Q18 |
| 603.881.2218,传真603.881.0120 Nashua NH 03062-2698 |
----------------- [通过并发实现更好的生活] ---------------- /

Butenhof先生在这篇usenet帖子中涵盖了相同的基础:

使用"volatile"不足以确保正确的内存可见性或线程之间的同步.使用互斥的就足够了,而且,除了诉诸各种非便携机代码的替代品,(或者说是更加困难的POSIX内存规则更微妙的影响一般适用,因为在我以前的帖子解释),一个互斥是必要的.

因此,布莱恩解释说,使用volatile无所作为,而是阻止编译器做有用并且需要优化,以使代码"线程安全的"任何提供任何帮助.欢迎你,当然,申报你想为"挥发性"什么 - 这是一个合法的ANSI C存储属性,毕竟.只是不要指望它为您解决任何线程同步问题.

所有这些同样适用于C++.


小智 5

这就是“易失性”所做的一切:“嘿编译器,这个变量可能会在任何时刻(在任何时钟滴答上)改变,即使没有本地指令作用于它。不要将此值缓存在寄存器中。”

这就对了。它告诉编译器你的值是易失性的——这个值可能随时被外部逻辑(另一个线程、另一个进程、内核等)改变。它的存在或多或少只是为了抑制编译器优化,编译器优化会默默地将一个值缓存在寄存器中,而该值对于缓存来说本质上是不安全的。

您可能会遇到像“Dr. Dobbs”这样的文章,这些文章将 volatile 视为多线程编程的灵丹妙药。他的方法并非完全没有优点,但它有一个根本缺陷,即让对象的用户对其线程安全负责,这往往与其他违反封装的行为存在相同的问题。