KVM VCPU 请求

概述

KVM 支持一个内部 API,该 API 允许线程请求 VCPU 线程执行某些活动。例如,一个线程可以通过 VCPU 请求来请求 VCPU 刷新其 TLB。该 API 包含以下函数

/* Check if any requests are pending for VCPU @vcpu. */
bool kvm_request_pending(struct kvm_vcpu *vcpu);

/* Check if VCPU @vcpu has request @req pending. */
bool kvm_test_request(int req, struct kvm_vcpu *vcpu);

/* Clear request @req for VCPU @vcpu. */
void kvm_clear_request(int req, struct kvm_vcpu *vcpu);

/*
 * Check if VCPU @vcpu has request @req pending. When the request is
 * pending it will be cleared and a memory barrier, which pairs with
 * another in kvm_make_request(), will be issued.
 */
bool kvm_check_request(int req, struct kvm_vcpu *vcpu);

/*
 * Make request @req of VCPU @vcpu. Issues a memory barrier, which pairs
 * with another in kvm_check_request(), prior to setting the request.
 */
void kvm_make_request(int req, struct kvm_vcpu *vcpu);

/* Make request @req of all VCPUs of the VM with struct kvm @kvm. */
bool kvm_make_all_cpus_request(struct kvm *kvm, unsigned int req);

通常,请求者希望 VCPU 在发出请求后尽快执行活动。这意味着大多数请求(kvm_make_request() 调用)之后会紧跟着调用 kvm_vcpu_kick(),而 kvm_make_all_cpus_request() 则内置了对所有 VCPU 的踢出操作。

VCPU 踢出

VCPU 踢出的目的是将 VCPU 线程从客户机模式中唤出,以便执行 KVM 维护操作。为此,会发送一个 IPI,强制进行客户机模式退出。然而,在踢出时 VCPU 线程可能不在客户机模式中。因此,根据 VCPU 线程的模式和状态,踢出可能会采取另外两种行动。所有三种行动如下所示

  1. 发送一个 IPI。这会强制进行客户机模式退出。

  2. 唤醒休眠的 VCPU。休眠的 VCPU 是处于客户机模式之外、并在等待队列上等待的 VCPU 线程。唤醒它们会将线程从等待队列中移除,允许线程再次运行。此行为可能会被抑制,请参见下方的 KVM_REQUEST_NO_WAKEUP。

  3. 什么也不做。当 VCPU 不在客户机模式且 VCPU 线程未休眠时,则无需执行任何操作。

VCPU 模式

VCPU 具有一个模式状态 vcpu->mode,用于跟踪客户机是否在客户机模式下运行,以及一些特定的客户机模式外状态。架构可以使用 vcpu->mode 来确保 VCPU 请求被 VCPU 看到(参见“确保请求被看到”),以及避免发送不必要的 IPI(参见“IPI 减少”),甚至确保等待 IPI 确认(参见“等待确认”)。定义了以下模式

OUTSIDE_GUEST_MODE

VCPU 线程处于客户机模式之外。

IN_GUEST_MODE

VCPU 线程处于客户机模式。

EXITING_GUEST_MODE

VCPU 线程正在从 IN_GUEST_MODE 转换到 OUTSIDE_GUEST_MODE。

READING_SHADOW_PAGE_TABLES

VCPU 线程处于客户机模式之外,但它希望某些 VCPU 请求(即 KVM_REQ_TLB_FLUSH)的发送者等待 VCPU 线程完成页表读取。

VCPU 请求内部实现

VCPU 请求仅仅是 vcpu->requests 位图的位索引。这意味着也可以使用通用位操作,例如 [atomic-ops] 中文档化的那些操作,例如。

clear_bit(KVM_REQ_UNBLOCK & KVM_REQUEST_MASK, &vcpu->requests);

然而,VCPU 请求使用者应避免这样做,因为它会破坏抽象。前 8 位保留用于架构无关请求;所有额外的位可用于架构相关请求。

架构无关请求

KVM_REQ_TLB_FLUSH

