什么时候使用volatile多线程?

Dav*_*ton 121 c++ concurrency multithreading atomic volatile

如果有两个线程访问全局变量,那么许多教程都说使变量volatile变为阻止编译器将变量缓存在寄存器中,从而无法正确更新.但是,访问共享变量的两个线程是通过互斥锁来调用保护的东西不是吗?但是在这种情况下,在线程锁定和释放互斥锁之间,代码处于一个关键部分,只有那个线程可以访问变量,在这种情况下变量不需要是volatile?

那么多线程程序中volatile的用途/目的是什么?

Joh*_*ing 160

简短快速回答: volatile对于平台无关的多线程应用程序编程(几乎)无用.它不提供任何同步,它不会创建内存栅栏,也不会确保操作的执行顺序.它不会使操作成为原子.它不会使您的代码神奇地保证线程安全. volatile可能是所有C++中最容易被误解的设施.有关详细信息,请参阅,内容volatile

另一方面,volatile确实有一些使用可能不那么明显.它的使用方式可以const用来帮助编译器显示您在以非受保护的方式访问某些共享资源时可能出错的位置.Alexandrescu在本文中讨论了这种用法.但是,这基本上是以一种经常被视为设计的方式使用C++类型系统,并且可以引起未定义的行为.

volatile专门用于与内存映射硬件,信号处理程序和setjmp机器代码指令连接时使用.这volatile直接适用于系统级编程,而不是普通的应用程序级编程.

2003 C++标准并没有说volatile在变量上应用任何类型的Acquire或Release语义.实际上,标准对多线程的所有问题都完全保持沉默.但是,特定平台会对volatile变量应用Acquire和Release语义.

[更新C++ 11]

C++ 11标准现在直接在内存模型和语言承认多线程,它提供了以独立于平台的方式处理它的库设施.但是语义volatile仍然没有改变. volatile仍然不是同步机制.Bjarne Stroustrup在TCPPPL4E中说得很多:

volatile除了直接处理硬件的低级代码外,请勿使用.

不要假设volatile在内存模型中有特殊含义.它不是.它不像在某些后来的语言中那样是一种同步机制.要获得同步,请使用atomic,a mutex或a condition_variable.

[/结束更新]

以上都适用于C++语言本身,如2003标准(现在的2011标准)所定义.但是,某些特定平台会增加额外的功能或限制volatile.例如,在MSVC 2010中(至少)Acquire和Release语义确实适用于volatile变量的某些操作. 来自MSDN:

优化时,编译器必须维护对volatile对象的引用以及对其他全局对象的引用之间的顺序.特别是,

对volatile对象的写入(volatile write)具有Release语义; 对在指令序列中写入易失性对象之前发生的全局或静态对象的引用将在编译二进制文件中的易失性写入之前发生.

读取volatile对象(volatile read)具有Acquire语义; 在读取编译二进制文件中的易失性读取之后,将在读取指令序列中的易失性存储器之后发生对全局或静态对象的引用.

