"volatile" 对多核系统的可移植 C 代码是否有任何保证?

Does "volatile" guarantee anything at all in portable C code for multi-core systems?

看了 bunch of other questions and their answers 之后,我的印象是 对于 C 中 "volatile" 关键字的确切含义并没有广泛的共识。

甚至标准本身似乎也不够明确,以至于每个人都同意what it means

除其他问题外:

  1. 它似乎根据您的硬件和编译器提供不同的保证。
  2. 它影响编译器优化,但不影响硬件优化,所以在一个高级处理器上,它自己进行 运行 次优化,甚至不清楚编译器是否 可以 阻止任何你想阻止的优化。 (一些编译器确实会生成指令以防止某些系统上的某些硬件优化,但这似乎并没有以任何方式标准化。)

总结一下这个问题,"volatile" 似乎(在大量阅读之后)保证了类似的东西:值将 read/written 而不仅仅是 from/to寄存器,但至少到内核的 L1 缓存,与 reads/writes 在代码中出现的顺序相同。 但这似乎没用,因为 reading/writing from/to 一个寄存器在同一个线程中已经足够了,而与 L1 缓存的协调并不能保证与其他线程的进一步协调。我无法想象什么时候只与 L1 缓存同步变得如此重要。

使用 1
唯一广泛同意使用 volatile 的似乎是旧的或嵌入式系统,其中某些内存位置被硬件映射到 I/O 函数,就像内存中的一个位(直接在硬件中)控制一盏灯,或者内存中的一个位,告诉您键盘键是否按下(因为它由硬件直接连接到键)。

似乎"use 1"不会出现在目标包括多核系统的可移植代码中。

使用 2
与 "use 1" 没有太大区别的是可以由中断处理程序随时读取或写入的内存(它可以控制灯或存储来自键的信息)。但是为此我们已经遇到了问题,即取决于系统,中断处理程序 a different core with its own memory cache 和 "volatile" 不能保证所有系统上的缓存一致性。

所以 "use 2" 似乎超出了 "volatile" 所能提供的范围。

使用 3
我看到的唯一其他无可争议的用途是防止通过指向编译器未意识到的同一内存的不同变量访问的错误优化是同一内存。但这可能只是无可争议的,因为人们没有谈论它——我只看到过一次提到它。而且我认为 C 标准已经认识到 "different" 指针(如函数的不同参数)可能指向相同的项目或附近的项目,并且已经指定编译器必须生成即使在这种情况下也能工作的代码。但是,我无法在最新的(500 页!)标准中快速找到该主题。

所以"use 3"可能根本不存在

因此我的问题是:

"volatile"是否可以保证多核系统的可移植 C 代码中的任何内容?


编辑 -- 更新

浏览 latest standard 后,看起来答案至少是 非常 有限的是:
1. 标准反复规定特定类型的特殊处理"volatile sig_atomic_t"。然而,该标准还指出,在多线程程序中使用信号函数会导致未定义的行为。所以这个用例似乎仅限于单线程程序与其信号处理程序之间的通信。
2. 标准还明确了"volatile"相对于setjmp/longjmp的含义。 (重要的示例代码在其他 questions and answers 中给出。)

所以更精确的问题变成了:
除了 (1) 允许单线程程序从其信号处理程序接收信息,或 (2) 允许 setjmp查看 setjmp 和 longjmp 之间修改的变量的代码?

这仍然是一个 yes/no 问题。

如果 "yes",如果您能展示一个无错误的可移植代码的示例,如果省略 "volatile" 就会变得有错误,那就太好了。如果 "no",那么我认为对于多核目标,编译器可以在这两种非常具体的情况之外自由忽略 "volatile"。

我不是专家,但 cppreference.com 有一些在我看来相当不错的东西 information on volatile。这是它的要点:

Every access (both read and write) made through an lvalue expression of volatile-qualified type is considered an observable side effect for the purpose of optimization and is evaluated strictly according to the rules of the abstract machine (that is, all writes are completed at some time before the next sequence point). This means that within a single thread of execution, a volatile access cannot be optimized out or reordered relative to another visible side effect that is separated by a sequence point from the volatile access.

