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

188

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

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

因此,如果你有一个全局的int型变量foo,并且foo被一个线程原子地设置并被另一个线程读取(可能使用适当的机器指令),则读取线程会像看到被信号处理程序调整或被外部硬件条件修改的变量一样看待这种情况,因此foo应该被声明为volatile(或者,在多线程情况下,使用内存屏障加载,这可能是更好的解决方案)。

我在哪里出错了?


7
volatile关键字只是告诉编译器不要缓存对volatile变量的访问,但并没有说明要对这种访问进行串行化。这个问题已经被重复讨论过很多次了,我不认为这个问题会给这些讨论增添什么内容。 - anon
17
@neil,我搜索了其他问题,找到了一个,但是我看到的任何现有解释都没有触发我真正理解为什么我错了。这个问题引出了这样的答案。 - Michael Ekstrand
1
如果想要深入研究CPU如何处理数据(通过它们的缓存),请查看以下链接:http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf - Sass
在Java中,volatile在读取时创建一个内存屏障,因此它可以用作线程安全标志,表示方法已经结束,因为它强制执行了在设置标志之前的代码与之间的happens-before关系。但在C中不是这种情况。 - Monstieur
2
@curiousguy,这就是我所说的“在C语言中不适用”的意思,它可以用于写入硬件寄存器等操作,而不像Java中通常用于多线程。 - Monstieur
显示剩余2条评论
9个回答

242
在多线程环境下,volatile存在的问题在于它不能提供我们所需的所有保证。它确实具有我们需要的一些属性,但不是全部,因此我们不能仅依赖volatile
然而,剩余的属性所需使用的基本元素也提供了volatile具备的那些属性,因此volatile实际上是不必要的。
对于共享数据的线程安全访问,我们需要确保以下内容:
  • 读/写实际发生(编译器不会只将值存储在寄存器中并延迟更新主内存)
  • 不发生重新排序。假设我们使用一个volatile变量作为标志来指示某些数据是否准备好进行读取。在我们的代码中,我们只需在准备数据之后设置标志,所以一切看起来都很好。但如果指令被重新排序,以致首先设置标志该怎么办?
volatile确保第一个点。它还保证不会发生不同的volatile读/写之间的重新排序。所有volatile内存访问将按照指定的顺序发生。这就是我们需要的volatile用于操作I/O寄存器或内存映射硬件的全部内容,但它对于多线程代码并不有帮助。在多线程代码中,volatile对象通常仅用于同步访问非易失性数据,这些访问仍然可以相对于volatile进行重新排序。
防止重新排序的解决方案是使用内存屏障,它向编译器和CPU指示不能在此点之间重新排序任何内存访问。在我们的volatile变量访问周围放置这样的屏障可确保即使非易失性访问也不会在volatile访问之间重新排序,从而允许我们编写线程安全的代码。然而,内存屏障还确保当到达屏障时执行所有待处理的读取/写入操作,因此它本身就能够为我们提供所需的所有内容,使volatile变得不必要。我们可以完全删除volatile限定符。
自C++11以来,原子变量(std::atomic<T>)为我们提供了所有相关的保证。

5
MSDN的一个例子展示了这一点,并声称指令无法在volatile访问之后重新排序:http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80).aspx - OJW
31
@OJW说:微软的编译器重新定义了volatile为一个完整的内存屏障(防止重新排序)。这不是标准的一部分,因此您不能在可移植代码中依赖于这种行为。 - jalf
5
@Skizz:不是的,这就是“编译器魔法”发挥作用的地方。内存屏障必须被CPU和编译器同时理解。如果编译器理解了内存屏障的语义,它就知道避免使用那些技巧(以及在屏障之间重新排序读/写操作)。幸运的是,编译器确实理解内存屏障的语义,所以最终一切都能正常工作。 :) - jalf
15
@Skizz:在C++11和C11之前,线程本身始终是平台相关的扩展。据我所知,每个提供线程扩展的C和C++环境也都提供了“内存屏障”扩展。无论如何,volatile对于多线程编程总是无用的。(除了在Visual Studio下,其中volatile 内存屏障扩展。) - Nemo
6
@guardian: 不是这样的,数据依赖分析将内存屏障视为外部函数,可能会更改任何曾经被别名引用过的变量。(地址从未被获取过的寄存器存储本地变量实际上是完全安全的) 即使在单线程代码中,global_x = 5; extern_call(); cout << global_x; 编译器也不能将其替换为 cout << 5;,因为 extern_call() 可能已更改该值。 - Ben Voigt
显示剩余36条评论

60

你也可以考虑从Linux内核文档中了解相关信息。