但是,您可能会注意到,如果您遵循上述链接,则在评论中存在一些争论,即在这种情况下获取/释放语义是否实际适用.

  • @Ben:不,请阅读C++中`volatile`实际**的内容.@John所说的是*正确*,故事的结尾.它与应用程序代码与库代码无关,或与"普通"vs"神似无所不知的程序员"无关.`volatile`对于线程之间的同步是不必要的和无用的.线程库不能用`volatile`来实现; 它必须依赖于特定于平台的细节,当你依赖它们时,你不再需要`volatile`. (36认同)
  • @Ben只是因为挑战你的信念不会使它屈尊俯就 (18认同)
  • 由于答案和第一个评论的居高临下的语气,我的一部分想要投票."volatile is useless"类似于"手动内存分配无用".如果你可以编写一个没有`volatile`的多线程程序,那是因为你站在那些使用`volatile`来实现线程库的人的肩膀上. (14认同)
  • @jalf:"volatile对于线程之间的同步是不必要和无用的"(这就是你所说的)与"volatile对于多线程编程无用"(这是John在答案中所说的)不同.你100%正确,但我不同意John(部分) - volatile仍可用于多线程编程(对于一组非常有限的任务) (6认同)
  • @GMan:一切有用的东西只在某些要求或条件下才有用.在一系列严格的条件下,Volatile对于多线程编程很有用(在某些情况下,甚至可能比替代方案更好(对某些更好的定义)).你说"忽略这个和......"但是当volatile对多线程很有用的情况时不会忽略任何东西.你制作了一些我从未声称过的东西.是的,volatile的用处是有限的,但它确实存在 - 但我们都同意它对同步没有用. (4认同)
  • 有些编译器确实为volatile提供了额外的语义,这对于多线程开发很有用,但这绝对不是标准的一部分. (3认同)
  • @Dan:所以你基本上说"它很有用!......假设这个,忽略了这个,那个......".如果做出足够的假设,任何事情都可能是真的; 你肯定已经放弃了C++作为一种语言. (3认同)
  • @curiousguy Unix自管道技巧在信号处理程序中比在volatile中更有用,并且允许更健壮的代码。 (3认同)
  • @Dan:我的观点是`volatile`是一个C++语言概念,而不是一个实现概念.但是你说它因为一些实现细节而很有用.这对它在C++中的用处没有任何影响. (2认同)

zeu*_*xcg 30

由于以下原因,易失性偶尔会有用:此代码:

/* global */ bool flag = false;

while (!flag) {}
Run Code Online (Sandbox Code Playgroud)

由gcc优化为:

if (!flag) { while (true) {} }
Run Code Online (Sandbox Code Playgroud)

如果该标志由另一个线程写入,那么这显然是不正确的.请注意,如果没有此优化,同步机制可能会起作用(取决于其他代码可能需要一些内存障碍) - 1个生产者 - 1个消费者场景中不需要互斥锁.

