为什么易失性在多线程 C 或 C++ 编程中没有用?

Why is volatile not considered useful in multithreaded C or C++ programming?

提问人:Michael Ekstrand 提问时间:3/21/2010 最后编辑:CommunityMichael Ekstrand 更新时间:7/25/2019 访问量:70694

问:

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

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

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

我怎么错了,哪里错了?

C C 多线程易 失性 C++-FAQ

评论

7赞 3/21/2010
所有 volatile 所做的就是编译器不应该缓存对 volatile 变量的访问。它没有提到序列化这种访问。这个问题已经在这里讨论过了不知道多少次,我认为这个问题不会给这些讨论增加任何内容。
17赞 Michael Ekstrand 3/22/2010
@neil,我搜索了其他问题,并找到了一个,但我看到的任何现有解释都无法触发我真正理解为什么我错了所需要的东西。这个问题引出了这样的答案。
1赞 Sass 5/5/2012
有关 CPU 如何处理数据(通过其缓存)的深入研究,请查看:rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf
0赞 Monstieur 4/14/2015
在 Java 中,读取时会创建一个内存屏障,因此它可以用作线程安全标志,表明方法已结束,因为它在设置标志之前与代码强制执行 happens-before 关系。在 C 中并非如此。volatile
2赞 Monstieur 11/15/2018
@curiousguy这就是我所说的“在 C 中不是这种情况”的意思,它可以用来写入硬件寄存器等,而不是像 Java 中常用的那样用于多线程。

答:

15赞 Jeremy Friesner 3/21/2010 #1

我不认为你错了——如果线程 A 以外的其他值更改了值,则 volatile 是必要的,以保证线程 A 将看到值更改。据我了解,volatile 基本上是一种告诉编译器“不要将此变量缓存在寄存器中,而是确保在每次访问时始终从 RAM 内存中读取/写入它”的方法。

这种混淆是因为 volatile 不足以实现许多事情。特别是,现代系统使用多级缓存,现代多核 CPU 在运行时进行一些花哨的优化,而现代编译器在编译时进行一些花哨的优化,这些都可能导致各种副作用以与你只看源代码时所期望的顺序不同的顺序出现。

因此,波动性是可以的,只要你记住,波动性变量中“观察到的”变化可能不会在你认为的确切时间发生。具体而言,不要尝试使用可变变量作为跨线程同步或排序操作的一种方式,因为它无法可靠地工作。

就我个人而言,我对易失性标志的主要(唯一?)用途是作为“pleaseGoAwayNow”布尔值。如果我有一个连续循环的工作线程,我会让它检查循环每次迭代的易失性布尔值,如果布尔值为 true,则退出。然后,主线程可以通过将布尔值设置为 true 来安全地清理工作线程,然后调用 pthread_join() 等待工作线程消失。

评论

8赞 Jeremy Friesner 3/21/2010
显然,只有当工作线程例程的性质保证定期检查布尔值时,它才有效。volatile-bool-flag 保证保持在范围内,因为 thread-shutdown 序列总是发生在保存 volatile-boolean 的对象被销毁之前,并且 thread-shutdown 序列在设置 bool 后调用 pthread_join()。pthread_join() 将阻塞,直到工作线程消失。信号有其自身的问题,尤其是在与多线程结合使用时。
3赞 Jeremy Friesner 3/21/2010
不能保证在布尔值为 true 之前工作线程完成其工作 - 事实上,当 bool 设置为 true 时,它几乎肯定会位于工作单元的中间。但是工作线程何时完成其工作单元并不重要,因为在任何情况下,主线程除了阻塞 pthread_join() 之外不会做任何事情,直到工作线程退出。因此,关闭顺序是井然有序的——易失性布尔值(和任何其他共享数据)在 pthread_join() 返回之前不会被释放,而 pthread_join() 在工作线程消失之前不会返回。
10赞 deft_code 3/22/2010
@Jeremy,你在实践中是正确的,但理论上它仍然可能破裂。在双核系统上,一个内核不断执行工作线程。另一个核心将布尔值设置为 true。但是,不能保证工作线程的核心会看到这种变化,即即使它反复检查布尔值,它可能永远不会停止。c++0x、java 和 c# 内存模型允许此行为。在实践中,这永远不会发生,因为繁忙的线程很可能会在某处插入内存屏障,之后它将看到对布尔值的更改。
4赞 FooF 8/1/2013
拿一个POSIX系统来说,使用实时调度策略,静态优先级比系统中其他进程/线程高,内核足够多,应该完全可以。在 Linux 中,您可以指定实时进程可以使用 100% 的 CPU 时间。如果没有更高优先级的线程/进程,它们将永远不会进行上下文切换,并且永远不会被 I/O 阻塞。但关键是 C/C++ 并不是为了强制执行正确的数据共享/同步语义。我发现搜索特殊情况来证明不正确的代码有时可能有效是无用的练习。SCHED_FIFOvolatile
3赞 David Grayson 9/12/2015
@Deft_code:为什么你认为工作线程可能永远不会看到变化?是因为工作线程不读取变量,还是因为 Supervisor 线程不写入变量?哪个线程需要内存屏障?
7赞 jpalecek 3/21/2010 #2

