std::memory_order和指令指令,澄清

std::memory_order and instruction order, clarification

本文关键字:指令 澄清 order memory std      更新时间:2023-10-16

这是这个问题的后续问题。

我想弄清楚指令排序的含义,以及它如何受到std::memory_order_acquirestd::memory_order_release等的影响

在我联系的问题中,已经提供了一些细节,但我觉得提供的答案并不是关于订单(这更像是我想要的),而是激励了我为什么这是必要的。

我将引用我将用作参考的相同示例

#include <thread>
#include <atomic>
#include <cassert>
#include <string>
std::atomic<std::string*> ptr;
int data;
void producer()
{
std::string* p  = new std::string("Hello");
data = 42;
ptr.store(p, std::memory_order_release);
}
void consumer()
{
std::string* p2;
while (!(p2 = ptr.load(std::memory_order_acquire)))
;
assert(*p2 == "Hello"); // never fires
assert(data == 42); // never fires
}
int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join(); t2.join();
}

简而言之,我想弄清楚两行的指令顺序到底发生了什么

ptr.store(p, std::memory_order_release);

while (!(p2 = ptr.load(std::memory_order_acquire)))

根据文件,专注于第一

。。。当前线程中的任何读取或写入都不能在此存储之后重新排序。。。

我看了几场演讲来了解这个订购问题,我理解为什么它现在很重要。我还不太清楚编译器是如何翻译顺序规范的,我认为文档中给出的例子也不是特别有用,因为在运行producer的线程中进行存储操作后,没有其他指令,因此无论如何都不会重新排序。然而,也有可能我误解了,有可能他们的意思是的等效组件

std::string* p  = new std::string("Hello");
data = 42;
ptr.store(p, std::memory_order_release);

会不会使得翻译的前两行永远不会在原子存储之后移动?同样,在线程运行生成器中,是否有可能在原子加载之前不会移动任何断言(或等效程序集)?假设我在存储之后有第三条指令,那么那些已经在原子加载之后的指令会发生什么?

我也尝试过编译这样的代码来保存带有-S标志的中间汇编代码,但它相当大,我真的无法计算

再次澄清,这个问题是关于排序的方式,而不是关于为什么这些机制是有用或必要的。

我知道,当涉及到内存排序时,人们通常会试图争论是否以及如何对操作进行排序,但在我看来,这是错误的方法!C++标准没有说明如何对指令进行重新排序,而是定义了发生在关系之前,它本身是基于已排序的before、synchronize和线程间发生在关系之前。

从存储版本读取值的获取加载与获取加载的同步,因此建立了先发生后发生的关系。由于"先发生后发生"关系的可传递性,在存储发布之前"排序"的操作也在获取加载之前"发生"。任何关于使用原子论实现的正确性的争论都应该始终依赖于先发生后发生的关系指令是否以及如何重新排序仅仅是应用发生前关系规则的结果

有关C++内存模型的更详细解释,您可以查看C/C++程序员的内存模型。

无原子:

std::string* ptr;
int data;
void producer()
{
std::string* p  = new std::string("Hello");
data = 42;
ptr = p;
}
void consumer()
{
std::string* p2;
while (!(p2 = ptr))
;
assert(*p2 == "Hello"); // never fires
assert(data == 42); // never fires
}

producer中,编译器可以在赋值到ptr之后自由地移动对数据的赋值。因为ptr在设置数据之前变为非null,所以可以触发相应的断言。

发布存储区禁止编译器执行此操作。

consumer中,编译器可以自由地将数据上的断言移动到循环之前。

加载获取禁止编译器执行此操作。

与排序无关,但编译器可以完全省略循环,因为如果循环开始时ptr为null,则没有任何东西可以有效地使其看起来不为null,从而导致无限循环,也可以假设它不会发生。

我认为文档中给出的例子也不是特别也很有用,因为在线程中执行存储操作之后制作人没有其他指示,因此什么都不会还是重新订购了。

如果有,它们无论如何都可以提前执行。那会有多痛?

生产者唯一必须保证的是,在设置标志之前,内存中的"生产"是完全写入的;否则,消费者将无法避免读取未初始化的内存(或对象的旧值)。

太迟设置已发布的对象将是灾难性的。但是,为什么过早地开始建立另一个已发布的对象(比如第二个)是个问题呢?

你怎么会过早地知道制片人做了什么您唯一可以做的就是检查标志,只有在设置了标志后,您才能观察到发布的对象。

因此,如果在修改标志之前有任何东西被重新排序,你应该看不到它

但是在x86-64:上GCC的汇编输出中没有什么可看的

producer():
sub     rsp, 8
mov     edi, 32
call    operator new(unsigned long)
mov     DWORD PTR data[rip], 42
lea     rdx, [rax+16]
mov     DWORD PTR [rax+16], 1819043144
mov     QWORD PTR [rax], rdx
mov     BYTE PTR [rax+20], 111
mov     QWORD PTR [rax+8], 5
mov     BYTE PTR [rax+21], 0
mov     QWORD PTR ptr[abi:cxx11][rip], rax
add     rsp, 8
ret

(如果你想知道,ptr[abi:cxx11]是一个修饰的名字,而不是一些时髦的asm语法,所以ptr[abi:cxx11][rip]的意思是ptr[rip]。)

可以概括为:

设置堆栈帧分配数据设置字符串对象分配ptr移除帧并返回

所以实际上没有什么值得注意的,除了ptr被分配到最后。

你必须选择另一个目标才能看到更有趣的东西。

回答您的评论可能很有用:

我仍然觉得我的问题不清楚,我的问题更像如下。假设(例如在生产者中)你再添加几个原子存储之后的语句,例如data2=175,可能data_3=10,其中data_2和data_3是全局。具体是什么重新订购现在受到影响?我知道你可能已经报道过了你的回答,所以如果我惹恼,我深表歉意

让我们摆弄一下你的producer()

void producer()
{
data = 41;
std::string* p  = new std::string("Hello");
data = 42;
ptr.store(p, std::memory_order_release);
}

consumer()能否在"data"中找到值41。否。42的值已经(逻辑上)存储到释放围栏点的数据中,并且如果consumer()找到值42,则42的存储将(至少看起来)发生在释放围栏之后。

好的,现在让我们进一步修补。。。

void producer()
{
data = 0xFF01;
std::string* p  = new std::string("Hello");
data = 0xFF02;
ptr.store(p, std::memory_order_release);
data = 0x0003
}

现在所有的赌注都落空了。data不是原子的,也不能保证consumer会找到什么。在大多数体系结构上,现实是唯一的候选者是0xFF02或0x0003,但在某些体系结构中,它可能会找到0xFF03和/或0x0002。如果16位int被写为2个单字节操作(从任意一端),那么在具有8位总线的体系结构上可能会发生这种情况。

但原则上,面对这样的数据竞赛,现在根本无法保证会存储什么。这是一场数据竞赛,因为无法控制consumer是否与该额外写入一起排序。