栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > 资讯 > 高等教育 > 考研政策

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

深入剖析 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 的内存地址写入怎么办?并发写相同内存地址其实很简单,CPU 从硬件上保证了基础内存操作的原子性。具体的操作有:

读/写 1 byte读/写 16 bit 对齐的 2 byte读/写 32 bit 对齐的 4 byte读/写 64 bit 对齐的 8 byte1.1.2 写覆盖问题

如果 CoreA 从内存中读出 i 后,写入 i 所在内存地址前这段时间内,CoreB 向 i 的内存地址写入数据怎么办?这种情况下会导致 CoreB 写入的数据被 CoreA 后面再写入的数据覆盖掉,使 CoreB 的写入数据丢失,而 CoreA 也不知道写入的数据已经在读出后被更新过了。为什么会出现这个问题呢?就是因为 ADD 指令不是原子操作,会产生两次内存操作。那怎么解决这个问题呢?既然 ADD 指令在硬件上不是原子的,那么就从软件上加锁来实现原子操作,使 CoreB 的的内存操作在 CoreA 的内存操作完成前不能执行。对应方法就是声明指令前缀LOCK,汇编代码变为lock add 1, i。

1.2 总线锁

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

1.2.1 QEMU 中的原子累加

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

#define qatomic_inc(ptr)        ((void) __sync_fetch_and_add(ptr, 1))

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

int main() {    int i = 1;    int *p = &i;    while(1) {        __sync_fetch_and_add(p, 1);    }    return 0;}
// add.s        .file   "add.c"        .text        .globl  main        .type   main, @functionmain:.LFB0:        .cfi_startproc        pushq   %rbp        .cfi_def_cfa_offset 16        .cfi_offset 6, -16        movq    %rsp, %rbp        .cfi_def_cfa_register 6        movl    $1, -12(%rbp)        leaq    -12(%rbp), %rax        movq    %rax, -8(%rbp).L2:        movq    -8(%rbp), %rax        lock addl       $1, (%rax)        jmp     .L2        .cfi_endproc.LFE0:        .size   main, .-main        .ident  "GCC: (Debian 6.3.0-18+deb9u1) 6.3.0 20170516"        .section        .note.GNU-stack,"",@progbits

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

1.2.2 Kernel 中的原子累加

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

static __always_inline voidatomic_inc(atomic_t *v){        instrument_atomic_read_write(v, sizeof(*v));        arch_atomic_inc(v);}static __always_inline void arch_atomic_inc(atomic_t *v){        asm volatile(LOCK_PREFIX "incl %0"                     : "+m" (v->counter) :: "memory");}#define LOCK_PREFIX LOCK_PREFIX_HERE "ntlock; "

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

1.2.3 CAS(Compare And Swap)

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

Golang 的 CAS
// bool Cas(int32 *val, int32 old, int32 new)// Atomically://        if(*val == old){//                *val = new;//                return 1;//        } else//                return 0;TEXT ·Cas(SB),NOSPLIT,$0-17        MOVQ        ptr+0(FP), BX        MOVL        old+8(FP), AX        MOVL        new+12(FP), CX        LOCK        CMPXCHGL        CX, 0(BX)        SETEQ        ret+16(FP)        RET
Java 的 CAS
inline jlong    Atomic::cmpxchg    (jlong    exchange_value, volatile jlong*    dest, jlong    compare_value) {  bool mp = os::is_MP();  __asm__ __volatile__ (LOCK_IF_MP(%4) "cmpxchgq %1,(%3)"                        : "=a" (exchange_value)                        : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)                        : "cc", "memory");  return exchange_value;}// Adding a lock prefix to an instruction on MP machine#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

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

1.2.4 LOCK#信号

具体来说,代码中的指令前面声明了 LOCK 前缀指令后,处理器就会在指令运行期间产生 LOCK#信号,使其他处理器不能通过总线访问内存。我们尝试从 8086 CPU 的引脚图中管中窥豹,了解下 LOCK#信号的原理。8086 CPU 存在一个 LOCK 引脚(图中 29 号引脚),低电平有效。当声明 LOCK 指令前缀时,会拉低 LOCK 引脚电平,进行 assert 操作,此时其他设备无法获取系统总线的控制权。当 LOCK 指令修饰的指令执行完成后,拉高 LOCK 引脚电平进行 de-assert。所以整个流程就清晰了,当想要通过非原子指令(例如 add)实现原子操作时,编程时需要在指令前声明 lock 指令前缀,运行时 lock 指令前缀会被处理器识别出来,并产生 LOCK#信号,使其独占内存总线,而其他处理器则无法通过内存总线访问内存,这样就实现了原子操作。所以也就解决了上面的写覆盖问题了。看起来很好,不过这样又引入了一个新问题:

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

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

