原子能遭受虚假商店吗?

thb*_*thb 31 c++ multithreading atomic memory-barriers

在C++中,原子能遭受虚假存储吗?

例如,假设mn是原子能和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)

此代码始终打印57运行时.(事实上​​,就我所知,它总是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之外的其他内容),那么它将不符合标准,因为它无法确保我提到的保证初步答案.

  • @thb:Boehm和Adve指出了普通(非原子,非易失性)内存访问的问题.原子是避免他们指出的问题的工具. (3认同)
  • @Christophe:但是,还必须考虑标准使用的"可观察行为"的含义.它仅包括同一程序中代码所做的观察.C和C++都明确允许使用显式锁来实现原子函数,在这种情况下,缺少类型安全性的观察,例如OS跨进程内存访问,内存映射硬件或调试工具,可能仍然能够观察到违反"可观察行为"模型的行为. (2认同)

Ben*_*igt 20

现有的答案提供了很多很好的解释,但是他们无法直接回答你的问题.开始了:

原子能遭受虚假商店?

是的,但您无法从没有数据竞争的C++程序中观察它们.

volatile实际上只禁止执行额外的内存访问.

C++内存模型是否禁止线程1表现得好像这样做?

++m;
++m;
Run Code Online (Sandbox Code Playgroud)

是的,但允许这个:

lock (shared_std_atomic_secret_lock)
{
    ++m;
    ++m;
}
Run Code Online (Sandbox Code Playgroud)

这是允许但是愚蠢的.更现实的可能性是:

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_locklast_operation_did_carry特征是无法用便携式C++表示的.

请注意,位于内存总线上的外设确实看到了中间值,但可以通过查看内存总线锁来正确解释这种情况.软件调试器将无法看到中间值.

在其他情况下,原子操作可以通过软件锁实现,在这种情况下:

  1. 软件调试器可以看到中间值,并且必须知道软件锁定以避免误解
  2. 硬件外围设备将看到软件锁定的更改以及原子对象的中间值.外围设备可能需要一些魔术来识别两者之间的关系.
  3. 如果原子对象在共享内存中,则其他进程可以看到中间值并且可能没有任何方法来检查软件锁/可能具有所述软件锁的单独副本
  4. 如果同一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,要求执行无法执行的操作将导致编译失败,而不是虚假的内存访问.

  • 我唯一知道的就像`memory_bus_lock {multiple ops}`那样是事务性内存.(例如英特尔的TSX.https://www.realworldtech.com/haswell-tm/).AFAIK,这是使用你在x86或任何典型RISC上用汇编语言描述的算法实现`int64_t m;``++ m`的唯一方法.(大多数RISC CPU支持[LL/SC](https://en.wikipedia.org/wiki/Load-link/store-conditional),但不支持嵌套,因此您无法对一对单词进行原子操作. (2认同)
  • 在x86上,您可以使用双字CAS(x86*甚至没有TSX-NI事务内存:`cmpxchg8b`)以原子方式递增双字整数(如32位x86上的`int64_t`).加载两个字,在寄存器中递增,然后尝试将CAS返回到内存中.否则重试.没有asm指令来锁定内存总线.(当然,通常原子操作仅在内部锁定其操作的一个高速缓存行,即不响应MESI请求在原子RMW的读取和写入之间无效或共享高速缓存行). (2认同)
  • 无论如何,因为你建议的机制是C++标准所允许的.没有主流硬件以这种方式工作,但这是一个有趣的思想实验.并且还建议在关心内部时使用`volatile atomic <T>`. (2认同)

Zal*_*ern 5

您修改后的问题与第一个问题的区别很大,因为我们已从顺序一致性转移到宽松的内存顺序.

关于和指定弱内存排序的推理都非常棘手.例如,请注意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中)稍微早一点,我们现在又超过了它,因此它可能与标准实际所说的有点过时,尽管我希望这更像是一个提出非原子变量可能发生的事情的问题.

编辑:我将添加更多关于"语义"的内容,或许可以帮助思考如何分析这种事情.

内存排序的目标是在跨线程的变量读写之间建立一组可能的顺序.在较弱的排序中,不能保证存在适用于所有线程的任何单个全局排序.仅此一点已经足够棘手了,人们应该确保在继续之前完全理解它.

指定排序涉及的两件事是地址和同步操作.实际上,同步操作具有两个侧面,并且这两个侧面通过共享地址连接.(围栏可以被认为适用于所有地址.)空间中的许多混淆来自于确定何时对一个地址的同步操作保证了其他地址的某些内容.例如,互斥锁定和解锁操作仅通过对互斥锁内的地址的获取和释放操作建立排序,但该同步适用于锁定和解锁互斥锁的线程的所有读取和写入.使用宽松排序访问的原子变量对发生的事情几乎没有约束,但这些访问可能具有对其他原子变量或互斥体的更强排序操作强加的排序约束.

主要的同步操作是acquirerelease.请参阅:http://en.cppreference.com/w/cpp/atomic/memory_order.这些是互斥锁发生的名称.获取操作适用于加载并防止当前线程上的任何内存操作被重新排序超过获取发生的点.它还建立了对同一变量的任何先前释放操作的排序.最后一位由加载的值控制.即,如果加载从具有释放同步的给定写入返回值,则现在针对该写入对加载进行排序,并且这些线程的所有其他存储器操作根据排序规则落入到位.

原子或读 - 修改 - 写操作在较大的排序中是它们自己的小序列.保证读取,操作和写入以原子方式发生.任何其他排序由操作的存储器顺序参数给出.例如,指定宽松排序表示没有约束否则适用于任何其他变量.即操作中没有暗示获得或释放.指定memory_order_acq_rel说不仅操作是原子的,而且读取是获取而写入是释放 - 如果线程从具有释放语义的另一个写入读取值,则所有其他原子现在在此线程中具有适当的排序约束.

fetch_add具有宽松内存顺序的A 可用于分析中的统计计数器.在操作结束时,所有线程都会做其他事情以确保所有那些计数器增量现在对最终读者可见,但是在中间状态我们不关心,只要最终总数加起来.然而,这并不意味着中间读取可以采样从不属于计数的值.例如,如果我们总是将偶数值添加到从0开始的计数器,则无论顺序如何,任何线程都不应读取奇数值.

由于无法指向标准中的特定文本片段,我认为除了在程序中以某种方式明确编码的那些原子变量之外没有任何副作用,我有点迟钝.很多事情都提到了副作用,但似乎理所当然地认为副作用是源指定的副作用而不是编译器构成的任何副作用.现在没有时间跟踪这个问题,但是如果不能保证这一点,那么有很多东西是行不通的,其中一部分std::atomic就是得到这个约束,因为其它变量无法保证.(它有点提供volatile,或者至少是有意提供.部分原因是我们对内存排序的这种程度的规范std::atomic是因为volatile从来没有足够好地指定详细推理并且没有一组约束满足所有需求.)