你的理解真的是错误的。

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

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;
}

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

245赞 jalf 3/21/2010 #3

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

但是,我们必须用于其余属性的基元也提供了这些属性,因此实际上没有必要。volatile

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

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

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

防止重新排序的解决方案是使用内存屏障,该屏障向编译器和 CPU 指示此时不能对内存访问进行重新排序。在易失性变量访问周围设置这样的屏障可以确保即使是非易失性访问也不会在易失性访问之间重新排序,从而允许我们编写线程安全的代码。

然而,内存屏障确保在达到屏障时执行所有挂起的读/写,因此它有效地为我们提供了所需的一切,使得没有必要。我们可以完全删除限定符。volatilevolatile

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

评论

5赞 jalf 3/21/2010
@jbcreix:你问的是哪个“它”?易失性或内存障碍?无论如何,答案几乎是一样的。它们都必须在编译器和 CPU 级别工作,因为它们描述了程序---的可观察行为,因此它们必须确保 CPU 不会对所有内容重新排序,从而改变它们所保证的行为。但是您目前无法编写可移植线程同步,因为内存屏障不是标准 C++ 的一部分(因此它们不可移植),并且不够强大而无法发挥作用。volatile
5赞 OJW 9/16/2011
MSDN 示例执行此操作,并声称无法通过易失性访问对指令进行重新排序:msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80).aspx
31赞 jalf 10/4/2011
@OJW:但是Microsoft的编译器重新定义为一个完整的内存屏障(防止重新排序)。这不是标准的一部分,因此不能在可移植代码中依赖此行为。volatile
15赞 Nemo 10/9/2012
@Skizz:线程本身始终是 C++11 和 C11 之前的平台相关扩展。据我所知,每个提供线程扩展的 C 和 C++ 环境也提供了“内存屏障”扩展。无论如何,对于多线程编程总是无用的。(在 Visual Studio 下除外,其中 volatile 内存屏障扩展。volatile
6赞 Ben Voigt 12/8/2016
@guardian:不,事实并非如此,数据依赖性分析将内存屏障视为一个外部函数,该函数可能会更改任何曾经被别名的变量。(寄存存储局部变量,其地址从未被获取,实际上是完全安全的)。即使在单线程代码中,编译器也无法将其替换为 because may have changed 了值。global_x = 5; extern_call(); cout << global_x;cout << 5;extern_call()
9赞 Potatoswatter 3/21/2010 #4

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尽管不可缓存,但不需要是易失性的,因为

  1. 另一个线程可以访问它。
  2. 这意味着对它的引用必须在某个时间(与运算符一起)进行。&
    • (或者引用了包含结构)
  3. pthread_mutex_lock是一个库函数。
  4. 这意味着编译器无法判断是否以某种方式获取了该引用。pthread_mutex_lock
  5. 这意味着编译器必须假设pthread_mutex_lock修改了共享标志
  6. 因此,必须从内存中重新加载变量。,虽然在这种情况下有意义,但却是无关紧要的。volatile
6赞 zebrabox 3/21/2010 #5

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

1)原子性,即如果我在内存中读取或写入一些数据,那么该数据将一次性读取/写入,并且不会由于上下文切换而中断或争用

2) 一致性,即读/写操作的顺序必须被视为多个并发环境之间的顺序相同 - 无论是线程、机器等

volatile 不符合上述任何一项,或者更具体地说,关于 volatile 应该如何表现的 C 或 C++ 标准不包括上述任何一项。

在实践中更糟糕的是,一些编译器(如英特尔安腾编译器)确实试图实现并发访问安全行为的某些元素(即通过确保内存围栏),但是编译器实现之间没有一致性,而且标准一开始并不要求实现这一点。

