在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.(如果您点击链接,那么,在论文的第1部分中,查看第一个项目符号项:“......提供的非正式规范”)
替代形式的问题
一个答案(赞赏)表明上述问题可能会被误解。如果有帮助,那么这是另一种形式的问题。
假设程序员试图告诉线程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似乎解释了多线程执行可以
可编辑的样本代码
如果您愿意,可以使用以下代码进行编译。
#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
。)但是,我在语义中看不到任何阻止它打印的 {{1} },6
或4
。
优秀的Cppreference.com states,“原子对象没有数据竞争,”这很好,但在这样的背景下,这是什么意思?
毫无疑问,这一切都意味着我不太了解语义。你可以对这个问题提出的任何照明都会受到赞赏。答复
@Christophe,@ ZalmanStern和@BenVoigt都巧妙地阐述了这个问题。他们的答案是合作而不是竞争。在我看来,读者应该注意所有三个答案:@Christophe first; @ZalmanStern第二名;和@BenVoigt最后总结一下。答案 0 :(得分:23)
您的代码在原子上使用了fetch_add(),它提供了以下保证:
原子将当前值替换为值和arg的算术加法结果。该操作是读 - 修改 - 写操作。内存受到订单价值的影响。
语义非常明确:在操作之前,它在操作之后是m + 2,并且没有线程访问这两个状态之间的内容,因为操作是原子的。
无论Boehm和Adve可以说什么,C ++编译器都遵守以下标准条款:
1.9 / 5:执行格式良好的程序的符合实现应该产生相同的可观察行为作为可能的 执行抽象机的相应实例 相同的程序和相同的输入。
如果C ++编译器生成的代码可能允许推测性更新干扰程序的可观察行为(也就是获得5或7之外的其他内容),那么它将不符合标准,因为它无法确保保证在我的初步答案中提到。
答案 1 :(得分:20)
现有的答案提供了很多很好的解释,但是它们无法直接回答你的问题。我们走了:
原子能遭受虚假商店吗?
实际上只允许volatile
执行额外的内存访问。
C ++内存模型是否禁止线程1表现得好像这样做?
++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 ++中表示。
请注意,位于内存总线上的外设会看到中间值,但可以通过查看内存总线锁来正确解释这种情况。软件调试器无法看到中间值。
在其他情况下,原子操作可以通过软件锁实现,在这种情况下:
memcpy
读取原子对象),则可以观察到中间值。形式上,这是未定义的行为。最重要的一点。 &#34;投机性写作&#34;是一个非常复杂的场景。如果我们重命名条件,这会更容易看到:
线程#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
,要求执行无法执行的操作将导致编译失败,而不是虚假的内存访问。
答案 2 :(得分:5)
您修改后的问题与第一个问题的区别很大,因为我们已从顺序一致性转移到宽松的内存顺序。
关于和指定弱内存排序的推理都非常棘手。例如。请注意C ++ 11和C ++ 14规范之间的区别:http://en.cppreference.com/w/cpp/atomic/memory_order#Relaxed_ordering。但是,原子性的定义确实阻止了fetch_add
调用允许任何其他线程看到除了以其他方式写入变量或其中一个加上2之外的值。(一个线程可以做任何事情,只要它保证其他线程无法观察到中间值。)
(为了获得可怕的具体内容,您可能希望在C ++规范中搜索&#34; read-modify-write&#34;例如http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf。)
或许在链接文件中对您有疑问的地方进行具体参考会有所帮助。那篇论文比第一个C ++并发内存模型规范(在C ++ 11中)稍早一点,我们现在又超过了另一个版本,所以它可能有点过时了,标准实际上说的是什么虽然我认为这更像是一个提出可能发生在非原子变量上的事情的问题。
编辑:我将添加更多关于&#34;语义&#34;也许有助于思考如何分析这类事情。
内存排序的目标是在跨线程的变量读写之间建立一组可能的顺序。在较弱的排序中,不能保证存在适用于所有线程的任何单个全局排序。仅此一点已经非常棘手了,人们应该确保在继续之前完全理解它。
指定排序涉及的两件事是地址和同步操作。实际上,同步操作具有两个侧面,并且这两个侧面通过共享地址连接。 (围栏可以被认为适用于所有地址。)空间中的许多混淆来自于确定何时对一个地址的同步操作保证了其他地址的某些内容。例如。互斥锁定和解锁操作仅通过对互斥锁内部地址的获取和释放操作建立排序,但该同步适用于锁定和解锁互斥锁的线程的所有读取和写入。使用宽松排序访问的原子变量对发生的事情几乎没有约束,但是那些访问可能具有对其他原子变量或互斥体进行更强排序操作所施加的排序约束。
主要的同步操作是acquire
和release
。见:http://en.cppreference.com/w/cpp/atomic/memory_order。这些是互斥锁发生的名称。获取操作适用于加载并防止当前线程上的任何内存操作被重新排序超过获取发生的点。它还建立了对同一变量的任何先前释放操作的排序。最后一位由加载的值控制。即如果加载从具有释放同步的给定写入返回值,则现在针对该写入对该加载进行排序,并且这些线程的所有其他内存操作根据排序规则落入到位。
Atomic或read-modify-write操作在较大的排序中是它们自己的小序列。保证读取,操作和写入以原子方式发生。任何其他排序由操作的存储器顺序参数给出。例如。指定宽松排序表示没有约束否则适用于任何其他变量。即该操作并未暗示获得或释放。指定memory_order_acq_rel
表示不仅操作是原子操作,而且读取是获取而写入是发布 - 如果线程从具有释放语义的另一个写入读取值,则所有其他原子现在都具有相应的在这个帖子中排序约束。
具有宽松内存顺序的fetch_add
可用于分析中的统计计数器。在操作结束时,所有线程都会做其他事情以确保所有那些计数器增量现在对最终读者可见,但在中间状态我们不关心,只要最终总数加起来。然而,这并不意味着中间读取可以采样从不属于计数的值。例如。如果我们总是将偶数值添加到从0开始的计数器,则无论顺序如何,都不应该读取奇数值。
由于无法指向标准中的特定文本,我认为除了在程序中以某种方式明确编码的原子变量之外没有副作用,我有点迟钝。很多事情都提到了副作用,但似乎理所当然地认为副作用是源指定的副作用而不是编译器构成的任何副作用。现在没有时间跟踪这个问题,但是如果不能保证这一点,那么有很多东西是行不通的,而std::atomic
的部分要点是因为它不是由其他变量保证。 (它有点由volatile
提供,或者至少是有意提供的。部分原因是我们对std::atomic
周围的内存排序有这种程度的规范是因为volatile
从未变得足够好指定详细说明并且没有一组约束符合所有需要。)