深入剖析 split locks,i++ 可能导致的灾难

Split lock 是 CPU 为了支持跨 cache line 进行原子内存访问而支持的内存总线锁。

有些处理器比如 ARM、RISC-V 不允许未对齐的内存访问,不会产生跨 cache line 的原子访问,所以不会产生 split lock,而 X86 是支持的。

split lock 对开发者来说是很方便的,因为不需要考虑内存不对齐访问的问题,但是这同时也是有代价的:一个产生 split lock 的指令会独占内存总线大约 1000 个时钟周期,对比正常情况下的 ADD 指令约只需要小于 10 个时钟周期,锁住内存总线导致其他 CPU 无法访问内存会严重影响系统性能。

因此 split lock 的检测与处理就非常重要,现在的 CPU 支持检测能力,检测到如果在内核态会直接 panic,在用户态则会尝试主动 sleep 来降低 split lock 产生的频率,或者 kill 用户态进程,进而缓解对内存总线的争抢。

在引入了虚拟化后,会尝试在 Host 侧处理,KVM 通知 QEMU 的 vCPU 线程主动 sleep 降低 split lock 产生的频率,甚至 kill 虚拟机。以上的结论也只是截止目前 2022/4/19(下同)的情况,近 2 年社区仍对 split lock 的处理有不同的看法,处理方式也是改变了多次,所以以下的分析仅讨论目前的情况。

1. Split lock 背景

1.1 从 i++说起

我们假设一个最简单的计算模型,一个 CPU(单核、没有开启 Hyper-threading、没有 Cache),一块内存。上面运行一个 C 程序在执行i++,对应的汇编代码是add 1, i

分析一下这里add指令的语义,需要两个操作数,源操作数 SRC 和目的操作数 DEST,实现的功能是DEST = DEST + SRC。这里 SRC 是立即数 1,DEST 是 i 的内存地址,CPU 需要先在内存中读出 i 的内容,然后加 1,最后把结果写入 i 所在的内存地址。总共产生了两次串行的内存操作。

如果计算架构复杂一点,有 2 个 CPU 核 CoreA 和 CoreB 的情况下,上面的i++代码就不得不考虑数据一致性的问题:

1.1.1 并发写问题

如果 CoreA 正在向 i 的内存地址中写入时,CoreB 同时向 i 的内存地址写入怎么办?

深入剖析 split locks,i++ 可能导致的灾难

并发写相同内存地址其实很简单,CPU 从硬件上保证了基础内存操作的原子性。

