我是不是应该期望 C++ 编译器会编译具有“按编码”的数据竞争的多线程代码,或者它可能会做其他事情?

Posted

技术标签:

【中文标题】我是不是应该期望 C++ 编译器会编译具有“按编码”的数据竞争的多线程代码,或者它可能会做其他事情?【英文标题】: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?我是否应该期望 C++ 编译器会编译具有“按编码”的数据竞争的多线程代码,或者它可能会做其他事情? 【发布时间】:2021-11-02 19:23:31 【问题描述】:

假设我有一个硬件,对于小于或等于 bool 大小的值的所有内存访问都是线程安全的,并且由于硬件或代码的原因,避免了与缓存有关的一致性问题。

我是否应该期望从多个线程对同一对象的非原子访问将被编译为“按编码”,因此我得到了平台的线程安全程序?

【问题讨论】:

不,你永远不会得到开箱即用的线程安全。对数据的访问将不是原子的。所以有诸如 std::atomic、std::mutex、std::condition_variable 等之类的东西来管理多线程的东西。 (缓存完整性由 CPU 硬件处理) 如果行为未定义,则(根据标准中的定义)该标准不会描述对所发生情况的任何限制。实际上,这意味着编译器被允许做任何它喜欢的事情,并且没有“编码”这样的事情。虽然标准允许实现产生一些为该实现记录的特定行为,但它要求没有实现这样做。一旦您开始争论“但我的硬件执行 X”,反论点就是“标准允许实现发出不受 X 影响的代码”。 @ArthurP.Golubev 如果行为未定义,则编译时决策无关紧要。 要考虑的一点是,如果没有任何内存栅栏(std::atomic、std::mutex 等),编译器可以假设它可以优化程序而不用担心多线程问题.因此,如果一个线程正在循环 while( bKeepThreadRunning ) 为真,并且循环中没有代码更改循环测试变量,则编译器可以完全优化读取。因此,除了优化器之外的竞争条件可能会破坏您的程序,因为数据流分析表明它可以比您希望的更积极地优化,即 while( bKeepThreadRunning ) 变为 while( true ) @ArthurP.Golubev 不,不是。如果行为未定义,则没有什么可以阻止编译器意外或无意地发出不受 X 影响的代码。不需要编译时间决策(甚至在编译器设计中做出的决策)。 【参考方案1】:

在 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-ifrule (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,链接中的示例案例很容易检测到编译。

如果编译器可以轻松检测到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

在我关于@987654391 实现问题的回答帖子https://***.com/a/68974430/1790694 中查看有关在C++ 中实现double-checked locking pattern 的详细信息(包括使用带有数据竞争的非原子 标志) @ in C++ Is there any potential problem with double-check lock for C++? (请记住,那里的代码包含线程中的多线程操作,这会影响线程中的所有访问操作,触发内存一致性并防止重新排序,因此整个代码先验不是编译为单线程)。

【讨论】:

写“不”的方式太长了。 "对于草稿文件№4861 ...它在6.9.2.1 22" 可以使用[intro.multithread.general]等符号名称,这要多得多在标准版本之间保持稳定。 @HolyBlackCat,谢谢你的提示。遗憾的是,由于某种原因,目前我无法将参考名称添加到答案帖子中的链接,但我考虑了这个想法。 C++ 标准明确避免为程序定义任何符合性标准,并明确指出即使它似乎对程序施加了约束,这仅仅是因为这样做比试图描述所有在何种情况下,实现需要或不需要按照另外描述的方式精确工作。【参考方案2】:

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

假设您有一个单核 CPU - 例如 80486。在这样的架构中,价值可能在哪里?答案是寄存器、缓存还是 RAM,具体取决于是否要对值进行操作。

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

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

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

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

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

【讨论】:

一个多核一致性机制可以自己在硬件层面进行一致性(将所有写入传播到其他核心,在写入后读取它)。 @ArthurP.Golubev,当然,但那是记忆连贯性。变量的最新值可能还没有回到内存中,因为在操作之后还没有发生写入。使用锁是一种明确声明值正在发生变化的方式,并且在锁被释放之前不要访问它。【参考方案3】:

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

甚至在第一个 C++ 标准发布之前,C 标准就指定 volatile 的语义是实现定义的,因此允许设计为适用于多任务或基于中断的系统的实现提供适合于无需特殊语法即可达到该目的,同时允许那些不打算支持此类任务的代码生成在较弱的语义就足够时效率稍高的代码,但在需要更强的语义时表现不佳。

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

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

【讨论】:

以上是关于我是不是应该期望 C++ 编译器会编译具有“按编码”的数据竞争的多线程代码,或者它可能会做其他事情?的主要内容,如果未能解决你的问题,请参考以下文章

它将在 DEV c++ 编译器上给出输出 28 我期望 26 考虑 float 为 4 字节 int 为 4 字节,char 为 1 字节 [重复]

将 C++ 库链接到具有非 C++ 主函数的程序

在应用程序中嵌入 C++ 编译器

constexpr 说明符性能没有达到我在 C++ 中的期望

Rust 编译器不期望一个可变引用,而应该期望一个可变引用

剖析 C++ 代码编译速度