使用C/Pthreads:共享变量需要是volatile吗?

36
在C编程语言和作为线程库的Pthreads中,共享线程之间的变量/结构是否需要声明为volatile?假设它们可能受到锁保护或未受保护(例如屏障)。 pthread POSIX标准对此有何规定,这是与编译器相关还是两者都不相关?
编辑添加:感谢您提供的好答案。但是,如果您没有使用锁;例如,如果您正在使用barriers,或者使用原始的compare-and-swap代码直接和原子地修改共享变量...
13个回答

27

谢谢回答;但是如果您不使用锁定(请参考编辑后的问题示例),该怎么办? - fuad
1
我认为这是错误的,可以看到我的回复。问题在于编译器可以随意地将线程中的值保留在本地寄存器中,除非它被标记为volatile。因此,需要使用volatile来确保数据被写回内存。 - jakobengblom2
4
如果您没有使用锁,几乎肯定需要使用显式的内存屏障。请注意,volatile不是内存屏障,因为它不会影响除了对volatile变量本身之外的任何其他加载和存储。它通常也会导致性能下降。 - bdonlan
2
这个答案是错误的。这篇文章是错误的。文章底部的评论解释了原因。文章作者完全误解了volatile的目的。阅读其他答案以了解volatile实际上的作用。 - Michael Dorst
如果编译器这样做了,那么该平台就不符合 PTHREADS 标准。 - David Schwartz

12
答案是绝对、毫不含糊的“不需要”。在适当的同步原语中,你不需要使用“volatile”。所有需要做的事情都由这些原语完成。
使用“volatile”既不必要也不充分。它不必要,因为适当的同步原语已经足够了。它不充分,因为它只禁用了一些优化,而不是可能会影响你的所有优化。例如,它不能保证原子性或在另一个CPU上的可见性。
但是,除非你使用volatile,否则编译器可以自由地将共享数据缓存到寄存器中任意长的时间...如果你希望数据被可预测地写入实际内存而不仅仅是由编译器自行决定缓存到寄存器中,你需要将其标记为volatile。或者,如果你在修改共享数据的函数离开后才访问共享数据,那么你可能没问题。但我建议不要依靠盲目的运气来确保值从寄存器写回内存。

没错,但即使您使用了volatile,CPU也可以将共享数据缓存到写入发布缓冲区中的任意时间长度。可能会影响您的优化集合并不完全与“volatile”禁用的优化集合相同。因此,如果您使用“volatile”,您是在依靠盲目的运气。

另一方面,如果您使用具有定义的多线程语义的同步原语,您可以保证事情会正常工作。此外,您不会承受“volatile”的巨大性能损失。那么为什么不这样做呢?


7
我认为volatile的一个非常重要的属性是,它使变量在修改时被写入内存,并且每次访问时都从内存中重新读取。这里其他答案混淆了volatile和同步,而从其他答案可以清楚地看出,volatile不是同步原语(功劳归功于)。但是,除非您使用volatile,否则编译器可以自由地将共享数据缓存在寄存器中任意长的时间...如果您希望数据可预测地被写入实际内存而不仅仅是由编译器自行决定地缓存在寄存器中,则需要将其标记为volatile。或者,如果您只在离开修改它的函数后访问共享数据,则可能没问题。但我建议不要依靠盲目的运气来确保值从寄存器写回内存。
特别是在寄存器丰富的机器上(即非x86),变量可以在寄存器中生存相当长的时间,好的编译器甚至可以将结构的部分或整个结构缓存在寄存器中。因此,您应该使用volatile,但是为了性能,还应将值复制到本地变量进行计算,然后进行显式写回。基本上,有效使用volatile意味着在C代码中进行一些加载存储思考。
无论如何,您必须使用某种操作系统级别提供的同步机制来创建正确的程序。
有关volatile的弱点的示例,请参见我的Decker算法示例,网址为http://jakob.engbloms.se/archives/65,这证明了volatile不能用于同步。

3
将变量长时间保存在寄存器中正是编译器优化的关键。使用volatile完全否定了这一点。请注意,对于GCC(和可能大多数编译器),函数调用会破坏内存,这意味着如果您写入一个非局部变量然后进行函数调用,编译器不允许将写操作推迟到函数调用后面 - 这似乎是您使用volatile的意图。但那不是volatile的作用... - Greg Rogers
2
Volatile是用于标记可能会自发改变的变量(将硬件实体映射到内存位置的嵌入式系统就是其中之一)。如果您正在使用独占锁定,则普通变量无法自发更改。Herb Sutter在这方面的文章非常好:http://www.ddj.com/hpc-high-performance-computing/212701484 - Greg Rogers
在使用GCC时,他们对标准中volatile的解释是保留给可能由硬件引起变化的内存,而不是由软件引起的内存更改。对于GCC,应该在软件中使用Memory Barrier。这是代码:asm volatile("": : :"memory")。微软的解释是内存可以由硬件和软件引起变化。 - jww
5
这个答案几乎没有一点正确的地方。特别是,volatile不会强制数据可预测地写入实际内存,并且标准也没有要求这样做。在大多数现代计算机上也不会这样做,标准也没有要求这样做。 - David Schwartz
非常混乱,没有好的答案。甚至没有解释“内存”是什么意思,这根据手头的问题可能有不同的含义(用于与外部卡通信的内存将是主内存,也就是RAM;内存也可以指共享的L3缓存;在其他情况下,是L1缓存等)。 - curiousguy

