我是否应该期望 C++ 编译器会编译带有数据竞争 "as coded" 的多线程代码,或者它可能会做其他事情?

Should I expect that a C++ compiler would compile multi-threaded code with a data race "as coded", or it may do into something else?

假设我有一个硬件,在该硬件上所有对小于或等于 bool 大小的值的内存访问都是线程安全的,并且由于硬件或代码。

我是否应该期望从多个线程对相同对象的非原子访问将仅“按编码”进行编译,以便我获得平台的线程安全程序?

在 C++11 之前,语言标准根本不关心多线程,并且不可能创建可移植(符合语言标准)的多线程 C++ 程序。必须使用第三方库,并且程序在代码级别的线程安全只能由这些库的内部提供,而这些库又使用相应的平台功能,编译器编译代码就像它是单一的一样-线程。

自 C++11 起,根据标准:

  • 两个表达式求值conflict 如果其中一个修改内存位置而另一个读取或修改相同的内存位置内存位置。
  • 两个动作是potentially concurrent如果 -- 它们由不同的线程执行,或者 -- 它们是无序的,至少有一个是由信号处理程序执行的,并且它们不是都由同一个信号处理程序调用执行的;
  • 程序的执行包含 data race 如果它包含两个潜在的并发冲突操作,至少其中之一这不是原子的,也不是 happens before 另一个,除了标准中描述的信号处理程序的特殊情况 ([intro.races] 部分 22 C++20 的要点:https://timsong-cpp.github.io/cppwp/n4868/intro.races#22).
  • 任何此类 data race 都会导致 undefined behavior.

atomic 操作对于涉及同一对象的任何其他原子操作是不可分割的。 一个操作happens before另一个意味着第一个操作的写入内存对第二个操作的读取有效。

根据语言的标准,undefined behaviour只是标准没有要求的

有些人错误地认为undefined behaviour只是运行时发生的事情,与编译无关,但标准运行undefined behaviour来规范编译,所以有在 undefined behaviour.

的情况下,编译和相应的执行都没有指定期望

语言标准不禁止编译器诊断 undefined behaviour

标准明确指出,在 undefined behaviour 的情况下,除了忽略不可预测的结果外,还允许以环境记录(包括编译器文档)的方式行事(字面意思是做所有事情可能,尽管有记录)在翻译和执行期间,并终止翻译或执行(https://timsong-cpp.github.io/cppwp/n4868/intro.defs#defns.undefined)。

因此,编译器甚至可以为 undefined behaviour.

的情况生成无意义的代码

data race不是实际上同时发生对对象的冲突访问时的状态,而是在执行甚至可能(取决于环境)对对象的冲突访问的代码时的状态(在语言层面上考虑相反是不可能的,因为由操作引起的硬件对内存的写入可能会在并发代码的范围内延迟未指定的时间(请注意,除此之外,操作可能在某些范围内编译器和硬件分散在并发代码上的限制))。

至于仅针对某些输入导致 undefined behaviour 的代码(执行时可能发生或不发生),

  • 一方面,as-if规则 (https://en.cppreference.com/w/cpp/language/as_if) 允许编译器生成仅对不会导致 undefined behaviour 的输入正确工作的代码(例如,以便在导致 undefined behaviour 的输入发生时发出诊断消息;在标准中明确指出发出诊断消息是允许的 undefined behaviour 的一部分);
  • 另一方面,在实践中,编译器生成代码时通常认为此类输入永远不会发生,请参阅 https://en.cppreference.com/w/cpp/language/ub
  • 中的此类行为示例

注意,与潜在(我在这里使用potential这个词是因为下面用*标记的注释中的内容)data races,示例的案例来自link 在编译时很容易检测到。

如果编译器可以轻松检测到 data race,一个合理的编译器会终止编译而不是编译任何东西,但是:

一方面,[*] 几乎不可能断定数据竞争一定会在 运行 时间内发生,只是因为在 运行 时间内它可能发生由于环境原因,单个代码上的所有并发代码实例都无法启动,这使得任何多线程代码先验都可能是单线程的,因此可能完全避免 data races (尽管在许多情况下它会破坏程序的语义,但这不是编译器关心的问题)。

另一方面,允许编译器注入一些代码,以便在 运行 时间内处理 data race(注意,不仅是为了发出诊断消息等明智的事情,但以任何(尽管有记录),甚至是有害的方式),但除了这样的注入将是一个有争议的(即使是在合理的情况下)开销之外的事实:

  • 由于翻译单元的单独编译,一些潜在的data races可能根本检测不到;
  • 一些潜在的 data races 可能存在或不存在于特定的执行中,这取决于 运行 时间输入数据,这将使注入的正确性变得可怕;
  • 由于代码的复杂结构和程序的逻辑,即使在可能的情况下,检测起来也可能足够复杂且成本太高data races

因此,目前编译器甚至不尝试检测data races是正常的。


除了data races本身,对于可能存在数据竞争且按单线程编译的代码,还存在以下问题:

  • as-if规则下(https://en.cppreference.com/w/cpp/language/as_if)一个变量可能会被删除,如果它寻找编译器没有区别,编译器不会考虑多线程,除非使用了该语言及其标准库的特定多线程手段;
  • 操作可能会根据 as-if 规则下的编译器和硬件在执行时根据其“编码”的内容重新排序,如果看起来没有区别,除非特定的多线程手段使用语言及其标准库,并且硬件可以实现各种不同的方法来限制重新排序,包括代码中对显式对应命令的要求;

问题中说明了下面几点不是这样的,但是为了完成可能问题的集合,理论上在某些硬件上是可能的:

  • 虽然有些人错误地认为多核一致性机制总是完全协调数据,即当一个对象被一个核心更新时,其他核心在读取时获得更新的值,多核是可能的一致性机制本身不会做一些甚至所有的一致性,但只有在代码中的相应命令触发时才会这样做,因此如果没有这些相应的命令,要写入对象的值就会卡在核心的缓存中,这样要么从不或迟于适当到达其他核心。

请注意,适当使用合理实现(详见下面标有**的注释)volatile变量修饰符如果使用volatile修饰符是可能的,解决了编译器的消除和重新排序问题,但没有通过硬件重新排序,也没有在缓存中“卡住”。

[**] 遗憾的是,实际上,该语言的标准说“通过 volatile glvalue 进行访问的语义是实现定义的”(https://timsong-cpp.github.io/cppwp/n4868/dcl.type.cv#5)。 尽管该语言的标准指出“volatile 是对实现的提示,以避免涉及对象的激进优化,因为对象的值可能会通过实现无法检测的方式进行更改。” (https://timsong-cpp.github.io/cppwp/n4868/dcl.type.cv#note-5),如果 volatile 的实现符合其预期目的,这将有助于避免编译器消除和重新排序,这对于环境可能访问的值是正确的(例如,硬件、操作系统、其他应用程序)的代码,正式编译器没有义务根据其预期目的实施 volatile。 但是,与此同时,该标准的现代版本指出“此外,对于某些实现,volatile 可能表示需要特殊的硬件指令才能访问该对象。” (https://timsong-cpp.github.io/cppwp/n4868/dcl.type.cv#note-5),这意味着一些实现也可能实现防止硬件重新排序和防止“卡在”缓存中,尽管这不是 volatile 的目的。


保证(只要实现符合标准),这三个问题,以及data races问题,只有使用特定的多线程手段才能解决,包括多线程部分自 C++11 以来的 C++ 标准库。

因此为了可移植,确认语言的标准,C++ 程序必须保护它的执行免受任何 data races.

如果编译器将代码编译为单线程(即忽略 data race),并且合理实现(如上面标有 ** 的注释中所述)volatile修饰符使用得当,不存在硬件问题的缓存和重排序,不使用数据竞争保护就可以得到线程安全的机器码(来自环境依赖,不确认从C++11开始的标准,C++代码)。


关于使用 非原子 bool 标志用于多线程特定环境的潜在安全示例,您可以在 https://en.cppreference.com/w/cpp/language/storage_duration#Static_local_variables 阅读static local variables(C++11 起)初始化的实现通常使用 double-checked locking pattern 的变体,这将 运行 已经初始化的局部静态的时间开销减少到单个 non-atomic boolean比较.

但请注意,这些解决方案依赖于环境,并且由于它们是编译器本身实现的一部分,而不是使用编译器的程序,因此无需担心是否符合那里的标准。

为了使您的程序符合语言标准并受到保护(只要编译器符合标准)免受编译器实现细节的自由,您必须保护 double-check lock 的标志免受数据竞争,最合理的方法是使用 std::atomicstd::atomic_bool.

在我的回答 post 中查看有关在 C++ 中实现 double-checked locking pattern 的详细信息(包括使用带有数据竞争的 非原子 标志) on the question about implementation of double-check lock in C++ Is there any potential problem with double-check lock for C++?(注意这里的代码包含了线程中的多线程操作,影响了线程中所有的访问操作,触发内存一致性,防止重排序,所以整个代码先验不会被编译因为它是单线程的)。

如果您有这样的硬件,那么答案是“有”。问题是,那个硬件是什么?

假设您有一个单核 CPU - 例如 80486。在这样的体系结构中,价值在哪里?答案是寄存器、缓存或 RAM,具体取决于该值是否将要被操作。

问题是,如果你有一个抢占式多线程操作系统,你不能保证,当上下文切换发生时,值已经从寄存器刷新到内存(缓存/RAM)。作为结果,该值可能在寄存器中作为刚刚产生该值的操作的结果,并且抢占可以发生在下一个将其从操作的“结果”寄存器移动到内存的操作代码之前。抢占式切换到另一个线程会导致新线程访问内存中的值,这是陈旧的。

因此,该硬件不是过去 40 年制造的任何硬件。

可以想象,有一个没有寄存器的 CPU 是可能的,即它使用 RAM 作为其寄存器集。但是,没有人做过其中之一,因为它会很慢。

所以在实践中,没有这样的硬件,所以答案是“不”,它不会是线程安全的。

您必须拥有协作式多任务处理 OS 之类的东西,而不是确保在 运行 新线程之前将寄存器中的操作结果移回 RAM。

几十年来,对于编译器来说,即使是那些旨在适用于多线程或基于中断的编程的编译器,在没有可变限定访问干预的情况下合并对对象的非限定访问也很常见,这并不令人惊讶.虽然 C 标准承认实现将所有访问视为 volatile 合格的可能性,但并不特别推荐这种处理。至于volatile够不够,好像有争议。

甚至在第一个 C++ 标准发布之前,C 标准就指定 volatile 的语义是实现定义的,因此允许设计为适用于多任务或基于中断的系统的实现在不需要特殊语法的情况下提供适合该目的的语义,同时允许那些不打算支持此类任务的人生成代码,当较弱的语义就足够时,这些代码会稍微更有效率,但在需要更强的语义时会以破坏的方式运行。

虽然有些人声称在将原子添加到语言标准之前不可能编写可移植的多线程代码,但这忽略了一个事实,即许多人可以并且确实编写了可移植的多线程代码在预期目标平台的所有实现中,其设计者使 volatile 的语义足够强大以支持此类代码而无需特殊语法。该标准没有指定为了适合该目的需要执行哪些实现,因为(1)它不要求实现适合该目的,并且(2)编译器编写者应该了解他们的客户' 比委员会更需要。

不幸的是,一些不受正常市场压力影响的编译器作者解释了标准未能要求所有实现以适合多线程或基于中断的程序的方式处理 volatile 而无需特殊语法作为判断,不应期望任何实现会这样做。因此,有很多代码如果由商业实现处理是可靠的,但不会被像 clang 或 gcc 这样的编译器可靠地处理,这些编译器被设计为在执行此类任务时需要特殊语法。