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


165

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

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

因此,如果您有一个全局int foo,并且foo被一个线程读取,并由另一个线程自动设置(可能使用适当的机器指令),则读取线程会以这种方式看到这种情况,就像看到一个由信号处理程序或由外部硬件条件修改,因此foo应声明volatile(或在多线程情况下,以内存限制的负载访问,这可能是一个更好的解决方案)。

我怎么在哪里错?


7
所有volatile都表示编译器不应缓存对volatile变量的访问。它并没有说明对此类访问进行序列化。我不知道讨论过多少次,并且我认为这个问题不会为讨论增加任何东西。

4
再一次,这是一个不应该得到的问题,在此之前被问了很多遍才被接受。请您别再这样做了。

14
@neil我搜索了其他问题,但发现了一个问题,但是我看到的任何现有解释都无法触发我真正理解我为什么错所需要的东西。这个问题引出了这样的答案。
Michael Ekstrand

1
有关CPU处理数据(通过其缓存)的深入研究,请访问:rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf
Sassafras_wot,

1
@curiousguy这就是我所说的“不是C的情况”,它可以用于写入硬件寄存器等,并且不像Java中常用的那样用于多线程。
Monstieur

Answers:


213

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

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

为了对共享数据进行线程安全访问,我们需要保证:

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

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

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

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

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


5
@jbcreix:您要问哪个“它”?易失性或记忆障碍?无论如何,答案几乎是相同的。它们都必须在编译器和CPU级别上工作,因为它们描述了程序的可观察行为---因此,它们必须确保CPU不会对所有内容重新排序,从而改变了它们所保证的行为。但是您目前无法编写可移植线程同步,因为内存屏障不是标准C ++的一部分(因此它们不是可移植的),并且volatile不够强大,无法使用。
jalf

4
一个MSDN示例做到了这一点,并声称不能通过易失性访问对指令进行重新排序:msdn.microsoft.com/en-us/library/12a04hfd
v=vs.80).aspx

27
@OJW:但是Microsoft的编译器重新定义volatile为完整的内存屏障(防止重新排序)。这不是标准的一部分,因此您不能在可移植代码中依赖此行为。
jalf

4
@Skizz:不,这就是公式的“编译器魔术”部分出现的地方。CPU和编译器必须理解内存障碍。如果编译器了解内存屏障的语义,那么它将知道避免类似的技巧(以及重新排序跨屏障的读取/写入)。幸运的是,编译器确实了解内存屏障的语义,因此最终一切都可以解决。:)
jalf 2012年

13
@Skizz:在C ++ 11和C11之前,线程本身始终是依赖于平台的扩展。据我所知,每个提供线程扩展的C和C ++环境也都提供了“内存屏障”扩展。无论如何,volatile对于多线程编程总是无用的。(在Visual Studio中除外,其中volatile 内存屏障扩展。)
Nemo 2012年

49

您也可以从Linux Kernel Documentation中考虑这一点。

C程序员经常用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);

如果所有代码都遵循锁定规则,则在保持the_lock的情况下shared_data的值不会意外更改。其他任何可能要与该数据一起使用的代码都将在锁上等待。自旋锁原语充当内存屏障-它们被明确编写为这样做-意味着不会在它们之间优化数据访问。因此,编译器可能认为自己知道了shared_data中的内容,但是spin_lock()调用由于充当内存屏障,将迫使其忘记所有已知信息。访问该数据不会有优化问题。

如果shared_data声明为volatile,则仍然需要锁定。但是,编译器也将被从优化访问shared_data防止的关键部分,当我们知道没有其他人可以用它来工作。持有锁定时,shared_data不会不稳定。在处理共享数据时,适当的锁定使volatile变得不必要-并可能有害。

易失性存储类最初用于内存映射的I / O寄存器。在内核中,寄存器访问也应该受到锁的保护,但是也不希望编译器在关键部分内“优化”寄存器访问。但是,在内核中,I / O内存访问总是通过访问器功能完成的。直接通过指针访问I / O内存的方法被皱了皱眉,并且不适用于所有体系结构。编写这些访问器是为了防止不必要的优化,因此,再次不需要volatile。

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

while (my_variable != what_i_want)
    cpu_relax();

调用cpu_relax()可以降低CPU功耗或降低超线程双处理器的功耗。它也恰好是一个内存屏障,因此,再一次不需要挥发。当然,开始忙碌通常是一种反社会行为。

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

  • 在直接I / O内存访问确实起作用的体系结构上,上述访问器功能可能会使用volatile。从本质上讲,每个访问器调用本身都会变成一个关键部分,并确保按程序员的预期进行访问。

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

  • jiffies变量的特殊之处在于,每次引用时它都可以具有不同的值,但是无需任何特殊锁定即可读取它。因此jiffies可能是易变的,但是强烈反对添加此类其他变量。在这方面,吉菲斯被认为是“愚蠢的遗产”问题(利纳斯的话);修复它比它值得的麻烦更多。

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

