yuqi-zheng

拆分锁(Split Locks):跨缓存行原子操作的隐形代价


x86-64 支持非对齐内存访问,包括对跨越两条缓存行的数据做原子操作。这类情形被称为 拆分锁(split lock)。x86 架构通过在操作期间锁住整条内存总线来保证正确性 —— 这一操作的代价大约是 1000 个 CPU 周期,期间会让机器上的所有其他核都停顿。


什么情况会出现 split lock

当原子操作针对的内存跨越了 64 字节的缓存行边界时,就会出现 split lock。CPU 无法独立地只锁住这两条受影响的缓存行(缓存一致性协议能处理单行原子操作、不需要总线锁),所以它回退为在总线上断言 LOCK# 信号,在该操作完成前,系统范围内所有其他内存事务都会被阻止。

Intel 文档原文:

对跨行地址(跨越缓存行边界的地址)的带锁内存访问,会让处理器在整个带锁访问期间锁住总线。[…] 总线锁对总线上的所有 agent 都有严重的性能影响。

惩罚大约是 1000 个 CPU 周期。与只把单条缓存行的访问串行化的普通原子不同,split lock 会让整机停转。


测量差异

对齐的原子自增(正常情况):

alignas(64) std::atomic<uint32_t> v{0};

auto start = std::chrono::steady_clock::now();
for (size_t i = 0; i < niter; ++i)
    v.fetch_add(1, std::memory_order_relaxed);
auto stop = std::chrono::steady_clock::now();

典型代价:每次操作约 6 ns

split-lock 原子自增(跨越缓存行边界):

char buf[128] = {};
// 在一个 64 字节对齐的缓冲区内,把原子放在偏移 61 处。
// 4 字节值横跨字节 [61, 64) 与 [64, 65) —— 两条缓存行。
auto* v = reinterpret_cast<std::atomic<uint32_t>*>(
    reinterpret_cast<uintptr_t>(buf + 64) - 3);

auto start = std::chrono::steady_clock::now();
for (size_t i = 0; i < niter; ++i)
    v->fetch_add(1, std::memory_order_relaxed);
auto stop = std::chrono::steady_clock::now();

典型代价:每次操作约 1194 ns —— 慢 200 倍。

这不是一种性能回退,而是一种以”性能问题”伪装起来的正确性危害:热路径上单个对齐不当的原子操作就能压垮内存总线,在其他核上的无关线程里引发可见的延迟尖峰。


用 perf 检测 split lock

Intel CPU 暴露了一个针对 split lock 的性能计数器:

$ perf stat -e sq_misc.split_lock ./program

 Performance counter stats for './program':

         1,000,000      sq_misc.split_lock:u

       1.203341403 seconds time elapsed

一百万次 split lock —— 每次循环一次,正是预期的。

注意:较新的 Intel 微架构(Ice Lake 及以后)可以配置为在 split lock 时抛出 #AC 异常,而不是悄悄做串行化。Linux 5.8+ 在内核层支持这个(split_lock_detect=fatal),能把静默的性能 bug 变成开发阶段立刻崩溃。


如何避免 split lock

使用自然对齐。 std::atomic<T> 在大多数平台上默认具有 alignof(T) 的对齐,这足够了。只要 4 字节原子不跨越缓存行内偏移 60–64 那段,它就永远不会跨越 64 字节边界。

存疑时加 alignas

struct alignas(64) Counter {
    std::atomic<uint32_t> value{0};
};

关注结构体布局。 把一个原子成员放在结构体末尾、恰好被挤到缓存行边界上,是意外 split lock 最常见的来源。用 offsetof 或 static assertion 验证布局。

发布前用 perf 审一遍 任何把原子放进手工管理的内存布局(arena、共享内存段、DMA 缓冲区)的代码。