1.3 缓存锁

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

1.3.1 MESI 协议

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

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

MESI 协议状态机如下:状态机的转换基于两种情况:

CPU 产生对 cache 的请求a. PrRd: CPU 请求读一个缓存块b. PrWr: CPU 请求写一个缓存块总线产生对 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 进行分析。注意最后一个操作步骤 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。比如有如下数据结构:

struct Data {   char padding[62]; // 62字节    int32_t value;  // 4字节} __attribute__((packed)) // 按实际字节对齐

被缓存到 cache line 大小为 64 字节的 cache 中时,value 成员会跨 cache line。此时如果想要通过LOCK ADD 指令操作 Data 结构中的 value 成员,就无法通过缓存锁解决,只能走老路,锁总线来保证数据一致性。而锁总线会引起严重的性能下降,访存延迟增加百倍左右,如果是内存密集型业务,性能会下降 2 个数量级。所以在现代 X86 处理器中,要避免写出会产生 split lock 的代码,并有能力检测出 Split lock 的产生。

2. 避免产生 Split lock

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

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

因为原子操作是比较基础的操作,所以我们以数据跨 cache line 存储为介入点进行分析。如果数据只存储在一个 cache line 中,那就可以解决问题。

2.1 编译器优化

我们前面的数据结构中有用到 __attribute__((packed)) 这个 GCC 的特性,表示不进行内存对齐优化。如果不引入 __attribute__((packed)),使用内存对齐优化时,编译器会对内存数据进行填充,比如在 padding 后填入 2 字节,使 value 的内存地址可以被 4 字节整除,从而达到对齐。被缓存到 cache 中时 value 也就不会跨 cache line 了。既然编译器可以优化后可以通过内存对齐避免跨 cache line 访问,为什么还要引入 __attribute__((packed))呢?这是因为通过 __attribute__((packed)) 强制按数据结构对齐,也有好处。比如基于数据结构的网络通信,不需要填充多余字节等。

2.2 注意事项

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

有条件的情况下,尽量使用编译器的内存对齐优化。在不能使用编译器优化时,考虑好结构体成员的大小与声明先后顺序。在产生可能不对齐的内存访问时,尽量不要使用原子指令来进行访问。3. Split lock 的检测与处理3.1 使用场景硬实时系统:当硬实时应用运行在一些核上,另一个普通程序运行在其他核上,普通程序可以产生 bus lock 来打破硬实时的要求。云计算:多租户运行在一个物理机上,一个虚拟机内产生 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,可以由特定的寄存器控制,下面是相关的控制寄存器。

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

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

3.3 内核处理支持

以 v5.17 版本为例进行分析。当前版本内核支持一个相关启动参数 split_lock_detect,配置项和对应功能如下:实现 split_lock_detect 主要分为 3 部分:配置、初始化、处理,下面我们逐项分析一下源码:

3.3.1 配置
-> start_kernel    -> sld_setup        -> split_lock_setup            -> __split_lock_setup        -> sld_state_setup

内核启动时,会先进行 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)) {                pr_info("MSR access failed: Disabledn");                return;        }                wrmsrl(MSR_TEST_CTRL, msr_test_ctrl_cache);        setup_force_cpu_cap(X86_FEATURE_SPLIT_LOCK_DETECT);}static bool split_lock_verify_msr(bool on){        u64 ctrl, tmp;        if (rdmsrl_safe(MSR_TEST_CTRL, &ctrl))                return false;        if (on)                ctrl |= MSR_TEST_CTRL_SPLIT_LOCK_DETECT;        else                ctrl &= ~MSR_TEST_CTRL_SPLIT_LOCK_DETECT;        if (wrmsrl_safe(MSR_TEST_CTRL, ctrl))                return false;        rdmsrl(MSR_TEST_CTRL, tmp);        return ctrl == tmp;}#define MSR_TEST_CTRL               0x00000033

__split_lock_setup 中尝试 enable/disable 33H MSR 进行 verify,结束也并没有 enable split lock #AC exception,而是仅留下一个全局变量 msr_test_ctrl_cache 当作后面操作这个 MSR 的 cache 使用。

