隐藏原子操作带来的性能瓶颈

💡 原文英文,约1500词,阅读约需6分钟。
📝

内容提要

文章指出,C++ 中复制 shared_ptr 会隐式触发原子引用计数操作。多线程下多个线程复制同一 shared_ptr 时,会在同一缓存行上产生激烈竞争,导致吞吐量几乎不随线程数增加而提升,甚至两线程时低于单线程。改用对不可变对象的引用可避免原子操作开销,基准测试显示16线程时速度提升约725倍。建议减少不必要的 shared_ptr 复制。

🔎

延伸解读

隐藏原子操作:shared_ptr 复制的性能陷阱

文章指出,复制 shared_ptr 会隐式触发原子引用计数操作,这在多线程下成为性能瓶颈。多个线程复制同一 shared_ptr 时,会在同一缓存行上激烈竞争,导致吞吐量几乎不随线程数增加而提升,甚至两线程时低于单线程。开发者应警惕这类看似无害的操作,减少不必要的 shared_ptr 复制。

基准测试揭示的扩展性差异

基准测试显示,使用 shared_ptr 复制时,吞吐量从单线程的 73.2 百万次/秒降至两线程的 21.0 百万次/秒,之后几乎不变;而使用不可变对象引用时,吞吐量随线程数线性增长,16 线程达到 15878.3 百万次/秒,速度提升约 725 倍。这直观展示了原子操作竞争对扩展性的严重影响。

缓存一致性开销:两线程性能反降的原因

文章解释,当多个线程在不同核心上更新同一原子变量(如 shared_ptr 的引用计数)时,包含该变量的缓存行必须在核心间同步,引入额外延迟。即使只有两个线程,这种缓存一致性开销也会导致吞吐量下降,因此两线程性能低于单线程。理解这一点有助于诊断类似的多线程性能问题。

优化策略:用不可变对象引用替代复制

为避免原子操作开销,文章建议在确保对象生命周期安全的前提下,使用对不可变对象的引用而非复制 shared_ptr。基准测试中,借用引用方式在 16 线程时比复制 shared_ptr 快约 725 倍。开发者应审查代码中不必要的 shared_ptr 复制,尤其是在高性能并发场景中。

❓

Q&A

为什么在多线程程序中复制 shared_ptr 会导致性能下降?

因为 shared_ptr 的引用计数是原子变量,每次复制或销毁都会原子地更新引用计数。多线程同时复制同一个 shared_ptr 时,会在同一个缓存行上产生激烈竞争,导致缓存一致性开销,从而限制吞吐量的扩展。

使用 shared_ptr 和引用不可变对象在性能上有什么差异?

基准测试显示,使用 shared_ptr 时吞吐量几乎不随线程数增加而提升,甚至两线程时低于单线程;而使用引用不可变对象时吞吐量随线程数线性扩展,16 线程时速度提升约 725 倍。

为什么两个线程时 shared_ptr 的吞吐量反而比单线程低?

因为两个线程在不同核心上同时更新同一个原子引用计数,缓存行需要在核心间同步,引入额外延迟和竞争,导致吞吐量下降。

如何避免 shared_ptr 复制带来的原子操作开销?

可以改用对不可变对象的引用,避免复制 shared_ptr,从而避免原子引用计数的更新。同时,应尽量减少不必要的 shared_ptr 复制。

在哪些场景下 shared_ptr 的原子操作开销可以忽略?

在单线程且工作负载较重的情况下,原子引用计数的开销几乎可以忽略;但在多线程环境下,竞争会成为瓶颈。

基准测试中使用了什么硬件和编译选项?

测试在 8 核 16 线程的 Intel Core i9-9900K CPU 上进行,使用 g++ -O3 -DNDEBUG -std=c++17 -pthread 编译。

🏷️

标签

➡️

继续阅读