关于多线程:使用C / Pthreads:共享变量是否需要可变?

关于多线程:使用C / Pthreads:共享变量是否需要可变?

Using C/Pthreads: do shared variables need to be volatile?

用C编程语言和Pthreads作为线程库; 线程之间共享的变量/结构是否需要声明为volatile? 假设它们可能受锁保护(可能没有障碍)。

pthread POSIX标准对此是否有任何说法,是否依赖于编译器?

编辑添加:感谢出色的回答。 但是,如果您不使用锁怎么办? 例如,如果您使用障碍物怎么办? 或者使用比较和交换等原语直接和原子地修改共享变量的代码...


只要您使用锁来控制对变量的访问,就不需要在其上使用volatile。实际上,如果将volatile放在任何变量上,则可能已经错了。

https://software.intel.com/zh-CN/blogs/2007/11/30/volatile-almost-useless-for-multi-threaded-programming/


答案是绝对的,毫无疑问的。除了适当的同步原语之外,您无需使用" volatile"。这些原语完成了所有需要完成的工作。

使用" volatile"既没有必要也不充分。这是没有必要的,因为适当的同步原语就足够了。这还不够,因为它只会禁用某些优化,而不是所有可能会咬住您的优化。例如,它不能保证在另一个CPU上的原子性或可见性。

But unless you use volatile, the compiler is free to cache the shared data in a register for any length of time... if you want your data to be written to be predictably written to actual memory and not just cached in a register by the compiler at its discretion, you will need to mark it as volatile. Alternatively, if you only access the shared data after you have left a function modifying it, you might be fine. But I would suggest not relying on blind luck to make sure that values are written back from registers to memory.

是的,但是即使您确实使用了volatile,CPU也可以在任何时间范围内自由地将共享数据缓存在写发布缓冲区中。可能会困扰您的优化设置与" volatile"禁用的优化设置并不完全相同。因此,如果您使用"易失性",那么您将依靠运气不佳。

另一方面,如果将同步原语与已定义的多线程语义一起使用,则可以确保一切正常。另外,您不会承受" volatile"带来的巨大性能损失。那么为什么不那样做呢?


我认为volatile的一个非常重要的特性是,它使变量在修改后写入内存,并在每次访问时从内存中重新读取。这里的其他答案混合了volatile和同步,从其他答案可以明显看出,volatile不是同步原语(应归功于信用)。

但是,除非您使用volatile,否则编译器可以自由地在任何时间长度内将共享数据缓存在寄存器中……如果您希望将数据写入到可预测的实际内存中,而不仅仅是缓存编译器自行决定,您需要将其标记为易失性。或者,如果仅在留下修改功能后才访问共享数据,则可能会很好。但是我建议不要依靠运气来确保将值从寄存器写回内存。

尤其是在寄存器丰富的机器(即非x86)上,变量可以在寄存器中生存很长时间,而好的编译器甚至可以将结构的一部分或整个结构缓存在寄存器中。因此,您应该使用volatile,但为了提高性能,还应将值复制到局部变量以进行计算,然后进行显式写回。本质上,有效使用volatile意味着在C代码中进行一些负载存储思考。

无论如何,您肯定必须使用某种操作系统级提供的同步机制来创建正确的程序。

有关volatile弱点的示例,请参见http://jakob.engbloms.se/archives/65上我的Decker算法示例,该示例很好地证明了volatile无法同步。


人们普遍认为关键字volatile适用于多线程编程。

汉斯·勃姆(Hans Boehm)指出,挥发物只有三种可移植的用途:

  • volatile可用于在与setjmp相同作用域中标记局部变量,其值应在longjmp中保留。目前尚不清楚此类使用的哪一部分会放慢速度,因为如果没有办法共享所讨论的局部变量,则原子性和排序约束将不起作用。 (甚至还不清楚通过要求在longjmp中保留所有变量来减慢这种使用的比例,但这是另一回事,在这里不予考虑。)
  • 当变量可以被"外部修改"时,可以使用volatile,但是实际上修改是由线程本身同步触发的,例如:因为基础内存被映射到多个位置。
  • volatile sigatomic_t可以用于以受限方式与同一线程中的信号处理程序通信。可以考虑削弱对sigatomic_t案例的要求,但这似乎是违反直觉的。

如果您出于速度考虑而使用多线程,那么降低代码速度绝对不是您想要的。对于多线程编程,经常错误地认为存在以下两个关键问题:volatile:

  • 原子性
  • 内存一致性,即另一个线程看到的线程操作顺序。

首先处理(1)。易失性不保证原子读取或写入。例如,在大多数现代硬件上,对129位结构的易失性读取或写入将不会成为原子操作。在大多数现代硬件上,对32位int进行volatile读写都是原子的,但是volatile与它无关。如果没有挥发物,那将是原子的。原子性是编译器的想法。在C或C ++标准中,没有什么是必须是原子的。

现在考虑问题(2)。有时,程序员将volatile视为关闭volatile访问的优化。在实践中基本上是这样。但这只是易失性访问,而不是非易失性访问。考虑以下片段:

