为什么在多线程 C 或者 C + + 编程中,易失性不被认为是有用的?

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

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

因此,如果你有一个全局 int foo,而且 foo是由一个线程读取并由另一个线程自动设置的(可能使用了一个合适的指令) ,那么读取线程看待这种情况的方式与它看待一个由信号处理程序调整的变量或者由外部硬件条件修改的变量的方式是一样的,因此 foo应该被声明为 volatile(或者,对于多线程情况,使用内存隔离加载访问,这可能是一个更好的解决方案)。

我哪里做错了?

66663 次浏览

我不认为你是错的——如果值被线程 A 以外的东西改变了,那么为了保证线程 A 会看到值的改变,就必须使用 volatile。根据我的理解,volatile 基本上是一种告诉编译器“不要在寄存器中缓存这个变量,而是要确保在每次访问时都从 RAM 内存中读/写它”的方式。

这种混淆是因为易失性不足以实现很多事情。特别是,现代系统使用多级缓存,现代多核 CPU 在运行时执行一些花哨的优化,现代编译器在编译时执行一些花哨的优化,这些都可能导致各种副作用以不同的顺序出现,如果你只看源代码的话。

因此,只要你记住,Volatile变量的“观察到的”变化可能不会在你认为它们会发生的确切时间发生,波动性就没有问题。具体来说,不要尝试使用易失性变量作为跨线程同步或排序操作的方法,因为它不会可靠地工作。

个人来说,我的主要(只有?)用作“ please GoAwayNow”布尔值。如果我有一个连续循环的工作线程,我会让它在循环的每次迭代中检查易失性布尔值,如果布尔值为真则退出。然后,主线程可以通过将布尔值设置为 true 来安全地清理辅助线程,然后调用 pthread _ join ()来等待辅助线程消失。

你的理解真的是错误的。

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

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

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

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

由于 C + + 11,原子变量(std::atomic)给我们所有相关的保证。

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. 因此变量必须从内存中重新加载。

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

1)原子性,即如果我向内存读写了一些数据,那么这些数据就会一次读写,不会因为上下文切换而中断或争用

2)一致性,即读/写操作的顺序必须是 看见了,才能在多个并发环境中保持一致——不管是线程还是机器

Volic 既不符合上述两种情况,更具体地说,关于 volic 应该如何表现的 c 或 c + + 标准也不符合上述两种情况。

这在实践中更糟糕,因为一些编译器(例如 Intel Itanium 编译器)确实试图实现一些并发访问安全行为的元素(即通过确保内存隔离) ,但是编译器实现之间没有一致性,而且标准首先并不要求这种实现。

将一个变量标记为 volatile 意味着每次都要强制在内存中刷新这个值,这在很多情况下只会降低代码的速度,因为这基本上降低了缓存的性能。

C # 和 java AFAIK 确实通过使可变性符合1)和2)但是对 c/c + + 编译器不能这么说,所以基本上可以按照你认为合适的方式处理它。

对于一些更深入(虽然不是无偏见的)的主题讨论阅读 这个

您也可以从 Linux 内核文档中考虑这个问题。

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

关于波动性要理解的关键点是它的 目的是抑制优化,这几乎从来没有什么 在内核中,必须保护共享数据 结构来防止不必要的并发访问,这在很大程度上是一种 不同的任务。防止不必要的过程 并发还将避免几乎所有与优化相关的问题 以更有效的方式。

类似的,并发访问的内核原语 数据安全(自旋锁、互斥锁、内存屏障等)的设计目的是 防止不必要的优化。如果正确使用它们, 将不需要使用易失性也。如果易失性仍然是 必要时,几乎可以肯定代码中的某个地方存在 bug 正确编写的内核代码,易失性只能起到减慢速度的作用 放下。

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

spin_lock(&the_lock);
do_something_on(&shared_data);
do_something_else_with(&shared_data);
spin_unlock(&the_lock);

如果所有代码都遵循锁定规则,则 share _ data 的值 在持有 _ lock 时不能意外更改 可能想要玩的数据将等待锁定。 自旋锁原语充当内存屏障——它们是显式的 这意味着数据访问将不会被优化 所以编译器可能认为它知道 Share _ data,而是 spin _ lock ()调用,因为它充当内存 会迫使它忘记它所知道的一切 访问该数据的优化问题。

如果 share _ data 被声明为易失性,那么锁定仍然是 但是编译器也会被阻止优化 访问 share _ data 内心的关键部分,当我们知道 没有其他人可以使用它。当锁被持有, Share _ data 不是易失性的 锁定使易失性变得不必要——并且可能有害。