KVM 的通用 MMU 通知器可能需要刷新客户机的所有 TLB 条目,通过调用 kvm_flush_remote_tlbs() 来实现。选择使用通用 kvm_flush_remote_tlbs() 实现的架构将需要处理此 VCPU 请求。

KVM_REQ_VM_DEAD

此请求通知所有 VCPU,虚拟机已死且不可用,例如由于致命错误或虚拟机的状态已被有意销毁。

KVM_REQ_UNBLOCK

此请求通知 vCPU 退出 kvm_vcpu_block。例如,它用于代表 vCPU 在主机上运行的计时器处理程序,或用于更新中断路由并确保分配的设备会唤醒 vCPU。

KVM_REQ_OUTSIDE_GUEST_MODE

此“请求”确保目标 vCPU 在请求发送者继续执行之前已退出客户机模式。目标无需采取任何操作,因此实际上没有为目标记录任何请求。此请求类似于“踢出”,但与踢出不同的是,它保证 vCPU 确实已退出客户机模式。踢出仅保证 vCPU 将在未来的某个时间点退出,例如,之前的踢出可能已经启动了该过程,但不能保证将被踢出的 vCPU 已完全退出客户机模式。

KVM_REQUEST_MASK

VCPU 请求在使用位操作之前应该通过 KVM_REQUEST_MASK 进行掩码操作。这是因为只有低 8 位用于表示请求编号。高位用作标志。目前只定义了两个标志。

VCPU 请求标志

KVM_REQUEST_NO_WAKEUP

此标志适用于仅需在客户机模式下运行的 VCPU 立即关注的请求。也就是说,休眠的 VCPU 无需为此类请求而被唤醒。休眠的 VCPU 会在稍后因其他原因被唤醒时处理这些请求。

KVM_REQUEST_WAIT

当带有此标志的请求通过 kvm_make_all_cpus_request() 发出时,调用者将等待每个 VCPU 确认其 IPI 后再继续。此标志仅适用于将接收 IPI 的 VCPU。例如,如果 VCPU 正在休眠,因此不需要 IPI,则请求线程不会等待。这意味着此标志可以安全地与 KVM_REQUEST_NO_WAKEUP 结合使用。有关带有 KVM_REQUEST_WAIT 的请求的更多信息,请参阅“等待确认”。

带有相关状态的 VCPU 请求

希望接收 VCPU 处理新状态的请求者需要确保在接收 VCPU 线程的 CPU 观察到请求时,新写入的状态对它可见。这意味着必须在新状态写入之后和设置 VCPU 请求位之前插入写内存屏障。此外,在接收 VCPU 线程一侧,必须在读取请求位之后和继续读取与之相关的新状态之前插入相应的读屏障。参见 [lwn-mb] 的场景 3,消息和标志,以及内核文档 [memory-barriers]

函数对 kvm_check_request() 和 kvm_make_request() 提供了内存屏障,允许 API 在内部处理此要求。

确保请求被看到

向 VCPU 发出请求时,我们希望避免接收 VCPU 在客户机模式下执行任意长时间而不处理请求。只要我们确保 VCPU 线程在进入客户机模式之前检查 kvm_request_pending(),并且在必要时踢出操作会发送 IPI 以强制退出客户机模式,我们就可以确保这种情况不会发生。必须特别注意覆盖 VCPU 线程最后一次 kvm_request_pending() 检查之后到进入客户机模式之前的这段时间,因为踢出 IPI 只会触发处于客户机模式或至少已禁用中断以准备进入客户机模式的 VCPU 线程退出客户机模式。这意味着优化的实现(参见“IPI 减少”)必须确定何时可以安全地不发送 IPI。除了 s390 之外的所有架构都采用的一个解决方案是

  • 在禁用中断和最后一次 kvm_request_pending() 检查之间将 vcpu->mode 设置为 IN_GUEST_MODE;

  • 在进入客户机时原子地启用中断。

