在 C11/C++11 中,可以在同一内存上混合原子/非原子操作

In C11/C++11, possible to mix atomic/non-atomic ops on the same memory?

本文关键字:混合 原子操作 内存 C++11 C11      更新时间:2023-10-16

是否可以在同一内存位置执行原子和非原子操作?

我问不是因为我真的想这样做,而是因为我试图理解 C11/C++11 内存模型。 他们像这样定义"数据竞赛":

如果程序的执行包含两个数据争用

,则程序的执行包含数据争用 不同线程中的冲突操作,其中至少有一个不是 原子的,两者都先于另一个发生。任何此类数据竞赛 导致未定义的行为。 -- C11 §5.1.2.4 p25, C++11 § 1.10 p21

"其中至少有一个不是原子的"部分困扰着我。 如果不可能混合原子和非原子操作,它只会说"在一个非原子的物体上"。

我看不到任何对原子变量执行非原子操作的直接方法。 C++ 中的std::atomic<T>不定义任何具有非原子语义的操作。 在 C 中,原子变量的所有直接读取/写入似乎都转换为原子操作。

我想memcpy()和其他直接内存操作可能是对原子变量执行非原子读/写的一种方式? 即。 memcpy(&atomicvar, othermem, sizeof(atomicvar)) ? 但这甚至是定义的行为吗? 在C++中,std::atomic是不可复制的,那么在 C 或 C++ 中memcpy()它是否会被定义为行为?