1
2
3
4
5
6
7
8
9
10
11
 volatile int Ready;      

    int Message[100];      

    void foo( int i ) {      

        Message[i/10] = 42;      

        Ready = 1;      

    }

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

您可能会认为解决方案是将所有内存引用标记为volatile。那真是愚蠢。正如前面引述的那样,这只会减慢您的代码速度。最糟糕的是,它可能无法解决问题。即使编译器不对引用进行重新排序,硬件也可能会重新排序。在此示例中,x86硬件不会对其重新排序。 Itanium(TM)处理器也不会,因为Itanium编译器会为易失性存储插入内存屏障。这是一个聪明的Itanium扩展。但是Power(TM)等芯片将重新排序。您真正需要订购的是内存挡板,也称为内存挡板。内存隔离栅防止在整个隔离栅上对内存操作进行重新排序,或者在某些情况下,可以防止在一个方向上重新排序。易失性与内存隔离栅无关。

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

  • POSIX线程
  • Windows(TM)线程
  • OpenMP的
  • 待定

基于Arch Robison(Intel)的文章


没有。

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

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


根据我的经验,不可以;您只需要在写入这些值时适当地使自己静音,或者构造程序以使线程在它们需要访问依赖于另一个线程的操作的数据之前停止。我的项目x264使用这种方法。线程共享大量数据,但其中绝大多数不需要互斥体,因为其只读或线程将等待数据变得可用并最终确定后才需要访问它。

现在,如果您有很多线程在它们的操作中完全交织在一起(它们在非常精细的级别上依赖于彼此的输出),那么这可能会困难得多-实际上,在这种情况下,我会考虑重新审视线程模型,以查看是否可以更干净地完成线程之间的分隔。


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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

相关问题:

  • 使用pthread互斥量保护变量是否可以保证也不会缓存该变量?
  • pthread_mutex_lock是否包含内存防护指令?

根本原因是C语言语义基于单线程抽象机。只要抽象机上程序的"可观察到的行为"保持不变,编译器就有权利对程序进行转换。如果它认为程序的行为在单个线程中执行时没有变化,它可以合并相邻或重叠的内存访问,多次重做一次内存访问(例如,在寄存器溢出时),或者简单地丢弃内存访问。因此,您可能会怀疑,如果实际上应该以多线程方式执行程序,则行为确实会改变。

正如Paul Mckenney在著名的Linux内核文档中指出的那样:

It _must_not_ be assumed that the compiler will do what you want
with memory references that are not protected by READ_ONCE() and
WRITE_ONCE(). Without them, the compiler is within its rights to
do all sorts of"creative" transformations, which are covered in
the COMPILER BARRIER section.

READ_ONCE()和WRITE_ONCE()被定义为对引用变量的易失性强制转换。从而:

1
2
int y;
int x = READ_ONCE(y);

等效于:

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

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

总之,您应该将多个线程共享的变量标记为volatile,或者使用volatile强制转换来访问它们。

正如Paul McKenney所指出的那样:

I have seen the glint in their eyes when they discuss optimization techniques that you would not want your children to know about!

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


仅当您在一个线程写入内容与另一个线程读取内容之间绝对不需要延迟时,挥发性才有用。但是,如果没有某种形式的锁定,您将不知道另一个线程何时写入数据,仅知道它是最新的可能值。

对于简单的值(具有不同大小的int和float),如果不需要显式的同步点,则互斥可能会过大。如果您不使用某种互斥或锁,则应将变量声明为volatile。如果您使用互斥锁,则一切准备就绪。

对于复杂的类型,必须使用互斥锁。它们的操作是非原子的,因此您可以阅读没有互斥量的半修改版本。


易失性意味着我们必须进入内存才能获取或设置该值。如果您未设置volatile,则编译后的代码可能会将数据长时间存储在寄存器中。

这意味着您应该将线程之间共享的变量标记为volatile,这样就不会出现一个线程开始修改值但在第二个线程出现并尝试读取值之前不写其结果的情况。 。

易失性是禁用某些优化的编译器提示。没有它,编译器的输出程序集可能是安全的,但您应始终将其用于共享值。

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


显然有人认为编译器将同步调用视为内存障碍。" Casey"假设恰好有一个CPU。

如果同步原语是外部函数,并且相关符号在编译单元之外可见(全局名称,导出的指针,可以修改它们的导出函数),则编译器会将它们(或任何其他外部函数调用)视为关于所有外部可见对象的内存栅栏。

否则,您将独自一人。而volatile可能是使编译器生成正确,快速代码的最佳工具。但是,当您需要volatile时,它通常不是可移植的,而它实际为您所做的工作在很大程度上取决于系统和编译器。


没有。

首先,不需要volatile。还有许多其他操作提供了不使用volatile的有保证的多线程语义。这些包括原子操作,互斥体等。

其次,volatile是不够的。对于声明为volatile的变量,C标准不提供任何有关多线程行为的保证。

因此,既没有必要也不充分,则使用它没有太多意义。

一个例外是特定平台(例如Visual Studio),在该平台中确实记录了多线程语义。


线程之间共享的变量应声明为" volatile"。这告诉
编译器,当一个线程写入此类变量时,写入应该是内存
(而不是寄存器)。


推荐阅读