此解决方案还需要在请求线程和接收 VCPU 中小心放置内存屏障。有了内存屏障,我们就可以排除 VCPU 线程在最后一次检查时观察到 !kvm_request_pending(),然后在为其发出的下一个请求(即使该请求是在检查后立即发出的)未收到 IPI 的可能性。这是通过 Dekker 内存屏障模式([lwn-mb] 的场景 10)完成的。由于 Dekker 模式需要两个变量,此解决方案将 vcpu->modevcpu->requests 配对。将它们代入模式得到

CPU1                                    CPU2
=================                       =================
local_irq_disable();
WRITE_ONCE(vcpu->mode, IN_GUEST_MODE);  kvm_make_request(REQ, vcpu);
smp_mb();                               smp_mb();
if (kvm_request_pending(vcpu)) {        if (READ_ONCE(vcpu->mode) ==
                                            IN_GUEST_MODE) {
    ...abort guest entry...                 ...send IPI...
}                                       }

如上所述,IPI 仅对处于客户机模式或已禁用中断的 VCPU 线程有用。这就是为什么 Dekker 模式的这种特定情况已扩展到在将 vcpu->mode 设置为 IN_GUEST_MODE 之前禁用中断。WRITE_ONCE() 和 READ_ONCE() 用于严格实现内存屏障模式,确保编译器不会干扰 vcpu->mode 经过精心规划的访问。

IPI 减少

由于只需一个 IPI 即可使 VCPU 检查任何/所有请求,因此它们可以合并。这很容易通过让第一个发送 IPI 的踢出操作也把 VCPU 模式更改为 !IN_GUEST_MODE 来完成。过渡状态 EXITING_GUEST_MODE 正是为此目的而使用。

等待确认

某些请求(设置了 KVM_REQUEST_WAIT 标志的请求)需要发送 IPI,并且需要等待确认,即使目标 VCPU 线程处于 IN_GUEST_MODE 之外的模式。例如,一种情况是当目标 VCPU 线程处于 READING_SHADOW_PAGE_TABLES 模式时,该模式在禁用中断后设置。为了支持这些情况,KVM_REQUEST_WAIT 标志将发送 IPI 的条件从检查 VCPU 是否处于 IN_GUEST_MODE 更改为检查它是否不处于 OUTSIDE_GUEST_MODE。

无请求的 VCPU 踢出

由于是否发送 IPI 的决定取决于两变量的 Dekker 内存屏障模式,因此很明显,无请求的 VCPU 踢出几乎从不正确。如果没有保证非 IPI 生成的踢出操作仍然会导致接收 VCPU 执行某个动作(就像最终的 kvm_request_pending() 检查对伴随请求的踢出操作所做的那样),那么该踢出可能根本没有任何作用。例如,如果对一个即将将其模式设置为 IN_GUEST_MODE 的 VCPU 发出了一个无请求的踢出操作(这意味着没有发送 IPI),那么 VCPU 线程可能会继续其进入过程,而实际上并未执行该踢出操作旨在启动的任何任务。

一个例外是 x86 的已发布中断机制。然而在这种情况下,即使是无请求的 VCPU 踢出也与上述相同的 local_irq_disable() + smp_mb() 模式相结合;已发布中断描述符中的 ON 位(Outstanding Notification,未决通知)扮演了 vcpu->requests 的角色。发送已发布中断时,在读取 vcpu->mode 之前设置 PIR.ON;同样,在 VCPU 线程中,vmx_sync_pir_to_irr() 在将 vcpu->mode 设置为 IN_GUEST_MODE 后读取 PIR。

其他注意事项

休眠的 VCPU

VCPU 线程可能需要在调用可能使其进入休眠状态的函数(例如 kvm_vcpu_block())之前和/或之后考虑请求。它们是否考虑以及如果考虑则需要考虑哪些请求,取决于架构。kvm_vcpu_block() 调用 kvm_arch_vcpu_runnable() 来检查它是否应该唤醒。这样做的原因之一是为架构提供一个在必要时可以检查请求的函数。

参考资料

[atomic-ops]

Documentation/atomic_bitops.txt 和 Documentation/atomic_t.txt

[memory-barriers]

Documentation/memory-barriers.txt