问题描述
考虑以下程序:
#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);
}
- 鉴于除了访问
x
之外,一切都可以是原子的,我如何保证断言通过? - 如果按原样不可能做到,那么对
x
的访问是否可以是原子的,但不比“放松”强? - 保证这一点所需的最少同步量(例如所有操作的最弱内存模型)是多少?
据我所知,如果没有任何形式的围栏或原子访问,存储 x = 1
可能(如果只是理论上如此)下沉到负载 y == 0
以下(如果本身不是由编译器),从而导致 x 和 y 均为 0 的潜在竞争(并触发该断言)。
我最初的天真印象是 SEQ_CST 保证了非原子变量的完全排序。也就是说,在 SEQ_CST 加载 x
之前排序的 y
的非原子(或宽松)存储保证实际首先发生;类似地,在 y
的非原子(或松弛)加载之前排序的 x
的 SEQ_CST 存储保证实际首先发生;放在一起会阻止比赛。但是,在进一步阅读 https://en.cppreference.com/w/cpp/atomic/memory_order 时,我认为文档实际上并没有说明这一点,而是仅在相反的情况下(在存储之前加载)或同时访问 {{1 }} 和 x
是 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 个线程编写,并且只有在该线程被 join
ed 之后才被读取。您可以将它们设为简单的 bool,也可以根据需要无条件地将它们写成 y_was_zero = (y == 0);
。 (但就简单性而言,这是中性的,尽管省去了查看它们的初始值设定项)。
- 保证这一点所需的最少同步(例如所有操作的最弱内存模型)是多少?
x
需要是 atomic<>
并且两个商店都需要是 seq_cst。 (这基本上相当于在做完存储后排空存储缓冲区)。
喜欢在https://preshing.com/20120515/memory-reordering-caught-in-the-act/
在实践中,我认为大多数机器上的两种负载都可以是 relaxed
(虽然 where private store-forwarding is possible 可能不是 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 赋值非原子化。但幸运的是,人们选择在现实生活中使用的编译器并不可怕,而且通常可用于低级系统编程。尽管如此,这并不能保证任何工作都能奏效。