在 x86-64 架构中,原子操作是构建多线程并发程序的基础。程序员可以使用原子的 test-and-set 操作来实现锁的获取,或者使用原子的 add 操作让多个线程安全地递增共享计数器。现代 CPU 通过缓存一致性协议来处理这些原子操作,允许各核心在锁定单个缓存行时,其他无关的内存访问可以正常进行。然而,当原子操作涉及跨缓存行边界的内存访问时 —— 即所谓的 split lock——Intel 和 AMD 的处理器缺乏同时锁定两个缓存行的能力,只能回退到所谓的「总线锁」(bus lock)机制。这种回退行为会引发显著的性能下降,并且可能对其他核心产生干扰。
Split Lock 的技术原理
缓存行是 CPU 缓存系统中的基本单位,在 x86-64 架构上通常为 64 字节。当一个原子操作访问的 8 字节(64 位)值跨越两个缓存行的边界时,处理器无法仅通过锁定单个缓存行来完成原子性保证。以 lock cmpxchg 指令为例,当目标地址的低字节位于缓存行末尾而高字节位于下一个缓存行开头时,CPU 必须执行更高级别的同步操作。
传统意义上的总线锁源于早期多处理器系统 —— 当时多个 CPU 连接到共享总线,每个 CPU 的引脚连接到相同的主板走线,最终连接到芯片组。锁定总线可以防止其他 CPU 进行内存访问,从而避免对原子操作造成干扰。现代 CPU 已经不再使用共享总线架构,转而采用非阻塞的分布式互连结构。但「总线锁」这一术语被保留下来,用于描述 split lock 触发的降级行为。
从实际测试数据来看,split lock 的影响在不同微架构之间存在巨大差异。在 Arrow Lake 上,split lock 仅影响 L2 缓存未命中的情况,延迟约为 7 微秒,且在不同核心类型之间保持恒定。这意味着只要程序访问的数据保持在 L2 缓存或更快的缓存层级中,就可以完全避免 split lock 带来的性能损失。相比之下,Zen 5 的 split lock 延迟约为 500 纳秒,虽然绝对数值看起来更好,但一旦访问超出 L1D 缓存,延迟会急剧恶化 ——L2 和 L3 性能下降约 10 倍。
跨微架构的性能表现
通过在多代硬件上进行核心间延迟测试,可以观察到 split lock 对内存子系统的深远影响。测试方法是在一个核心上持续执行 _InterlockedCompareExchange64(编译为 lock cmpxchg),同时从另一个核心读取或写入目标地址,并故意将目标地址的对齐方式设置为跨缓存行边界。
Intel 平台的表现呈现出明显的架构差异。Arrow Lake 的 P-Core 和 E-Core 在 split lock 场景下延迟都稳定在 7 微秒左右,但两者的行为模式不同 ——E-Core 可以在 L2 命中时完全不受 split lock 影响,而 P-Core 则更容易受到干扰。Alder Lake(Golden Cove P-Core + Gracemont E-Core)的表现更为复杂:正常情况下 P-Core 之间的缓存行弹跳延迟低于 E-Core,但 split lock 场景下 P-Core 承受着极高的延迟惩罚,反而是 E-Core 展现出最佳的 split lock 延迟表现。Skylake 作为较老的架构,其 split lock 延迟优于 Arrow Lake 和 Alder Lake,虽然比 Zen 2 略差,但尚未进入微秒级别。
AMD 平台的表现则呈现出另一番景象。Zen 5 在跨缓存行原子操作时一旦发生 L1D 未命中,L2 和 L3 的性能都会遭受灾难性的 10 倍回归。Zen 2 的 split lock 延迟高于 Zen 5,但仍优于 Intel 的新架构。有趣的是,AMD 较老的 Piledriver 架构反而展现出最佳的 split lock 延迟表现 —— 仅为正常原子操作延迟的 2 到 3 倍,这远优于所有现代平台。Piledriver 甚至在 L3 缓存命中的情况下也不受 split lock 影响,只有 DRAM 访问会受到明显冲击。
对内存一致性与带宽的影响
Split lock 不仅仅影响核心间延迟,还会干扰同一系统上其他应用程序的性能。测试方法是在一对核心上持续运行 split lock 压力测试,同时在其他核心上运行内存延迟和带宽微基准测试,以及 Geekbench 6 的实际工作负载。
在 Arrow Lake 上,split lock 导致的竞争会使 L3 和 DRAM 性能下降约 50%,但 4 MB 的 L2 缓存(在四核 E-Core 集群中共享)完全不受影响,即使 split lock 恰好在同一集群内的核心上运行。Zen 5 的情况更为严峻 ——split lock 竞争使 L2 和 L3 带宽及延迟都下降了约 10 倍。Geekbench 6 的照片滤镜工作负载(产生大量缓存未命中流量)在 split lock 竞争下性能下降显著,而资产压缩工作负载虽然产生的 L3 未命中流量较少,但也无法完全幸免。
Alder Lake 展现出了优秀的隔离能力 —— 尽管其自身的 split lock 性能可能较差,但在 split lock 竞争下,Geekbench 6 的两个工作负载几乎没有任何性能损失。这与其微基准测试结果(显示 L3 性能仅有轻微下降,DRAM 延迟略有上升)相符。Piledriver 同样表现出色,其 10 MB 的 L3 缓存完全不受 split lock 影响,这使得在实际工作负载中性能下降微乎其微。
Linux 系统的检测与缓解机制
较新的 Intel 和 AMD 处理器支持捕获 split lock 操作,使内核能够轻松检测使用 split lock 的进程并实施缓解。Linux 内核默认启用这一功能,并在检测到 split lock 时插入人为延迟,以减轻「嘈杂邻居」效应。
实测显示,在未修改 sysctl 设置的 Linux 系统上,split lock 延迟达到了机械硬盘寻道时间的水平 —— 对于 CPU 而言这是极其漫长的等待。这种延迟应该能有效消除上述的嘈杂邻居效应,因为 split lock 核心间延迟测试在大部分时间内根本没有在运行。
然而,这种默认行为是否合理值得商榷。对于多用户或服务器系统而言,内核的默认策略是有意义的 —— 当运行大量任务时,一致性性能至关重要。限制来自恶意应用程序(如上述测试代码)的嘈杂邻居效应是一个合理的选择,可以与其他 QoS 机制配合使用,例如以较低的锁定时钟速度运行、分区缓存以及限制内存带宽使用。
但在消费级系统上,Linux 的默认行为似乎是对一个本不存在的问题的过度反应。核心间延迟测试以远超正常应用程序的速率使用原子操作,而 split lock 变体更是极端。游戏程序使用 split lock 已有多年,在 AMD Zen 2 和 Zen 5 上也未造成问题。然而,一款在 Linux 上运行 10 FPS、在 Windows 上运行 200 FPS 的游戏却是 Linux 的真实问题。从本质上讲,易用性是消费级市场的决定性因素 —— 每个用户需要排查的 OS 问题都是 OS 本身的失败。
工程实践建议
基于实测数据,程序员应该尽一切努力避免 split lock。首要原则是确保涉及原子操作的 数据结构按 64 字节对齐 —— 这正好是一个缓存行的大小。对于全局变量,可以使用编译器属性 __attribute__((aligned(64)));对于堆分配的数据,可以通过显式的内存对齐分配函数来实现。
如果代码必须处理可能跨缓存行的数据结构,应该考虑将其拆分或重组。例如,将一个跨缓存行的 16 字节原子值替换为两个独立的 8 字节原子值,并使用额外的同步机制来保证组合操作的原子性。这种重构虽然增加了代码复杂度,但可以获得更好的性能。
在调试层面,Linux 提供了 sched_setaffinity 用于将关键线程绑定到特定核心,以避开可能运行 split lock 代码的其他线程。性能分析工具如 perf 可以通过 syscall:sys_enter_futex 和相关事件来监控原子操作的热点,帮助识别潜在的 split lock 场景。
从硬件发展的角度看,AMD 和 Intel 都有进一步优化 split lock 处理的空间。未来的微架构可能会在核心私有缓存层级实现更高效的 split lock 隔离,或者从根本上改进跨缓存行原子操作的硬件支持。当前的「总线锁」术语已经不能准确描述现代 CPU 上的行为,硬件厂商的文档应该更精确地说明 split lock 对其具体实现的影响。