4

有一种普遍的观念认为,关键字volatile对于多线程编程是有好处的。

Hans Boehm 指出,volatile只有三种可移植的用途:

  • volatile可以用来标记与setjmp在同一作用域中的局部变量,其值应该在longjmp跨越时保留。如果没有办法共享相关局部变量,则不清楚这类使用的比例会受到何种影响。(甚至不清楚要求所有变量在longjmp跨越时都被保留的这类使用的比例会受到何种影响,但这是一个单独的问题,在此不予考虑。)
  • volatile可能用于在变量可能“外部修改”时,但实际上是由线程本身同步触发修改,例如因为底层内存在多个位置映射。
  • volatile sigatomic_t可以在同一线程中以受限制的方式与信号处理程序通信。可以考虑弱化sigatomic_t情况下的要求,但这似乎相当反直觉。
如果您为了速度而进行多线程处理,则减慢代码绝对不是您想要的。对于多线程编程,volatile经常被错误地认为解决了以下两个关键问题:
1. 原子性 2. 内存一致性,即一个线程操作的顺序如何被另一个线程看到。
让我们先来解决第一个问题。Volatile不能保证原子读写。例如,在大多数现代硬件上,129位结构的volatile读写不会是原子的。在大多数现代硬件上,32位int的volatile读写是原子的,但这与volatile无关。即使没有volatile,它也很可能是原子的。原子性取决于编译器的心情。C或C++标准中没有任何内容说明它必须是原子的。
现在考虑问题(2)。有时程序员认为volatile可以关闭volatile访问的优化。在实践中,这在很大程度上是正确的。但这仅适用于volatile访问,而不适用于非volatile访问。考虑下面的片段:
 volatile int Ready;       

    int Message[100];      

    void foo( int i ) {      

        Message[i/10] = 42;      

        Ready = 1;      

    }

它试图在多线程编程中做一些非常合理的事情:写一条消息,然后将其发送到另一个线程。另一个线程将等待 Ready 变为非零,然后读取消息。尝试使用 gcc 4.0 或 icc 编译此代码并使用 "gcc -O2 -S" 进行编译。两者都会首先对 Ready 进行存储,以便可以与 i/10 的计算重叠。重新排序不是编译器错误,而是积极优化器在执行其工作。

您可能认为解决方案是标记所有内存引用为 volatile。那真是太愚蠢了。正如之前的引用所说,这只会减慢您的代码。更糟糕的是,它可能无法解决问题。即使编译器不重新排序引用,硬件也可能会这样做。在这个例子中,x86 硬件不会重新排序。像 Itanium(TM) 处理器这样的芯片也不会重新排序,因为 Itanium 编译器会为 volatile 存储插入内存屏障。这是一个聪明的 Itanium 扩展。但是像 Power(TM) 这样的芯片会重新排序。您真正需要的是 内存屏障,也称为 内存栅栏。内存屏障可以防止跨越栅栏的内存操作重新排序,或者在某些情况下,防止在一个方向上重新排序。Volatile 与内存屏障无关。

那么多线程编程的解决方案是什么?使用实现原子性和屏障语义的库或语言扩展。当按预期使用时,库中的操作将插入正确的屏障。以下是一些示例:

  • POSIX 线程
  • Windows(TM) 线程
  • OpenMP
  • TBB

基于 Intel 的 Arch Robison 文章


2

NO.

volatile仅在读取一个可以独立于CPU读/写命令而改变的内存位置时才需要。在线程情况下,CPU完全控制每个线程对内存的读写, 因此编译器可以假设内存是连贯的,并优化CPU指令以减少不必要的内存访问。

volatile的主要用途是访问内存映射I/O。在这种情况下,底层设备可以独立于CPU更改内存位置的值。如果您在此条件下不使用volatile,CPU可能会使用先前缓存的内存值,而不是读取新更新的值。


1
你的第一段是不正确的。CPU 不知道 volatile 关键字。只有 C 编译器知道,它只约束 C 编译器使用该内存位置的方式。不同的机制用于管理每个 CPU 对内存的视图(因为每个核心/处理器单元有不同的视角,它们将彼此视为外部但具有缓存一致性)。作为参考,这些其他机制是原子操作和内存屏障,但最好还是坚持使用由库提供的锁定基元。 - Paul Bone

2
根据我的经验,不需要;您只需要在写入这些值时正确地使用互斥锁,或者构建程序结构,使得线程在需要访问依赖于另一个线程的操作的数据之前停止。我的项目x264就是使用这种方法的;线程共享大量数据,但其中绝大部分都不需要互斥锁,因为它们要么是只读的,要么线程会等待数据可用并最终确定后再访问它。

