拆分锁(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 缓冲区)的代码。