C程序员通常认为volatile意味着变量可以在当前执行线程之外被更改;因此,当使用共享数据结构时,他们有时会在内核代码中使用它。换句话说,他们已经将易失类型视为一种简单的原子变量,但实际上并非如此。在内核代码中使用volatile几乎从来都是不正确的;本文档描述了其中的原因。
关于易失性的关键点是要理解它的目的是抑制优化,这几乎永远不是人们真正想做的事情。在内核中,必须保护共享数据结构免受不必要的并发访问,这是完全不同的任务。防止不必要的并发性的过程也将以更有效的方式避免几乎所有与优化相关的问题。
像易失性一样,使并发访问数据安全的内核原语(自旋锁、互斥锁、内存屏障等)都是设计用来防止不必要的优化。如果它们被正确地使用,就不需要再使用易失性。如果仍然需要易失性,则几乎肯定存在代码中的错误。在编写正确的内核代码中,易失性只会减慢速度。
考虑一个典型的内核代码块:
spin_lock(&the_lock); do_something_on(&shared_data); do_something_else_with(&shared_data); spin_unlock(&the_lock);
如果所有代码都遵循锁定规则,则在持有the_lock时,shared_data的值不会意外更改。任何想要使用该数据的其他代码都将等待锁定。自旋锁原语充当内存屏障-它们明确地编写为这样-这意味着数据访问不会在它们之间进行优化。因此,编译器可能认为它知道shared_data中的内容,但是由于自旋锁()调用充当内存屏障,它将强制它忘记它所知道的任何内容。对该数据的访问不会出现任何优化问题。
如果将shared_data声明为易失性,则仍然需要锁定。但是,编译器还将防止在关键部分内对shared_data的访问进行优化,因为我们知道没有其他人可以与其一起工作。在保持锁定时,shared_data不是易失性。在处理共享数据时,适当的锁定使易失性变得不必要-并且可能有害。
易失性存储类最初是为内存映射I/O寄存器而设计的。在内核中,寄存器访问也应受到锁定的保护,但是人们也不希望编译器在关键部分内“优化”寄存器访问。但是,在内核中,I/O内存访问始终通过访问器函数进行;直接通过指针访问I/O内存不受欢迎,并且在所有体系结构上都无法工作。这些访问器被编写为防止不必要的优化,因此,一次又一次地,易失性是不必要的。
当处理器正在忙于等待变量的值时,可能会有人想使用易失性。执行繁忙等待的正确方法是:
while (my_variable != what_i_want) cpu_relax();
cpu_relax()调用可以降低CPU功耗或让超线程双处理器让出时间片;它也恰好充当内存屏障,因此,一次又一次地,易

3
@curiousguy: 是的。请参阅http://gcc.gnu.org/onlinedocs/gcc-4.0.4/gcc/Extended-Asm.html 。 - Sebastian Mach
1
spin_lock() 看起来像是一个普通的函数调用。它的特殊之处在于编译器会特别处理它,以便生成的代码会“忘记”在 spin_lock() 之前已经读取并存储在寄存器中的 shared_data 值,因此该值必须在 spin_lock() 后的 do_something_on() 中重新读取。 - Joshua Chia
1
我的观点是,从函数名称spin_lock()无法判断它是否执行了特殊操作。我不知道它的具体内容。特别是,我不知道实现中有什么可以防止编译器优化掉后续读取操作。 - Joshua Chia
2
Syncopated说得很有道理。这意味着程序员应该了解这些“特殊函数”的内部实现,或者至少对它们的行为非常了解。这引发了其他问题,例如-这些特殊函数是否标准化并保证在所有架构和编译器上以相同的方式工作?是否有可用的此类函数列表,或者至少是否有一种惯例使用代码注释来向开发人员发出信号,表明所讨论的函数保护代码免受“被优化掉”的影响? - JustAMartin
1
@Tuntable:私有静态变量可以通过指针被任何代码访问,并且其地址被获取。也许数据流分析能够证明指针从未逃逸,但这通常是一个非常困难的问题,与程序大小超线性相关。如果您有一种保证不存在别名的方法,则将访问移动到自旋锁上实际上是可以的。但是如果不存在别名,则volatile也是无意义的。在所有情况下,“调用无法看到其函数体的函数”的行为都是正确的。 - Ben Voigt
显示剩余10条评论

15

我认为你没有错 - volatile对于保证线程A看到值的改变是必要的,如果这个值被除了线程A之外的其他东西改变了。据我所知,volatile基本上是一种告诉编译器“不要将这个变量缓存到寄存器中,而是确保每次访问都从RAM内存中读取/写入”的方法。

混淆的原因是volatile对于实现许多事情来说是不够的。特别是,现代系统使用多级缓存,现代多核CPU在运行时进行某些花哨的优化,现代编译器在编译时进行某些花哨的优化,所有这些都可能导致各种副作用以不同的顺序出现,这与您仅查看源代码时期望的顺序不同。