static void __init sld_state_setup(void){        enum split_lock_detect_state state = sld_warn; // 默认配置        char arg[20];        int i, ret;        if (!boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT) &&            !boot_cpu_has(X86_FEATURE_BUS_LOCK_DETECT))                return;        ret = cmdline_find_option(boot_command_line, "split_lock_detect",                                  arg, sizeof(arg));        if (ret >= 0) {                for (i = 0; i < ARRAY_SIZE(sld_options); i++) {                        if (match_option(arg, ret, sld_options[i].option)) {                                state = sld_options[i].state;                                break;                        }                }        }        sld_state = state;}static inline bool match_option(const char *arg, int arglen, const char *opt){        int len = strlen(opt), ratelimit;        if (strncmp(arg, opt, len))                return false;                if (sscanf(arg, "ratelimit:%d", &ratelimit) == 1 &&            ratelimit > 0 && ratelimit <= 1000) {                ratelimit_state_init(&bld_ratelimit, HZ, ratelimit);                ratelimit_set_flags(&bld_ratelimit, RATELIMIT_MSG_ON_RELEASE);                return true;        }        return len == arglen;}

sld_state_setup 做的是解析出内核启动参数 split_lock_detect 的配置(可以看到默认的配置是 warn 级别),如果是 ratelimit 配置,会使用内核的 ratelimit 库初始化一个 bld_ratelimit 全局变量给 handle 阶段用。

3.3.2 初始化
-> start_kernel    -> init_intel        -> split_lock_init        -> bus_lock_init

setup 完成基本的 verify 和获取启动参数配置后,会尝试进行硬件 enbale 操作。

3.3.2.1 split lock init
static void split_lock_init(void){                if (sld_state == sld_ratelimit) {                split_lock_verify_msr(false);                return;        }        if (cpu_model_supports_sld)                split_lock_verify_msr(sld_state != sld_off);}

split lock 的 init 中,如果发现配置的参数是 ratelimit,会 disable split lock 的硬件检测。其他非 off 参数(warn、fatal)则会 enable 硬件。

3.3.2.2 bus lock init
static void bus_lock_init(void){        u64 val;                if (!boot_cpu_has(X86_FEATURE_BUS_LOCK_DETECT) ||            (boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT) &&            (sld_state == sld_warn || sld_state == sld_fatal)) ||            sld_state == sld_off)                return;                rdmsrl(MSR_IA32_DEBUGCTLMSR, val);        val |= DEBUGCTLMSR_BUS_LOCK_DETECT;        wrmsrl(MSR_IA32_DEBUGCTLMSR, val);}#define DEBUGCTLMSR_BUS_LOCK_DETECT (1UL <<  2)

bus lock 的 init 中,如果 CPU 不支持 bus lock 则不会 enable bus lock 的硬件检测,如果 CPU 同时支持 split lock 与 bus lock 的硬件检测并且配置参数还是 warn 或 fatal 时也不会 enable 硬件。所以从代码看 enable CPU 的 bus lock 检测情况有两种:一是 CPU 支持 bus lock 检测的情况下并且配置参数指定为 ratelimt,二是 CPU 不支持 split lock 检测但支持 bus lock 检测并且配置参数非 off。

