【问题标题】:Relaxed Atomics and Memory Coherence in the Absence of Synchronisation没有同步时的松弛原子和内存一致性
【发布时间】:2020-02-22 17:39:27
【问题描述】:

我编写了一个基本的图形调度程序,它以无等待的方式同步任务执行。由于图拓扑是不可变的,我想我会让所有原子操作放松。然而,随着我对 CPU 硬件的了解越来越多,我开始担心我的数据结构在内存模型较弱的平台上的行为(我只在 x86 上测试过我的代码)。这是困扰我的场景:

线程 1 (T1) 和线程 2 (T2) 分别同时更新(非原子)内存位置 X 和 Y,然后继续执行其他不相关的任务。

线程 3 (T3) 在 T1 和 T2 完成后拾取一个依赖任务,加载 X 和 Y,并将它们相加。没有获取/释放同步、线程连接或调用锁,并且 T3 的任务保证在 T1 和 T2 完成后调度。

假设 T1、T2 和 T3 被调度(由操作系统)在不同的 CPU 内核上,我的问题是:在没有任何内存栅栏或类似锁的指令的情况下,T3 是否保证看到最新的X 和 Y 的值? 另一种问法是:如果不插入栅栏,存储后多久可以执行加载,或者对此没有任何保证?强>

我担心无法保证执行 T1 和 T2 的内核在 T3 的内核尝试加载该信息时已刷新其存储缓冲区。我倾向于将数据竞争视为由于同时发生的加载和存储(或存储和存储)而发生的数据损坏。但是,我开始意识到,考虑到微尺度 CPU 的分布式特性,我不太确定 同时 的真正含义。根据 CppRef:

具有两个冲突评估的程序存在数据竞争,除非:

  • 两个评估都在同一个线程或同一个信号处理程序中执行,或者
  • 两个冲突的计算都是原子操作(参见 std::atomic),或者
  • 其中一个冲突的评估发生在另一个之前(参见 std::memory_order)

这似乎暗示任何使用我的图形调度程序的人都会遇到数据竞争(假设他们自己不保护它),即使我可以保证 T3 在 T1 和 T2 完成之前不会执行。我还没有在我的测试中观察到数据竞争,但我还没有天真地认为仅靠测试就足以证明这一点。

【问题讨论】:

  • 我对 C++ 内存模型并不十分熟悉,但我想如果没有围栏,T3 可以很容易地看到缓存中的陈旧数据。如果您不进行任何线程连接或以其他方式进行同步,那么将 T3 安排在 T1 和 T2 之后就相当于君子协定。
  • 存储后多久可以执行加载, ISO C++ 对时间进行零保证。当您需要的只是在调度程序本身的某处获取/释放同步时,依靠时间/距离来确保正确性几乎总是一个坏主意,例如T1 和 T2 使用发布存储声明自己完成。否则 T3 在 T1 和 T2 之后执行意味着什么?
  • (是的,在 x86 上进行测试并不能证明任何事情;硬件内存模型基本上是 acq_rel,因此编译时重新排序会选择一些合法的顺序,然后该顺序使用 acq_rel 运行。)
  • @PeterCordes 干杯,彼得!我想在添加任何额外的同步之前我会先问一下,因为我对非 SC 原子还是很陌生,并且认为太多是理所当然的。

标签: c++ multithreading atomic memory-barriers memory-model


【解决方案1】:

存储后多久可以执行加载

ISO C++ 对时序做出零保证。依靠时间/距离来确保正确性几乎总是一个坏主意。

在这种情况下,您只需要在调度程序本身的某处获取/释放同步,例如T1 和 T2 使用 release-store 声明自己完成,调度程序使用获取负载检查它。

否则 T3 在 T1 和 T2 之后执行意味着什么? 如果调度程序可以提前看到“I'm done”存储,它可以在 T1 或 T2 没有的情况下启动 T3完成了所有的商店。

如果您确保 T3 中的一切都发生在 T1 和 T2 之后(使用获取负载“同步”来自每个 T1 和 T2 的发布存储),您不会甚至需要在 T1 和 T2 中使用原子,只需在调度程序机制中。

与 seq_cst 相比,获取加载和释放存储相对便宜。在真正的硬件上,seq_cst 必须在存储后刷新存储缓冲区,而 release 则不需要。 x86 免费提供 acq_rel。


(是的,在 x86 上进行测试并不能证明任何事情;硬件内存模型基本上是 acq_rel,因此编译时重新排序会选择一些合法的顺序,然后该顺序使用 acq_rel 运行。)


我不确定启动 new 线程是否能保证该线程中的所有内容都“发生在”该线程中的该点之后。如果是这样,那么这在形式上是安全的。

如果不是,那么理论上 IRIW 重新排序是值得担心的。 (所有使用 seq_cst 加载的线程都必须同意 seq_cst 存储的全局顺序,但不能与较弱的内存顺序一致。实际上,PowerPC 是可以在现实生活中执行此操作的硬件,AFAIK,并且仅适用于短窗口。@987654321 @. 任何std::thread 构造函数都将涉及系统调用并且在实践中足够长,并且无论ISO C++是否正式保证这一点,无论如何都会涉及障碍。

如果您不是开始一个新线程,而是存储一个标志供工作人员查看,那么 acq/rel 就足够了; happens-before is transitive 所以 A -> B 和 B -> C 意味着 A -> C。

【讨论】:

  • 感谢彼得提供的补充细节!我在一个完全不知道图形结构的线程池上安排我的任务,因此图形本身必须执行获取/释放同步。每个任务都有一个原子的“待处理”计数器,当它达到 0 时会对其进行调度。这是执行释放 fetch_sub 的理想场所,但我不太确定在没有编译器对其进行优化的情况下将随附的获取放在哪里。我想我可以在执行任务之前在接收任务的线程上进行获取交换。我需要再考虑一下!
  • @AllothTian:哦,我明白了,所以你本身没有“经理”线程。是的,您可能希望在计数器上使用原子 RMW,而不是某种布尔数组。 RMW 并不便宜,但除非您在每个线程中所做的工作太小,否则很少有额外的开销就可以了。并且布尔数组仍然需要 T1 和 T2 来竞争对缓存行的写访问,这是大部分成本。
  • @AllothTian:是的,要在检查线程已准备好运行后“声明”线程,您需要某种至少获得强度的原子 RMW,以确保只有 1 个工作人员声明它,并与发布商店同步。
  • 是的,任务有一个计数器,它位于自己的缓存行中以避免错误共享,并且任何线程都可以将其他任务排入线程池,因此没有集中管理器。支付额外的 RMW 是不幸的,但我认为我没有其他选择,因为我怀疑 atomic_thread_fences 会做任何事情(而且可能太重了)。
猜你喜欢
  • 2014-10-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-06-25
  • 2021-04-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多