它还给出了一些用途:

Uses of volatile

1) static volatile objects model memory-mapped I/O ports, and static const volatile objects model memory-mapped input ports, such as a real-time clock

2) static volatile objects of type sig_atomic_t are used for communication with signal handlers.

3) volatile variables that are local to a function that contains an invocation of the setjmp macro are the only local variables guaranteed to retain their values after longjmp returns.

4) In addition, volatile variables can be used to disable certain forms of optimization, e.g. to disable dead store elimination or constant folding for microbenchmarks.

当然,它提到volatile对线程同步没有用:

Note that volatile variables are not suitable for communication between threads; they do not offer atomicity, synchronization, or memory ordering. A read from a volatile variable that is modified by another thread without synchronization or concurrent modification from two unsynchronized threads is undefined behavior due to a data race.

首先,对于 volatile 访问和类似的含义的不同解释,历史上存在各种问题。参见这项研究:Volatiles Are Miscompiled, and What to Do about It.

除了该研究中提到的各种问题外,volatile 的行为是可移植的,除了它们的一个方面:当它们充当 记忆障碍时 。内存屏障是一种机制,可以防止代码的并发无序执行。使用volatile作为内存屏障肯定是不可移植的。

C 语言是否保证来自 volatile 的内存行为显然是有争议的,尽管我个人认为该语言是明确的。首先我们有了副作用的正式定义,C17 5.1.2.3:

Accessing a volatile object, modifying an object, modifying a file, or calling a function that does any of those operations are all side effects, which are changes in the state of the execution environment.

该标准将术语排序定义为确定评估(执行)顺序的一种方式。定义正式繁琐:

Sequenced before is an asymmetric, transitive, pair-wise relation between evaluations executed by a single thread, which induces a partial order among those evaluations. Given any two evaluations A and B, if A is sequenced before B, then the execution of A shall precede the execution of B. (Conversely, if A is sequenced before B, then B is sequenced after A.) If A is not sequenced before or after B, then A and B are unsequenced. Evaluations A and B are indeterminately sequenced when A is sequenced either before or after B, but it is unspecified which.13) The presence of a sequence point between the evaluation of expressions A and B implies that every value computation and side effect associated with A is sequenced before every value computation and side effect associated with B. (A summary of the sequence points is given in annex C.)

上面的 TL;DR 基本上是,如果我们有一个包含副作用的表达式 A,它必须在另一个表达式 B 之前执行,以防 BA.

之后排序

通过这部分可以优化 C 代码:

In the abstract machine, all expressions are evaluated as specified by the semantics. An actual implementation need not evaluate part of an expression if it can deduce that its value is not used and that no needed side effects are produced (including any caused by calling a function or accessing a volatile object).

这意味着程序可以按照标准在其他地方规定的顺序(求值顺序等)来求值(执行)表达式。但如果它可以推断出它未被使用,则它不需要评估(执行)一个值。例如,操作 0 * x 不需要评估 x 并且只需将表达式替换为 0.

除非 访问变量是一种副作用。这意味着如果 xvolatile,它 必须 评估(执行)0 * x 即使结果将始终为 0。不允许优化.

此外,该标准谈到了可观察到的行为:

The least requirements on a conforming implementation are:

  • Accesses to volatile objects are evaluated strictly according to the rules of the abstract machine.
    /--/ This is the observable behavior of the program.

考虑到以上所有情况,符合规范的实现(编译器 + 底层系统)可能不会以未排序的顺序执行对 volatile 对象的访问,以防书面 C 源代码的语义另有说明。

这意味着在这个例子中

volatile int x;
volatile int y;
z = x;
z = y;

两个赋值表达式 必须 求值并且 z = x; 必须 z = y; 之前求值。将这两个操作外包给两个不同的未排序内核的多处理器实现不符合要求!

问题是编译器不能对预取缓存和指令流水线等做太多事情,尤其是当 运行 在 OS 之上时。因此编译器将这个问题交给了程序员,告诉他们内存屏障现在是程序员的责任。而C标准明确指出问题需要编译器来解决。