3.3.3 处理3.3.3.1 split lock handle
DEFINE_IDTENTRY_ERRORCODE(exc_alignment_check){        char *str = "alignment check";        if (notify_die(DIE_TRAP, str, regs, error_code, X86_TRAP_AC, SIGBUS) == NOTIFY_STOP)                return;        if (!user_mode(regs)) // 如果不是用户态                die("Split lock detectedn", regs, error_code);        local_irq_enable();        if (handle_user_split_lock(regs, error_code))                goto out;        do_trap(X86_TRAP_AC, SIGBUS, "alignment check", regs,                error_code, BUS_ADRALN, NULL);out:        local_irq_disable();}bool handle_user_split_lock(struct pt_regs *regs, long error_code){        if ((regs->flags & X86_EFLAGS_AC) || sld_state == sld_fatal)                return false;        split_lock_warn(regs->ip);        return true;}static void split_lock_warn(unsigned long ip){        pr_warn_ratelimited("#AC: %s/%d took a split_lock trap at address: 0x%lxn",                            current->comm, current->pid, ip);                sld_update_msr(false);        set_tsk_thread_flag(current, TIF_SLD);}

#AC exception 产生后,如果不是用户态,会调用 die,进入 panic 流程。如果是在用户态,那么配置为 fatal 时就会向当前用户态进程发送 SIGBUS 信号,用户态进程不手动捕获 SIGBUS 就会被 kill。如果是用户态,配置为 warn 时,会打印一条警告日志并输出当前进程信息,同时 disable split lock 的检测,并通过设置当前进程 flags 的 TIF_SLD 位表示这个进程已经被检测过一次了。

-> context_switch    -> __switch_to_xtravoid __switch_to_xtra(struct task_struct *prev_p, struct task_struct *next_p){        unsigned long tifp, tifn;        tifn = read_task_thread_flags(next_p);        tifp = read_task_thread_flags(prev_p);        if ((tifp ^ tifn) & _TIF_SLD)                switch_to_sld(tifn);}void switch_to_sld(unsigned long tifn){        sld_update_msr(!(tifn & _TIF_SLD));}

在 context_switch 进行进程切换时,如果 prev 进程和 next 进程的 flags 中 TIF_SLD 位不同,那么就会进行 split lock detect 的切换,切换的依据是 next 进程的 TIF_SLD 位。举个例子,CPU 0 上进程 A 在触发了一次 split lock 的 warn 检测后,CPU 0 的 split lock 检测会被 disable,避免频繁产生警告日志,进程 A 的 flags 置位 TIF_SLD,进程 A 执行完成后,切换到进程 B 运行在 CPU 0 上,切换时由于 A 与 B 的 flags 的 TIF_SLD 位不同,我们就再根据进程 B 的 flags 来 enable split lock 的检测,进程 B 执行完成后,再次切换到进程 A 运行时,会再 disable split lock。上面的机制就实现了每个进程只 warn once 的需求。

3.3.3.2 bus lock handle
static __always_inline void exc_debug_user(struct pt_regs *regs,                                           unsigned long dr6){                if (dr6 & DR_BUS_LOCK)                handle_bus_lock(regs);}void handle_bus_lock(struct pt_regs *regs){        switch (sld_state) {        case sld_off:                break;        case sld_ratelimit:                                while (!__ratelimit(&bld_ratelimit))                        msleep(20);                                fallthrough;        case sld_warn:                pr_warn_ratelimited("#DB: %s/%d took a bus_lock trap at address: 0x%lxn",                                    current->comm, current->pid, regs->ip);                break;        case sld_fatal:                force_sig_fault(SIGBUS, BUS_ADRALN, NULL);                break;        }}

#DB trap 产生后,warn 和 fatal 的流程和#AC 基本差不多,我们这里重点分析 ratelimit,这也是引入 bus lock 的一个强需求。强制把 bus lock 产生的频率降低到配置的 ratelimit。原理就是如果频率超出设定,就直接 sleep 20 ms,直到频率降下来(这里的频率是整个系统产生 bus lock 的频率)。降频过后通过编译器的 fallthrough 流入 sld_warn case 产生一条警告日志。

4. 虚拟化环境的检测与处理

前面的分析都是针对物理机上的内核与用户态程序(VMX root mode)。在虚拟化环境中需要再考虑一些问题,比如如果 split lock 来自于 Guest 中,Host 如何检测?怎么避免对其他 Guest 的影响?如果直接在 Host 上 enable bus lock ratelimit,可能会影响没有准备好的 Guest。如果直接把 split lock 的检测开关暴露给 Guest,Host 或其他 Guest 怎么处理等等。CPU 可以在 VMX mode 下支持 split lock 检测的#AC trap,后面具体怎么做由 hypervisor 决定。大部分 hypervisor 会直接把 trap 转发到 Guest 中,如果 Guest 没有准备好,可能会产生 crash。之前的 VMX 版本中就有这个问题。因此首先就要让 hypervisor 正确处理 trap。

4.1 虚拟化环境处理流程

下面是虚拟化环境对 split lock 与 bus lock 的整体处理流程图:Guest 中尝试 split lock 操作,会 vm exit 到 kvm 中,如果硬件不支持 split lock 检测或者为 legacy #AC,会把#AC 注入给 Guest 处理,如果硬件支持检测,那么会根据配置产生警告,甚至尝试 SIGBUS。Guest 进行 bus lock 后,会 vm exit 到 kvm 中,kvm 通知 QEMU,vCPU 线程会主动进行 sleep 降频。下面我们再自底向上进行分析。

4.2 硬件支持4.2.1 split lock

#AC exception 被包含在 NMI exit reason 里面。

4.2.2 bus lock

在 VMX non-root mode 下,CPU 检测到 bus lock 后会产生 VM exit,reason 为 74。

4.3 KVM 支持
-> vmx_handle_exit    -> __vmx_handle_exit        -> kvm_vmx_exit_handlers            -> handle_exception_nmi // split lock            -> handle_bus_lock_vmexit // bus lock
4.3.1 split lock
static int handle_exception_nmi(struct kvm_vcpu *vcpu){        switch (ex_no) {        case AC_VECTOR:                if (vmx_guest_inject_ac(vcpu)) {                        kvm_queue_exception_e(vcpu, AC_VECTOR, error_code);                        return 1;                }                                if (handle_guest_split_lock(kvm_rip_read(vcpu)))                        return 1;}// Guest handlebool vmx_guest_inject_ac(struct kvm_vcpu *vcpu){        if (!boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT))                return true;        return vmx_get_cpl(vcpu) == 3 && kvm_read_cr0_bits(vcpu, X86_CR0_AM) &&               (kvm_get_rflags(vcpu) & X86_EFLAGS_AC);}// KVM handlebool handle_guest_split_lock(unsigned long ip){        if (sld_state == sld_warn) {                split_lock_warn(ip);                return true;        }        pr_warn_once("#AC: %s/%d %s split_lock trap at address: 0x%lxn",                     current->comm, current->pid,                     sld_state == sld_fatal ? "fatal" : "bogus", ip);        current->thread.error_code = 0;        current->thread.trap_nr = X86_TRAP_AC;        force_sig_fault(SIGBUS, BUS_ADRALN, NULL);        return false;}

Guest 内部产生 split lock 操作时,由于是#AC exception,会 VM exit 出来。这里首先要说明一下#AC exception 本身有两种类型:

legacy #AC exceptionsplit lock #AC exception

KVM 根据 Host 和 Guest 状态最终产生两种行为:

让 Guest 处理:把#AC 注入到 Guest 中。如果硬件不支持 split lock 检测,无条件注入到 Guest 中。如果 Host enable 了 split lock 检测,则只有在产生#AC exception 为 Guest 用户态且为 legacy #AC 情况下才会注入到 Guest 中。让 HOST 处理:产生警告甚至发送 SIGBUS。不注入到 Guest 中时,Host 如果配置的是 warn 则每个 vCPU 线程只 warn 一次,如果配置的是 fatal 则产生 SIGBUS。4.3.2 bus lock
#define EXIT_REASON_BUS_LOCK            74static int (*kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {    [EXIT_REASON_BUS_LOCK]                = handle_bus_lock_vmexit,}static int handle_bus_lock_vmexit(struct kvm_vcpu *vcpu){                to_vmx(vcpu)->exit_reason.bus_lock_detected = true;        return 1;}union vmx_exit_reason {        struct {                u32        basic                        : 16;                u32        reserved16                : 1;                u32        reserved17                : 1;                u32        reserved18                : 1;                u32        reserved19                : 1;                u32        reserved20                : 1;                u32        reserved21                : 1;                u32        reserved22                : 1;                u32        reserved23                : 1;                u32        reserved24                : 1;                u32        reserved25                : 1;                u32        bus_lock_detected        : 1;                u32        enclave_mode                : 1;                u32        smi_pending_mtf                : 1;                u32        smi_from_vmx_root        : 1;                u32        reserved30                : 1;                u32        failed_vmentry                : 1;        };        u32 full;};

VM exit 后,执行根据 reason 74 索引的函数 handle_bus_lock_vmexit,其中仅仅是把 exit_reason 的 bus_lock_detected 置位。

static int vmx_handle_exit(struct kvm_vcpu *vcpu, fastpath_t exit_fastpath){        int ret = __vmx_handle_exit(vcpu, exit_fastpath);                if (to_vmx(vcpu)->exit_reason.bus_lock_detected) {                if (ret > 0)                        vcpu->run->exit_reason = KVM_EXIT_X86_BUS_LOCK;                vcpu->run->flags |= KVM_RUN_X86_BUS_LOCK;                return 0;        }        return ret;}

执行完__vmx_handle_exit 后,检测到 bus_lock_detected 置位后会设置返回给用户态的 exit_reason 和 flags。

4.4 QEMU 支持

以 v6.2.0 版本为例进行分析:

-> kvm_cpu_exec    -> kvm_vcpu_ioctl    -> kvm_arch_post_run    -> kvm_arch_handle_exitint kvm_arch_handle_exit(CPUState *cs, struct kvm_run *run){    switch (run->exit_reason) {        case KVM_EXIT_X86_BUS_LOCK:                        ret = 0;            break;    }}MemTxAttrs kvm_arch_post_run(CPUState *cpu, struct kvm_run *run) {    if (run->flags & KVM_RUN_X86_BUS_LOCK) {        kvm_rate_limit_on_bus_lock();    }}static void kvm_rate_limit_on_bus_lock(void){    uint64_t delay_ns = ratelimit_calculate_delay(&bus_lock_ratelimit_ctrl, 1);    if (delay_ns) {        g_usleep(delay_ns / SCALE_US);    }}

QEMU 通过 KVM 返回值了解到 Guest 中有 bus lock 产生,进入 kvm_rate_limit_on_bus_lock 中,也是通过 sleep 实现 ratelimit,达到降低 Guest 产生 bus lock 频率的效果。Host 上的 ratelimit 是通过 split_lock_detect 启动参数来控制的,那么 Guest 呢?

int kvm_arch_init(MachineState *ms, KVMState *s) {    if (object_dynamic_cast(OBJECT(ms), TYPE_X86_MACHINE)) {        X86MachineState *x86ms = X86_MACHINE(ms);        if (x86ms->bus_lock_ratelimit > 0) {            ret = kvm_check_extension(s, KVM_CAP_X86_BUS_LOCK_EXIT);            if (!(ret & KVM_BUS_LOCK_DETECTION_EXIT)) {                error_report("kvm: bus lock detection unsupported");                return -ENOTSUP;            }            ret = kvm_vm_enable_cap(s, KVM_CAP_X86_BUS_LOCK_EXIT, 0,                                    KVM_BUS_LOCK_DETECTION_EXIT);            if (ret < 0) {                error_report("kvm: Failed to enable bus lock detection cap: %s",                             strerror(-ret));                return ret;            }            ratelimit_init(&bus_lock_ratelimit_ctrl);            ratelimit_set_speed(&bus_lock_ratelimit_ctrl,                                x86ms->bus_lock_ratelimit, BUS_LOCK_SLICE_TIME);        }    }}static void x86_machine_get_bus_lock_ratelimit(Object *obj, Visitor *v,                                const char *name, void *opaque, Error **errp){    X86MachineState *x86ms = X86_MACHINE(obj);    uint64_t bus_lock_ratelimit = x86ms->bus_lock_ratelimit;    visit_type_uint64(v, name, &bus_lock_ratelimit, errp);}static void x86_machine_class_init(ObjectClass *oc, void *data){    object_class_property_add(oc, X86_MACHINE_BUS_LOCK_RATELIMIT, "uint64_t",                                x86_machine_get_bus_lock_ratelimit,                                x86_machine_set_bus_lock_ratelimit, NULL, NULL);}#define X86_MACHINE_BUS_LOCK_RATELIMIT  "bus-lock-ratelimit"

QEMU 对 Guest bus lock 的 ratelimit 从启动参数 bus-lock-ratelimit 中获取。

4.5 Libvirt 支持

Libvirt 支持 QEMU 的 bus-lock-ratelimit 启动参数目前还没 upsteam:https://listman.redhat.com/archives/libvir-list/2021-December/225755.html

5. 总结

由于 X86 硬件的特性,支持跨 cache line 的原子语义,实现上需要用 split lock 维持原子性,但这却需要以整个系统的访存性能为代价。开发者逐渐意识到这个 feature 害人不浅,尽量不去使用,比如内核的开发者保证自己不会产生 split lock,甚至不惜内核 panic。而用户态程序则会产生警告,然后降低程序的执行频率或者由内核进行 kill。虚拟化环境由于软件栈较多,会尽量由 Host 侧 KVM 与 QEMU 处理,进行告警甚至 kill 虚拟机,或通知 QEMU 进行降频。

Referenceshttps://www.agner.org/optimize/instruction_tables.pdfhttps://www.kernel.org/doc/html/latest/x86/buslock.htmlhttps://en.wikipedia.org/wiki/Cache_coherencehttps://lwn.net/Articles/790464/https://lwn.net/Articles/850011/https://lwn.net/Articles/816918/https://lore.kernel.org/all/20200410115517.176308876@linutronix.de/T/#mb0a765c7b9799d1a06d54d31f4a47db15c01ecdehttps://lore.kernel.org/all/20200131200134.GD18946@linux.intel.com/T/#u
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/news/1527686.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号