提问人:Michael Ekstrand 提问时间:3/21/2010 最后编辑:CommunityMichael Ekstrand 更新时间:7/25/2019 访问量:70694
为什么易失性在多线程 C 或 C++ 编程中没有用?
Why is volatile not considered useful in multithreaded C or C++ programming?
问:
正如我最近发布的这个答案所展示的那样,我似乎对多线程编程环境中的效用(或缺乏效用)感到困惑。volatile
我的理解是这样的:任何时候,只要一个变量可以在访问它的一段代码的控制流之外被更改,那么该变量应该被声明为 .信号处理程序、I/O 寄存器和由另一个线程修改的变量都构成了这种情况。volatile
因此,如果你有一个全局 int ,并且由一个线程读取并由另一个线程进行原子设置(可能使用适当的机器指令),则读取线程看到这种情况的方式与它看到由信号处理程序调整或由外部硬件条件修改的变量的方式相同,因此应该声明 (或者, 对于多线程情况,使用内存围栏负载进行访问,这可能是更好的解决方案)。foo
foo
foo
volatile
我怎么错了,哪里错了?
答:
我不认为你错了——如果线程 A 以外的其他值更改了值,则 volatile 是必要的,以保证线程 A 将看到值更改。据我了解,volatile 基本上是一种告诉编译器“不要将此变量缓存在寄存器中,而是确保在每次访问时始终从 RAM 内存中读取/写入它”的方法。
这种混淆是因为 volatile 不足以实现许多事情。特别是,现代系统使用多级缓存,现代多核 CPU 在运行时进行一些花哨的优化,而现代编译器在编译时进行一些花哨的优化,这些都可能导致各种副作用以与你只看源代码时所期望的顺序不同的顺序出现。
因此,波动性是可以的,只要你记住,波动性变量中“观察到的”变化可能不会在你认为的确切时间发生。具体而言,不要尝试使用可变变量作为跨线程同步或排序操作的一种方式,因为它无法可靠地工作。
就我个人而言,我对易失性标志的主要(唯一?)用途是作为“pleaseGoAwayNow”布尔值。如果我有一个连续循环的工作线程,我会让它检查循环每次迭代的易失性布尔值,如果布尔值为 true,则退出。然后,主线程可以通过将布尔值设置为 true 来安全地清理工作线程,然后调用 pthread_join() 等待工作线程消失。
评论
SCHED_FIFO
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
问题是,这不是我们想要的线程安全属性。
例如,线程安全计数器只是(类似 linux 内核的代码,不知道 c++0x 等效项):
atomic_t counter;
...
atomic_inc(&counter);
这是原子的,没有内存障碍。如有必要,应添加它们。添加 volatile 可能无济于事,因为它不会将访问与附近的代码相关联(例如,将元素附加到计数器正在计数的列表中)。当然,您不需要看到程序之外的计数器递增,并且优化仍然是可取的,例如。
atomic_inc(&counter);
atomic_inc(&counter);
仍然可以优化到
atomically {
counter+=2;
}
如果优化器足够智能(它不会改变代码的语义)。
在多线程上下文中的问题在于,它不能提供我们需要的所有保证。它确实有一些我们需要的属性,但不是全部,所以我们不能单独依赖。volatile
volatile
但是,我们必须用于其余属性的基元也提供了这些属性,因此实际上没有必要。volatile
对于对共享数据的线程安全访问,我们需要保证:
- 读/写实际上发生了(编译器不会只是将值存储在寄存器中,而是将更新主存储器推迟到很久以后)
- 不会发生重新排序。假设我们使用一个变量作为标志来指示某些数据是否准备好被读取。在我们的代码中,我们只需在准备数据后设置标志,因此一切看起来都很好。但是,如果对指令进行重新排序,以便首先设置标志呢?
volatile
volatile
确实保证了第一点。它还保证在不同的易失性读/写之间不会发生重新排序。所有内存访问都将按指定的顺序进行。这就是我们所需要的一切:操作 I/O 寄存器或内存映射硬件,但它在多线程代码中没有帮助,因为在多线程代码中,对象通常仅用于同步对非易失性数据的访问。这些访问仍然可以相对于这些访问重新排序。volatile
volatile
volatile
volatile
防止重新排序的解决方案是使用内存屏障,该屏障向编译器和 CPU 指示此时不能对内存访问进行重新排序。在易失性变量访问周围设置这样的屏障可以确保即使是非易失性访问也不会在易失性访问之间重新排序,从而允许我们编写线程安全的代码。
然而,内存屏障也确保在达到屏障时执行所有挂起的读/写,因此它有效地为我们提供了所需的一切,使得没有必要。我们可以完全删除限定符。volatile
volatile
从 C++11 开始,原子变量 () 为我们提供了所有相关的保证。std::atomic<T>
评论
volatile
volatile
volatile
global_x = 5; extern_call(); cout << global_x;
cout << 5;
extern_call()
volatile
对于实现自旋锁互斥锁的基本结构很有用(尽管还不够),但是一旦你有了它(或更高级的东西),你就不需要另一个了。volatile
多线程编程的典型方法不是在机器级别保护每个共享变量,而是引入引导程序流的保护变量。而不是你应该有volatile bool my_shared_flag;
pthread_mutex_t flag_guard_mutex; // contains something volatile
bool my_shared_flag;
这不仅封装了“困难的部分”,而且从根本上说是必要的: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 );
my_shared_flag
尽管不可缓存,但不需要是易失性的,因为
- 另一个线程可以访问它。
- 这意味着对它的引用必须在某个时间(与运算符一起)进行。
&
- (或者引用了包含结构)
pthread_mutex_lock
是一个库函数。- 这意味着编译器无法判断是否以某种方式获取了该引用。
pthread_mutex_lock
- 这意味着编译器必须假设
pthread_mutex_lock
修改了共享标志! - 因此,必须从内存中重新加载变量。,虽然在这种情况下有意义,但却是无关紧要的。
volatile
要使数据在并发环境中保持一致,需要应用两个条件:
1)原子性,即如果我在内存中读取或写入一些数据,那么该数据将一次性读取/写入,并且不会由于上下文切换而中断或争用
2) 一致性,即读/写操作的顺序必须被视为多个并发环境之间的顺序相同 - 无论是线程、机器等
volatile 不符合上述任何一项,或者更具体地说,关于 volatile 应该如何表现的 C 或 C++ 标准不包括上述任何一项。
在实践中更糟糕的是,一些编译器(如英特尔安腾编译器)确实试图实现并发访问安全行为的某些元素(即通过确保内存围栏),但是编译器实现之间没有一致性,而且标准一开始并不要求实现这一点。
将变量标记为可变变量仅意味着您每次都强制将该值刷新到内存或从内存中刷新该值,这在许多情况下只会减慢代码速度,因为您基本上已经破坏了缓存性能。
c# 和 java AFAIK 确实通过使 volatile 粘附在 1) 和 2) 来解决这个问题,但是对于 c/c++ 编译器来说,情况并非如此,因此基本上可以按照您认为合适的方式使用它。
有关该主题的更深入(尽管不是公正的)讨论,请阅读此内容
评论
您也可以从 Linux 内核文档中考虑这一点。
C 程序员经常将 volatile 视为变量 可以在当前执行线程之外进行更改;作为 结果,当 正在使用共享数据结构。换句话说,他们一直 已知将挥发性类型视为一种简单的原子变量,它 他们不是。在内核代码中使用 volatile 几乎从不 正确;本文档介绍了原因。
关于挥发性,要理解的关键点是它的 目的是压制优化,这几乎从来都不是什么 真的很想做。在内核中,必须保护共享数据 防止不需要的并发访问的结构,这在很大程度上是一个 不同的任务。防止不必要的过程 并发性还可以避免几乎所有与优化相关的问题 以更有效的方式。
与 volatile 一样,内核原语可以并发访问 数据安全(自旋锁、互斥锁、内存屏障等)旨在 防止不必要的优化。如果它们使用得当,有 也不需要使用挥发性。如果挥发性仍 必要时,几乎可以肯定代码中的某处存在错误。在 正确编写的内核代码,易失性只能起到减慢事情的作用 下。
考虑一个典型的内核代码块:
spin_lock(&the_lock); do_something_on(&shared_data); do_something_else_with(&shared_data); spin_unlock(&the_lock);
如果所有代码都遵循锁定规则,则值为 shared_data 在举行the_lock时不能意外更改。任何其他代码 可能想要玩弄该数据将等待锁定。 自旋锁基元充当内存屏障 - 它们是显式的 为此而编写 - 这意味着数据访问不会得到优化 穿过他们。因此,编译器可能会认为它知道将要包含的内容 shared_data,但 spin_lock() 调用,因为它充当内存 障碍,会迫使它忘记它所知道的任何事情。不会有 访问该数据的优化问题。
如果shared_data被声明为易失性,则锁定仍将是 必要。但是编译器也会被阻止优化 当我们知道时,可以在关键部分访问shared_data 没有其他人可以使用它。当锁被握住时, shared_data不易失。在处理共享数据时,适当的 锁定使易失性变得不必要,并且可能有害。
易失性存储类最初用于内存映射 I/O 寄存 器。在内核中,寄存器访问也应该 受锁保护,但也不需要编译器 “优化”关键部分内的寄存器访问。但是,在 内核,I/O 内存访问始终通过访问器完成 功能;直接通过指针访问 I/O 内存是皱眉头 不适用于所有架构。这些访问器是 编写是为了防止不必要的优化,因此,再一次,volatile 是 必要。
人们可能想使用挥发性的另一种情况是当 处理器正忙于等待变量的值。右边 执行繁忙等待的方法是:
while (my_variable != what_i_want) cpu_relax();
cpu_relax() 调用可以降低 CPU 功耗或让位于 超线程双处理器;它也恰好可以作为记忆 屏障,因此,再一次,挥发性是不必要的。答案是肯定的 忙碌等待通常是一种反社会行为。
在极少数情况下,挥发性在以下情况下是有意义的 内核:
上述访问器函数可能会使用 volatile on 直接 I/O 内存访问确实有效的体系结构。本质上 每个访问器调用本身都成为一个小的关键部分,并且 确保访问按程序员的预期进行。
内联汇编代码,它更改内存,但没有其他内存 可见的副作用,有被GCC删除的风险。添加挥发性 关键字添加到 asm 语句中,将阻止此删除。
jiffies 变量的特殊之处在于它可以具有不同的值 每次引用,但无需任何特殊即可读取 锁定。所以 jiffies 可以是不稳定的,但添加其他 这种类型的变量是强烈反对的。Jiffies 被考虑 在这方面成为一个“愚蠢的遗产”问题(莱纳斯的话);修复它 会比它的价值更麻烦。
指向相干内存中可能被修改的数据结构的指针 有时,I/O 设备可以合法地不稳定。环形缓冲区 由网络适配器使用,其中该适配器将指针更改为 指示已处理哪些描述符,就是一个示例 情况类型。
对于大多数代码,上述易失性理由均不适用。 因此,volatile 的使用很可能被视为一个错误和 将对代码进行额外的审查。开发人员 想使用挥发性应该退后一步,想想什么 他们真的在努力完成。
评论
volatile
comp.programming.threads FAQ 有 Dave Butenhof 的经典解释:
问题 56:为什么我不需要声明共享变量 VOLATILE?
但是,我担心编译器和 线程库满足其各自的规范。符合要求 C 编译器可以全局分配一些共享(非易失性)变量到 一个寄存器,当 CPU 从 线程到线程。每个线程都有自己的私有值 这个共享变量,这不是我们想要的共享变量 变量。
从某种意义上说,这是真的,如果编译器对 变量和pthread_cond_wait的相应作用域(或 pthread_mutex_lock) 函数。在实践中,大多数编译器不会尝试 在对外部的调用中保留全局数据的寄存器副本 函数,因为很难知道例程是否可能 以某种方式可以访问数据的地址。
所以是的,一个严格遵守的编译器确实是(但非常 积极地)到 ANSI C 可能不适用于没有 挥发性的。但最好有人修复它。因为任何 SYSTEM(即 实际上,内核、库和 C 编译器的组合), 不提供 POSIX 内存一致性保证 不符合 符合 POSIX 标准。时期。系统不能要求您使用 可变的共享变量的正确行为,因为 POSIX 仅要求 POSIX 同步功能是必需的。
因此,如果你的程序因为你没有使用易失性而中断,那就是一个错误。 它可能不是 C 语言中的错误,也不是线程库中的错误,也不是 内核。但这是一个系统错误,以及其中一个或多个组件 将不得不努力修复它。
你不想使用volatile,因为,在任何它使 任何区别,它都会比适当的贵得多 非易失性变量。(ANSI C 要求“序列点”用于易失性 每个表达式的变量,而 POSIX 只需要它们 同步操作 -- 计算密集型线程应用程序 将看到更多的内存活动使用 volatile,并且,之后 总而言之,真正减慢你速度的是记忆活动。
/---[ 戴夫·布滕霍夫 ]-----------------------[ [email protected] ]---\
|数字设备公司 110 Spit Brook Rd ZKO2-3/Q18 |
|603.881.2218, 传真 603.881.0120 Nashua NH 03062-2698 |
-----------------[ 通过并发实现更好的生活 ]----------------/
Butenhof先生在这篇Usenet帖子中涵盖了许多相同的内容:
使用“volatile”不足以确保适当的内存 线程之间的可见性或同步。互斥锁的使用是 足够了,并且,除非求助于各种非便携式机器 代码替代,(或 POSIX 内存的更微妙的含义 通常更难应用的规则,如 我之前的帖子),互斥锁是必要的。
因此,正如 Bryan 所解释的那样,使用挥发性可以完成 无非是为了阻止编译器使有用和可取的 优化,在制作代码“线程”方面没有任何帮助 安全”。当然,欢迎您声明任何您想要的内容 “易失性”——毕竟,这是一个合法的 ANSI C 存储属性。只 不要指望它能为您解决任何线程同步问题。
所有这些都同样适用于 C++。
评论
这就是“挥发性”所做的一切: “嘿编译器,这个变量可以随时更改(在任何时钟滴答声上),即使没有本地指令作用于它。不要在寄存器中缓存此值。
那就是 IT。它告诉编译器,你的值是可变的——这个值可以随时被外部逻辑(另一个线程、另一个进程、内核等)改变。它的存在或多或少只是为了抑制编译器优化,这些优化将静默地缓存在寄存器中的值,而该值本质上是不安全的。
你可能会遇到像“Dr. Dobbs”这样的文章,这些文章将易失性作为多线程编程的灵丹妙药。他的方法并非完全没有优点,但它有一个根本的缺陷,即让对象的用户对其线程安全负责,这往往与其他封装冲突存在相同的问题。
根据我的旧 C 标准,“构成对具有可变限定类型的对象的访问的内容是实现定义的”。因此,C 编译器编写者可以选择“易失性”表示“多进程环境中的线程安全访问”。但他们没有。
取而代之的是,在多核多进程共享内存环境中使关键部分线程安全所需的操作被添加为新的实现定义功能。而且,由于摆脱了“易失性”将在多进程环境中提供原子访问和访问顺序的要求,编译器编写者优先考虑代码减少,而不是依赖于历史实现的“易失性”语义。
这意味着关键代码部分周围的“易失性”信号量之类的东西,在新硬件和新编译器上不起作用,可能曾经在旧硬件上与旧编译器一起使用,而旧示例有时没有错,只是旧。
评论
volatile
volatile
下一个:定义和声明有什么区别?
评论
volatile