thb*_*thb 31 c++ multithreading atomic memory-barriers
在C++中,原子能遭受虚假存储吗?
例如,假设m和n是原子能和m = 5最初.在主题1中,
m += 2;
Run Code Online (Sandbox Code Playgroud)
在线程2中,
n = m;
Run Code Online (Sandbox Code Playgroud)
结果:最终值n应为5或7,对吧?但它可能是虚假的6吗?它是虚假的4或8,甚至是其他什么?
换句话说,C++内存模型是否禁止线程1表现得好像这样做?
++m;
++m;
Run Code Online (Sandbox Code Playgroud)
或者,更奇怪的是,好像它做到了这一点?
tmp = m;
m = 4;
tmp += 2;
m = tmp;
Run Code Online (Sandbox Code Playgroud)
参考文献:H.-J.Boehm&SV Adve,2008,图1.(如果您点击链接,那么,在论文的第1部分中,查看第一个项目符号项:"......提供的非正式规范")
替代形式的问题
一个答案(赞赏)表明,上述问题可能会被误解.如果有帮助,那么这是另一种形式的问题.
假设程序员试图告诉线程1 跳过操作:
bool a = false;
if (a) m += 2;
Run Code Online (Sandbox Code Playgroud)
C++内存模型是否禁止线程1在运行时表现,就好像它这样做?
m += 2; // speculatively alter m
m -= 2; // oops, should not have altered! reverse the alteration
Run Code Online (Sandbox Code Playgroud)
我问,因为之前联系过的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;
}
Run Code Online (Sandbox Code Playgroud)
此代码始终打印5或7运行时.(事实上,就我所知,它总是7在我运行时打印.)但是,我在语义中看不到任何阻止它打印的东西6,4或者8.
优秀的Cppreference.com 声明, "原子对象没有数据竞争",这很好,但在这样的背景下,它意味着什么?
毫无疑问,这一切都意味着我不太了解语义.你可以对这个问题提出的任何照明都会受到赞赏.
解答
@Christophe,@ ZalmanStern和@BenVoigt都巧妙地阐述了这个问题.他们的答案是合作而不是竞争.在我看来,读者应该注意所有三个答案:@Christophe first; @ZalmanStern第二名; 和@BenVoigt最后总结一下.
Chr*_*phe 23
您的代码在原子上使用fetch_add(),它提供以下保证:
原子上用值和arg的算术加法结果替换当前值.该操作是读 - 修改 - 写操作.内存受到订单价值的影响.
语义非常明确:在操作之前它是m,在操作之后它是m + 2,并且没有线程访问这两个状态之间的内容,因为操作是原子的.
无论Boehm和Adve可能会说什么,C++编译器都遵循以下标准条款:
1.9/5:执行格式良好的程序的符合实现应该产生与具有相同程序和相同输入的抽象机的相应实例的可能执行之一相同的可观察行为.
如果C++编译器生成的代码可能允许推测性更新干扰程序的可观察行为(也就是获得5或7之外的其他内容),那么它将不符合标准,因为它无法确保我提到的保证初步答案.
Ben*_*igt 20
现有的答案提供了很多很好的解释,但是他们无法直接回答你的问题.开始了:
原子能遭受虚假商店?
volatile实际上只禁止执行额外的内存访问.
C++内存模型是否禁止线程1表现得好像这样做?
Run Code Online (Sandbox Code Playgroud)++m; ++m;
是的,但允许这个:
Run Code Online (Sandbox Code Playgroud)lock (shared_std_atomic_secret_lock) { ++m; ++m; }
这是允许但是愚蠢的.更现实的可能性是:
std::atomic<int64_t> m;
++m;
Run Code Online (Sandbox Code Playgroud)
成
memory_bus_lock
{
++m.low;
if (last_operation_did_carry)
++m.high;
}
Run Code Online (Sandbox Code Playgroud)
硬件平台的位置memory_bus_lock和last_operation_did_carry特征是无法用便携式C++表示的.
请注意,位于内存总线上的外设确实看到了中间值,但可以通过查看内存总线锁来正确解释这种情况.软件调试器将无法看到中间值.
在其他情况下,原子操作可以通过软件锁实现,在这种情况下:
memcpy读取原子对象),则可以观察到中间值.形式上,这是未定义的行为.最重要的一点."推测性写作"是一个非常复杂的场景.如果我们重命名条件,则更容易看到这个:
线程#1
if (my_mutex.is_held) o += 2; // o is an ordinary variable, not atomic or volatile
return o;
Run Code Online (Sandbox Code Playgroud)
线程#2
{
scoped_lock l(my_mutex);
return o;
}
Run Code Online (Sandbox Code Playgroud)
这里没有数据竞争.如果线程#1锁定了互斥锁,则写入和读取不会发生无序.如果它没有锁定互斥锁,则线程会无序运行但两者都只执行读取操作.
因此,编译器不能允许看到中间值.这个C++代码不是正确的重写:
o += 2;
if (!my_mutex.is_held) o -= 2;
Run Code Online (Sandbox Code Playgroud)
因为编译器发明了数据竞争.但是,如果硬件平台提供了无竞争推测写入机制(也许是Itanium?),编译器可以使用它.所以硬件可能会看到中间值,即使C++代码不能.
如果硬件不应该看到中间值,则需要使用volatile(可能除了原子之外,因为volatileread-modify-write不能保证原子).因此volatile,要求执行无法执行的操作将导致编译失败,而不是虚假的内存访问.
您修改后的问题与第一个问题的区别很大,因为我们已从顺序一致性转移到宽松的内存顺序.
关于和指定弱内存排序的推理都非常棘手.例如,请注意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中)稍微早一点,我们现在又超过了它,因此它可能与标准实际所说的有点过时,尽管我希望这更像是一个提出非原子变量可能发生的事情的问题.
编辑:我将添加更多关于"语义"的内容,或许可以帮助思考如何分析这种事情.
内存排序的目标是在跨线程的变量读写之间建立一组可能的顺序.在较弱的排序中,不能保证存在适用于所有线程的任何单个全局排序.仅此一点已经足够棘手了,人们应该确保在继续之前完全理解它.
指定排序涉及的两件事是地址和同步操作.实际上,同步操作具有两个侧面,并且这两个侧面通过共享地址连接.(围栏可以被认为适用于所有地址.)空间中的许多混淆来自于确定何时对一个地址的同步操作保证了其他地址的某些内容.例如,互斥锁定和解锁操作仅通过对互斥锁内的地址的获取和释放操作建立排序,但该同步适用于锁定和解锁互斥锁的线程的所有读取和写入.使用宽松排序访问的原子变量对发生的事情几乎没有约束,但这些访问可能具有对其他原子变量或互斥体的更强排序操作强加的排序约束.
主要的同步操作是acquire和release.请参阅:http://en.cppreference.com/w/cpp/atomic/memory_order.这些是互斥锁发生的名称.获取操作适用于加载并防止当前线程上的任何内存操作被重新排序超过获取发生的点.它还建立了对同一变量的任何先前释放操作的排序.最后一位由加载的值控制.即,如果加载从具有释放同步的给定写入返回值,则现在针对该写入对加载进行排序,并且这些线程的所有其他存储器操作根据排序规则落入到位.
原子或读 - 修改 - 写操作在较大的排序中是它们自己的小序列.保证读取,操作和写入以原子方式发生.任何其他排序由操作的存储器顺序参数给出.例如,指定宽松排序表示没有约束否则适用于任何其他变量.即操作中没有暗示获得或释放.指定memory_order_acq_rel说不仅操作是原子的,而且读取是获取而写入是释放 - 如果线程从具有释放语义的另一个写入读取值,则所有其他原子现在在此线程中具有适当的排序约束.
fetch_add具有宽松内存顺序的A 可用于分析中的统计计数器.在操作结束时,所有线程都会做其他事情以确保所有那些计数器增量现在对最终读者可见,但是在中间状态我们不关心,只要最终总数加起来.然而,这并不意味着中间读取可以采样从不属于计数的值.例如,如果我们总是将偶数值添加到从0开始的计数器,则无论顺序如何,任何线程都不应读取奇数值.
由于无法指向标准中的特定文本片段,我认为除了在程序中以某种方式明确编码的那些原子变量之外没有任何副作用,我有点迟钝.很多事情都提到了副作用,但似乎理所当然地认为副作用是源指定的副作用而不是编译器构成的任何副作用.现在没有时间跟踪这个问题,但是如果不能保证这一点,那么有很多东西是行不通的,其中一部分std::atomic就是得到这个约束,因为其它变量无法保证.(它有点提供volatile,或者至少是有意提供.部分原因是我们对内存排序的这种程度的规范std::atomic是因为volatile从来没有足够好地指定详细推理并且没有一组约束满足所有需求.)