如何在多线程加载之前同步商店?

How do I synchronize a store before a load in multiple threads?

考虑以下程序:

#include <thread>
#include <atomic>
#include <cassert>

int x = 0;
std::atomic<int> y = {0};
std::atomic<bool> x_was_zero = {false};
std::atomic<bool> y_was_zero = {false};

void write_x_load_y()
{
    x = 1;
    if (y == 0)
        y_was_zero = true;
}

void write_y_load_x()
{
    y = 1;
    if (x == 0)
        x_was_zero = true;
}

int main()
{
    std::thread a(write_x_load_y);
    std::thread b(write_y_load_x);
    a.join();
    b.join();
    assert(!x_was_zero || !y_was_zero);
}
  1. 鉴于除访问 x 之外的所有内容都可以是原子的约束,我如何保证断言通过?
  2. 如果这不可能按原样进行,是否可以访问 x 可以是原子的但不比“放松”更强?
  3. 保证这一点所需的最少 同步量(例如,所有操作的最弱内存模型)是多少?

据我了解,如果没有任何形式的围栏或原子访问,商店 x = 1 有可能(如果只是理论上如此)低于负载 y == 0(已被CPU 如果不是编译器本身的话),导致 x 和 y 均为 0 的潜在竞争(并触发该断言)。

我最初的印象是 SEQ_CST 保证了非原子变量的总排序。也就是说,在 y 的 SEQ_CST 加载之前订购的 x 的非原子(或松散)存储保证实际首先发生;类似地,在 x 的非原子(或松散)加载之前订购的 y 的 SEQ_CST 存储保证实际首先发生;放在一起会阻止比赛。然而,在进一步阅读 https://en.cppreference.com/w/cpp/atomic/memory_order 时,我认为文档实际上并没有这么说,而是这种排序只保证相反的情况(在存储之前加载),或者访问两者的情况 xy 是 SEQ_CST.

同样,我曾天真地认为内存屏障会强制屏障之前的所有加载或存储发生在它之后的所有加载或存储之前,但阅读 https://en.cppreference.com/w/cpp/atomic/atomic_thread_fence 似乎暗示它再次仅适用于强制在屏障之前对负载进行排序,在屏障之后进行存储。我认为,这在这里也无济于事,除非我应该在比“商店和货物之间”更不明显的地方设置障碍。

这里应该使用什么同步方式?有可能吗?

这个想法有致命的缺陷,不可能在非原子的 ISO C++ 中保证安全 x。数据争用未定义行为 (UB) 是不可避免的,因为一个线程无条件地写入 x 而另一个线程无条件地读取它。

充其量您将通过使用编译器屏障强制一个线程将实际内存状态与抽象机状态同步来滚动您自己的原子。但即便如此,在没有 volatile 的情况下滚动你自己的原子也不是很安全:https://lwn.net/Articles/793253/ 解释了为什么 Linux 内核的手动滚动原子使用 volatile 强制转换来进行纯存储和纯加载。这在普通编译器上为您提供了类似于松散原子的东西,但当然来自 ISO C++ 的零保证。

When to use volatile with multi threading? 基本上从不——你可以通过使用 atomic<int>mo_relaxed 获得同样高效的 asm。 (或者在 x86 上,甚至获取和释放在 asm 中都是免费的。)

如果您要尝试这样做,实际上在大多数实现中,std::atomic_thread_fence(std::memory_order_seq_cst) 将阻止跨它的非原子操作的编译时重新排序。 (例如,在 GCC 中,我认为它基本上等同于 x86 asm("mfence" ::: "memory")1,它会阻止编译时重新排序并且也是一个完整的障碍。但我认为其中一些“优势”是ISO C++ 不需要的实现细节。

脚注 1:顺便说一句,通常您需要一个带有堆栈内存的虚拟 lock add,而不是实际的 mfence,因为 mfence 速度较慢。


半相关:您的 bool 变量不需要是原子的。 IDK 如果使它们成为原子或多或少会分散注意力;如果他们不是,我倾向于更简单。它们每个最多由 1 个线程编写,并且仅在该线程被 joined 后读取。您可以将它们设为简单的 bool,如果需要,也可以无条件地将它们写成 y_was_zero = (y == 0);。 (但就简单性而言,这是中性的,尽管可以节省查看它们的初始化程序)。


  1. What is the least amount of synchronization (e.g. weakest memory models for all operations) necessary to guarantee this?

x 需要 atomic<> 并且两家商店都需要 seq_cst。 (这基本上等同于在完成存储后清空存储缓冲区)。

喜欢https://preshing.com/20120515/memory-reordering-caught-in-the-act/

实际上我认为在大多数机器上两个负载都可以是 relaxed(虽然 可能不是 POWER)。 为了保证 ISO C++,我认为您还需要在两个负载上 seq_cst,因此所有 4 个操作都是跨多个对象的全局总操作顺序的一部分,与程序顺序。没有通过 release/acquire 同步来创建先行关系。

一般来说,seq_cst 是 ISO C++ 内存模型中唯一的排序,它必须转换为内存模型中基于实际连贯状态的存在而阻止 StoreLoad 重新排序,即使没有人查看它,以及通过本地重新排序访问该状态的各个线程。 (ISO C++ 只讨论其他线程可以观察到的内容,理论上假设的观察者可能不会限制代码生成。但实际上他们这样做是因为编译器不进行整个程序线程间分析。)


如果您出于某种原因无法使 x 成为 atomic<>

使用 C++20 atomic_ref<> 构造对 x 的引用,您可以使用它来执行 xref.store(1, mo_seq_cst)xref.load(mo_seq_cst).

或使用 GNU C/C++ atomic builtins__atomic_store_n(&x, 1, __ATOMIC_SEQ_CST)(这正是 C++20 atomic_ref 旨在包装的内容。)

或者对于半便携的东西,*(volatile int*)&x = 1; 和一个屏障,这可能会或可能不会起作用,具体取决于编译器。如果 DeathStation 9000 愿意,它当然可以使 volatile int 赋值成为非原子的。但幸运的是,人们选择在现实生活中使用的编译器并不可怕,而且通常可用于低级系统编程。尽管如此,这并不能保证任何工作。