挥发性记忆体类最初是用于内存映射 I/O 的 在内核中,寄存器访问也应该是 受到锁的保护,但也不希望编译器 在关键部分“优化”寄存器访问。但是,在 在内核中,I/O 内存访问总是通过访问器完成的 函数; 通过指针直接访问 I/O 内存是不允许的 并不能在所有的体系结构上工作 写来防止不必要的优化,所以,再一次,易失性是 没必要。

另一种可能诱使人们使用 volatile 的情况是 处理器正在忙-等待变量的值 繁忙等待的方式是:

while (my_variable != what_i_want)
cpu_relax();

CPU _ release ()调用可以降低 CPU 功耗,或者向 超线程双处理器; 它也恰好用作内存 所以,再一次,不稳定是不必要的。当然, 忙碌的等待通常是一种反社会行为开始。

仍然有一些罕见的情况下,易失性在 内核:

  • 上面提到的访问器函数可能在 直接 I/O 存储器访问工作的架构, 每个访问器调用本身成为一个小的关键部分 确保访问按照程序员的预期进行

  • 更改内存但没有其他内存的内联程序集代码 可见的副作用,风险被 GCC 删除 关键字到 asm 语句将阻止此删除

  • Jiffies 变量的特殊之处在于它可以有不同的值 每次它被引用,但是它可以被读取没有任何特殊的 所以 jiffies 可以是不稳定的,但是加入其他 强烈反对这种类型的变量 在这方面是一个“愚蠢的遗产”问题(莱纳斯的话) ; 解决它 会带来更多的麻烦

  • 指向相干存储器中可能被修改的数据结构的指针 通过 I/O 设备可以,有时,合法的易失性 由网络适配器使用,其中该适配器将指针更改为 指示处理了哪些描述符,就是这样的一个示例 情况类型

对于大多数代码来说,上述关于可变性的理由都不适用。 因此,可能会认为易失性的使用是一个错误和 将对代码带来额外的审查 诱惑使用易失性应该后退一步,并考虑什么 他们真的在努力完成。

返回文章页面常见问题解答:

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

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

在某种意义上,如果编译器对 变量和 pthread _ cond _ wait (或 Pthread _ mutex _ lock)函数 将全局数据的注册副本通过调用保存到外部 因为很难知道这个例行程序是否 以某种方式访问数据的地址。

所以,是的,一个严格遵守(但非常)的编译器是真的 (具有攻击性)到 ANSI C 可能无法在没有 但是最好有人修复它。因为任何系统(即, 内核、库和 C 编译器的组合) 不提供 POSIX 内存一致性保证的不 CONFORM 系统不能要求你使用 为了正确的行为,共享变量上的 只要求有必要使用 POSIX 同步函数。

所以如果你的程序因为你没有使用易失性而中断,那是一个 BUG。 它可能不是 C 中的 bug,或者线程库中的 bug,或者 但它是一个系统错误,并且其中一个或多个组件 得想办法解决。

你不会想要使用易失性,因为,在任何系统上 任何差异,它将远远超过一个适当的成本 非易失性变量。(ANSI C 要求易失性的“序列点” 变量,而 POSIX 只需要它们处于 同步操作——计算密集型线程应用程序 将看到更多的内存活动使用易失性,并且,之后 所有这些,都是记忆活动真正让你慢下来的。)

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

布滕霍夫先生在《 这篇文章:

使用“ volatile”不足以确保正确的内存 线程之间的可见性或同步 除非借助于各种不可携带的机器 代码替代方案,(或者 POSIX 内存的更微妙含义 一般而言,这些规则较难应用,如 互斥对象是必要的。

因此,正如布莱恩解释的那样,挥发性的使用完成了 只不过是为了防止编译器变得有用和令人满意 优化,不提供任何帮助使代码“线程” 当然,你可以申报任何你想要的东西 毕竟,这是一个合法的 ANSI C 存储属性 不要指望它能为您解决任何线程同步问题。

所有这些都同样适用于 C + + 。

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

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

您可能会遇到诸如“ Dr。 Dobbs”之类的文章,它们将不稳定性作为多线程编程的一些灵丹妙药。他的方法并非完全没有价值,但它有一个根本性的缺陷,即让对象的用户对其线程安全负责,这往往与其他违反封装的行为有相同的问题。

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

相反,在多核心多进程共享内存环境中使关键部分线程安全所需的操作被添加为新的实现定义的特性。而且,编译器编写人员不再需要在多进程环境中提供原子访问和访问顺序,他们优先考虑代码缩减,而不是依赖于历史实现的语义。

这意味着关键代码段周围的“易失性”信号量(它们不能在新硬件和新编译器之间工作)可能曾经在旧硬件上与旧编译器一起工作,而旧的示例有时并没有错,只是老了。