对于大多数代码,以上所有关于volatile的理由均不适用。结果,使用volatile可能会被视为一个错误,并将对代码进行更多审查。试图使用volatile的开发人员应该退后一步,思考他们真正想要实现的目标。



1
spin_lock()看起来像常规函数调用。它的特殊之处在于,编译器将对其进行特殊处理,以使生成的代码将“忘记”在spin_lock()之前已读取并存储在寄存器中的shared_data的任何值,因此必须重新读取该值。在spin_lock()之后执行do_something_on()?
切分

1
@underscore_d我的观点是,我无法从函数名称spin_lock()看出它做了一些特别的事情。我不知道里面有什么。特别是,我不知道实现中有什么会阻止编译器优化后续读取。
切分

1
Syncopated有一个好处。从本质上讲,这意味着程序员应该了解那些“特殊功能”的内部实现,或者至少非常了解其行为。这就提出了其他问题,例如-这些特殊功能是否已标准化并保证在所有体系结构和所有编译器上均以相同的方式工作?是否有可用的此类功能列表,或者至少存在使用代码注释向开发人员发出信号的约定,即有关功能可以保护代码免于“被优化”?
JustAMartin

1
@Tuntable:任何代码都可以通过指针来触摸私有静态变量。并且其地址正在被占用。也许数据流分析能够证明指针永远不会逃逸,但这通常是一个非常困难的问题,程序大小是超线性的。如果您可以保证不存在别名,那么实际上可以通过自旋锁移动访问。但是,如果不存在别名,volatile也是毫无意义的。在所有情况下,“调用无法看到其身体的功能”行为都是正确的。
Ben Voigt

11

我不认为您是错的-如果值是由线程A以外的其他值更改的,那么volatile是必须的,以确保线程A看到值更改。据我所知,volatile基本上是一种告诉变量的方法。编译器“不要在寄存器中缓存此变量,而是确保每次访问时始终从RAM存储器中读取/写入它”。

造成混淆的原因是,volatile不足以实现许多事情。特别是,现代系统使用多级缓存,现代多核CPU在运行时进行一些优化,现代编译器在编译时进行一些优化,所有这些都会导致各种副作用以不同的方式出现。如果仅查看源代码,则顺序会与您期望的顺序相反。

只要您牢记volatile变量的“观察到”变化可能不会在您认为会发生的确切时间发生,那么volatile就可以了。具体来说,不要尝试使用易变变量作为跨线程同步或排序操作的方法,因为它不能可靠地工作。

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


2
您的布尔标志可能不安全。您如何保证工作人员完成其任务,并确保该标志一直保留在作用域中,直到被读取(如果已读取)?这是信号的工作。如果不涉及互斥锁,Volatile非常适合实现简单的自旋锁,因为别名安全性意味着编译器认为mutex_lock(以及所有其他库函数)可能会更改flag变量的状态。
Potatoswatter

6
显然,只有在工作线程的例程的性质足以保证定期检查布尔值的情况下,它才起作用。保证volatile-bool-flag保留在范围内,因为线程关闭序列总是在包含volatile-boolean的对象被销毁之前发生,并且线程关闭序列在设置bool之后调用pthread_join()。pthread_join()将阻塞,直到辅助线程消失为止。信号有其自身的问题,特别是与多线程结合使用时。
杰里米·弗里斯纳

2
不能保证工作线程在布尔值成立之前就完成工作-实际上,当布尔值设置为true时,它几乎肯定会在工作单元的中间。但是工作线程何时完成其工作单元都没有关系,因为在任何情况下,主线程除了在pthread_join()内部阻塞直到工作线程退出之前不会做任何事情。因此关闭顺序是有序的-直到pthread_join()返回之后,才释放易失性布尔(和其他共享数据),并且直到工作线程消失后,pthread_join()才返回。
杰里米·弗里斯纳

10
@杰里米,您在实践中是正确的,但从理论上讲它仍然可能会损坏。在两核系统上,一个核一直在执行您的工作线程。另一个核心将布尔值设置为true。但是,不能保证工作线程的核心将永远看到该更改,即,即使它反复检查布尔值,也可能永远不会停止。c ++ 0x,java和c#内存模型允许这种行为。在实践中,永远不会发生这种情况,因为繁忙线程很可能在某个位置插入内存屏障,之后它将看到bool的更改。
deft_code 2010年

