原子会遭受虚假存储吗?
Can atomics suffer spurious stores?
在 C++ 中,原子会遭受虚假存储吗?
例如,假设 m
和 n
是原子,并且最初是 m = 5
。在线程 1 中,
m += 2;
在线程 2 中,
n = m;
结果:n
的最终值应该是5或者7吧?但它会是 6 吗?会不会是 4 或 8,甚至是别的东西?
换句话说,C++ 内存模型是否禁止线程 1 表现得像它那样做?
++m;
++m;
或者,更奇怪的是,好像它这样做了?
tmp = m;
m = 4;
tmp += 2;
m = tmp;
参考:H.-J. Boehm & S. V. Adve, 2008, 图 1。(如果您遵循 link,那么,在论文的第 1 部分中,请参阅第一个项目符号:"The informal specifications provided by ...")
替代形式的问题
一个答案(赞赏)表明上面的问题可能被误解了。如果有帮助,那么这里是替代形式的问题。
假设程序员试图告诉线程 1 跳过操作:
bool a = false;
if (a) m += 2;
C++ 内存模型是否禁止线程 1 在 运行 时像它那样做?
m += 2; // speculatively alter m
m -= 2; // oops, should not have altered! reverse the alteration
我问是因为 Boehm 和 Adve 早些时候 linked 似乎解释了多线程执行可以
- 推测性地改变一个变量,然后
- 稍后当推测性更改被证明是不必要的时,将变量改回其原始值。
可编译示例代码
如果您愿意,这里有一些您可以实际编译的代码。
#include <iostream>
#include <atomic>
#include <thread>
// For the orignial question, do_alter = true.
// For the question in alternate form, do_alter = false.
constexpr bool do_alter = true;
void f1(std::atomic_int *const p, const bool do_alter_)
{
if (do_alter_) p->fetch_add(2, std::memory_order_relaxed);
}
void f2(const std::atomic_int *const p, std::atomic_int *const q)
{
q->store(
p->load(std::memory_order_relaxed),
std::memory_order_relaxed
);
}
int main()
{
std::atomic_int m(5);
std::atomic_int n(0);
std::thread t1(f1, &m, do_alter);
std::thread t2(f2, &m, &n);
t2.join();
t1.join();
std::cout << n << "\n";
return 0;
}
当我 运行 时,此代码总是打印 5
或 7
。 (事实上,据我所知,当我 运行 它总是打印 7
。)但是,我在语义 中看不到任何东西 可以阻止它来自打印 6
、4
或 8
.
优秀Cppreference.comstates,"Atomic objects are free of data races,"很好,但是在这样的语境下,是什么意思?
毫无疑问,这一切意味着我对语义的理解不是很好。如果您能就此问题提供任何启发,我们将不胜感激。
答案
@Christophe、@ZalmanStern 和@BenVoigt 各自用技巧阐明了问题。他们的答案是合作而不是竞争。在我看来,读者应该注意所有三个答案:首先是@Christophe; @ZalmanStern 第二;最后总结一下@BenVoigt。
您的代码在原子上使用了 fetch_add(),这提供了以下保证:
Atomically replaces the current value with the result of arithmetic addition of the value and arg. The operation is read-modify-write operation. Memory is affected according to the value of order.
语义crystal清晰:操作前是m,操作后是m+2,没有线程访问这两个状态之间的内容,因为操作是原子的。
编辑:关于您的备选问题的其他内容
无论 Boehm 和 Adve 怎么说,C++ 编译器都遵守以下标准条款:
1.9/5: A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible
executions of the corresponding instance of the abstract machine with
the same program and the same input.
如果 C++ 编译器生成的代码允许推测性更新干扰程序的可观察行为(也就是获得 5 或 7 以外的东西),则它不符合标准,因为它无法确保我最初回答中提到的保证。
您修改后的问题与第一个问题有很大不同,因为我们已经从顺序一致性转变为宽松的内存顺序。
推理和指定弱内存排序都可能相当棘手。例如。请注意此处指出的 C++11 和 C++14 规范之间的区别:http://en.cppreference.com/w/cpp/atomic/memory_order#Relaxed_ordering。但是,原子性的定义确实阻止了 fetch_add
调用允许任何其他线程查看除以其他方式写入变量的值或其中之一加 2 之外的值。(一个线程几乎可以做任何事情,只要它保证其他线程无法观察到中间值。)
(要非常具体,您可能想在 C++ 规范中搜索 "read-modify-write",例如 http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf。)
也许在链接论文中给出您有疑问的地方的具体参考会有所帮助。该论文比第一个 C++ 并发内存模型规范(在 C++11 中)早了一点点,我们现在是另一个版本,所以它也可能与标准实际所说的内容有点过时了,尽管我希望这更像是一个问题,它提出了可能发生在非原子变量上的事情。
编辑:我将添加更多关于 "the semantics" 的内容,以帮助思考如何分析此类事情。
内存排序的目标是在跨线程的变量读写之间建立一组可能的顺序。在较弱的排序中,不保证存在适用于所有线程的任何单一全局排序。仅这一点就已经很棘手了,应该确保在继续之前完全理解它。
指定排序涉及的两件事是地址和同步操作。实际上,同步操作有两个方面,这两个方面通过共享地址连接。 (栅栏可以被认为适用于所有地址。)space 中的许多混淆来自于弄清楚一个地址上的同步操作何时保证其他地址的某些内容。例如。互斥量锁定和解锁操作仅通过对互斥量内部地址的获取和释放操作建立排序,但该同步适用于锁定和解锁互斥量的线程的 all 读取和写入。使用宽松排序访问的原子变量对发生的事情几乎没有限制,但这些访问可能具有由对其他原子变量或互斥体的更严格排序的操作强加的排序约束。
主要的同步操作是acquire
和release
。请参阅:http://en.cppreference.com/w/cpp/atomic/memory_order。这些是根据互斥体发生的情况命名的。 acquire 操作应用于加载并防止当前线程上的任何内存操作被重新排序超过 acquire 发生的点。它还建立了对同一变量的任何先前发布操作的排序。最后一位由加载的值决定。 IE。如果加载 returns 来自具有释放同步的给定写入的值,则加载现在针对该写入进行排序,并且这些线程的所有其他内存操作根据排序规则落到位。
原子操作或读-修改-写操作在较大的顺序中是它们自己的小序列。保证读取、操作和写入以原子方式发生。任何其他顺序由操作的内存顺序参数给出。例如。指定宽松的顺序表示没有其他约束适用于任何其他变量。 IE。该操作没有隐含的获取或释放。指定 memory_order_acq_rel
表示不仅操作是原子的,而且读取是获取,写入是释放——如果线程从另一个具有释放语义的写入中读取一个值,那么所有其他原子现在都有适当的此线程中的排序约束。
具有宽松内存顺序的 fetch_add
可用于分析中的统计计数器。在操作结束时,所有线程都会做一些其他事情来确保所有这些计数器增量现在对最终 reader 可见,但在中间状态我们不关心,只要最终总数加起来.然而,这并不意味着中间读取可以对从未作为计数一部分的值进行采样。例如。如果我们总是将偶数值添加到从 0 开始的计数器,则无论顺序如何,任何线程都不应读取奇数。
我有点迟疑,因为无法指出标准中的一段特定文本,该文本表示除了程序中以某种方式明确编码的原子变量之外,原子变量不会有任何副作用。很多事情都提到了副作用,但似乎理所当然地认为副作用是由源代码指定的,而不是由编译器构成的。现在没有时间追踪这个,但是如果不能保证这一点,很多东西将无法工作,std::atomic
的部分目的是获得这个约束,因为它不能保证其他变量。 (它在某种程度上是由 volatile
提供的,或者至少是打算提供的。我们对 std::atomic
周围的内存排序有这种程度的规范的部分原因是因为 volatile
从来没有变得足够好指定详细推理,没有一组约束满足所有需求。)
现有的答案提供了很多很好的解释,但并不能直接回答你的问题。我们开始吧:
can atomics suffer spurious stores?
是的,但是您无法从没有数据竞争的 C++ 程序中观察到它们。
实际上只有volatile
被禁止执行额外的内存访问。
does the C++ memory model forbid thread 1 from behaving as though it did this?
++m;
++m;
是的,但是这个是允许的:
lock (shared_std_atomic_secret_lock)
{
++m;
++m;
}
允许但愚蠢。一个更现实的可能性是把这个:
std::atomic<int64_t> m;
++m;
进入
memory_bus_lock
{
++m.low;
if (last_operation_did_carry)
++m.high;
}
其中 memory_bus_lock
和 last_operation_did_carry
是硬件平台的特性,无法用可移植的 C++ 表达。
请注意,位于内存总线上的外围设备 确实 看到中间值,但可以通过查看内存总线锁来正确解释这种情况。软件调试器将无法看到中间值。
其他情况下,原子操作可以通过软件锁来实现,此时:
- 软件调试器可以看到中间值,必须注意软件锁定以避免误解
- 硬件外设将看到软件锁的变化,以及原子对象的中间值。外设可能需要一些魔法来识别两者之间的关系。
- 如果原子对象在共享内存中,其他进程可以看到中间值并且可能没有任何方法检查软件锁/可能有所述软件锁的单独副本
- 如果同一 C++ 程序中的其他线程以导致数据竞争的方式破坏类型安全(例如,使用
memcpy
读取原子对象),它们可以观察到中间值。正式地说,这是未定义的行为。
最后一点很重要。 “推测性写入”是一个非常复杂的场景。如果我们重命名条件,则更容易看到这一点:
线程#1
if (my_mutex.is_held) o += 2; // o is an ordinary variable, not atomic or volatile
return o;
话题#2
{
scoped_lock l(my_mutex);
return o;
}
这里没有数据竞争。如果线程 #1 锁定了互斥量,则写入和读取不能无序发生。如果它没有锁定互斥锁,线程 运行 无序但都只执行读取。
因此编译器不允许看到中间值。此 C++ 代码不是正确的重写:
o += 2;
if (!my_mutex.is_held) o -= 2;
因为编译器发明了数据竞争。然而,如果硬件平台提供了一种无竞争的推测性写入机制(也许是 Itanium?),编译器就可以使用它。因此硬件可能会看到中间值,即使 C++ 代码看不到。
如果硬件不应该看到中间值,则需要使用 volatile
(可能除了原子,因为 volatile
读-修改-写不能保证原子)。使用 volatile
,要求一个不能按写入执行的操作将导致编译失败,而不是虚假的内存访问。
在 C++ 中,原子会遭受虚假存储吗?
例如,假设 m
和 n
是原子,并且最初是 m = 5
。在线程 1 中,
m += 2;
在线程 2 中,
n = m;
结果:n
的最终值应该是5或者7吧?但它会是 6 吗?会不会是 4 或 8,甚至是别的东西?
换句话说,C++ 内存模型是否禁止线程 1 表现得像它那样做?
++m;
++m;
或者,更奇怪的是,好像它这样做了?
tmp = m;
m = 4;
tmp += 2;
m = tmp;
参考:H.-J. Boehm & S. V. Adve, 2008, 图 1。(如果您遵循 link,那么,在论文的第 1 部分中,请参阅第一个项目符号:"The informal specifications provided by ...")
替代形式的问题
一个答案(赞赏)表明上面的问题可能被误解了。如果有帮助,那么这里是替代形式的问题。
假设程序员试图告诉线程 1 跳过操作:
bool a = false;
if (a) m += 2;
C++ 内存模型是否禁止线程 1 在 运行 时像它那样做?
m += 2; // speculatively alter m
m -= 2; // oops, should not have altered! reverse the alteration
我问是因为 Boehm 和 Adve 早些时候 linked 似乎解释了多线程执行可以
- 推测性地改变一个变量,然后
- 稍后当推测性更改被证明是不必要的时,将变量改回其原始值。
可编译示例代码
如果您愿意,这里有一些您可以实际编译的代码。
#include <iostream>
#include <atomic>
#include <thread>
// For the orignial question, do_alter = true.
// For the question in alternate form, do_alter = false.
constexpr bool do_alter = true;
void f1(std::atomic_int *const p, const bool do_alter_)
{
if (do_alter_) p->fetch_add(2, std::memory_order_relaxed);
}
void f2(const std::atomic_int *const p, std::atomic_int *const q)
{
q->store(
p->load(std::memory_order_relaxed),
std::memory_order_relaxed
);
}
int main()
{
std::atomic_int m(5);
std::atomic_int n(0);
std::thread t1(f1, &m, do_alter);
std::thread t2(f2, &m, &n);
t2.join();
t1.join();
std::cout << n << "\n";
return 0;
}
当我 运行 时,此代码总是打印 5
或 7
。 (事实上,据我所知,当我 运行 它总是打印 7
。)但是,我在语义 中看不到任何东西 可以阻止它来自打印 6
、4
或 8
.
优秀Cppreference.comstates,"Atomic objects are free of data races,"很好,但是在这样的语境下,是什么意思?
毫无疑问,这一切意味着我对语义的理解不是很好。如果您能就此问题提供任何启发,我们将不胜感激。
答案
@Christophe、@ZalmanStern 和@BenVoigt 各自用技巧阐明了问题。他们的答案是合作而不是竞争。在我看来,读者应该注意所有三个答案:首先是@Christophe; @ZalmanStern 第二;最后总结一下@BenVoigt。
您的代码在原子上使用了 fetch_add(),这提供了以下保证:
Atomically replaces the current value with the result of arithmetic addition of the value and arg. The operation is read-modify-write operation. Memory is affected according to the value of order.
语义crystal清晰:操作前是m,操作后是m+2,没有线程访问这两个状态之间的内容,因为操作是原子的。
编辑:关于您的备选问题的其他内容
无论 Boehm 和 Adve 怎么说,C++ 编译器都遵守以下标准条款:
1.9/5: A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input.
如果 C++ 编译器生成的代码允许推测性更新干扰程序的可观察行为(也就是获得 5 或 7 以外的东西),则它不符合标准,因为它无法确保我最初回答中提到的保证。
您修改后的问题与第一个问题有很大不同,因为我们已经从顺序一致性转变为宽松的内存顺序。
推理和指定弱内存排序都可能相当棘手。例如。请注意此处指出的 C++11 和 C++14 规范之间的区别:http://en.cppreference.com/w/cpp/atomic/memory_order#Relaxed_ordering。但是,原子性的定义确实阻止了 fetch_add
调用允许任何其他线程查看除以其他方式写入变量的值或其中之一加 2 之外的值。(一个线程几乎可以做任何事情,只要它保证其他线程无法观察到中间值。)
(要非常具体,您可能想在 C++ 规范中搜索 "read-modify-write",例如 http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf。)
也许在链接论文中给出您有疑问的地方的具体参考会有所帮助。该论文比第一个 C++ 并发内存模型规范(在 C++11 中)早了一点点,我们现在是另一个版本,所以它也可能与标准实际所说的内容有点过时了,尽管我希望这更像是一个问题,它提出了可能发生在非原子变量上的事情。
编辑:我将添加更多关于 "the semantics" 的内容,以帮助思考如何分析此类事情。
内存排序的目标是在跨线程的变量读写之间建立一组可能的顺序。在较弱的排序中,不保证存在适用于所有线程的任何单一全局排序。仅这一点就已经很棘手了,应该确保在继续之前完全理解它。
指定排序涉及的两件事是地址和同步操作。实际上,同步操作有两个方面,这两个方面通过共享地址连接。 (栅栏可以被认为适用于所有地址。)space 中的许多混淆来自于弄清楚一个地址上的同步操作何时保证其他地址的某些内容。例如。互斥量锁定和解锁操作仅通过对互斥量内部地址的获取和释放操作建立排序,但该同步适用于锁定和解锁互斥量的线程的 all 读取和写入。使用宽松排序访问的原子变量对发生的事情几乎没有限制,但这些访问可能具有由对其他原子变量或互斥体的更严格排序的操作强加的排序约束。
主要的同步操作是acquire
和release
。请参阅:http://en.cppreference.com/w/cpp/atomic/memory_order。这些是根据互斥体发生的情况命名的。 acquire 操作应用于加载并防止当前线程上的任何内存操作被重新排序超过 acquire 发生的点。它还建立了对同一变量的任何先前发布操作的排序。最后一位由加载的值决定。 IE。如果加载 returns 来自具有释放同步的给定写入的值,则加载现在针对该写入进行排序,并且这些线程的所有其他内存操作根据排序规则落到位。
原子操作或读-修改-写操作在较大的顺序中是它们自己的小序列。保证读取、操作和写入以原子方式发生。任何其他顺序由操作的内存顺序参数给出。例如。指定宽松的顺序表示没有其他约束适用于任何其他变量。 IE。该操作没有隐含的获取或释放。指定 memory_order_acq_rel
表示不仅操作是原子的,而且读取是获取,写入是释放——如果线程从另一个具有释放语义的写入中读取一个值,那么所有其他原子现在都有适当的此线程中的排序约束。
具有宽松内存顺序的 fetch_add
可用于分析中的统计计数器。在操作结束时,所有线程都会做一些其他事情来确保所有这些计数器增量现在对最终 reader 可见,但在中间状态我们不关心,只要最终总数加起来.然而,这并不意味着中间读取可以对从未作为计数一部分的值进行采样。例如。如果我们总是将偶数值添加到从 0 开始的计数器,则无论顺序如何,任何线程都不应读取奇数。
我有点迟疑,因为无法指出标准中的一段特定文本,该文本表示除了程序中以某种方式明确编码的原子变量之外,原子变量不会有任何副作用。很多事情都提到了副作用,但似乎理所当然地认为副作用是由源代码指定的,而不是由编译器构成的。现在没有时间追踪这个,但是如果不能保证这一点,很多东西将无法工作,std::atomic
的部分目的是获得这个约束,因为它不能保证其他变量。 (它在某种程度上是由 volatile
提供的,或者至少是打算提供的。我们对 std::atomic
周围的内存排序有这种程度的规范的部分原因是因为 volatile
从来没有变得足够好指定详细推理,没有一组约束满足所有需求。)
现有的答案提供了很多很好的解释,但并不能直接回答你的问题。我们开始吧:
can atomics suffer spurious stores?
是的,但是您无法从没有数据竞争的 C++ 程序中观察到它们。
实际上只有volatile
被禁止执行额外的内存访问。
does the C++ memory model forbid thread 1 from behaving as though it did this?
++m; ++m;
是的,但是这个是允许的:
lock (shared_std_atomic_secret_lock) { ++m; ++m; }
允许但愚蠢。一个更现实的可能性是把这个:
std::atomic<int64_t> m;
++m;
进入
memory_bus_lock
{
++m.low;
if (last_operation_did_carry)
++m.high;
}
其中 memory_bus_lock
和 last_operation_did_carry
是硬件平台的特性,无法用可移植的 C++ 表达。
请注意,位于内存总线上的外围设备 确实 看到中间值,但可以通过查看内存总线锁来正确解释这种情况。软件调试器将无法看到中间值。
其他情况下,原子操作可以通过软件锁来实现,此时:
- 软件调试器可以看到中间值,必须注意软件锁定以避免误解
- 硬件外设将看到软件锁的变化,以及原子对象的中间值。外设可能需要一些魔法来识别两者之间的关系。
- 如果原子对象在共享内存中,其他进程可以看到中间值并且可能没有任何方法检查软件锁/可能有所述软件锁的单独副本
- 如果同一 C++ 程序中的其他线程以导致数据竞争的方式破坏类型安全(例如,使用
memcpy
读取原子对象),它们可以观察到中间值。正式地说,这是未定义的行为。
最后一点很重要。 “推测性写入”是一个非常复杂的场景。如果我们重命名条件,则更容易看到这一点:
线程#1
if (my_mutex.is_held) o += 2; // o is an ordinary variable, not atomic or volatile
return o;
话题#2
{
scoped_lock l(my_mutex);
return o;
}
这里没有数据竞争。如果线程 #1 锁定了互斥量,则写入和读取不能无序发生。如果它没有锁定互斥锁,线程 运行 无序但都只执行读取。
因此编译器不允许看到中间值。此 C++ 代码不是正确的重写:
o += 2;
if (!my_mutex.is_held) o -= 2;
因为编译器发明了数据竞争。然而,如果硬件平台提供了一种无竞争的推测性写入机制(也许是 Itanium?),编译器就可以使用它。因此硬件可能会看到中间值,即使 C++ 代码看不到。
如果硬件不应该看到中间值,则需要使用 volatile
(可能除了原子,因为 volatile
读-修改-写不能保证原子)。使用 volatile
,要求一个不能按写入执行的操作将导致编译失败,而不是虚假的内存访问。