因此,只要记住volatile变量的'观察到'更改可能不会在您认为的精确时间发生,volatile就可以使用。具体而言,请勿尝试使用volatile变量作为在线程间同步或排序操作的方法,因为它不能可靠地工作。

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


8
显然,这种方法只有在工作线程的例行程序能够保证定期检查布尔值的情况下才有效。volatile 布尔标志保证其范围始终存在,因为线程关闭序列始终发生在保存 volatile 布尔值的对象被销毁之前,并且线程关闭序列在设置布尔值后调用 pthread_join()。pthread_join() 会阻塞,直到工作线程消失。信号本身也存在问题,特别是在与多线程一起使用时。 - Jeremy Friesner
3
工作线程并不能保证在布尔变量为True之前完成其工作--实际上,它几乎肯定会在工作单元中间执行时布尔变为True。但这并不重要,因为无论如何主线程都不会做任何事情,除了在pthread_join()内阻塞,直到工作线程退出。所以关闭序列是有序的--volatile bool(和任何其他共享数据)将在pthread_join()返回之后才被释放,并且pthread_join()只有在工作线程消失后才会返回。 - Jeremy Friesner
10
@Jeremy,你的实践是正确的,但从理论上讲,它仍然可能出现问题。在双核系统中,一个核心不断执行你的工作线程,而另一个核心将布尔值设置为true。然而,并不能保证工作线程所在的核心会看到那个变化,即使它反复检查布尔值也可能永远无法停止。这种行为是允许的C++0x,Java和C#内存模型中。在实践中,这种情况是不太可能出现的,因为繁忙的线程很可能在某个位置插入了一个内存屏障,在此之后,它就会看到布尔值的改变。 - deft_code
4
使用 POSIX 系统,采用实时调度策略 SCHED_FIFO,比系统中的其他进程/线程具有更高的静态优先级,并且拥有足够的核心,完全可以实现。在 Linux 中,您可以指定实时进程可使用 100% 的 CPU 时间。如果没有更高优先级的线程/进程,它们将不会发生上下文切换,并且永远不会被 I/O 阻塞。但重点是,C/C++ 的 volatile 并不适用于强制实施正确的数据共享/同步语义。我认为搜索特殊情况来证明错误代码可能有时可以工作是无用的练习。 - FooF
3
为什么你认为工作线程可能永远无法看到更改?是因为工作线程没有读取变量还是因为监管线程没有写入变量?哪个线程需要内存屏障? 为了让内容更加通俗易懂而不改变原意,我的翻译如下:为什么你认为工作线程可能永远无法看到变量的更改?是因为工作线程没有读取变量,还是因为监管线程没有写入变量呢?那么,哪个线程需要使用内存屏障呢? - David Grayson
显示剩余6条评论

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不需要是volatile的,尽管它是不可缓存的,因为:

  1. 另一个线程可以访问它。
  2. 这意味着它的引用必须在某个时候被获取(使用&运算符)。
    • (或者对包含结构体的引用被获取)
  3. pthread_mutex_lock是一个库函数。
  4. 这意味着编译器无法确定pthread_mutex_lock是否以某种方式获取了该引用。
  5. 这意味着编译器必须假设pthread_mutex_lock修改了共享标志
  6. 因此,变量必须从内存中重新加载。虽然在这种情况下volatile是有意义的,但它是多余的。

7

您的理解真的是错误的。

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

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

如果优化器足够聪明(它没有改变代码的语义),


6

在并发环境中,要使数据一致,需要满足两个条件:

1)原子性,即如果我读取或写入某些数据到内存,则该数据在一次操作中被读取/写入,并且不会由于上下文切换等原因而被打断或争用。

2)一致性,即读/写操作的顺序必须在多个并发环境(线程、机器等)中“看起来”是相同的。

volatile 不符合上述任何一个条件,或者更确切地说,c 或 c++标准中关于 volatile 的行为方式不包括上述任何一个条件。

实际上,情况更糟糕,因为某些编译器(例如英特尔 Itanium 编译器)确实尝试实现某些元素的并发访问安全行为(即通过确保内存屏障),但是编译器实现之间没有一致性,而且标准本身也没有要求实现这一点。

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

c# 和 Java 似乎通过使 volatile 遵守 1)和 2)来解决了这个问题,但是对于 c/c++ 编译器来说并非如此,因此您可以根据自己的需要使用它。

有关该主题的更深入(尽管不是无偏见的)讨论,请参阅此处


3
+1 - 保证原子性是我之前所缺失的一部分。我假设加载整数是原子性的,因此使用volatile防止重排序可以在读取端提供完整的解决方案。在大多数架构上,我认为这是一个不错的假设,但并不是保证。 - Michael Ekstrand
个人对内存的读写何时是可中断的和非原子性的?有什么好处吗? - batbrat