编译器不一定关心解决问题,因此 volatile 作为内存屏障是不可移植的。它已成为实施质量问题。

To summarize the problem, it appears (after reading a lot) that "volatile" guarantees something like: The value will be read/written not just from/to a register, but at least to the core's L1 cache, in the same order that the reads/writes appear in the code.

不,绝对不会。这使得 volatile 对于 MT 安全代码几乎毫无用处。

如果是这样,那么 volatile 对于多线程共享的变量来说非常好,因为在典型的 CPU(即多核或multi-CPU on motherboard)能够以一种方式进行合作,使得 C/C++ 或 Java 多线程的正常实现成为可能,并且具有典型的预期成本(也就是说,成本不是很高在大多数原子或非内容互斥操作上)。

但是 volatile 在缓存中提供任何有保证的顺序(或 "memory visibility"),无论是在理论上还是在实践中。

(注:以下是基于对标准文档的合理解释、标准的意图、历史实践以及对编译器编写者的期望的深刻理解。这种方法基于历史、实际实践以及期望和对现实世界中真实人物的理解,这比解析一个不知道是恒星规范写作并且已经修改了很多次的文档的话要强大和可靠得多。)

实际上,volatile 确实保证了 ptrace-ability,即能够在任何优化级别 为 运行ning 程序使用调试信息的能力,以及事实上,调试信息对这些易变对象有意义:

  • 您可以使用 ptrace(类似 ptrace 的机制)在涉及 volatile 对象的操作后的序列点处设置有意义的断点:您确实可以恰好在这些点处断点(请注意,这仅适用于您愿意设置许多断点,因为任何 C/C++ 语句都可能被编译为许多不同的程序集起点和终点,就像在一个大规模展开的循环中一样);
  • 当线程执行停止时,您可以读取所有易失性对象的值,因为它们具有规范表示(遵循各自类型的 ABI);一个非 volatile 局部变量可能有一个非典型的表示,f.ex。移位表示:用于索引数组的变量可能会乘以单个对象的大小,以便于索引;或者它可能被一个指向数组元素的指针所取代(只要变量的所有使用都被类似地转换)(考虑在整数中将 dx 更改为 du);
  • 您还可以修改这些对象(只要内存映射允许,因为具有 const 限定的静态生命周期的易失性对象可能位于映射为只读的内存范围内)。

实践中的 volatile 保证比严格的 ptrace 解释多一点:它还保证 volatile 自动变量在堆栈上有一个地址,因为它们没有分配给寄存器,寄存器分配会进行 ptrace 操作更微妙(编译器可以输出调试信息来解释变量是如何分配给寄存器的,但是读取和更改寄存器状态比访问内存地址稍微复杂一些)。

请注意,编译器的 "zero optimization" 模式提供了完整的程序调试能力,即至少在序列点考虑所有变量的可变性,该模式仍然执行简单的优化,如算术简化(通常不能保证在所有模式下都没有优化)。但是 volatile 比非优化强:x-x 可以简化为非 volatile 整数 x 但不是 volatile 对象。

所以 volatile 意味着保证按原样编译,就像系统调用的编译器从源代码到 binary/assembly 的翻译不是重新解释,已更改,或由编译器以任何方式优化。请注意,库调用可能是也可能不是系统调用。许多官方系统函数实际上是库函数,提供了一层薄薄的插入,通常在最后服从内核。 (特别是 getpid 不需要进入内核并且可以很好地读取包含信息的 OS 提供的内存位置。)

Volatile交互是真机与外界的交互,必须遵循"abstract machine"。它们不是程序部分与其他程序部分的内部交互。编译器只能根据它所知道的进行推理,即内部程序部分。

易失性访问的代码生成应该遵循与该内存位置最自然的交互:这应该不足为奇。这意味着 一些易失性访问应该是原子的 :如果在架构上读取或写入 long 表示的自然方式是原子的,那么它应该是volatile long 的读取或写入将是原子的, 因为编译器不应生成愚蠢的低效代码来逐字节访问易失性对象,例如 .

