为什么在线程同步中不需要使用volatile关键字?

14

我看到 volatile 关键字并不适用于线程同步,实际上在这些情况下根本不需要使用它。

虽然我知道仅使用该关键字是不够的,但我不明白为什么完全没有必要使用它。

例如,假设我们有两个线程,线程 A 仅从共享变量读取,线程 B 仅向共享变量写入。通过正确的同步(如 pthreads 互斥锁)进行强制执行。

如果没有使用 volatile 关键字,则编译器可能会查看线程 A 的代码并说:“此处似乎未修改变量,但我们有很多读取;让我们只读取一次,缓存值并优化掉所有后续读取。” 它还可能查看线程 B 的代码并说:“我们在此处有大量写入此变量,但没有读取;因此,不需要写入所写的值,并且因此让我们优化掉所有写入。”

这两种优化都是不正确的。而 两种 其中一种 可以通过使用 volatile 关键字来防止。因此,我可能会得出结论,尽管volatile不能用于同步线程,但对于任何在线程之间共享的变量仍然是必要的。 (注意:我现在已经阅读到实际上不需要使用 volatile 来防止写入优化;因此我无法想出如何防止这种不正确的优化)

我知道我的理解是错误的。但为什么?


1
编译器会看到读和写操作,因此不会将变量优化掉。您似乎混淆了编译时可见性和运行时访问。 - user3629249
2
@user3629249:不是这样的。这两个代码可能在完全不同的编译单元中。 - Karoly Horvath
8
通俗易懂地说,编译器可以自由地重新排列或缓存读写操作(只要在可观察行为上没有任何区别)。同步原语具有一些“魔法”属性,它们创建了屏障,编译器不允许通过这些屏障移动读写操作...(实际情况比这更复杂,如果需要完整解释,请等待正式答案)。 - Karoly Horvath
1
@user3629249:这些都不相关,这不是它的工作方式。我只是试图举个例子,让你明白为什么这是一个坏主意。 - Karoly Horvath
2
如果所有的工作都在互斥保护块内完成,那么选择仅读取一次并缓存或仅写入最终值是完全合法的。为什么这样做是不正确的?如果互斥被持有,直到它被释放之前没有其他人会读取或修改变量,因此变量不能被另一个线程改变(使读取优化可接受),也不能被另一个线程读取,因此另一个线程只能看到“之前”或“之后”的状态,中间值是否被写入并不重要,只要最终值是正确的。 - ShadowRanger
显示剩余8条评论
4个回答

9
例如,假设我们有两个线程,线程A仅从共享变量中读取,而线程B仅向共享变量中写入。通过例如pthread互斥锁进行适当的同步。如果没有volatile关键字,编译器可能会查看线程A的代码并说:“这个变量似乎没有被修改,但我们有很多读取;让我们只读取一次,缓存值并优化掉所有后续读取。”它也可能查看线程B的代码并说:“我们在这里有很多对该变量的写入,但没有读取;因此,不需要写入的值,因此让我们优化掉所有写入。”
与大多数线程同步原语一样,pthread互斥锁操作具有显式定义的内存可见性语义。
平台要么支持pthread,要么不支持。如果支持pthread,则支持pthread互斥锁。这些优化是安全的还是不安全的。如果它们是安全的,那就没有问题。如果它们不安全,那么任何进行这些优化的平台都不支持pthread互斥锁。
例如,你说“这里似乎没有修改变量”,但实际上可能有其他线程在那里修改它。除非编译器能够证明其优化不会破坏任何符合规范的程序,否则它不能进行优化。而符合规范的程序可以在另一个线程中修改变量。无论如何,大多数平台都会自动完成大部分操作。编译器只是被阻止了解互斥操作的内部工作原理。任何其他线程可以执行的操作,互斥操作本身也可以执行。因此,在进入和退出这些函数之前,编译器必须“同步”内存。例如,编译器不能在调用pthread_mutex_lock时保留寄存器中的值,因为它不知道pthread_mutex_lock是否访问该值。或者,如果编译器对互斥函数有特殊的了解,那么它将包括了解在这些调用中访问其他线程可访问的值的无效性。
一个需要使用volatile的平台几乎是无法使用的。你需要为每个函数或类制作版本,以适应对象可能被另一个线程看到或从另一个线程看到的特定情况。在许多情况下,你几乎只能将所有内容都设置为volatile,而不缓存值在寄存器中是性能上的非启动项。
正如你可能已经听过很多次的那样,在C语言中指定的volatile语义与线程的使用方式并不相容。它不仅不足够,而且禁用了许多完全安全且几乎必要的优化。

6
不冒犯,但你对他的态度像在针对一个笨蛋。他的问题很明智,只是他不了解那些可见性语义......你没有详细解释。专注于重要的事情。 - Karoly Horvath
3
他知道volatile不足以解决问题。他想知道为什么需要在已经告诉编译器不要优化的代码中再次告诉它不要优化。只需要指出你不必重复告诉它,因为它第一次就会听。我认为深入探讨特定平台的实现细节并不有帮助。 - David Schwartz
2
@KarolyHorvath 高层次的观点是,该平台支持POSIX线程,并因此执行必要的操作以使其正常工作。您不必告诉编译器两次,它会听取您的指示。 - David Schwartz
1
@DavidSchwartz 这一切是因为大多数线程函数并不像普通函数一样被处理,而是提供了某些内存同步和可见性保证——这对每个人来说都不是显而易见的。(例如,在http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_11 上的 pthreads) - nos
1
这就是我也想说的,但失败了。哈哈。 - Karoly Horvath
显示剩余13条评论

4
简化已给出的答案,对于一个简单的原因,您不需要在互斥锁中使用volatile
  • 如果编译器知道什么是互斥操作(通过识别pthread_*函数或因为您使用了std::mutex),它就会知道如何处理与优化相关的访问(这甚至对于std::mutex是必需的)
  • 如果编译器无法识别它们,pthread_*函数对它来说是完全不透明的,并且涉及任何类型的非局部持续对象的任何优化都不能穿过不透明函数

1
我非常想知道我的答案有什么问题。 - SergeyA

1
关键字volatile告诉编译器将变量的任何写入或读取视为“可观察的副作用”。这就是它的全部作用。当然,可观察的副作用不能被优化掉,并且必须按照程序指示的顺序出现在外部世界中;编译器不得重新排列可观察的副作用相互之间。 但编译器可以将它们与非可观察的副作用重新排序。因此,volatile仅适用于访问内存映射硬件、类Unix信号处理程序等。对于线程间并发,请使用std::atomic或更高级别的同步对象,如mutexcondition_variablepromise/future

1
简言之,如果不使用互斥锁或信号量,则存在错误。一旦B线程释放互斥锁(并且A线程获得它),包含来自B线程的共享变量值的寄存器中的任何值都保证被写入缓存或内存,这将在A线程运行并读取此变量时防止竞争条件。
保证实现取决于体系结构/编译器。

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