6
comp.programming.threads常见问题解答FAQ中有Dave Butenhof的一篇经典的解释

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

然而,我关心的是编译器和线程库都满足各自的规范的情况。符合C规范的编译器可以全局分配一些共享(非volatile)变量到一个寄存器中,并在CPU从一个线程传递到另一个线程时保存并恢复该寄存器。每个线程将拥有自己的私有值,这不是我们从共享变量所期望的。

在某种意义上,如果编译器足够了解变量和pthread_cond_wait(或pthread_mutex_lock)函数的相应作用域,那么这是真实的。事实上,大多数编译器不会尝试跨越调用外部函数保留全局数据的寄存器副本,因为很难知道该例程是否能以某种方式访问数据的地址。

因此,确实,严格(但非常激进)遵守ANSI C的编译器可能不会在没有volatile的情况下与多个线程一起工作。但是必须有人解决这个问题,因为任何未提供POSIX内存一致性保证的系统(实际上,是内核、库和C编译器的组合)都不符合POSIX标准。该系统不能要求您在共享变量上使用volatile以获得正确的行为,因为POSIX仅要求使用POSIX同步函数。

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

您不想使用volatile,因为在任何使其产生差异的系统上,它都比适当的非volatile变量昂贵得多。 (ANSI C要求每个表达式在volatile变量处具有“序列点”,而POSIX仅在同步操作处需要它们 - 计算密集型的线程化应用程序将使用volatile看到更多的内存活动,并且归根结底,真正使您的速度变慢的是内存活动。)

/---[ Dave Butenhof ]-----------------------[ butenhof@zko.dec.com ]---\
| Digital Equipment Corporation 110 Spit Brook Rd ZKO2-3/Q18 |
| 603.881.2218, FAX 603.881.0120 Nashua NH 03062-2698 |
-----------------[ Better Living Through Concurrency ]----------------/

Butenhof先生在这篇usenet文章中讨论了许多相同的问题:(点击此处查看)
“volatile”的使用不足以确保线程之间的正确内存可见性或同步。使用互斥锁是足够的,除非通过各种非可移植的机器码替代方案(或更微妙的POSIX内存规则的暗示,在我之前的帖子中解释了这些规则更难以普遍应用),否则互斥锁是必需的。
因此,正如Bryan所解释的那样,使用volatile仅仅防止编译器做出有用且可取的优化,对于使代码“线程安全”毫无帮助。当然,您可以将任何东西声明为“volatile”——毕竟,这是合法的ANSI C存储属性。只是不要指望它能为您解决任何线程同步问题。
所有这些同样适用于C ++。

链接已经失效,它似乎不再指向你想引用的内容了。如果没有这个文本,它就成了一个毫无意义的回答。 - jww

5
这就是"volatile"所做的全部内容: “嘿,编译器,这个变量可能在任何时刻(在任何时钟周期)发生改变,即使没有任何本地指令作用于它。不要将此值缓存到寄存器中。”
只有这些。它告诉编译器你的值是易变的——这个值可能会被外部逻辑(另一个线程、另一个进程、内核等)随时更改。它存在的主要目的是抑制编译器优化,这些优化会在本质上不安全的情况下静默地缓存一个值到寄存器中。
你可能会遇到像“Dr. Dobbs”那样的文章,宣传“volatile”是多线程编程的万能药。他的方法并非完全没有优点,但它有一个根本性缺陷,即使对象的用户对其线程安全负责,也容易出现封装违规的问题。

3
根据我的旧C标准,"构成对具有volatile限定类型的对象的访问是实现定义的"。因此,C编译器编写者可以选择使"volatile"表示"在多进程环境下的线程安全访问"。但他们并没有这样做。
相反,在多核多进程共享内存环境中使关键部分线程安全所需的操作被作为新的实现定义特性添加了进来。由于不需要"volatile"提供原子访问和访问排序的多进程环境,编译器编写者将代码缩减优先于历史实现相关的"volatile"语义。
这意味着像"volatile"信号量之类的关键代码段周围,它们在新的硬件和新的编译器上不能正常工作,可能曾经在旧的硬件和旧的编译器上工作过,而且旧的示例有时候并没有错,只是老旧了。

旧的例子要求程序使用适合低级编程的高质量编译器进行处理。不幸的是,“现代”编译器已经认为标准没有要求它们以有用的方式处理“易失性”,这表明需要它们这样做的代码是错误的,而不是认识到标准没有努力禁止符合但质量低劣到无用的实现,但并不以任何方式容忍低质量但符合的编译器已经变得流行。 - supercat
在大多数平台上,识别volatile需要做什么以允许以硬件相关但编译器无关的方式编写操作系统是相当容易的。要求程序员使用实现相关的特性而不是使volatile按照要求工作会削弱制定标准的目的。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接