4
以POSIX系统为基础,使用实时调度策略SCHED_FIFO,比系统中的其他进程/线程更高的静态优先级,应该有足够的核心。在Linux中,您可以指定实时进程可以使用100%的CPU时间。如果没有更高优先级的线程/进程,它们将永远不会上下文切换,也永远不会被I / O阻塞。但关键是C / C ++ volatile并非旨在强制执行适当的数据共享/同步语义。我发现寻找特殊情况来证明错误的代码有时可能有用,这是没有用的练习。
FooF

7

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_lockmodifes共享的标志
  6. 因此,必须从内存中重新加载变量。volatile,尽管在这种情况下有意义,但却是无关紧要的。

6

您的理解确实是错误的。

易失性变量具有的属性是“对该变量的读取和写入是程序可感知行为的一部分”。这意味着该程序可以运行(如果使用了适当的硬件):

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不适合以上任何一种方法-更具体地说,关于volatile应该如何表现的c或c ++标准都不包括上述任何一种方法。

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

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

c#和java AFAIK通过使volatile坚持1)和2)来解决这个问题,但是对于c / c ++编译器却不能这么说,因此基本上可以按照自己的意愿进行处理。

对于这个主题的更深入的讨论(尽管并非没有偏见),请阅读以下内容


3
+1-保证原子性是我所缺少的另一部分。我以为加载一个int是原子的,因此防止重新排序的volatile提供了读取侧的完整解决方案。我认为这在大多数架构上都是不错的假设,但这不是保证。
Michael Ekstrand

个人何时对内存进行读写操作是可中断的和非原子的?有什么好处吗?
batbrat

5

comp.programming.threads常见问题解答由Dave Butenhof进行了经典解释

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

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

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

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

因此,如果您的程序因未使用volatile而中断,那将是一个BUG。它可能不是C中的错误,也不是线程库中的错误,也不是内核中的错误。但这是一个SYSTEM错误,其中一个或多个组件将必须修复。

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

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

布滕霍夫先生在 此Usenet帖子中观点

使用“ volatile”不足以确保适当的内存可见性或线程之间的同步。互斥锁的使用就足够了,并且,除了通过使用各种非便携式机器代码替代方案(或POSIX内存规则的更微妙的含义,这些规则通常很难应用,如我之前的文章中所述)之外,互斥锁是必需的。

因此,正如Bryan所解释的那样,使用volatile只能阻止编译器进行有用的且合乎需要的优化,而对使代码“线程安全”毫无帮助。当然,欢迎您将任何您想要的内容声明为“易失性”-毕竟,这是合法的ANSI C存储属性。只是不要期望它为您解决任何线程同步问题。

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


链接断开;它似乎不再指向您想要引用的内容。没有文字,这是毫无意义的答案。
jww

3

这就是“ volatile”正在做的所有事情:“嘿,编译器,即使没有本地指令在起作用,此变量也可能随时改变(在任何时钟滴答时)。不要将此值缓存在寄存器中。”

这就对了。它告诉编译器您的值是易失性的-该值可以随时通过外部逻辑(另一个线程,另一个进程,内核等)更改。它或多或少地存在于抑制编译器优化的过程中,该优化器将以静默方式将一个值固有地缓存在EVER缓存中的寄存器中。

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


3

根据我的旧C标准,“对具有volatile限定类型的对象的访问构成是实现定义的”。因此,C编译器作者可能选择“易失性”的意思是“在多进程环境中进行线程安全的访问”。但是他们没有。

相反,添加了在多核多进程共享内存环境中确保关键部分线程安全所需的操作,作为新的实现定义功能。并且,摆脱了“易失性”将在多进程环境中提供原子访问和访问排序的要求,编译器作者将代码减少的优先级置于依赖于历史实现的“易失性”语义上。

这意味着关键代码部分周围的“易失性”信号灯之类的东西在新硬件上无法在新硬件上运行,而曾经在旧硬件上已与旧编译器一起使用,而旧示例有时也不会出错,只是旧版本。


旧的示例要求由适合低级编程的高质量编译器处理程序。不幸的是,“现代”编译器已经接受了以下事实:标准不要求他们以有用的方式处理“易失性”,这表明要求他们这样做的代码已损坏,而不是意识到标准没有做出任何贡献。努力禁止符合标准但质量低劣以至无用的实现,但绝不容忍已经流行的低质量但合规的编译器
supercat

在大多数平台上,很容易认识到volatile需要做些什么来允许人们以与硬件相关但与编译器无关的方式编写操作系统。要求程序员使用依赖于实现的功能,而不是volatile按要求进行工作,这破坏了制定标准的目的。
超级猫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.