[英]Can atomic operations on a non-atomic<> pointer be safe and faster than atomic<>?
我有十几个线程在读取一个指针,而一个线程可能会在一小时左右更改一次该指针。
读者是超级、超级、超级时间敏感的。 我听说atomic<char**>
或进入主 memory 的速度是什么,我想避免这种情况。
在现代(比如 2012 年及以后)服务器和高端台式机 Intel 中,如果正常读写,是否可以保证 8 字节对齐的常规指针不撕裂? 我的一个测试运行一个小时没有看到眼泪。
否则,如果我以原子方式写入并正常读取,会更好(或更糟)吗? 例如通过将两者结合起来?
请注意,还有其他关于混合原子和非原子操作的问题,这些问题没有指定 CPU,并且讨论转向语言律师。 这个问题不是关于规范,而是关于究竟会发生什么,包括我们是否知道在规范未定义的情况下会发生什么。
x86 永远不会将 asm 加载或存储到对齐的指针宽度值。 这个问题的那一部分,以及您的另一个问题( 现代英特尔上的 C++11:我是疯了还是非原子对齐的 64 位加载/存储实际上是原子的? )都是为什么 integer 分配在自然对齐的变量原子上的重复x86?
这就是为什么atomic<T>
编译器实现起来如此便宜,以及为什么使用它没有缺点的部分原因。
在 x86 上读取atomic<T>
的唯一实际成本是,它无法跨多次读取同一 var 优化到寄存器中。 但是无论如何你都需要让你的程序正常工作(即让线程注意到指针的更新)。 在非 x86 上,只有mo_relaxed
与普通 asm 负载一样便宜,但 x86 强大的 memory model 甚至可以使 seq_cst 负载便宜。
如果在一个 function 中多次使用指针,做T* local_copy = global_ptr;
因此编译器可以将local_copy
在寄存器中。 将此视为从 memory 加载到私有寄存器中,因为这正是它的编译方式。 原子对象上的操作不会优化,因此如果您想在每个循环中重新读取一次全局指针,请以这种方式编写您的源代码。 或者一旦在循环之外:以这种方式编写您的源代码并让编译器管理本地变量。
显然你一直试图避免atomic<T*>
因为你对std::atomic::load()
纯加载操作的性能有一个巨大的误解。 std::atomic::store()
会慢一些,除非您使用 memory_order 释放或放松,但在 x86 上 std::atomic 没有额外的 seq_cst 加载成本。
在这里避免atomic<T*>
没有性能优势。 它可以安全、便携地完成您所需要的工作,并为您的主要阅读用例提供高性能。 每个读取它的核心都可以访问其私有 L1d 缓存中的副本。 写入使行的所有副本无效,因此写入者拥有独占所有权 (MESI),但从每个内核的下一次读取将获得一个共享副本,该副本可以再次在其私有缓存中保持热状态。
(这是一致性缓存的好处之一:读者不必一直检查某个共享副本。写入者在写入之前被迫确保任何地方都没有陈旧的副本。这一切都是由硬件完成的,而不是使用软件 asm 指令。我们运行多个 C++ 线程的所有 ISA 都具有缓存一致的共享 memory,这就是为什么volatile
可以用于滚动您自己的原子( 但不要这样做),就像人们在 Z656473FZ6E7DAB566473FZ6E7DAB56473FZ6E7DAB566473FZ6E7DAB56473FZ6E7DAB4 . 或者就像你试图在不使用volatile
的情况下做的那样,它只适用于调试版本。绝对不要那样做!)
原子加载编译为编译器用于其他所有内容的相同指令,例如mov
。 在 asm 级别,每个对齐的加载和存储都是一个原子操作(对于 2 个大小的幂,最多 8 个字节)。 atomic<T>
只需要阻止编译器假设没有其他线程在访问之间写入 object。
(与纯加载/纯存储不同, 整个 RMW 的原子性不会免费发生; ptr_to_int++
将编译为lock add qword [ptr], 4
。但在无竞争的情况下,这仍然比缓存未命中要快得多DRAM,只需要一个“缓存锁”在核心内拥有该行的独占所有权。就像每次操作 20 个周期,如果你什么都不做,只是在 Haswell 上背靠背( https://agner.org/optimize / ),但其他代码中间只有一个原子 RMW 可以与周围的 ALU 操作很好地重叠。)
与需要 RWlock 的任何内容相比,纯只读访问是使用原子的无锁代码真正闪耀的地方- atomic<>
读取器不会相互竞争,因此读取端可以完美地扩展到这样的用例(或 RCU 或一个序列锁)。
在 x86 上, seq_cst
加载(默认排序)不需要任何屏障指令,这要归功于 x86 的硬件内存排序 model(程序顺序加载/存储,加上带有存储转发的存储缓冲区)。 这意味着您可以在使用指针的读取端获得完整的性能,而无需削弱以acquire
或consume
memory 订单。
如果存储性能是一个因素,您可以使用std::memory_order_release
因此存储也可以是普通的mov
,而无需使用mfence
或xchg
耗尽存储缓冲区。
我听说
atomic<char**>
或进入主 memory 的速度是多少
无论你读什么都误导了你。
即使在内核之间获取数据也不需要进入实际的 DRAM,只需要共享最后一级缓存即可。 由于您使用的是 Intel CPU,因此 L3 缓存是缓存一致性的后盾。
在核心写入缓存行之后,它仍将位于 MESI 修改后的 state 中的私有 L1d 缓存中(并且在所有其他缓存中无效;这就是 MESI 保持缓存一致性的方式 = 任何地方都没有行的陈旧副本)。 因此,来自该高速缓存行的另一个核心上的负载将在私有 L1d 和 L2 高速缓存中丢失,但 L3 标签会告诉硬件哪个核心拥有该行的副本。 一条消息通过环形总线到达该核心,使其将线路写回 L3。 从那里可以将其转发到仍在等待加载数据的核心。 这几乎就是内核间延迟的衡量标准——在一个内核上存储和在另一个内核上获得价值之间的时间。
这所花费的时间(内核间延迟)大致类似于 L3 缓存中未命中的负载并且必须等待 DRAM,例如 40ns 对 70ns,具体取决于 CPU。 也许这就是你读到的。 (多核 Xeon 在环形总线上的跳数更多,内核之间以及从内核到 DRAM 的延迟更多。)
但这仅适用于写入后的第一次加载。 数据由加载它的内核上的 L2 和 L1d 缓存以及 L3 的共享 state 缓存。 在那之后,任何频繁读取指针的线程都会使该行在运行该线程的核心上的快速私有 L2 甚至 L1d 缓存中保持热状态。 L1d 缓存有 4-5 个周期的延迟,每个时钟周期可以处理 2 个负载。
并且该行将在 L3 中的 Shared state 中,任何其他内核都可以命中,因此只有第一个内核支付全部内核间延迟损失。
(在 Skylake-AVX512 之前,Intel 芯片使用包含 L3 缓存,因此 L3 标签可以用作窥探过滤器,用于内核之间基于目录的缓存一致性。如果某行在某些私有缓存中的 Shared state 中,则它在 Shared state 中也有效在 L3 中。即使在 L3 缓存不保持包含属性的 SKX 上,数据在内核之间共享后也会在 L3 中存在一段时间。)
在调试版本中,每个变量都在 C++ 语句之间存储/重新加载到 memory。 这并不(通常)比正常优化构建慢 400 倍这一事实表明,当 memory 访问缓存时,在非竞争情况下访问并不会太慢。 (将数据保存在寄存器中比 memory 更快,因此调试构建通常非常糟糕。如果您使用memory_order_relaxed
使每个变量atomic<T>
,这将有点类似于没有优化的编译,除了像++
之类的东西)。 为了清楚起见,我并不是说atomic<T>
使您的代码以调试模式速度运行。 每次源提到它时,都需要从 memory (通过缓存)重新加载可能已异步更改的共享变量,而atomic<T>
会这样做。
正如我所说,读取atomic<char**> ptr
将编译为 x86 上的mov
负载,没有额外的栅栏,与读取非原子 object 完全相同。
除了它会阻止一些编译时重新排序,并且像volatile
一样阻止编译器假设值永远不会改变并将负载提升到循环之外。 它还阻止编译器发明额外的读取。 见https://lwn.net/Articles/793253/
我有十几个线程在读取一个指针,而一个线程可能会在一小时左右更改一次该指针。
您可能需要 RCU,即使这意味着为每个非常不频繁的写入复制一个相对较大的数据结构。 RCU 使阅读器真正成为只读的,因此阅读端缩放是完美的。
C++ 11/14/17 的其他答案:读者/作者锁……没有读者锁? 建议涉及多个 RWlock 的事情,以确保读者总是可以拿到一个。 这仍然涉及所有读者都争相修改的某个共享缓存行上的原子 RMW。 如果您有读取 RWlock 的读取器,他们可能会因为内核间延迟而停止,因为他们将包含锁的缓存线放入 MESI Modified state。
(Hardware Lock Elision 用于解决避免读取器之间争用的问题,但已被所有现有硬件上的微码更新禁用。)
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.