为什么编译器不合并冗余 std::atomic 写入?
Why don't compilers merge redundant std::atomic writes?
我想知道为什么没有编译器准备将相同值的连续写入合并到单个原子变量,例如:
#include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
y.store(1, order);
y.store(1, order);
}
我试过的每个编译器都会发出上面写的 3 次。哪个合法的、无种族歧视的观察者能看出上述代码与单次写入的优化版本之间的区别(即 'as-if' 规则不适用)?
如果变量是可变的,那么显然没有优化是适用的。是什么阻止了我的情况?
这是 compiler explorer 中的代码。
由于包含在 std::atomic 对象中的变量应该从多个线程访问,因此我们应该期望它们的行为至少像使用 volatile 关键字声明一样。
这是 CPU 架构引入缓存行等之前的标准和推荐做法。
[EDIT2] 有人可能会争辩说 std::atomic<> 是多核时代的 volatile
变量。正如 C/C++ 中定义的那样,volatile
仅足以同步来自 单个线程 的原子读取,并使用 ISR 修改变量(在本例中从主线程看,实际上是原子写入。
我个人感到欣慰的是,没有编译器会优化对原子变量的写入。如果写入被优化掉,您如何保证这些写入中的每一个都可能被其他线程中的读者看到?不要忘记,这也是 std::atomic<> 合同的一部分。
考虑这段代码,编译器的疯狂优化会极大地影响结果。
#include <atomic>
#include <thread>
static const int N{ 1000000 };
std::atomic<int> flag{1};
std::atomic<bool> do_run { true };
void write_1()
{
while (do_run.load())
{
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
}
}
void write_0()
{
while (do_run.load())
{
flag = -1; flag = -1; flag = -1; flag = -1;
}
}
int main(int argc, char** argv)
{
int counter{};
std::thread t0(&write_0);
std::thread t1(&write_1);
for (int i = 0; i < N; ++i)
{
counter += flag;
std::this_thread::yield();
}
do_run = false;
t0.join();
t1.join();
return counter;
}
[编辑] 起初,我并没有提出 volatile
是实现原子的核心,但是...
由于有人怀疑volatile
是否与原子有关,所以我调查了此事。这是 VS2017 stl 的原子实现。正如我推测的那样,volatile 关键字无处不在。
// from file atomic, line 264...
// TEMPLATE CLASS _Atomic_impl
template<unsigned _Bytes>
struct _Atomic_impl
{ // struct for managing locks around operations on atomic types
typedef _Uint1_t _My_int; // "1 byte" means "no alignment required"
constexpr _Atomic_impl() _NOEXCEPT
: _My_flag(0)
{ // default constructor
}
bool _Is_lock_free() const volatile
{ // operations that use locks are not lock-free
return (false);
}
void _Store(void *_Tgt, const void *_Src, memory_order _Order) volatile
{ // lock and store
_Atomic_copy(&_My_flag, _Bytes, _Tgt, _Src, _Order);
}
void _Load(void *_Tgt, const void *_Src,
memory_order _Order) const volatile
{ // lock and load
_Atomic_copy(&_My_flag, _Bytes, _Tgt, _Src, _Order);
}
void _Exchange(void *_Left, void *_Right, memory_order _Order) volatile
{ // lock and exchange
_Atomic_exchange(&_My_flag, _Bytes, _Left, _Right, _Order);
}
bool _Compare_exchange_weak(
void *_Tgt, void *_Exp, const void *_Value,
memory_order _Order1, memory_order _Order2) volatile
{ // lock and compare/exchange
return (_Atomic_compare_exchange_weak(
&_My_flag, _Bytes, _Tgt, _Exp, _Value, _Order1, _Order2));
}
bool _Compare_exchange_strong(
void *_Tgt, void *_Exp, const void *_Value,
memory_order _Order1, memory_order _Order2) volatile
{ // lock and compare/exchange
return (_Atomic_compare_exchange_strong(
&_My_flag, _Bytes, _Tgt, _Exp, _Value, _Order1, _Order2));
}
private:
mutable _Atomic_flag_t _My_flag;
};
MS stl 中的所有专业化都在关键函数上使用 volatile。
下面是其中一个关键函数的声明:
inline int _Atomic_compare_exchange_strong_8(volatile _Uint8_t *_Tgt, _Uint8_t *_Exp, _Uint8_t _Value, memory_order _Order1, memory_order _Order2)
您会注意到所需的 volatile uint8_t*
包含 std::atomic 中包含的值。这种模式可以在整个 MS std::atomic<> 实现中观察到,这不是 gcc 团队或任何其他 stl 提供商以不同方式完成的理由。
当您在一个线程中更改原子的值时,其他线程可能正在检查它并根据原子的值执行操作。您给出的示例非常具体,以至于编译器开发人员认为它不值得优化。但是,如果一个线程正在设置,例如原子的连续值:0
、1
、2
等,另一个线程可能正在将某些东西放入原子值指示的槽中。
简而言之,因为标准(例如 [intro.multithread]
中 20 左右和以下的段落)不允许这样做。
有必须满足的先行发生保证,其中排除了重新排序或合并写入(第 19 段甚至明确说明了重新排序)。
如果您的线程将三个值(比方说 1、2 和 3)一个接一个地写入内存,则不同的线程可能会读取该值。例如,如果您的线程被中断(或者即使它并发运行)并且另一个线程 也 写入该位置,那么观察线程必须以与它们发生了(通过安排或巧合,或任何原因)。那是一个保证。
如果您只进行一半的写入(甚至只进行一次),这怎么可能?不是。
如果您的线程写出 1 -1 -1 而另一个线程偶尔写出 2 或 3 怎么办?如果第三个线程观察到该位置并等待一个特定的值,但由于它被优化掉了,所以永远不会出现怎么办?
如果未按要求执行存储(以及加载),则无法提供所提供的保证。所有这些,并且顺序相同。
注意:我正要对此发表评论,但它有点太罗嗦了。
一个有趣的事实是,这种行为在 C++ 中不是数据竞争。
第 14 页的注释 21 很有趣:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf(我的重点):
The execution of a program contains a data race if it contains two
conflicting actions in different threads, at least one of which is
not atomic
还有第 11 页注释 5 :
“Relaxed” atomic operations are not synchronization operations even
though, like synchronization operations, they cannot contribute to
data races.
因此,根据 C++ 标准,对原子的冲突操作绝不是数据竞争。
这些操作都是原子的(而且特别宽松),但是这里没有数据竞争,伙计们!
我同意在任何(合理的)平台上这两者之间没有 reliable/predictable 区别:
include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
y.store(1, order);
y.store(1, order);
}
和
include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
}
但在提供的 C++ 内存模型定义中,它不是数据竞争。
我不太明白为什么要提供这个定义,但它确实给了开发人员一些卡片,让他们在线程之间进行随意的通信,他们可能知道(在他们的平台上)在统计上是可行的。
例如,设置一个值 3 次然后读回它会显示对该位置的某种程度的争用。这些方法不是确定性的,但许多有效的并发算法也不是确定性的。
例如,超时 try_lock_until()
始终是竞争条件,但仍然是一种有用的技术。
C++ 标准似乎为您提供了围绕 'data races' 的确定性,但允许某些具有竞争条件的娱乐和游戏,这些竞争条件在最终分析中是不同的。
简而言之,该标准似乎指定其他线程可能会看到一个值被设置 3 次的 'hammering' 效果,其他线程必须能够看到该效果(即使他们有时可能看不到! ).
在这种情况下,几乎所有其他线程在某些情况下可能会看到锤击的现代平台。
让我们从三个商店紧挨着的病态案例走远一点。假设存储之间有一些重要的工作正在进行,并且此类工作根本不涉及 y
(以便数据路径分析可以确定这三个存储实际上是冗余的,至少在该线程中是这样),并且本身不会引入任何内存障碍(因此其他东西不会强制存储对其他线程可见)。现在很可能其他线程有机会在商店之间完成工作,也许其他线程操纵 y
并且该线程有某种原因需要将其重置为 1(第二个商店)。如果前两个商店被删除,那将改变行为。
您指的是消除死店。
不禁止消除原子死存储,但更难证明原子存储符合条件。
Traditional compiler optimizations, such as dead store elimination, can be performed on atomic operations, even sequentially consistent ones.
Optimizers have to be careful to avoid doing so across synchronization points because another thread of execution can observe or modify memory, which means that the traditional optimizations have to consider more intervening instructions than they usually would when considering optimizations to atomic operations.
In the case of dead store elimination it isn’t sufficient to prove that an atomic store post-dominates and aliases another to eliminate the other store.
一般情况下,原子 DSE 的问题在于它涉及寻找同步点,在我的理解中,这个术语意味着代码中存在 happen-before[=42= 的点] 线程 A 上的指令与 另一个 线程 B 上的指令之间的关系。
考虑由线程 A 执行的这段代码:
y.store(1, std::memory_order_seq_cst);
y.store(2, std::memory_order_seq_cst);
y.store(3, std::memory_order_seq_cst);
可以优化成y.store(3, std::memory_order_seq_cst)
吗?
如果线程 B 正在等待查看 y = 2
(例如使用 CAS),它永远不会观察到代码是否得到优化。
然而,根据我的理解,在 y = 2
上进行 B 循环和 CASsing 是一场数据竞争,因为两个线程的指令之间没有总顺序。
A 的指令在 B 的循环之前执行的执行是可观察的(即允许的),因此编译器可以优化为 y.store(3, std::memory_order_seq_cst)
.
如果线程 A 和 B 在线程 A 中的存储之间以某种方式同步,则不允许进行优化(将引入部分顺序,可能导致 B 可能观察到 y = 2
)。
很难证明不存在这种同步,因为它涉及考虑更广泛的范围并考虑架构的所有怪癖。
据我了解,由于原子操作的年龄相对较小,而且在推理内存顺序、可见性和同步方面存在困难,编译器不会对原子操作执行所有可能的优化,直到一个更健壮的框架建立检测和理解必要条件。
我相信您的示例是上面给出的计数线程的简化,因为它没有任何其他线程或任何同步点,就我所见,我想编译器可以优化这三个存储。
该模式的一个实际用例,如果线程在不依赖或修改 y
的更新之间做了一些重要的事情,可能是:*线程 2 读取 y
的值到检查线程 1 取得了多少进展。`
所以,也许线程 1 应该作为步骤 1 加载配置文件,作为步骤 2 将其解析的内容放入数据结构,并作为步骤 3 显示主要 window,而线程 2 是等待步骤 2 完成,以便它可以并行执行另一个取决于数据结构的任务。 (当然,这个例子需要 acquire/release 语义,而不是宽松的顺序。)
我很确定一个符合规范的实现允许线程 1 在任何中间步骤不更新 y
——虽然我没有仔细研究语言标准,但如果它不支持硬件我会感到震惊另一个线程轮询 y
可能永远看不到值 2.
但是,这是一个假设的实例,优化掉状态更新可能是悲观的。也许编译器开发人员会来这里说为什么编译器选择不这样做,但一个可能的原因是让你搬起石头砸自己的脚,或者至少碰了自己的脚趾。
编译器作者不能只执行优化。他们还必须说服自己,优化在编译器编写者打算应用它的情况下是有效的,它不会在它无效的情况下应用,它不会破坏实际上被破坏的代码但是"works" 在其他实现上。这可能比优化本身需要更多的工作。
另一方面,我可以想象在实践中(即在应该完成某项工作而不是基准测试的程序中),这种优化将节省很少的执行时间。
因此,编译器编写者会先查看成本,然后再查看收益和风险,然后可能会做出反对的决定。
C++11 / C++14 标准所写 确实允许将三个存储 folded/coalesced 合并为最终值的一个存储。即使在这样的情况下:
y.store(1, order);
y.store(2, order);
y.store(3, order); // inlining + constant-folding could produce this in real code
该标准不保证在y
(使用原子负载或CAS)上旋转的观察者将永远看到y == 2
。依赖于此的程序会出现数据竞争错误,但只是普通错误类型的竞争,而不是 C++ 未定义行为类型的数据竞争。 (它只是非原子变量的 UB)。期望 有时 看到它的程序甚至不一定是错误的。 (请参阅下面的回复:进度条。)
可以选择(在编译时)在 C++ 抽象机上可能的任何顺序作为 总是 发生的顺序。这就是假设规则在起作用。在这种情况下,好像 所有三个存储在全局顺序中背靠背发生,在 y=1
和 [= 之间没有发生来自其他线程的加载或存储15=].
它不依赖于目标架构或硬件;就像 compile-time reordering 的松散原子操作一样,即使在针对强顺序的 x86 时也是如此。编译器不必保留任何您可能从考虑正在编译的硬件中想到的任何内容,因此您需要障碍。障碍可能会编译成零 asm 指令。
那么为什么编译器不做这个优化呢?
这是一个实施质量问题,可以改变实际硬件上观察到的性能/行为。
最明显的问题是进度条。将存储从循环中下沉(不包含其他原子操作)并将它们全部折叠成一个将导致进度条保持在 0,然后在最后达到 100%。
没有 C++11 std::atomic
方法可以 阻止 他们在你不想要的情况下这样做,所以现在编译器只是选择从不将多个原子操作合并为一个。 (将它们全部合并为一个操作不会改变它们相对于彼此的顺序。)
编译器编写者正确地注意到,程序员期望每次源 y.store()
时,原子存储实际上都会发生在内存中。 (请参阅此问题的大多数其他答案,这些答案声称存储需要单独发生,因为可能的读者正在等待看到中间值。)即它违反了 principle of least surprise.
但是,在某些情况下它会非常有用,例如避免循环中无用的 shared_ptr
引用计数 inc/dec。
显然,任何重新排序或合并都不能违反任何其他排序规则。例如,num++; num--;
仍然必须是运行时和编译时重新排序的完全障碍,即使它不再触及 num
处的内存。
正在讨论扩展 std::atomic
API 以让程序员控制此类优化,届时编译器将能够优化有用,即使在并非故意低效的精心编写的代码中也会发生这种情况。以下工作组讨论/提案链接中提到了一些有用的优化案例示例:
- http://wg21.link/n4455:N4455 没有 Sane 编译器会优化 Atomics
- http://wg21.link/p0062: WG21/P0062R1: 编译器什么时候应该优化原子?
另请参阅 Richard Hodges 对同一问题 (see the comments). See also the last section of 的回答中关于同一主题的讨论,我在其中更详细地论证了允许进行此优化。 (在这里保持简短,因为那些 C++ 工作组链接已经承认当前编写的标准确实允许它,并且当前的编译器只是没有故意优化。)
在当前标准中,volatile atomic<int> y
将是一种确保不允许优化存储到它的方法。 (作为 cppreference 上的 Herb Sutter points out in an SO answer, volatile
and atomic
already share some requirements, but they are different). See also std::memory_order
's relationship with volatile
。
不允许优化掉对 volatile
对象的访问(因为它们可能是内存映射的 IO 寄存器,例如)。
使用 volatile atomic<T>
主要解决了进度条问题,但它有点难看,几年后可能看起来很傻 if/when C++ 决定使用不同的语法来控制优化,以便编译器可以开始做它在实践中。
我认为我们可以确信编译器不会开始进行这种优化,直到有一种方法可以控制它。希望它将是某种选择加入(如 memory_order_release_coalesce
),当编译为 C++ 时,不会改变现有代码 C++11/14 代码的行为。但它可能就像 wg21/p0062 中的提案:用 [[brittle_atomic]]
.
标记 don't-optimize cases
wg21/p0062 警告即使 volatile atomic
也不能解决所有问题,并且不鼓励将其用于此目的 。它给出了这个例子:
if(x) {
foo();
y.store(0);
} else {
bar();
y.store(0); // release a lock before a long-running loop
for() {...} // loop contains no atomics or volatiles
}
// A compiler can merge the stores into a y.store(0) here.
即使使用 volatile atomic<int> y
,编译器也可以将 y.store()
从 if/else
中取出并只执行一次,因为它仍然只执行 1 个具有相同值的存储. (这将在 else 分支的长循环之后)。特别是如果商店只有 relaxed
或 release
而不是 seq_cst
.
volatile
确实停止了问题中讨论的合并,但这指出 atomic<>
上的其他优化对于实际性能也可能存在问题。
其他不优化的原因包括:没有人编写过复杂的代码来让编译器安全地进行这些优化(永远不会出错)。这还不够,因为 N4455 说 LLVM 已经实现或可以轻松实现它提到的几个优化。
不过,让程序员感到困惑的原因当然是有道理的。无锁代码很难从一开始就正确编写。
不要随便使用原子武器:它们并不便宜而且优化不多(目前根本没有)。但是,使用 std::shared_ptr<T>
避免冗余原子操作并不总是那么容易,因为它没有非原子版本(尽管 one of the answers here 提供了一种为 gcc 定义 shared_ptr_unsynchronized<T>
的简单方法) .
我想知道为什么没有编译器准备将相同值的连续写入合并到单个原子变量,例如:
#include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
y.store(1, order);
y.store(1, order);
}
我试过的每个编译器都会发出上面写的 3 次。哪个合法的、无种族歧视的观察者能看出上述代码与单次写入的优化版本之间的区别(即 'as-if' 规则不适用)?
如果变量是可变的,那么显然没有优化是适用的。是什么阻止了我的情况?
这是 compiler explorer 中的代码。
由于包含在 std::atomic 对象中的变量应该从多个线程访问,因此我们应该期望它们的行为至少像使用 volatile 关键字声明一样。
这是 CPU 架构引入缓存行等之前的标准和推荐做法。
[EDIT2] 有人可能会争辩说 std::atomic<> 是多核时代的 volatile
变量。正如 C/C++ 中定义的那样,volatile
仅足以同步来自 单个线程 的原子读取,并使用 ISR 修改变量(在本例中从主线程看,实际上是原子写入。
我个人感到欣慰的是,没有编译器会优化对原子变量的写入。如果写入被优化掉,您如何保证这些写入中的每一个都可能被其他线程中的读者看到?不要忘记,这也是 std::atomic<> 合同的一部分。
考虑这段代码,编译器的疯狂优化会极大地影响结果。
#include <atomic>
#include <thread>
static const int N{ 1000000 };
std::atomic<int> flag{1};
std::atomic<bool> do_run { true };
void write_1()
{
while (do_run.load())
{
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
flag = 1; flag = 1; flag = 1; flag = 1;
}
}
void write_0()
{
while (do_run.load())
{
flag = -1; flag = -1; flag = -1; flag = -1;
}
}
int main(int argc, char** argv)
{
int counter{};
std::thread t0(&write_0);
std::thread t1(&write_1);
for (int i = 0; i < N; ++i)
{
counter += flag;
std::this_thread::yield();
}
do_run = false;
t0.join();
t1.join();
return counter;
}
[编辑] 起初,我并没有提出 volatile
是实现原子的核心,但是...
由于有人怀疑volatile
是否与原子有关,所以我调查了此事。这是 VS2017 stl 的原子实现。正如我推测的那样,volatile 关键字无处不在。
// from file atomic, line 264...
// TEMPLATE CLASS _Atomic_impl
template<unsigned _Bytes>
struct _Atomic_impl
{ // struct for managing locks around operations on atomic types
typedef _Uint1_t _My_int; // "1 byte" means "no alignment required"
constexpr _Atomic_impl() _NOEXCEPT
: _My_flag(0)
{ // default constructor
}
bool _Is_lock_free() const volatile
{ // operations that use locks are not lock-free
return (false);
}
void _Store(void *_Tgt, const void *_Src, memory_order _Order) volatile
{ // lock and store
_Atomic_copy(&_My_flag, _Bytes, _Tgt, _Src, _Order);
}
void _Load(void *_Tgt, const void *_Src,
memory_order _Order) const volatile
{ // lock and load
_Atomic_copy(&_My_flag, _Bytes, _Tgt, _Src, _Order);
}
void _Exchange(void *_Left, void *_Right, memory_order _Order) volatile
{ // lock and exchange
_Atomic_exchange(&_My_flag, _Bytes, _Left, _Right, _Order);
}
bool _Compare_exchange_weak(
void *_Tgt, void *_Exp, const void *_Value,
memory_order _Order1, memory_order _Order2) volatile
{ // lock and compare/exchange
return (_Atomic_compare_exchange_weak(
&_My_flag, _Bytes, _Tgt, _Exp, _Value, _Order1, _Order2));
}
bool _Compare_exchange_strong(
void *_Tgt, void *_Exp, const void *_Value,
memory_order _Order1, memory_order _Order2) volatile
{ // lock and compare/exchange
return (_Atomic_compare_exchange_strong(
&_My_flag, _Bytes, _Tgt, _Exp, _Value, _Order1, _Order2));
}
private:
mutable _Atomic_flag_t _My_flag;
};
MS stl 中的所有专业化都在关键函数上使用 volatile。
下面是其中一个关键函数的声明:
inline int _Atomic_compare_exchange_strong_8(volatile _Uint8_t *_Tgt, _Uint8_t *_Exp, _Uint8_t _Value, memory_order _Order1, memory_order _Order2)
您会注意到所需的 volatile uint8_t*
包含 std::atomic 中包含的值。这种模式可以在整个 MS std::atomic<> 实现中观察到,这不是 gcc 团队或任何其他 stl 提供商以不同方式完成的理由。
当您在一个线程中更改原子的值时,其他线程可能正在检查它并根据原子的值执行操作。您给出的示例非常具体,以至于编译器开发人员认为它不值得优化。但是,如果一个线程正在设置,例如原子的连续值:0
、1
、2
等,另一个线程可能正在将某些东西放入原子值指示的槽中。
简而言之,因为标准(例如 [intro.multithread]
中 20 左右和以下的段落)不允许这样做。
有必须满足的先行发生保证,其中排除了重新排序或合并写入(第 19 段甚至明确说明了重新排序)。
如果您的线程将三个值(比方说 1、2 和 3)一个接一个地写入内存,则不同的线程可能会读取该值。例如,如果您的线程被中断(或者即使它并发运行)并且另一个线程 也 写入该位置,那么观察线程必须以与它们发生了(通过安排或巧合,或任何原因)。那是一个保证。
如果您只进行一半的写入(甚至只进行一次),这怎么可能?不是。
如果您的线程写出 1 -1 -1 而另一个线程偶尔写出 2 或 3 怎么办?如果第三个线程观察到该位置并等待一个特定的值,但由于它被优化掉了,所以永远不会出现怎么办?
如果未按要求执行存储(以及加载),则无法提供所提供的保证。所有这些,并且顺序相同。
注意:我正要对此发表评论,但它有点太罗嗦了。
一个有趣的事实是,这种行为在 C++ 中不是数据竞争。
第 14 页的注释 21 很有趣:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf(我的重点):
The execution of a program contains a data race if it contains two conflicting actions in different threads, at least one of which is not atomic
还有第 11 页注释 5 :
“Relaxed” atomic operations are not synchronization operations even though, like synchronization operations, they cannot contribute to data races.
因此,根据 C++ 标准,对原子的冲突操作绝不是数据竞争。
这些操作都是原子的(而且特别宽松),但是这里没有数据竞争,伙计们!
我同意在任何(合理的)平台上这两者之间没有 reliable/predictable 区别:
include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
y.store(1, order);
y.store(1, order);
}
和
include <atomic>
std::atomic<int> y(0);
void f() {
auto order = std::memory_order_relaxed;
y.store(1, order);
}
但在提供的 C++ 内存模型定义中,它不是数据竞争。
我不太明白为什么要提供这个定义,但它确实给了开发人员一些卡片,让他们在线程之间进行随意的通信,他们可能知道(在他们的平台上)在统计上是可行的。
例如,设置一个值 3 次然后读回它会显示对该位置的某种程度的争用。这些方法不是确定性的,但许多有效的并发算法也不是确定性的。
例如,超时 try_lock_until()
始终是竞争条件,但仍然是一种有用的技术。
C++ 标准似乎为您提供了围绕 'data races' 的确定性,但允许某些具有竞争条件的娱乐和游戏,这些竞争条件在最终分析中是不同的。
简而言之,该标准似乎指定其他线程可能会看到一个值被设置 3 次的 'hammering' 效果,其他线程必须能够看到该效果(即使他们有时可能看不到! ). 在这种情况下,几乎所有其他线程在某些情况下可能会看到锤击的现代平台。
让我们从三个商店紧挨着的病态案例走远一点。假设存储之间有一些重要的工作正在进行,并且此类工作根本不涉及 y
(以便数据路径分析可以确定这三个存储实际上是冗余的,至少在该线程中是这样),并且本身不会引入任何内存障碍(因此其他东西不会强制存储对其他线程可见)。现在很可能其他线程有机会在商店之间完成工作,也许其他线程操纵 y
并且该线程有某种原因需要将其重置为 1(第二个商店)。如果前两个商店被删除,那将改变行为。
您指的是消除死店。
不禁止消除原子死存储,但更难证明原子存储符合条件。
Traditional compiler optimizations, such as dead store elimination, can be performed on atomic operations, even sequentially consistent ones.
Optimizers have to be careful to avoid doing so across synchronization points because another thread of execution can observe or modify memory, which means that the traditional optimizations have to consider more intervening instructions than they usually would when considering optimizations to atomic operations.
In the case of dead store elimination it isn’t sufficient to prove that an atomic store post-dominates and aliases another to eliminate the other store.
一般情况下,原子 DSE 的问题在于它涉及寻找同步点,在我的理解中,这个术语意味着代码中存在 happen-before[=42= 的点] 线程 A 上的指令与 另一个 线程 B 上的指令之间的关系。
考虑由线程 A 执行的这段代码:
y.store(1, std::memory_order_seq_cst);
y.store(2, std::memory_order_seq_cst);
y.store(3, std::memory_order_seq_cst);
可以优化成y.store(3, std::memory_order_seq_cst)
吗?
如果线程 B 正在等待查看 y = 2
(例如使用 CAS),它永远不会观察到代码是否得到优化。
然而,根据我的理解,在 y = 2
上进行 B 循环和 CASsing 是一场数据竞争,因为两个线程的指令之间没有总顺序。
A 的指令在 B 的循环之前执行的执行是可观察的(即允许的),因此编译器可以优化为 y.store(3, std::memory_order_seq_cst)
.
如果线程 A 和 B 在线程 A 中的存储之间以某种方式同步,则不允许进行优化(将引入部分顺序,可能导致 B 可能观察到 y = 2
)。
很难证明不存在这种同步,因为它涉及考虑更广泛的范围并考虑架构的所有怪癖。
据我了解,由于原子操作的年龄相对较小,而且在推理内存顺序、可见性和同步方面存在困难,编译器不会对原子操作执行所有可能的优化,直到一个更健壮的框架建立检测和理解必要条件。
我相信您的示例是上面给出的计数线程的简化,因为它没有任何其他线程或任何同步点,就我所见,我想编译器可以优化这三个存储。
该模式的一个实际用例,如果线程在不依赖或修改 y
的更新之间做了一些重要的事情,可能是:*线程 2 读取 y
的值到检查线程 1 取得了多少进展。`
所以,也许线程 1 应该作为步骤 1 加载配置文件,作为步骤 2 将其解析的内容放入数据结构,并作为步骤 3 显示主要 window,而线程 2 是等待步骤 2 完成,以便它可以并行执行另一个取决于数据结构的任务。 (当然,这个例子需要 acquire/release 语义,而不是宽松的顺序。)
我很确定一个符合规范的实现允许线程 1 在任何中间步骤不更新 y
——虽然我没有仔细研究语言标准,但如果它不支持硬件我会感到震惊另一个线程轮询 y
可能永远看不到值 2.
但是,这是一个假设的实例,优化掉状态更新可能是悲观的。也许编译器开发人员会来这里说为什么编译器选择不这样做,但一个可能的原因是让你搬起石头砸自己的脚,或者至少碰了自己的脚趾。
编译器作者不能只执行优化。他们还必须说服自己,优化在编译器编写者打算应用它的情况下是有效的,它不会在它无效的情况下应用,它不会破坏实际上被破坏的代码但是"works" 在其他实现上。这可能比优化本身需要更多的工作。
另一方面,我可以想象在实践中(即在应该完成某项工作而不是基准测试的程序中),这种优化将节省很少的执行时间。
因此,编译器编写者会先查看成本,然后再查看收益和风险,然后可能会做出反对的决定。
C++11 / C++14 标准所写 确实允许将三个存储 folded/coalesced 合并为最终值的一个存储。即使在这样的情况下:
y.store(1, order);
y.store(2, order);
y.store(3, order); // inlining + constant-folding could produce this in real code
该标准不保证在y
(使用原子负载或CAS)上旋转的观察者将永远看到y == 2
。依赖于此的程序会出现数据竞争错误,但只是普通错误类型的竞争,而不是 C++ 未定义行为类型的数据竞争。 (它只是非原子变量的 UB)。期望 有时 看到它的程序甚至不一定是错误的。 (请参阅下面的回复:进度条。)
可以选择(在编译时)在 C++ 抽象机上可能的任何顺序作为 总是 发生的顺序。这就是假设规则在起作用。在这种情况下,好像 所有三个存储在全局顺序中背靠背发生,在 y=1
和 [= 之间没有发生来自其他线程的加载或存储15=].
它不依赖于目标架构或硬件;就像 compile-time reordering 的松散原子操作一样,即使在针对强顺序的 x86 时也是如此。编译器不必保留任何您可能从考虑正在编译的硬件中想到的任何内容,因此您需要障碍。障碍可能会编译成零 asm 指令。
那么为什么编译器不做这个优化呢?
这是一个实施质量问题,可以改变实际硬件上观察到的性能/行为。
最明显的问题是进度条。将存储从循环中下沉(不包含其他原子操作)并将它们全部折叠成一个将导致进度条保持在 0,然后在最后达到 100%。
没有 C++11 std::atomic
方法可以 阻止 他们在你不想要的情况下这样做,所以现在编译器只是选择从不将多个原子操作合并为一个。 (将它们全部合并为一个操作不会改变它们相对于彼此的顺序。)
编译器编写者正确地注意到,程序员期望每次源 y.store()
时,原子存储实际上都会发生在内存中。 (请参阅此问题的大多数其他答案,这些答案声称存储需要单独发生,因为可能的读者正在等待看到中间值。)即它违反了 principle of least surprise.
但是,在某些情况下它会非常有用,例如避免循环中无用的 shared_ptr
引用计数 inc/dec。
显然,任何重新排序或合并都不能违反任何其他排序规则。例如,num++; num--;
仍然必须是运行时和编译时重新排序的完全障碍,即使它不再触及 num
处的内存。
正在讨论扩展 std::atomic
API 以让程序员控制此类优化,届时编译器将能够优化有用,即使在并非故意低效的精心编写的代码中也会发生这种情况。以下工作组讨论/提案链接中提到了一些有用的优化案例示例:
- http://wg21.link/n4455:N4455 没有 Sane 编译器会优化 Atomics
- http://wg21.link/p0062: WG21/P0062R1: 编译器什么时候应该优化原子?
另请参阅 Richard Hodges 对同一问题
在当前标准中,volatile atomic<int> y
将是一种确保不允许优化存储到它的方法。 (作为 cppreference 上的 Herb Sutter points out in an SO answer, volatile
and atomic
already share some requirements, but they are different). See also std::memory_order
's relationship with volatile
。
不允许优化掉对 volatile
对象的访问(因为它们可能是内存映射的 IO 寄存器,例如)。
使用 volatile atomic<T>
主要解决了进度条问题,但它有点难看,几年后可能看起来很傻 if/when C++ 决定使用不同的语法来控制优化,以便编译器可以开始做它在实践中。
我认为我们可以确信编译器不会开始进行这种优化,直到有一种方法可以控制它。希望它将是某种选择加入(如 memory_order_release_coalesce
),当编译为 C++ 时,不会改变现有代码 C++11/14 代码的行为。但它可能就像 wg21/p0062 中的提案:用 [[brittle_atomic]]
.
wg21/p0062 警告即使 volatile atomic
也不能解决所有问题,并且不鼓励将其用于此目的 。它给出了这个例子:
if(x) {
foo();
y.store(0);
} else {
bar();
y.store(0); // release a lock before a long-running loop
for() {...} // loop contains no atomics or volatiles
}
// A compiler can merge the stores into a y.store(0) here.
即使使用 volatile atomic<int> y
,编译器也可以将 y.store()
从 if/else
中取出并只执行一次,因为它仍然只执行 1 个具有相同值的存储. (这将在 else 分支的长循环之后)。特别是如果商店只有 relaxed
或 release
而不是 seq_cst
.
volatile
确实停止了问题中讨论的合并,但这指出 atomic<>
上的其他优化对于实际性能也可能存在问题。
其他不优化的原因包括:没有人编写过复杂的代码来让编译器安全地进行这些优化(永远不会出错)。这还不够,因为 N4455 说 LLVM 已经实现或可以轻松实现它提到的几个优化。
不过,让程序员感到困惑的原因当然是有道理的。无锁代码很难从一开始就正确编写。
不要随便使用原子武器:它们并不便宜而且优化不多(目前根本没有)。但是,使用 std::shared_ptr<T>
避免冗余原子操作并不总是那么容易,因为它没有非原子版本(尽管 one of the answers here 提供了一种为 gcc 定义 shared_ptr_unsynchronized<T>
的简单方法) .