175

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

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

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

我怎么错了?

4

9 回答 9

229

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

但是,我们必须为其余属性使用的原语也提供了可以使用的原语volatile,因此实际上没有必要。

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

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

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

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

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

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

于 2010-03-20T23:17:02.897 回答
54

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

C 程序员经常认为 volatile 意味着可以在当前执行线程之外更改变量;因此,当使用共享数据结构时,他们有时会在内核代码中使用它。换句话说,众所周知,他们将 volatile 类型视为一种简单的原子变量,但事实并非如此。在内核代码中使用 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);

如果所有代码都遵循锁定规则,shared_data 的值不会在持有 the_lock 时发生意外变化。任何其他可能想要使用该数据的代码都将在锁上等待。自旋锁原语充当内存屏障——它们被明确写入这样做——这意味着数据访问不会在它们之间进行优化。所以编译器可能认为它知道 shared_data 中的内容,但是 spin_lock() 调用,因为它充当内存屏障,将迫使它忘记它知道的任何内容。访问该数据不会有优化问题。

如果 shared_data 被声明为 volatile,则仍然需要锁定。但是当我们知道没有其他人可以使用它时,编译器也将被阻止优化对临界区中 shared_data 的访问持有锁时,shared_data 不是易失性的。在处理共享数据时,适当的锁定使 volatile 变得不必要 - 并且可能有害。

volatile 存储类最初用于内存映射 I/O 寄存器。在内核中,寄存器访问也应该受到锁的保护,但也不希望编译器“优化”临界区中的寄存器访问。但是,在内核中,I/O 内存访问总是通过访问函数完成的;直接通过指针访问 I/O 内存是不受欢迎的,并且不适用于所有架构。这些访问器是为了防止不必要的优化而编写的,所以再一次, volatile 是不必要的。

另一种可能会使用 volatile 的情况是处理器忙于等待变量的值。执行繁忙等待的正确方法是:

while (my_variable != what_i_want)
    cpu_relax();

cpu_relax() 调用可以降低 CPU 功耗或让给超线程双处理器;它也恰好用作内存屏障,因此,再一次, volatile 是不必要的。当然,忙于等待通常是一种反社会行为。

在内核中仍然有一些罕见的情况下 volatile 有意义:

  • 上述访问器函数可能会在直接 I/O 内存访问确实有效的架构上使用 volatile。本质上,每个访问器调用本身都成为一个小的关键部分,并确保访问按程序员的预期进行。

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

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

  • 指向可能被 I/O 设备修改的连贯内存中的数据结构的指针有时可能是合法的易失性。网络适​​配器使用的环形缓冲区(该适配器更改指针以指示已处理哪些描述符)就是这种情况的一个示例。

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

于 2010-03-21T02:59:42.327 回答
14

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

混乱是因为 volatile 不足以实现许多事情。特别是现代系统使用多级缓存,现代多核 CPU 在运行时进行一些花哨的优化,现代编译器在编译时进行一些花哨的优化,这些都可能导致各种副作用以不同的方式出现。如果您只是查看源代码,请按照您期望的顺序进行排序。

所以 volatile 很好,只要您记住 volatile 变量中的“观察到的”变化可能不会在您认为它们会发生的确切时间发生。具体来说,不要尝试使用 volatile 变量作为跨线程同步或排序操作的一种方式,因为它不会可靠地工作。

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

于 2010-03-20T22:19:57.487 回答
9

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,虽然在这种情况下有意义,但是是无关的。
于 2010-03-20T23:18:09.367 回答
7

你的理解确实是错误的。

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

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

于 2010-03-20T22:43:18.467 回答
6

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

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

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

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

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

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

c# 和 java AFAIK 确实通过使 volatile 遵守 1) 和 2) 来解决这个问题,但是对于 c/c++ 编译器来说不能这样说,所以基本上按照你认为合适的方式使用它。

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

于 2010-03-21T01:28:08.593 回答
6

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

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

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

从某种意义上说,如果编译器对变量和 pthread_cond_wait(或 pthread_mutex_lock)函数的各自范围有足够的了解,这是正确的。实际上,大多数编译器不会尝试在调用外部函数时保留全局数据的寄存器副本,因为很难知道例程是否可能以某种方式访问​​数据的地址。

所以是的,确实,严格(但非常积极地)符合 ANSI C 的编译器可能无法在没有 volatile 的情况下使用多个线程。但最好有人修复它。因为任何不提供 POSIX 内存一致性保证的系统(实际上是内核、库和 C 编译器的组合)都不符合 POSIX 标准。时期。系统不能要求您在共享变量上使用 volatile 以获得正确的行为,因为 POSIX 只要求 POSIX 同步函数是必需的。

所以如果你的程序因为你没有使用 volatile 而中断,那就是一个 BUG。它可能不是 C 中的错误,也可能不是线程库中的错误,也可能不是内核中的错误。但这是一个系统错误,其中一个或多个组件必须努力修复它。

您不想使用 volatile,因为在任何有影响的系统上,它都会比适当的非易失性变量昂贵得多。(ANSI C 需要在每个表达式中为 volatile 变量提供“序列点”,而 POSIX 仅在同步操作中需要它们——计算密集型线程应用程序将使用 volatile 看到更多的内存活动,毕竟,这是内存活动真的让你慢下来。)

/---[戴夫布滕霍夫]-----------[butenhof@zko.dec.com]---\
| Digital Equipment Corporation 110 Spit Brook Rd ZKO2-3/Q18 |
| 603.881.2218,传真 603.881.0120 纳舒厄 NH 03062-2698 |
-----------------[通过并发改善生活]----------------/

Butenhof 先生在这篇 usenet 帖子中涵盖了许多相同的领域:

使用“易失性”不足以确保正确的内存可见性或线程之间的同步。使用互斥体就足够了,而且,除了求助于各种不可移植的机器代码替代方案(或者更难以普遍应用的 POSIX 内存规则的更微妙含义,如我之前的帖子中所解释的那样),互斥锁是必要的。

因此,正如 Bryan 解释的那样,使用 volatile 只会阻止编译器进行有用且理想的优化,对使代码“线程安全”没有任何帮助。当然,欢迎您将任何您想要的东西声明为“易失性”——毕竟这是一个合法的 ANSI C 存储属性。只是不要指望它能为您解决任何线程同步问题。

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

于 2010-10-05T08:05:10.667 回答
5

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

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

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

于 2014-08-02T01:54:26.663 回答
3
于 2014-11-14T11:34:30.070 回答