将变量标记为可变变量仅意味着您每次都强制将该值刷新到内存或从内存中刷新该值,这在许多情况下只会减慢代码速度,因为您基本上已经破坏了缓存性能。

c# 和 java AFAIK 确实通过使 volatile 粘附在 1) 和 2) 来解决这个问题,但是对于 c/c++ 编译器来说,情况并非如此,因此基本上可以按照您认为合适的方式使用它。

有关该主题的更深入(尽管不是公正的)讨论,请阅读此内容

评论

3赞 Michael Ekstrand 3/22/2010
+1 - 保证原子性是我缺少的另一部分。我假设加载 int 是原子的,因此防止重新排序的易失性在读取端提供了完整的解决方案。我认为这在大多数架构上都是一个不错的假设,但这并不能保证。
0赞 batbrat 9/27/2019
什么时候对内存的单个读取和写入是可中断的和非原子的?有什么好处吗?
62赞 user1831086 3/21/2010 #6

您也可以从 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 的使用很可能被视为一个错误和 将对代码进行额外的审查。开发人员 想使用挥发性应该退后一步,想想什么 他们真的在努力完成。

评论

3赞 Sebastian Mach 12/22/2011
@curiousguy:是的。另请参阅 gcc.gnu.org/onlinedocs/gcc-4.0.4/gcc/Extended-Asm.html
1赞 Joshua Chia 3/29/2016
spin_lock() 看起来像一个常规函数调用。编译器会特别处理它,以便生成的代码将“忘记”在 spin_lock() 之前读取并存储在寄存器中的任何 shared_data 值,以便必须在 spin_lock() 之后的 do_something_on() 中重新读取该值,这有什么特别之处?
1赞 Joshua Chia 7/7/2016
@underscore_d 我的观点是,我无法从函数名称 spin_lock() 中看出它做了一些特别的事情。我不知道里面有什么。特别是,我不知道实现中有什么阻止编译器优化后续读取。
2赞 JustAMartin 7/27/2016
切分有道理。这实质上意味着程序员应该知道这些“特殊功能”的内部实现,或者至少非常了解它们的行为。这引发了额外的问题,例如 - 这些特殊函数是否标准化并保证在所有架构和所有编译器上以相同的方式工作?是否有此类函数的可用列表,或者至少是否有使用代码注释向开发人员发出信号的约定,即相关函数可以保护代码不被“优化”?
1赞 Ben Voigt 7/8/2017
@Tuntable:任何代码都可以通过指针触摸私有静态。它的地址正在被占用。也许数据流分析能够证明指针永远不会转义,但这通常是一个非常困难的问题,程序大小是超线性的。如果您有办法保证不存在别名,那么在旋转锁之间移动访问权限实际上应该是可以的。但是,如果不存在别名,那也是没有意义的。在所有情况下,“调用其主体看不见的函数”行为都是正确的。volatile
6赞 Tony Delroy 10/5/2010 #7

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++。

评论

0赞 jww 8/15/2016
链接已断开;它似乎不再指向你想引用的内容。没有文字,它就是一个毫无意义的答案。
5赞 Zack Yezek 8/2/2014 #8

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

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

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

3赞 david 11/14/2014 #9

根据我的旧 C 标准,“构成对具有可变限定类型的对象的访问的内容是实现定义的”。因此,C 编译器编写者可以选择“易失性”表示“多进程环境中的线程安全访问”。但他们没有。

取而代之的是,在多核多进程共享内存环境中使关键部分线程安全所需的操作被添加为新的实现定义功能。而且,由于摆脱了“易失性”将在多进程环境中提供原子访问和访问顺序的要求,编译器编写者优先考虑代码减少,而不是依赖于历史实现的“易失性”语义。

这意味着关键代码部分周围的“易失性”信号量之类的东西,在新硬件和新编译器上不起作用,可能曾经在旧硬件上与旧编译器一起使用,而旧示例有时没有错,只是旧。

评论

0赞 supercat 7/18/2018
旧示例要求程序由适合低级编程的高质量编译器处理。不幸的是,“现代”编译器认为标准不要求他们以有用的方式处理“易失性”这一事实表明要求他们这样做的代码被破坏了,而不是认识到标准没有努力禁止符合但质量低到无用的实现, 但绝不容忍已经流行的低质量但合规的编译器
0赞 supercat 7/18/2018
在大多数平台上,很容易认识到需要做些什么才能允许人们以依赖于硬件但独立于编译器的方式编写操作系统。要求程序员使用依赖于实现的功能,而不是按要求进行工作,这破坏了制定标准的目的。volatilevolatile