原子变量的初始化(无论是通过构造函数还是atomic_init()(被定义为非原子变量。 但这是一次性操作:不允许第二次初始化原子变量。 放置 new 或显式析构函数调用也可能不是原子的。 但在所有这些情况下,似乎无论如何都不会定义可能正在对未初始化值进行操作的并发原子操作。

对非原子变量执行原子操作似乎完全不可能:C 和 C++ 都没有定义任何可以对非原子变量进行操作的原子函数。

那么这里的故事是什么呢? 它真的是关于memcpy(),还是初始化/销毁,还是其他什么?

我认为你忽略了另一种情况,相反的顺序。考虑一个初始化的int,其存储被重用于创建std::atomic_int。所有原子操作都在其 ctor 完成后发生,因此在初始化的内存上。但是,对现在覆盖的int进行任何并发的非原子访问也必须被禁止。

(我在这里假设存储寿命足够并且没有任何作用(

我不完全确定,因为我认为对int的第二次访问无论如何都是无效的,因为访问表达式的类型int与当时对象的类型不匹配(std::atomic<int>(。但是,"对象当时的类型">假定单个线性时间进程在多线程环境中不成立。C++11 一般来说,通过对"全局状态"未定义行为本身做出这样的假设来解决这一问题,并且该问题的规则似乎适合该框架。

所以也许可以改写一下:如果单个内存位置包含一个原子对象和一个非原子对象,并且如果在创建另一个(较新(对象之前没有对最早创建的(较旧(对象的销毁进行排序,那么对旧对象的访问与对较新对象的访问冲突,除非前者被安排 - 在后者之前。

免责声明:我不是并行大师。

是否可以在同一内存上混合原子/非原子操作,以及如果 那么,如何呢?

您可以将其编写在代码中并进行编译,但它可能会产生未定义的行为。

在谈论原子组学时,重要的是要了解他们解决了什么样的O问题。

您可能知道,我们所说的"内存"是能够容纳内存的多层实体集。
首先我们有 RAM,然后是 缓存行 ,然后是寄存器。

在单核处理器上,我们没有任何同步问题。 在多核处理器上,我们拥有所有这些。 每个内核都有自己的一组寄存器和缓存行。

这不会引起什么问题。

其中第一个是内存重新排序 - CPU 可能会决定在运行时清理一些读/写指令以使代码运行得更快。 这可能会产生一些奇怪的结果,这些结果在带来这组指令的高级代码上是完全不可见的。
这个现象最经典的例子是"两个线程 - 两个整数"的例子:

int i=0;
int j=0;
thread a -> i=1, then print j
thread b -> j=1 then print i;

从逻辑上讲,结果"00"不可能是。 要么 A 先结束,结果可能是"01",要么 B 先结束,结果可能是"10"。 如果两者同时结束,则结果可能是"11"。 然而,如果你构建模仿这个位置的小程序并在循环中运行它,非常奇怪地你会看到结果"00">

另一个问题是内存不可见。 就像我之前提到的,变量的值可以缓存在其中一个缓存行中,也可以存储在其中一个已注册的缓存行中。 当 CPU 更新变量值时 - 它可能会延迟将新值写回 RAM。 它可能会将值保留在缓存/注册表中,因为(编译器优化(被告知该值将很快再次更新, 所以为了使程序更快 - 再次更新值,然后才将其写回 RAM。如果其他 CPU(以及线程或进程(依赖于新值,则可能会导致未定义的行为。

例如,看看这个伪代码:

bool b = true;
while (b) -> print 'a'
new thread -> sleep 4 seconds -> b=false;

字符"a"可以无限打印,因为b可能会被缓存并且永远不会更新。

在处理平行主义时,还有更多的问题。

原子通过(简而言之(告诉编译器/CPU 如何正确地从 RAM 读取和写入数据来解决此类问题,而无需进行不需要的清理(阅读内存顺序(。 内存顺序可能会强制 CPU 将其值写回 RAM,或者从 RAM 读取值,即使它们已缓存。

因此,尽管您可以将非原子动作与原子动作混合在一起,但您只完成了部分工作。

例如,让我们回到第二个示例:

atomic bool b = true;
while (reload b) print 'a'
new thread - > b = (non atomicly) false. 

因此,尽管一个线程一次又一次地从 RAM 中重新读取 b 的值,但另一个线程可能不会将false写回 RAM。

因此,尽管您可以在代码中混合使用此类操作,但它会产生欠精细的行为。

我对这个主题很感兴趣,因为我的代码有时我需要串行访问一系列地址,有时需要并行访问相同的地址,同时以某种管理争用的方式。

因此,不完全是原始问题提出的情况,该问题(我认为(意味着并行代码中的并或几乎是原子和非原子操作,但很接近。

我已经通过一些狡猾的强制转换来说服我的 C11 编译器允许我访问一个整数,更有用的是原子和非原子("直接"(的指针,已经确定这两种类型在我的 x86_64 系统上都是正式无锁的。 也就是说,原子和非原子类型的大小是相同的。

我绝对不会尝试在并行上下文中混合使用两种类型的地址访问,那注定要失败。但是,我已经成功地在串行代码中使用了"直接"语法操作,在并行代码中使用了"原子"语法,为我提供了两全其美的串行访问(和更简单的语法(,并在并行时安全地管理争用。

因此,只要您不尝试在并行代码中混合使用这两种方法并坚持使用无锁类型,这可能意味着指针的大小,您就可以做到这一点。

我对这个主题很感兴趣,因为我的代码有时我需要串行访问一系列地址,有时需要并行访问相同的地址,同时以某种管理争用的方式。

因此,不完全是原始问题所提出的情况,该问题(我认为(意味着并行代码中的原子和非原子操作并或几乎如此,但很接近。

我已经通过一些狡猾的强制转换来说服我的 C11 编译器允许我访问一个整数,更有用的是原子和非原子("直接"(的指针,已经确定这两种类型在我的 x86_64 系统上都是正式无锁的。 我的解释(可能过于简单(是原子和非原子类型的大小相同,并且硬件可以在单个操作中更新这些类型。

我绝对不会尝试在并行上下文中混合两种类型的地址访问,我认为这注定要失败。但是,我已经成功地在串行代码中使用了"直接"语法操作,在并行代码中使用了"原子"语法,为我提供了两全其美的串行访问(和更简单的语法(,并在并行时安全地管理争用。

因此,只要您不尝试在并行代码中混合使用这两种方法并坚持使用无锁类型,这可能意味着指针的大小,您就可以做到这一点。