PAYATHON 2026

哪些硬件或系统条件会导致原子 fetch_add 出现长时间停顿?

数据小陈

可以。即使没有激烈争用,单次 fetch_add 的完整调用也可能耗时很长。延迟可能来自原子操作等待硬件完成,也可能是调用线程暂时没有执行,需要分别排查。

C++ 没有为 fetch_add 规定可移植的最大耗时。仅凭示例代码,也无法给出它在某台机器上的最坏延迟。

lock-free 为什么不保证低延迟?

lock-free 是进度保证,不是执行时间承诺。它不保证某次调用在固定的纳秒数内返回,也不保证每个竞争线程都能立即完成操作。即使有更强的 wait-free 保证,有限步骤也不能直接换算成固定的墙钟时间。C++ 工作草案的进度保证条款区分了这些进度要求,并讨论了缓存争用等因素对进度的影响。

还要检查具体实现。常见平台上的 std::atomic<int> 通常是 lock-free,但 C++ 不要求它在所有平台上都如此。可以通过 v.is_lock_free() 检查;如果不是 lock-free,排查时还需要考虑实现内部的锁。

std::memory_order::relaxed 放宽了内存顺序约束,但不会取消 RMW 的原子性要求。操作仍然必须安全地更新共享位置。

没有激烈争用,硬件为什么仍会等待?

获取 cache line 的写入权限

在常见的缓存一致性系统中,原子 RMW 需要取得目标 cache line 的相应写入权限。如果目标数据不在本地缓存、最新副本位于其他核心,或者其他核心持有共享副本,都可能产生额外的一致性通信。

即使只有一次更新,也可能需要跨核心甚至跨 NUMA 节点传输数据。内存控制器或互连上的其他流量也可能增加排队时间。具体延迟取决于微架构、数据位置和系统负载,不能给出统一数值。

没有线程频繁修改 v,也不代表它所在的 cache line 没有争用。其他线程更新同一 cache line 上的相邻对象,可能造成 false sharing。

不过,持有缓存副本的线程被操作系统换出,并不意味着必须等它重新获得 CPU 才能交出数据。缓存一致性通常由硬件处理,不能将它等同于线程持有软件 mutex。

split lock 和非 WB 内存访问

在适用的 x86 平台上,如果 locked 访问跨越两个 cache line,或者目标内存不是 write-back(WB)类型,可能触发代价高昂的 bus lock。系统还可能配置异常检测、限速或终止策略。Linux 内核文档说明了这些条件及处理方式。

这条异常路径需要满足特定前提。示例中的 v 是正常声明的 std::atomic<int>,在常见 x86 ABI 和普通内存映射下具有所需对齐,因此不应首先怀疑 split lock。只有实际涉及错误对齐、非标准布局或特殊内存映射时,才应重点检查这一方向。

不要通过故意错误对齐 std::atomic 来构造测试,这会破坏 C++ 对象的使用前提。也不要假定普通 C++ 原子操作适用于 MMIO,设备内存需要遵循平台和设备规定。

原子操作可能由重试循环实现

一次 C++ fetch_add 不一定对应一条机器指令。例如,AArch64 上可能使用 LDXR/STXR 循环,也可能使用 LSE 的 LDADD 等指令。使用前一种方式时,exclusive store 失败后需要重试,因此一次调用可能执行多轮指令。具体采用哪条路径,取决于编译目标、编译器和运行时支持。Arm 的 LSE 说明介绍了这两种实现方式。

在 x86 上,如果旧值没有被使用,编译器可能生成 lock add;需要旧值时则可能生成 lock xadd。应以实际反汇编为准。

为什么完整调用耗时可能远大于指令执行时间?

在线程测量开始后、结束前,如果发生调度抢占、中断处理或虚拟机 vCPU 暂停,测得的墙钟时间就会包含这些间隔。SMI 等固件活动也可能造成应用难以直接观察的停顿。Linux 的 OSNOISE 文档列出了线程、IRQ、SoftIRQ、NMI 和 SMI 等干扰来源。

如果目标页尚未建立可写映射,还可能涉及缺页、copy-on-write 等处理。示例中的 v 已在创建线程前初始化,因此不能直接认定它会发生首次访问缺页;这需要运行环境和跟踪证据支持。

这些情况都可能延长包围 fetch_add 的计时区间,但不表示 CPU 在整段时间内一直执行同一条原子指令。

“流水线完全停住”的说法也过于笼统。等待中的原子操作会影响相关指令和指令退休;乱序执行核心能否继续执行独立指令,取决于指令语义和微架构资源。不能仅凭一段耗时推断整个流水线的状态。

如何检查示例中的 #1 和 #2?

先确认实现,再区分墙钟耗时与实际执行情况。下面的代码可以替换原程序,记录两个调用附近的耗时:

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::atomic<int> v{0};
    using Clock = std::chrono::steady_clock;
    Clock::duration elapsed1{}, elapsed2{};

    std::cout << std::boolalpha
              << "lock-free: " << v.is_lock_free() << '\n';

    auto t1 = std::thread([&] {
        const auto start = Clock::now();
        v.fetch_add(1, std::memory_order::relaxed); // #1
        elapsed1 = Clock::now() - start;
    });

    auto t2 = std::thread([&] {
        const auto start = Clock::now();
        v.fetch_add(1, std::memory_order::relaxed); // #2
        elapsed2 = Clock::now() - start;
    });

    t1.join();
    t2.join();

    std::cout << "#1 ns: "
              << std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed1).count()
              << '\n';
    std::cout << "#2 ns: "
              << std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed2).count()
              << '\n';
}

这个示例测量的时间区间包含计时开销和系统干扰,不能直接得出原子指令的纯硬件延迟。输出单位是纳秒,也不意味着计时器具有纳秒级精度。

进一步排查时,可按以下顺序进行:

  1. 查看优化后的反汇编,确认是 locked 指令、LL/SC 循环,还是运行库调用。
  2. 使用常驻线程重复采样,观察耗时分布和长尾;原程序只执行两次操作,无法据此估计延迟分布。
  3. 对照调度、中断和缺页记录,检查长样本是否包含线程停止执行的时间。
  4. 控制线程亲和性、NUMA 布局和数据位置,并检查 false sharing;这些措施能减少变量,不能自动建立硬实时上界。
  5. 只有在证据指向相应路径时,再检查 split lock 日志、特殊内存属性或平台相关性能计数器。

对于原示例,#1 或 #2 的完整调用确实可能出现很长的观测耗时。但只有排除了调度和测量干扰,并结合指令及硬件证据,才能把延迟归因于原子 RMW 本身。

备注:内容仅供参考。