问题背景
最近修改了一下调度器负载均衡的逻辑,目的是让普通的task不会运行在有虚拟机vcpu线程的cpu上。但是同事测试的时候发现某些条件下有些普通task会调度到虚机vcpu所在的cpu上,即使此时还有其他的cpu没有运行vcpu线程。
这显然不符合我们最开始的预期,首先我们测试的虚机都是mwait透传的,理论上虚机所在的cpu利用率应该是100%,然后我们的负载均衡优化应该要把普通task迁移到空闲核(没有虚机vcpu线程的cpu)上去运行。
最开始一直怀疑我写的代码逻辑有问题,再次梳理我的代码逻辑以及实际测试还是没发现问题。总之,经过一系列的排查,发现其实是测试机器上虚机的xml配置项有差异,我的机器上的pit的timer配置是<timer name='pit' tickpolicy='discard'/>,而测试有问题的机器上的该配置是<timer name='pit' tickpolicy='delay'/>
本文不详细展开排查过程,只看一下这两个配置项对系统的影响及其过程梳理。
前置知识
中断
与中断相关的一些寄存器:
IRR:记录已到达 Local APIC 但尚未派发给 CPU 核心的中断。
ISR:记录已派发给 CPU 核心、正在被服务的中断。
EOI:通知 APIC 当前中断处理已完成。
TMR:记录每个中断的触发模式(边沿触发或电平触发)。
TPR:设置中断优先级阈值。APIC 不会投递向量号低于 TPR 值的未屏蔽中断。
ICR:发送 IPI(处理器间中断)到其他 CPU 或自身。
一个普通中断的处理过程简化如下:
- 设置IRR中对应bit
- 清除IRR对应bit,设置ISR对应bit
- 执行中断处理程序
- 写EOI,清理ISR中最高位的bit
当然,实际处理过程不是这么简单的,更详细的寄存器和中断处理过程可以查看intel的manual。
中断虚拟化
(AI总结)
在没有AVIC或APICv的早期x86虚拟化环境中,所有中断相关操作都必须由Hypervisor(如KVM)介入模拟,效率很低。
- 模拟设备与APIC:KVM需要在软件层面完整模拟PIC、IOAPIC和每个vCPU的Local APIC。客户机访问虚拟APIC寄存器会触发
VM-Exit,由KVM模拟读写。 - 中断注入的“等待窗口”:当有中断需注入,但客户机当前禁止中断(
RFLAGS.IF=0)时,KVM无法强行注入。它会设置一个 “中断窗口” ,让CPU在客户机打开中断的瞬间触发VM-Exit,KVM再趁机注入。这个过程涉及频繁的VM-Exit和上下文切换,开销巨大。
Intel的硬件方案:APICv
Intel的APIC Virtualization (APICv) 是一系列硬件特性的集合,旨在将中断处理逻辑卸载到CPU硬件上。
- 虚拟APIC页:硬件直接模拟客户机对APIC的访问,无需
VM-Exit。它通过Virtual-interrupt Delivery特性,让硬件自动评估并交付待处理的虚拟中断。 - Posted Interrupt (PI):这是APICv的核心高效机制。Hypervisor只需将中断信息写入内存中的Posted Interrupt Descriptor (PID),然后发送一个物理IPI通知目标CPU。目标CPU的硬件会自动将中断从PID同步到虚拟APIC页并注入客户机,全程无需VM-Exit。
AMD的硬件方案:AVIC
AMD的Advanced Virtual Interrupt Controller (AVIC) 在思路上与Intel APICv类似,但实现细节不同。
- 虚拟APIC后备页:AVIC为每个vCPU提供一个vAPIC backing page,客户机可直接访问特定APIC寄存器,无需Hypervisor模拟。
- 门铃机制:当Hypervisor需要注入中断时,它会设置vAPIC后备页中的IRR位,然后写入一个Doorbell MSR来通知目标物理CPU。目标CPU硬件收到门铃后,会检查vAPIC状态并自动注入中断,同样无需VM-Exit。
流程
首先我们看一下libvirt中对于tickpolicy的描述:
tickpolicy
The tickpolicy attribute determines what happens when QEMU misses a deadline for injecting a tick to the guest. This can happen, for example, because the guest was paused.
delay
Continue to deliver ticks at the normal rate. The guest OS will not notice anything is amiss, as from its point of view time will have continued to flow normally. The time in the guest should now be behind the time in the host by exactly the amount of time during which ticks have been missed.
catchup
Deliver ticks at a higher rate to catch up with the missed ticks. The guest OS will not notice anything is amiss, as from its point of view time will have continued to flow normally. Once the timer has managed to catch up with all the missing ticks, the time in the guest and in the host should match.
merge
Merge the missed tick(s) into one tick and inject. The guest time may be delayed, depending on how the OS reacts to the merging of ticks
discard
Throw away the missed ticks and continue with future injection normally. The guest OS will see the timer jump ahead by a potentially quite significant amount all at once, as if the intervening chunk of time had simply not existed; needless to say, such a sudden jump can easily confuse a guest OS which is not specifically prepared to deal with it. Assuming the guest OS can deal correctly with the time jump, the time in the guest and in the host should now match.
以上可知,当qemu错过了向虚机注入tick的deadline时,如果tickpolicy设置成dealy则还是按照正常速率去发送tick,而tickpolicy如果是设置成discard的话则直接丢掉当前的tick。
我们看一下从libvirt->qemu->kvm是怎么执行这个pit的tickpolicy的,以及这些是怎么影响我们的代码的。
在libvirt的virDomainTimerDefParseXML的函数中,解析timer的name、tickpolicy等属性,结果就是会给起虚机的qmeu命令行中加一条kvm-pit.lost_tick_policy={delay,discard}参数。
在qemu中,通过do_pit_initialize函数来初始化pit,在这里向kvm发送KVM_CREATE_PIT2的ioctl请求,然后就根据xml中设置的pit的tickpolicy来走不同的分支。如果xml中设置的是<timer name='pit' tickpolicy='delay'/>,则直接break;如果xml中设置的是<timer name='pit' tickpolicy='discard'/>,则发送KVM_REINJECT_CONTROL的ioctl。我们看一下这两个不同的分支会导致系统行为上有什么区别。
static void do_pit_initialize(KVMPITState *s, Error **errp)
{
struct kvm_pit_config config = {
.flags = 0,
};
int ret;
ret = kvm_vm_ioctl(kvm_state, KVM_CREATE_PIT2, &config);
if (ret < 0) {
error_setg(errp, "Create kernel PIC irqchip failed: %s",
strerror(-ret));
return;
}
switch (s->lost_tick_policy) {
case LOST_TICK_POLICY_DELAY:
break; /* enabled by default */
case LOST_TICK_POLICY_DISCARD:
if (kvm_check_extension(kvm_state, KVM_CAP_REINJECT_CONTROL)) {
struct kvm_reinject_control control = { .pit_reinject = 0 };
ret = kvm_vm_ioctl(kvm_state, KVM_REINJECT_CONTROL, &control);
if (ret < 0) {
error_setg(errp,
"Can't disable in-kernel PIT reinjection: %s",
strerror(-ret));
return;
}
}
break;
default:
error_setg(errp, "Lost tick policy not supported.");
return;
}
return;
}
我们先看一下KVM_CREATE_PIT2这个ioctl实际上请求kvm做了什么。这个主要是通过kvm_create_pit去创建一个kvm-pit内核线程,然后设置一个hrtimer,这个timer周期性地去让kvm-pit线程触发pit_do_work函数去实际注入中断。
接下来我们看一下KVM_REINJECT_CONTROL这个ioctl在kvm中会做什么:
int kvm_vm_ioctl_reinject(struct kvm *kvm, struct kvm_reinject_control *control)
{
struct kvm_pit *pit = kvm->arch.vpit;
mutex_lock(&pit->pit_state.lock);
kvm_pit_set_reinject(pit, control->pit_reinject);
mutex_unlock(&pit->pit_state.lock);
return 0;
}
static void kvm_pit_set_reinject(struct kvm_pit *pit, bool reinject)
{
struct kvm_kpit_state *ps = &pit->pit_state;
struct kvm *kvm = pit->kvm;
if (atomic_read(&ps->reinject) == reinject)
return;
/*
* AMD SVM AVIC accelerates EOI write and does not trap.
* This cause in-kernel PIT re-inject mode to fail
* since it checks ps->irq_ack before kvm_set_irq()
* and relies on the ack notifier to timely queue
* the pt->worker work iterm and reinject the missed tick.
* So, deactivate APICv when PIT is in reinject mode.
*/
if (reinject) {
kvm_set_apicv_inhibit(kvm, APICV_INHIBIT_REASON_PIT_REINJ);
/* The initial state is preserved while ps->reinject == 0. */
kvm_pit_reset_reinject(pit);
kvm_register_irq_ack_notifier(kvm, &ps->irq_ack_notifier);
kvm_register_irq_mask_notifier(kvm, 0, &pit->mask_notifier);
} else {
kvm_clear_apicv_inhibit(kvm, APICV_INHIBIT_REASON_PIT_REINJ);
kvm_unregister_irq_ack_notifier(kvm, &ps->irq_ack_notifier);
kvm_unregister_irq_mask_notifier(kvm, 0, &pit->mask_notifier);
}
atomic_set(&ps->reinject, reinject);
}
在do_pit_initialize中我们就可以注意到,如果用户的xml中设置的是<timer name='pit' tickpolicy='discard'/>的话,struct kvm_reinject_control control = { .pit_reinject = 0 };将kvm_reinject_control结构体中的pit_reinject变量初始化为0了。所以在使用KVM_REINJECT_CONTROL ioctl到kvm_pit_set_reinject函数中的时候,传入的bool reinject参数应该是0,所以应该走else那个分支,这里清理掉了一些notifier回调并设置kvm->arch.apicv_inhibit_reasons为APICV_INHIBIT_REASON_PIT_REINJ。
我们看一下 kvm_clear_apicv_inhibit(kvm, APICV_INHIBIT_REASON_PIT_REINJ);执行了什么:
void __kvm_set_or_clear_apicv_inhibit(struct kvm *kvm,
enum kvm_apicv_inhibit reason, bool set)
{
unsigned long old, new;
lockdep_assert_held_write(&kvm->arch.apicv_update_lock);
if (!(kvm_x86_ops.required_apicv_inhibits & BIT(reason)))
return;
old = new = kvm->arch.apicv_inhibit_reasons;
if (reason != APICV_INHIBIT_REASON_IRQWIN)
set_or_clear_apicv_inhibit(&new, reason, set);
set_or_clear_apicv_inhibit(&new, APICV_INHIBIT_REASON_IRQWIN,
atomic_read(&kvm->arch.apicv_nr_irq_window_req));
if (!!old != !!new) {
/*
* Kick all vCPUs before setting apicv_inhibit_reasons to avoid
* false positives in the sanity check WARN in vcpu_enter_guest().
* This task will wait for all vCPUs to ack the kick IRQ before
* updating apicv_inhibit_reasons, and all other vCPUs will
* block on acquiring apicv_update_lock so that vCPUs can't
* redo vcpu_enter_guest() without seeing the new inhibit state.
*
* Note, holding apicv_update_lock and taking it in the read
* side (handling the request) also prevents other vCPUs from
* servicing the request with a stale apicv_inhibit_reasons.
*/
kvm_make_all_cpus_request(kvm, KVM_REQ_APICV_UPDATE);
kvm->arch.apicv_inhibit_reasons = new;
if (new) {
unsigned long gfn = gpa_to_gfn(APIC_DEFAULT_PHYS_BASE);
int idx = srcu_read_lock(&kvm->srcu);
kvm_zap_gfn_range(kvm, gfn, gfn+1);
srcu_read_unlock(&kvm->srcu, idx);
}
} else {
kvm->arch.apicv_inhibit_reasons = new;
}
}
主要过程可以概括为:查看set/clear的bit(在这里是APICV_INHIBIT_REASON_PIT_REINJ)前后resons是否发生了变化(这里的变化是指从无到有或者从有到无这种),如果发生了改变,那么就kick所有vcpu,在vcpu重新vcpu_enter_guest的时候会通过kvm_vcpu_update_apicv更新,根据是否开启avic设置kvm_lapic中的相关字段,并通过refresh_apicv_exec_ctrl从硬件层面开/关apicv;且如果改变之后的reasons非0,那么就获取guest中APIC_DEFAULT_PHYS_BASE(这个就是apic相关的起始地址,一个页面大小)的gfn,然后通过把这一个页面给invalidate掉。
#define IO_APIC_DEFAULT_PHYS_BASE 0xfec00000
#define APIC_DEFAULT_PHYS_BASE 0xfee00000
在x86系统上,这俩就分别是ioapic和lapic的默认的物理地址,可以通过cat /proc/iomem看到这些地址
当然,如果acpi表中指定了比如ioapic的地址的话,可能并不是这两个默认地址,可能进行了重映射了
所以到这里我们可以知道,如果设置的是<timer name='pit' tickpolicy='delay'/>,那么就是会set APICV_INHIBIT_REASON_PIT_REINJ,这里会关闭apicv,并解除guest中对于backing page的访问权限;如果设置的是<timer name='pit' tickpolicy='discard'/>,则开启avic(没有其他原因关闭的情况下)。也就是,pit的tickpolicy如果设置的是delay的话,amd平台上会把avic给关闭掉(因为只有amd平台的AVIC_REQUIRED_APICV_INHIBITS才包含APICV_INHIBIT_REASON_PIT_REINJ这个原因),采用软件模拟中断的方式。(这个在kvm_pit_set_reinject函数的注释中也可以看到)
所以我们遇到的问题是怎么发生的呢?
1.amd机器上配置的是<timer name='pit' tickpolicy='delay'/>,虚机启动没有使用avic,只能使用模拟中断的方式
2.kvm准备注入中断,但是发现这时候guest中是关中断的,需要enable_irq_window创建一个中断窗口,等guest开中断时触发vm exit再注入中断(这个可以通过kvm_check_and_inject_events的代码逻辑看到)
3.多个vcpu同时请求irq window的话,可能频繁set/clear APICV_INHIBIT_REASON_IRQWIN这个inhibit reason,导致kvm->arch.apicv_update_lock锁竞争
4.vcpu_enter_guest的时候等锁,vcpu线程睡眠,deactivate_task从rq中摘出,导致我的代码出现问题.
上游应该是已经修了这个问题了,有兴趣可以看一下这个patch:6563ddadd169 (“KVM: SVM: Fix IRQ window inhibit handling across multiple vCPUs”) https://lore.kernel.org/all/20260123224514.2509129-3-seanjc@google.com/
其实到这里,我们还有个疑问:为什么pit的tickpolicy设置成delay就需要关闭avic呢?
其实这个在kvm_pit_set_reinject函数的注释中就已经表达清楚了。AMD AVIC是可以加速写EOI的,也就是不用vm exit就可以写EOI。而这种in-kernel的pit重新注入需要在kvm_set_irq注入之前检查ps->irq_ack,并且依赖对ps->irq_ack的xchg检查。ps->irq_ack的作用是什么?它的值为1时代表上一个中断已经触发了EOI写,执行路径是kvm_ioapic_update_eoi->kvm_ioapic_update_eoi_one->kvm_notify_acked_irq,然后这里通知注册的notifier去执行kvm_pit_ack_irq更新。
所以这里我们可以得出,pit的tickpolicy设置成delay会让中断reinject,中断的reinject依赖kvm感知写EOI,才能在kvm中触发相关的notifier,而AMD的AVIC会加速EOI写而不产生vm exit。因此,开启avic的时候kvm也就没法感知eoi写所以就不支持pit tickpolicy=delay这种方式了。
static void pit_do_work(struct kthread_work *work)
{
struct kvm_pit *pit = container_of(work, struct kvm_pit, expired);
struct kvm *kvm = pit->kvm;
struct kvm_vcpu *vcpu;
unsigned long i;
struct kvm_kpit_state *ps = &pit->pit_state;
if (atomic_read(&ps->reinject) && !atomic_xchg(&ps->irq_ack, 0))
return;
kvm_set_irq(kvm, KVM_PIT_IRQ_SOURCE_ID, 0, 1, false);
kvm_set_irq(kvm, KVM_PIT_IRQ_SOURCE_ID, 0, 0, false);
/*
* Provides NMI watchdog support via Virtual Wire mode.
* The route is: PIT -> LVT0 in NMI mode.
*
* Note: Our Virtual Wire implementation does not follow
* the MP specification. We propagate a PIT interrupt to all
* VCPUs and only when LVT0 is in NMI mode. The interrupt can
* also be simultaneously delivered through PIC and IOAPIC.
*/
if (atomic_read(&kvm->arch.vapics_in_nmi_mode) > 0)
kvm_for_each_vcpu(i, vcpu, kvm)
kvm_apic_nmi_wd_deliver(vcpu);
}
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 857879363@qq.com