否则,volatile关键字太奇怪了,无法使用 - 它不提供任何内存排序保证兼具易失性和非易失性访问,也不提供任何原子操作 - 即除了禁用的寄存器缓存外,你不会从编译器中获得volatile关键字的帮助.

  • @jalf:请参阅Arch Robinson的文章(链接到本页的其他地方),第10条评论(由"Spud"撰写).基本上,重新排序不会改变代码的逻辑.发布的代码使用该标志来取消任务(而不是表示任务已完成),因此在代码之前或之后取消任务无关紧要(例如:`while(work_left){do_piece_of_work(); if(cancel)break;}`,如果在循环中重新排序取消,逻辑仍然有效.我有一段代码类似的工作:如果主线程要终止,它为其他线程设置标志,但它不...... (15认同)
  • ...如果其他线程在终止之前对其工作循环进行额外的几次迭代,只要它在设置标志后很快发生,就很重要.当然,这是我能想到的唯一用途及其相当利基(并且可能无法在写入易变变量的平台上不能使更改对其他线程可见,尽管至少在x86和x86-64这个作品).我当然不会建议任何人在没有充分理由的情况下实际执行此操作,我只是说像"volatile在多线程代码中没有用"这样的全面声明并非100%正确. (15认同)
  • `volatile`不会阻止重新排序内存访问.`volatile`访问不会相互重新排序,但它们提供*no*保证关于非易失性`对象的重新排序,因此,它们基本上也没用作标志. (13认同)
  • @Ben:我想你已经把它颠倒了."volatile是无用的"人群依赖于一个简单的事实:*volatile不能防止重新排序*,这意味着它对同步完全没用.其他方法可能同样无用(正如您所提到的,链接时代码优化可能允许编译器查看您认为编译器将其视为黑盒子的代码),但这并不能解决`volatile`的缺陷. (13认同)
  • 如果我记得,C++ 0x atomic,意味着做得很好,许多人认为(错误地)是由volatile完成的. (4认同)
  • *但是,当有一个简单的解决方案实际上可以保证完美工作时,为什么要经历所有这些麻烦呢?* FFS,为什么人们一直假设我提倡使用“易失性”???这真是令人沮丧。我正在努力促进对主流 CPU 中缓存的理解,以便人们在正确使用“atomic<T>”(也许使用“mo_relaxed”)时可以做出有关性能的正确决策。您阅读了我对此答案的编辑吗?或者我的答案中的任何粗体或##标题文本?它们的措辞都非常谨慎,表示*不要*实际使用“易失性”,但 CPU 的工作原理如下。 (4认同)
  • 由于我正在寻找一个好的答案,我可以链接到为什么volatile是无用的,我不能让这些评论在这里.Dan在这里说的只适用于x86,因为它具有强大的底层内存模型.他在这里提出的建议与许多其他平台上的其他所有volatile一样(例如,不保证你不会从缓存中读取过时的值)也同样被破坏了.所以是的,如果你想要一个不仅仅在x86下工作的程序,那么volatile实际上是*永远不会有用的.. (2认同)
  • 我对所有这些的总结是:(1.)存在volatile对多线程正确/有用的例子(@ Dan的例子:使用volatile bool来停止线程的循环.[另一个例子](http://stackoverflow.com/a/246392/52074))(1.addendum)volatile对多线程正确/有用的例子是依赖于编译器/实现的(一个编译器可能具有类似于原子的行为,而另一个可能没有)和硬件相关(arm vs x86)(3)语句"volatile在多线程代码中永远不正确/有用"是错误的,因为至少存在一个计数器示例 (2认同)
  • @supercat正是std :: atomic对内存模型的影响要小于获取/释放。 (2认同)
  • @Johann你是在挑剔还是很困惑?我本以为任何C ++程序员都会知道,访问数组外部的内存是未定义的行为,但是仍然可以正常工作-这就是参数的全部要点。但是,是的,我在那里丢失了“内存”,并且我不是没有意思指向数组末尾,因为这不是未定义的行为。 (2认同)
  • @Voo:我不仅仅指 x86-64。我的意思是 PowerPC、MIPS、ARM、RISC-V 等。C++ 实现希望跨过“std::thread”启动的所有 SMP 系统,或者可以运行单个系统映像内核的系统。具有非一致共享内存的混合 SOC 显然存在,但据我所知,不作为 ISO C++ 兼容编译器的编译器目标。([有关它们的评论中的讨论](/sf/ask/4096123671/?noredirect=1#comment103396268_58516052),我在这个问题的答案中链接了它。) (2认同)

Pet*_*des 30

在 C++11 中,通常从不使用 volatile用于线程,仅用于 MMIO

但是 TL:DR,它mo_relaxed在具有一致缓存(即一切)的硬件上确实像原子一样“工作” ;阻止编译器将变量保存在寄存器中就足够了。 atomic不需要内存屏障来创建原子性或线程间可见性,只需让当前线程在操作之前/之后等待以在该线程对不同变量的访问之间创建排序。 mo_relaxed不需要任何障碍,只需加载、存储或 RMW。

对于在 C++11 之前的糟糕日子中使用volatile(以及用于障碍的内联汇编)滚动你自己的原子是使某些事情工作的唯一好方法。但这取决于很多关于实现如何工作的假设,并且从未得到任何标准的保证。std::atomicvolatile

例如,Linux 内核仍然使用它自己的手动原子和volatile,但只支持少数特定的 C 实现(GNU C、clang,也许还有 ICC)。部分原因是因为 GNU C 扩展和内联 asm 语法和语义,但也因为它取决于关于编译器如何工作的一些假设。

对于新项目来说,这几乎总是错误的选择;您可以使用std::atomic(with std::memory_order_relaxed) 使编译器发出与volatile. std::atomicmo_relaxed过时volatile的线程目的。(除了可能在某些编译器上解决错过的优化错误atomic<double>。)

std::atomic主流编译器(如 gcc 和 clang)的内部实现不仅仅volatile内部使用;编译器直接公开原子加载、存储和 RMW 内置函数。(例如,在“普通”对象上运行的GNU C__atomic内置函数。)


Volatile 在实践中是可用的(但不要这样做)

也就是说,由于 CPU 的工作方式(连贯缓存)和关于应该如何工作的共享假设,因此volatile在实践中可用于诸如exit_now真实 CPU 上所有(?)现有 C++ 实现上的标志之类的事情volatile。但其他的不多,也不推荐。 此答案的目的是解释现有 CPU 和 C++ 实现的实际工作方式。如果您不关心这一点,您只需要知道std::atomicmo_relaxed 已过时volatile用于线程处理。

(ISO C++ 标准对此相当模糊,只是说volatile访问应该严格按照 C++ 抽象机的规则进行评估,而不是优化掉。鉴于实际实现使用机器的内存地址空间来模拟 C++ 地址空间,这意味着volatile读取和赋值必须编译以加载/存储指令以访问内存中的对象表示。)


正如另一个答案指出的那样,exit_now标志是不需要任何同步的线程间通信的简单情况:它不发布数组内容已准备好或类似的东西。只是一个被另一个线程中未优化的负载迅速注意到的商店。

    // global
    bool exit_now = false;

    // in one thread
    while (!exit_now) { do_stuff; }

    // in another thread, or signal handler in this thread
    exit_now = true;
Run Code Online (Sandbox Code Playgroud)

在没有 volatile 或 atomic的情况下,无数据竞争 UB 的 as-if 规则和假设允许编译器将其优化为仅检查一次标志的 asm,然后再进入(或不)无限循环。这正是真实编译器在现实生活中发生的事情。(并且通常优化掉大部分,do_stuff因为循环永远不会退出,所以如果我们进入循环,任何可能使用结果的后续代码都无法访问)。

 // Optimizing compilers transform the loop into asm like this
    if (!exit_now) {        // check once before entering loop
        while(1) do_stuff;  // infinite loop
    }
Run Code Online (Sandbox Code Playgroud)

多线程程序卡在优化模式但在 -O0 中正常运行是一个例子(带有 GCC 的 asm 输出的描述),这是在 x86-64 上 GCC 是如何发生的。还有MCU 编程 - C++ O2 优化在循环时中断在electronics.SE 上的时显示了另一个示例。

我们通常希望CSE 和提升从循环中加载的积极优化,包括全局变量。

在 C++11 之前,volatile bool exit_now使这项工作按预期工作的一种方法(在普通的 C++ 实现上)。但是在 C++11 中,数据竞争 UB 仍然适用,volatile因此实际上并不能保证ISO 标准可以在任何地方工作,即使假设硬件一致缓存也是如此。

请注意,对于较宽的类型,volatile不能保证不会撕裂。我在这里忽略了这种区别,bool因为它在正常实现中不是问题。但这也是部分原因volatile仍然受制于数据竞争 UB 而不是等同于放松原子的部分原因。

请注意,“按预期”并不意味着线程正在exit_now等待另一个线程实际退出。或者甚至exit_now=true在继续此线程中的后续操作之前,它甚至等待易失性存储甚至全局可见。(atomic<bool>默认情况下mo_seq_cst,它至少会在任何以后的 seq_cst 加载之前等待。在许多 ISA 上,您只会在存储后获得一个完整的障碍)。

C++11 提供了一种非 UB 方式来编译相同的

“继续运行”或“立即退出”标志应std::atomic<bool> flagmo_relaxed

使用

  • flag.store(true, std::memory_order_relaxed)
  • while( !flag.load(std::memory_order_relaxed) ) { ... }

将为您提供与您获得的完全相同的 asm(没有昂贵的障碍说明) volatile flag.

除了无撕裂之外,atomic还使您能够在没有 UB 的情况下在一个线程中存储并在另一个线程中加载,因此编译器无法将负载提升到循环之外。(没有数据竞争 UB 的假设允许我们对非原子非易失性对象进行积极的优化。)这个特性与纯加载和纯存储的特性atomic<T>几乎相同volatile

atomic<T>也将 make+=等转化为原子 RMW 操作(比原子加载到临时、操作和单独的原子存储中要昂贵得多。如果您不想要原子 RMW,请使用本地临时代码编写代码)。

使用seq_cst您从 获得的默认排序while(!flag),它还添加了排序保证 wrt。非原子访问,以及其他原子访问。

(理论上,ISO C ++标准不排除原子能公司的编译时优化,但实际上编译器不会因为没有办法控制的情况下,不会好的。有一些情况下甚至volatile atomic<T>可能不如果编译器确实优化,则对原子的优化有足够的控制,所以现在编译器没有。请参阅为什么编译器不合并冗余 std::atomic 写入? 请注意,wg21/p0062 建议不要volatile atomic在当前代码中使用以防止优化原子。)


volatile 在真正的 CPU 上确实适用于这个(但仍然不使用它)

即使使用弱排序的内存模型(非 x86)。但实际上不要使用它,而是使用atomic<T>with mo_relaxed!本节的重点是解决对真实 CPU 工作方式的误解,而不是证明volatile. 如果您正在编写无锁代码,您可能会关心性能。了解缓存和线程间通信的成本对于良好的性能通常很重要。

真正的 CPU 具有一致的缓存/共享内存:在一个内核的存储变得全局可见后,其他内核无法加载过时的值。 (另请参阅Myths Programmers Being about CPU Caches,其中讨论了一些 Java volatiles,相当于 C++atomic<T> Being具有 seq_cst 内存顺序的。)

当我说load 时,我的意思是一条访问内存的 asm 指令。这就是一个volatile访问确保,并且是一样的东西左值到右值非原子/非易失性C ++变量的转换。(例如local_tmp = flagwhile(!flag))。

您唯一需要克服的是编译时优化,在第一次检查后根本不会重新加载。每次迭代的任何负载+检查就足够了,无需任何排序。如果此线程和主线程之间没有同步,那么谈论存储发生的确切时间或负载的顺序是没有意义的。循环中的其他操作。只有当它对该线程可见时才重要。当您看到 exit_now 标志设置时,您退出。典型的 x86 Xeon 上的内核间延迟在不同的物理内核之间可能大约40ns


理论上:硬件上的 C++ 线程没有一致的缓存

我看不出有任何方法可以远程高效,仅使用纯 ISO C++ 而无需程序员在源代码中进行显式刷新。

理论上,您可以在与此不同的机器上实现 C++ 实现,需要编译器生成的显式刷新以使其他内核上的其他线程可见。(或者读取不使用可能过时的副本)。C++ 标准并没有让这成为不可能,但是 C++ 的内存模型是围绕在一致的共享内存机器上高效而设计的。例如,C++ 标准甚至谈到了“读-读一致性”、“写-读一致性”等。标准中的一个注释甚至指出了与硬件的连接:

http://eel.is/c++draft/intro.races#19

[注意:前面的四个一致性要求有效地禁止编译器将原子操作重新排序为单个对象,即使这两个操作都是宽松加载。这有效地使大多数硬件提供的缓存一致性保证可用于 C++ 原子操作。— 尾注 ]

没有机制让release存储只刷新自身和一些选择的地址范围:它必须同步所有内容,因为如果他们的获取加载看到这个释放存储(形成一个release-sequence 在线程之间建立了一个happens-before 关系,保证早期由写线程完成的非原子操作现在可以安全读取。除非它在发布存储之后对它们进行了进一步的写入......)否则编译器会是真的聪明的证明,只有少数的高速缓存行需要冲洗。

相关:我的回答是 mov + mfence 在 NUMA是否安全?详细介绍了没有一致共享内存的 x86 系统是不存在的。还相关:加载和存储在 ARM 上重新排序以获取更多关于加载/存储到相同的信息位置的更多信息。

这里我想用非相干共享存储集群,但他们不是单一系统映像机器。每个一致性域都运行一个单独的内核,因此您不能在其上运行单个 C++ 程序的线程。相反,您运行程序的单独实例(每个实例都有自己的地址空间:一个实例中的指针在另一个实例中无效)。

为了让它们通过显式刷新相互通信,您通常会使用 MPI 或其他消息传递 API 使程序指定哪些地址范围需要刷新。


真正的硬件不会std::thread跨越缓存一致性边界运行:

存在一些非对称 ARM 芯片,它们具有共享的物理地址空间,但没有内部可共享的缓存域。所以不连贯。(例如,注释线程A8 内核和 Cortex-M3,如 TI Sitara AM335x)。

但是不同的内核会在这些内核上运行,而不是可以在两个内核上运行线程的单个系统映像。我不知道有任何 C++ 实现std::thread在没有一致缓存的情况下跨 CPU 内核运行线程。

特别是对于 ARM,GCC 和 clang 生成代码假设所有线程都在同一个内部可共享域中运行。事实上,ARMv7 ISA手册说

此架构 (ARMv7) 的编写预期所有使用相同操作系统或管理程序的处理器都在同一个内部共享共享域中

因此,不同域之间的非连贯共享内存只是为了在不同内核下的不同进程之间进行通信的共享内存区域的显式系统特定使用。

另请参阅有关在该编译器中使用(内部共享屏障)与(系统)内存屏障的代码生成的CoreCLR讨论。dmb ishdmb sy

我断言其他任何其他 ISA 的 C++ 实现都不会std::thread跨具有非一致性缓存的内核运行。 我没有证据表明不存在这样的实现,但这似乎不太可能。除非您针对以这种方式工作的特定奇特硬件,否则您对性能的考虑应该假设所有线程之间具有类似 MESI 的缓存一致性。(不过,最好atomic<T>以保证正确性的方式使用!)


相干缓存让一切变得简单

但是在具有一致缓存的多核系统上,实现发布存储只是意味着将提交提交到该线程的存储的缓存中,而不是进行任何显式刷新。(https://preshing.com/20120913/acquire-and-release-semantics/https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/)。(并且获取加载意味着命令访问另一个内核中的缓存)。

内存屏障指令只会阻塞当前线程的加载和/或存储,直到存储缓冲区耗尽;这总是尽可能快地发生。(或者对于LoadLoad / LoadStore 屏障,阻塞直到之前的加载完成。)(内存屏障是否确保缓存一致性已经完成?解决了这个误解)。因此,如果您不需要订购,只需在其他线程中提示可见性即可mo_relaxed。(也是volatile,但不要那样做。)

另请参见C/C++11 到处理器的映射

有趣的事实:在 x86 上,每个 asm 存储都是一个释放存储,因为 x86 内存模型基本上是 seq-cst 加上存储缓冲区(带有存储转发)。


半相关的 re:存储缓冲区、全局可见性和一致性:C++11 保证很少。大多数真正的 ISA(PowerPC 除外)确实保证所有线程都可以就另外两个线程出现两个存储的顺序达成一致。(在正式的计算机架构内存模型术语中,它们是“多副本原子”)。

另一个误解是需要的存储栅栏汇编指令刷新存储缓冲区其他处理器看到我们的店在所有。实际上,存储缓冲区总是试图尽可能快地耗尽自己(提交到 L1d 缓存),否则它会填满并停止执行。一个完整的屏障/栅栏的作用是停止当前线程,直到存储缓冲区被耗尽,所以我们后面的加载出现在我们之前的存储之后的全局顺序中。

(X86,强烈下令ASM存储模式意味着volatile在x86最终可能让你更接近mo_acq_rel,不同的是编译时与非原子变量仍可能发生重新排序,但大多数非x86已经弱有序内存模型,以便volatilerelaxed大约为在mo_relaxed允许的情况下弱。)

  • 写得很好。这正是我所寻找的(给出*所有*事实),而不是一个笼统的声明,只是说“对于单个全局共享布尔标志使用原子而不是易失性”。 (3认同)
  • @bernie:我在多次声称不使用“atomic”可能导致不同线程*在缓存*中的同一变量具有不同值而感到沮丧后写了这篇文章。/捂脸。在缓存中,否,在 CPU *寄存器* 中,是(使用非原子变量);CPU 使用一致缓存。我希望关于 SO 的其他问题不要充满对“原子”的解释,以免传播对 CPU 工作原理的误解。(因为出于性能原因,理解这一点很有用,并且还有助于解释为什么 ISO C++ 原子规则是这样编写的。) (3认同)

归档时间:

查看次数:

45050 次

最近记录:

6 年,2 月 前