您应该能够通过了解架构来确定这一点。您不必了解有关编译器的任何信息,因为 volatile 意味着编译器应该是透明的.

但是 volatile 只是强制发出针对特定情况优化最少的预期程序集以执行内存操作:volatile 语义意味着一般情况语义。

一般情况是编译器在没有关于构造的任何信息时所做的事情:f.ex。通过动态调度在左值上调用虚函数是一种一般情况,在编译时确定表达式指定的对象类型后直接调用覆盖程序是一种特殊情况。编译器始终对所有构造进行一般情况处理,并且它遵循 ABI。

Volatile 对同步线程或提供"memory visibility"没有任何特殊作用:volatile 仅提供抽象级别的保证 从正在执行或停止的线程内部看,即CPU核心内部

  • volatile 没有说明哪些内存操作到达主 RAM(您可以使用汇编指令或系统调用设置特定的内存缓存类型以获得这些保证);
  • volatile 不保证何时将内存操作提交给任何级别的缓存(甚至 L1 也不行).

只有第二点意味着volatile在大多数线程间通信问题中没有用;第一点基本上与任何不涉及与 CPU(s) 之外但仍在内存总线上的硬件组件通信的编程问题无关。

volatile 的 属性 从核心的角度提供有保证的行为 运行 线程意味着异步信号传递给该线程,这些信号来自 运行该线程的执行顺序的观点,请参阅源代码顺序中的操作。

除非您计划向您的线程发送信号(一种非常有用的方法来整合有关当前 运行ning 线程的信息,之前没有商定的停止点),否则 volatile 不适合您。

ISO C 标准,不,但是 实际上我们 运行 线程跨越的所有机器都有一致的共享内存,所以 volatile 实际上有效有点像 _Atomicmemory_order_relaxed,至少对于足够小的类型的纯加载/纯存储操作是这样。 (但当然只有 _Atomic 会给你 之类的 n += 1;

还有一个问题是 volatile 对编译器究竟意味着什么。该标准允许回旋余地,但在现实世界的编译器中,这意味着加载或存储必须实际发生在 asm 中。不多也不少。 (不以这种方式工作的编译器无法正确编译使用手动 volatile 的 C11 之前的多线程代码,因此事实上的标准是编译器普遍有用和任何人想要的要求实际上使用它们。ISO C 为实现留下了足够的选择,DeathStation 9000 可能是 ISO C 兼容的并且几乎完全无法用于实际程序,并且破坏了大多数实际代码库。)

保证 volatile 访问按源顺序发生的要求通常被解释为按该顺序放置 asm,让 运行时间重新排序由目标机器的内存模型决定。 volatile 访问未按顺序排列。其他任何东西,所以普通操作仍然可以独立于它们进行优化。


When to use volatile with multi threading? 是问题的 C++ 版本。答:基本不用,用stdatomic。我在那里的回答解释了为什么缓存一致性使 volatile 在实践中有用:我不知道 shared_var.store(1, std::memory_order_relaxed) 在哪里需要显式刷新任何内容以使存储对其他核心可见的 C 或 C++ 实现。它编译为一个普通的 asm 存储指令,变量窄到足以成为“自然”原子。

(内存屏障只会让这个核心等待,例如,直到存储从存储缓冲区提交到 L1d 缓存并因此变得全局可见,然后再做 loads/stores。所以他们命令这个核心访问一致的共享内存。)

例如,Linux 内核依赖于此,使用 volatile 用于线程间可见性,使用 asm() 用于内存屏障来排序这些访问,以及用于 atomic-RMW操作。所有可以 运行 单个 Linux 实例的多核系统都具有一致的共享内存。

有一些罕见的系统具有不连贯的共享内存,例如某些集群。但是您不会 运行 跨不同一致性域的同一进程的线程。 (或者运行单个实例OS就可以了)。相反,共享内存必须以不同于普通可缓存回写的方式进行映射,或者您必须进行显式刷新。