现在,如果您有多个线程在其操作中都相互交错(它们在非常细粒度的级别上依赖于彼此的输出),那么这可能会更难--实际上,在这种情况下,我会考虑重新审视线程模型,看看是否可以通过更多线程间的分离来更清晰地完成它。


1

POSIX 7保证像pthread_lock这样的函数也会同步内存

https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_11 "4.12 内存同步"说:

The following functions synchronize memory with respect to other threads:

pthread_barrier_wait()
pthread_cond_broadcast()
pthread_cond_signal()
pthread_cond_timedwait()
pthread_cond_wait()
pthread_create()
pthread_join()
pthread_mutex_lock()
pthread_mutex_timedlock()
pthread_mutex_trylock()
pthread_mutex_unlock()
pthread_spin_lock()
pthread_spin_trylock()
pthread_spin_unlock()
pthread_rwlock_rdlock()
pthread_rwlock_timedrdlock()
pthread_rwlock_timedwrlock()
pthread_rwlock_tryrdlock()
pthread_rwlock_trywrlock()
pthread_rwlock_unlock()
pthread_rwlock_wrlock()
sem_post()
sem_timedwait()
sem_trywait()
sem_wait()
semctl()
semop()
wait()
waitpid()
因此,如果您的变量在pthread_mutex_lockpthread_mutex_unlock之间受到保护,则不需要进一步同步,因为您可能会尝试使用volatile来提供同步。
相关问题:

0
根本原因是C语言的语义基于“单线程抽象机器”。只要程序在抽象机器上的“可观测行为”保持不变,编译器就有权对程序进行转换。它可以合并相邻或重叠的内存访问,在需要寄存器溢出的情况下多次重新执行内存访问,或者仅丢弃一次内存访问,如果它认为程序的行为在单个线程中执行时没有改变。因此,正如您可能怀疑的那样,如果程序实际上应该以多线程方式执行,其行为确实会发生变化。
正如Paul Mckenney在著名的Linux内核文档中指出的那样:
“绝不能假定编译器会按照你的意愿处理未受 READ_ONCE() 和 WRITE_ONCE() 保护的内存引用。没有它们,编译器有权进行各种“创造性”的转换,这些转换在“编译器屏障”部分有所涉及。”
READ_ONCE() 和 WRITE_ONCE() 被定义为参考变量的易失性转换。因此:
int y;
int x = READ_ONCE(y);

等同于:

int y;
int x = *(volatile int *)&y;

因此,除非你进行 'volatile' 访问,否则无论你使用何种同步机制,都不能确保访问会发生仅一次。调用外部函数(例如 pthread_mutex_lock)可能会强制编译器对全局变量进行内存访问。但这仅在编译器无法确定外部函数是否更改这些全局变量时才会发生。采用复杂的过程间分析和链接时优化的现代编译器使得这种技巧变得毫无用处。

总之,你应该将多个线程共享的变量标记为 volatile 或者使用 volatile 强制类型转换进行访问。


正如Paul McKenney所指出的那样:
“当他们讨论你不想让你的孩子知道的优化技术时,我看到了他们眼中的闪光!”

但是看看 C11/C++11 会发生什么。


0

volatile 意味着我们必须去内存中获取或设置这个值。如果你不设置 volatile,编译后的代码可能会将数据存储在寄存器中很长一段时间。

这意味着你应该将在线程之间共享的变量标记为 volatile,以便你不会出现这样的情况:一个线程开始修改值,但在第二个线程来读取值之前没有写入其结果。

volatile 是一个编译器提示,它禁用了某些优化。编译器的输出汇编可能没有使用它也是安全的,但对于共享值,你应该始终使用它。

如果你没有使用系统提供的昂贵的线程同步对象,这一点尤其重要 - 例如,你可能有一个数据结构,可以通过一系列原子更改使其保持有效。许多不分配内存的堆栈就是这种数据结构的例子,因为你可以向堆栈添加一个值,然后移动末尾指针或者在移动末尾指针后从堆栈中删除一个值。当实现这样的结构时,volatile 变得至关重要,以确保你的原子指令实际上是原子的。


1
“volatile”并不能保证原子性。它是用来表示程序外部的某些东西正在修改变量的内容。 - Allen
1
即使使用了volatile关键字,像"a = a + 1;"这样简单的操作也不是原子性的。这只意味着编译器会重新加载'a'进行此操作,并立即将其存储回去。仍然存在另一个线程可以争夺它的窗口。 - bdonlan

0

如果您需要在一个线程写入一些东西和另一个线程读取它之间绝对没有延迟,那么Volatile将非常有用。但是,如果没有某种形式的锁定,您就不知道其他线程写入数据的时间,只知道它是最近可能的值。

对于简单值(int和各种大小的float),如果您不需要显式的同步点,则互斥可能过度。如果您不使用互斥或某种类型的锁定,则应声明变量为volatile。如果您使用互斥,那么可以了。

对于复杂类型,必须使用互斥。对它们的操作是非原子的,因此,在没有互斥的情况下,您可能会读取到部分更改的版本。


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