具体的操作有:

  • 读/写 1 byte
  • 读/写 16 bit 对齐的 2 byte
  • 读/写 32 bit 对齐的 4 byte
  • 读/写 64 bit 对齐的 8 byte
  • 1.1.2 写覆盖问题

    如果 CoreA 从内存中读出 i 后,写入 i 所在内存地址前这段时间内,CoreB 向 i 的内存地址写入数据怎么办?

    深入剖析 split locks,i++ 可能导致的灾难

    这种情况下会导致 CoreB 写入的数据被 CoreA 后面再写入的数据覆盖掉,使 CoreB 的写入数据丢失,而 CoreA 也不知道写入的数据已经在读出后被更新过了。

    为什么会出现这个问题呢?就是因为 ADD 指令不是原子操作,会产生两次内存操作。

    那怎么解决这个问题呢?既然 ADD 指令在硬件上不是原子的,那么就从软件上加锁来实现原子操作,使 CoreB 的的内存操作在 CoreA 的内存操作完成前不能执行。

    深入剖析 split locks,i++ 可能导致的灾难

    对应方法就是声明指令前缀LOCK,汇编代码变为lock add 1, i

    1.2 总线锁

    LOCK指令前缀声明后,随同执行的指令会变为原子指令。原理就是在随同指令执行期间,锁住系统总线,禁止其他处理器进行内存操作,使其独占内存来实现原子操作。

    深入剖析 split locks,i++ 可能导致的灾难

    下面举几个例子:

    1.2.1 QEMU 中的原子累加

    QEMU 中的函数 qatomic_inc(ptr),把参数 ptr 指向的内存数据进行进行加 1。

    
    

    原理是调用 GCC 内置的__sync_fetch_and_add 函数,我们手写一个 C 程序,看下__sync_fetch_and_add 的汇编实现。

    
    
    
    

    可以看到__sync_fetch_and_add 的汇编实现就是在 add 指令前声明了 lock 指令前缀。

    1.2.2 Kernel 中的原子累加

    Kernel 中的 atomic_inc 函数,把参数 v 指向的内存数据进行进行加 1。

    
    

    可以看到,同样是声明了 lock 指令前缀。

    1.2.3 CAS(Compare And Swap)

    编程语言中的 CAS 接口为开发者提供了原子操作,实现无锁机制。

    Golang 的 CAS

    
    

    Java 的 CAS

    
    

    可以看到,CAS 同样是使用 lock 指令前缀来实现的,那么 lock 指令前缀具体是怎么实现的呢?

    1.2.4 LOCK#信号

    具体来说,代码中的指令前面声明了 LOCK 前缀指令后,处理器就会在指令运行期间产生 LOCK#信号,使其他处理器不能通过总线访问内存。

    我们尝试从 8086 CPU 的引脚图中管中窥豹,了解下 LOCK#信号的原理。

    深入剖析 split locks,i++ 可能导致的灾难

    8086 CPU 存在一个 LOCK 引脚(图中 29 号引脚),低电平有效。当声明 LOCK 指令前缀时,会拉低 LOCK 引脚电平,进行 assert 操作,此时其他设备无法获取系统总线的控制权。当 LOCK 指令修饰的指令执行完成后,拉高 LOCK 引脚电平进行 de-assert。

    所以整个流程就清晰了,当想要通过非原子指令(例如 add)实现原子操作时,编程时需要在指令前声明 lock 指令前缀,运行时 lock 指令前缀会被处理器识别出来,并产生 LOCK#信号,使其独占内存总线,而其他处理器则无法通过内存总线访问内存,这样就实现了原子操作。所以也就解决了上面的写覆盖问题了。

    看起来很好,不过这样又引入了一个新问题:

    1.2.5 总线锁引起的性能下降问题

    现在处理器的核越来越多,如果每个核都频繁的产生 LOCK#信号,来独占内存总线,这样其余的核不能访问内存,导致性能会有很大的下降,该怎么办?

    深入剖析 split locks,i++ 可能导致的灾难

    1.3 缓存锁

    INTEL 为了优化总线锁导致的性能问题,在 P6 后的处理器上,引入了缓存锁(cache locking)机制:通过缓存一致性协议保证多个 CPU 核访问跨 cache line 的内存地址的多次访问的原子性与一致性,而不需要锁内存总线。

    1.3.1 MESI 协议

    先以常见的 MESI 简单介绍一下缓存一致性协议。MESI 分为四种状态:

    1. 已修改 Modified (M) 缓存行是脏的(dirty),与主存的值不同。如果别的 CPU 内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).
    2. 独占 Exclusive (E) 缓存行只在当前缓存中,但是干净的(clean)–缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。
    3. 共享 Shared (S) 缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。
    4. 无效 Invalid (I) 缓存行是无效的

    MESI 协议状态机如下:

    深入剖析 split locks,i++ 可能导致的灾难

    状态机的转换基于两种情况:

    1. CPU 产生对 cache 的请求a. PrRd: CPU 请求读一个缓存块b. PrWr: CPU 请求写一个缓存块
    2. 总线产生对 cache 的请求a. BusRd: 窥探器请求指出其他处理器请求读一个缓存块b. BusRdX: 窥探器请求指出其他处理器请求写一个该处理器不拥有的缓存块c. BusUpgr: 窥探器请求指出其他处理器请求写一个该处理器拥有的缓存块d. Flush: 窥探器请求指出请求回写整个缓存到主存e. FlushOpt: 窥探器请求指出整个缓存块被发到总线以发送给另外一个处理器(缓存到缓存的复制)

    简单来说,通过 MESI 协议,每个 CPU 不仅知道自身对 cache 的读写操作,还进行总线嗅探(snooping),可以知道其他 CPU 对 cache 的的读写操作,所以除了自身对 cache 的修改也会根据其他 CPU 对 cache 的修改来改变 cache 的状态。

    1.3.2 缓存锁原理

    缓存锁是依赖缓存一致性协议来保证内存访问的原子性,因为缓存一致性协议会阻止被多个 CPU 缓存的内存地址被多个 CPU 同时修改。

    下面我们以一个例子分析缓存锁是如何基于 MESI 协议实现内存读写的原子性。

    我们还是假设有两个 CPU Core,CoreA 与 CoreB 进行分析。

    深入剖析 split locks,i++ 可能导致的灾难

    注意最后一个操作步骤 4,CoreB 修改 cache 中的数据后,当 CoreA 想再次修改时,会被 CoreB 嗅探到,只有等 CoreB 的数据同步到主存与 CoreA 后,CoreA 才会进行修改。

    可以看到 CoreB 修改的数据没有丢失,被同步给了 CoreA 与主存。并且实现上述的操作没有锁内存总线,只是 CoreA 的修改操作被堵塞了一下,这相比锁整个内存总线是可控的。

    上面是一个比较简单的情况,两个 CPU Core 的写入是串行的。那么如果在操作步骤 2 后,CoreA 与 CoreB 同时下发写请求呢?会产生两个 Core 的 cache 都进入 M 状态吗?

    答案是否定的,MESI 协议保证了上面同时进入 M 的情况不会发生。根据 MESI 协议,一个 Core 的 PrWr 操作只能在其 cache 为 M 或 E 状态时自由的执行,如果是 S 状态,其他 Core 的 cache 必须先被设置为 I 状态,实现的方式是通过一个叫 Request For Ownership(RFO)的总线广播进行的,RFO 是一个总线事务,如果两个 Core 同时向总线进行 RFO 广播都想 Invalid 对方的 cache,总线会进行仲裁,最终结果会是只有一个 Core 广播成功,而另一个 Core 会失败,其 cache 会被设置为 I 状态。所以我们能看到,引入 cache 层后,原子操作由锁内存总线变为了由总线仲裁来实现。

    如果声明了 LOCK 指令前缀,那么对应的 cache 地址会被总线锁定,在上面的例子中,其他 Core 在访问时会等到指令执行结束后再进行访问,也即变为了串行操作,实现了对 cache 读写的原子性。

    那么总结一下缓存锁:在代码指令前面声明了 LOCK 指令前缀,想要原子访问内存数据,如果内存数据可以被缓存在 CPU 的 cache 中,运行时通常不会在总线上产生 LOCK#信号,而是通过缓存一致性协议、总线仲裁机制与 cache 锁定来阻止两个或以上的 CPU 核,对同一块地址的并发访问。

    那么是不是所有的总线锁都可以被优化为缓存锁呢?答案是否定的,不能被优化的情况就是 split lock。

    1.4 Split lock

    由于缓存一致性协议的粒度是一个 cache line,当原子操作的数据跨 cache line 时,依赖缓存锁机制无法保证数据一致性,会退化为总线锁来保证一致性,这种情况就是 split lock,split 也可以理解为访存的 cache 被 split 为两个 line。

    比如有如下数据结构:

    
    

    被缓存到 cache line 大小为 64 字节的 cache 中时,value 成员会跨 cache line。

    深入剖析 split locks,i++ 可能导致的灾难

    此时如果想要通过LOCK ADD 指令操作 Data 结构中的 value 成员,就无法通过缓存锁解决,只能走老路,锁总线来保证数据一致性。

    而锁总线会引起严重的性能下降,访存延迟增加百倍左右,如果是内存密集型业务,性能会下降 2 个数量级。所以在现代 X86 处理器中,要避免写出会产生 split lock 的代码,并有能力检测出 Split lock 的产生。

    2. 避免产生 Split lock

    回顾一下 Split lock 的产生条件:

    1. 对数据执行原子访问
    2. 要访问的数据在 cache 中跨 cache line 存储

    因为原子操作是比较基础的操作,所以我们以数据跨 cache line 存储为介入点进行分析。

    如果数据只存储在一个 cache line 中,那就可以解决问题。

    2.1 编译器优化

    我们前面的数据结构中有用到 __attribute__((packed)) 这个 GCC 的特性,表示不进行内存对齐优化。

    如果不引入 __attribute__((packed)),使用内存对齐优化时,编译器会对内存数据进行填充,比如在 padding 后填入 2 字节,使 value 的内存地址可以被 4 字节整除,从而达到对齐。被缓存到 cache 中时 value 也就不会跨 cache line 了。

    深入剖析 split locks,i++ 可能导致的灾难

    既然编译器可以优化后可以通过内存对齐避免跨 cache line 访问,为什么还要引入 __attribute__((packed))呢?

    这是因为通过 __attribute__((packed)) 强制按数据结构对齐,也有好处。比如基于数据结构的网络通信,不需要填充多余字节等。

    2.2 注意事项

    我们在编写代码过程中,有以下几点需要注意:

    1. 有条件的情况下,尽量使用编译器的内存对齐优化。
    2. 在不能使用编译器优化时,考虑好结构体成员的大小与声明先后顺序。
    3. 在产生可能不对齐的内存访问时,尽量不要使用原子指令来进行访问。

    3. Split lock 的检测与处理

    3.1 使用场景

    1. 硬实时系统:当硬实时应用运行在一些核上,另一个普通程序运行在其他核上,普通程序可以产生 bus lock 来打破硬实时的要求。
    2. 云计算:多租户运行在一个物理机上,一个虚拟机内产生 bus lock 可以干扰其他虚拟机的性能。

    下面主要针对云环境,自底向上进行分析。

    3.2 硬件检测支持

    当尝试 split lock 操作时会产生 Alignment Check (#AC) exception,当获取 bus lock 并执行后会产生 Debug(#DB) trap。

    硬件这里区分下了 split lock 与 bus lock:

  • split lock 指操作数跨两个 cache line 的原子操作
  • bus lock 有两类情况可以产生,要么是 writeback 内存的 split lock,要么是非 writeback 内存的任何 lock 操作
  • 概念上,split lock 是 bus lock 的一种,split lock 倾向于跨 cache line 访问,bus lock 倾向锁总线的操作。

    3.2.1 相关寄存器(MSR)

    发生 split lock 和 bus lock 时是否产生对应的 exception,可以由特定的寄存器控制,下面是相关的控制寄存器。

    1. MSR_MEMORY_CTRL/MSR_TEST_CTRL:33H 这个 MSR 的 bit 29,控制 split lock 引起的#AC exception。

    深入剖析 split locks,i++ 可能导致的灾难

    2. IA32_DEBUGCTL:这个 MSR 的 bit 2,控制 bus lock 引起的#DB exception。

    深入剖析 split locks,i++ 可能导致的灾难

    3.3 内核处理支持

    以 v5.17 版本为例进行分析。当前版本内核支持一个相关启动参数 split_lock_detect,配置项和对应功能如下:

    深入剖析 split locks,i++ 可能导致的灾难

    实现 split_lock_detect 主要分为 3 部分:配置、初始化、处理,下面我们逐项分析一下源码:

    3.3.1 配置

    
    

    内核启动时,会先进行 sld(split lock detect)的 setup。

    static void __init __split_lock_setup(void){        if (!split_lock_verify_msr(false)) {                pr_info("MSR access failed: Disabledn");                return;        }        rdmsrl(MSR_TEST_CTRL, msr_test_ctrl_cache);        if (!split_lock_verify_msr(true)) {                p
    

    来源:字节跳动技术团队

    声明:本站部分文章及图片转载于互联网,内容版权归原作者所有,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

    上一篇 2022年4月6日
    下一篇 2022年4月6日

    相关推荐