
虚拟化是支撑现代云计算的技术,它完全通过软件创建出完整、隔离的计算机。多年来,被称为完全虚拟化的标准方法依赖于“陷阱-模拟”(trap-and-emulate)模型,其中客户机操作系统是一个无感知的参与者,这导致了显著的性能开销。本文通过探讨一种更优雅的解决方案——半虚拟化——来解决这个根本性的效率问题。半虚拟化并非采用欺骗的方式,而是在客户机操作系统和 hypervisor 之间建立一种协作式对话。在接下来的章节中,您将了解这种对话背后的核心原理,并看到这种协作如何被应用于解决虚拟系统中最棘手的一些挑战。首先,在“原理与机制”一章中,我们将深入探讨超调用(hypercall)的艺术和协作式设计的哲学。然后,在“应用与跨学科联系”一章中,我们将探讨这些原理如何释放接近本机的 I/O 性能、实现智能资源管理,乃至增强系统安全性。让我们从理解使这种强大方法成为可能的根本性思维转变开始。
要理解半虚拟化的精妙之处,我们必须首先领会所有虚拟化技术核心的那个宏大幻象。hypervisor(或虚拟机监视器,VMM)的目标是凭空,或者更确切地说,利用单一物理宿主机的资源,凭空变出一台功能齐全的计算机——拥有自己的处理器、内存和设备。几十年来,实现这一目标的主流哲学是我们所谓的“严格裁判”模型,其正式名称为完全虚拟化。在这个模型中,客户机操作系统是一个不知情的参与者。它相信自己运行在真实硬件上,并像往常一样发出命令。hypervisor 像一个警惕的裁判,让大多数操作通过,但会对任何“特权”指令——即客户机试图接触真实硬件的指令——吹哨。当这种情况发生时,硬件会触发一个陷阱(trap),即一次虚拟机退出(VM exit),控制权被移交给 hypervisor,由其模拟预期的效果,然后恢复客户机的运行。这种陷阱-模拟方法非常巧妙,它允许未经修改的操作系统(比如您的标准 Windows 桌面版)在虚拟机中运行。但这是有代价的。
每一次陷阱都是一次世界切换(world-switch),一次从客户机现实到 hypervisor 现实的昂贵上下文切换。想象一下,在一场戏剧中,每当演员需要拿起一个道具,戏剧就会暂停,场内灯光亮起,一个舞台工作人员走上台把道具递给他。整个流程不断被打断。对于每秒发生数千或数百万次的操作来说,这种开销可能是毁灭性的。这就是半虚拟化登场的地方,它不是一个更严格的裁判,而是演出的合作者。
半虚拟化改变了客户机和 hypervisor 之间的根本关系。它摒弃了幻象,开启了一场开放的、协作式的对话。客户机操作系统经过修改——它被告知自己生活在一个虚拟世界中。客户机不再尝试执行那些它知道会失败并引发陷阱的特权操作,而是直接请求 hypervisor 的帮助。这种明确的请求就是超调用(hypercall)。
超调用之于 hypervisor,就如同系统调用之于操作系统内核:一个通往更高特权层的、正式、高效、由软件定义的入口点。通过用精简的、软件定义的调用取代笨拙的、硬件驱动的陷阱,我们通常可以实现显著的性能提升。考虑一个简单而频繁的操作,比如通过 CLI 指令禁用中断。在陷阱-模拟的世界里,这会引起一次完整的虚拟机退出。而在半虚拟化的世界里,客户机则进行一次超调用。预期的成本节省 可以建模为陷阱-模拟序列的成本()减去软件超调用的成本(),再加上或减去其他因素,如流水线停顿和模拟逻辑。其关键洞见在于,软件路径几乎总是比完整的硬件上下文切换更直接、干扰更小。
但半虚拟化的真正艺术不仅在于创建超调用,更在于知道何时使用——以及何时不使用它们。想象一下,我们正在为客户机操作系统设计一个用于定时器和中断的半虚拟化接口。客户机需要读取当前时间、设置一个未来的定时器中断,并确认一个已送达的中断。这些操作中,哪些应该设计为超调用?
指导原则是:最小化到 hypervisor 的转换。超调用比陷阱便宜,但仍远比一次简单的内存读取昂贵。因此,我们必须区分那些改变宿主机状态的行为和仅仅读取信息的行为。
pv_set_timer)需要 hypervisor 在宿主机上编程一个真实的物理定时器。这是一个改变状态的操作,必须有 hypervisor 的干预。因此,它必须是一个超调用。pv_ack_interrupt)会通知 hypervisor,客户机已经处理完一个事件,从而允许 hypervisor 更新其状态,并可能取消对物理中断线的屏蔽。这也改变了宿主机可见的状态,因此必须是一个超调用。pv_get_time_ns)是一个只读操作。如果我们将它实现为一个超调用,那么每当客户机的调度器想要检查时间——可能每秒数十万次——就会触发一次昂贵的世界切换。一个远为优雅的解决方案是,让 hypervisor 维护一个与客户机共享的内存页,并不断用当前时间更新它。客户机随后只需通过一次简单的、极速的内存访问即可读取该值,无需超调用。这个简单的例子揭示了半虚拟化的核心哲学:一种深思熟虑的、协作式的设计,它区分只读操作和状态改变操作,仅在绝对必要时才使用超调用,并尽可能利用共享内存等巧妙技巧来避免它们。
借助这种协作哲学,半虚拟化为虚拟化系统中的一些最臭名昭著的性能瓶颈提供了优雅的解决方案。这些问题源于“语义鸿沟”——即 hypervisor 在硬件层面看到一种情况,而客户机的意图却完全是另一回事。
考虑自旋锁(spinlock),这是一种常见的同步原语,其中等待锁的处理器核心只是在一个紧凑的循环中自旋,反复检查锁的状态。在物理机上,如果锁的持有时间非常短,这是合理的;自旋的 CPU 保持着“热”状态并随时待命,避免了昂贵的上下文切换。
在虚拟机中,这可能是灾难性的。想象一个客户机有两个虚拟 CPU(vCPU),A 和 B,运行在一个只有一个物理 CPU(pCPU)的宿主机上。vCPU A 获取了一个锁,然后被 hypervisor 抢占,hypervisor 决定调度 vCPU B。vCPU B 现在开始运行并尝试获取同一个锁。它发现锁被持有,于是开始自旋。从 hypervisor 的角度来看,vCPU B 正在以 100% 的利用率运行,是一个非常繁忙和重要的 vCPU!它会很乐意地将完整的时间片分配给 vCPU B。但这简直是一场灾难。vCPU B 正在为一个由 vCPU A 持有的锁而自旋,而 vCPU A 无法运行来释放这个锁,因为它没有被调度到 pCPU 上。自旋者正在主动阻止锁持有者取得进展。这就是锁持有者抢占(lock-holder preemption)问题。
半虚拟化解决方案异常简单:修改客户机的自旋锁。在短暂自旋后,它不再继续燃烧 CPU 周期,而是进行一次超调用:H_yield。这等于告诉 hypervisor:“我知道我看起来很忙,但实际上我只是在等待另一个 vCPU。请取消我的调度,去运行其他任务。”这弥合了语义鸿沟。hypervisor 现在理解了客户机的真实意图,可以调度另一个 vCPU(理想情况下是锁的持有者!),从而将一个病态低效的场景转变为一个高效的场景。这个简单的让步(yield)可以将浪费的 CPU 时间从整个时间片(毫秒级)降低到单次超调用(微秒级)的微小成本。
在完全虚拟化中,一个更严重的性能问题是 I/O。模拟网卡或硬盘的成本极高。在最简单的模型中,每一次对设备 I/O 端口的读写都可能导致一次虚拟机退出。一个执行频繁网络或磁盘 I/O 的工作负载几乎会把所有时间都花费在进出 hypervisor 的转换上,导致性能彻底瘫痪。
半虚拟化通过一个通常被称为 virtio 的框架来解决这个瓶颈。其思想同样基于协作和批处理。hypervisor 和客户机不再模拟一个具有各种奇特寄存器的真实硬件,而是约定一种标准化的、简化的、基于内存的数据结构:一组共享内存环或队列。
当客户机想要发送一个网络数据包时,它不会写入一个模拟的 I/O 端口。相反,它将数据包的描述符放入内存中的共享队列。它可以将几十甚至几百个这样的请求排入队列。然后,仅当队列已满或需要立即响应时,它才通过一次超调用给 hypervisor 一个“提醒”(kick)。hypervisor 醒来后,一次性处理共享队列中的整批请求,将结果放回另一个共享队列,并向客户机发送一个单一的通知。
其结果是虚拟机退出(VM exit)的分布发生了戏剧性的变化。对于 I/O 密集型工作负载,启用半虚拟化驱动程序会使 IO 端口退出的数量急剧下降,而 hypercall 退出的数量则略有上升。最终效果是退出总数的大幅减少,从而实现接近本机的 I/O 性能。这种方法非常有效,如今即使对于硬件辅助的虚拟机也已成为标准。现代系统通常使用硬件支持(HVM)运行未经修改的客户机操作系统,但会为网络和磁盘安装特殊的半虚拟化驱动程序,以兼得两者的优点:兼容性和性能。
随着客户机和 hypervisor 之间对话的日益复杂,人们清楚地认识到,半虚拟化不仅仅是性能优化技巧。它关乎于在两个软件层之间定义一个正式、稳定且可靠的契约。这个契约不仅管理性能,还涉及发现、正确性和安全性。
客户机操作系统如何知道自己正运行在 hypervisor 上,以及该 hypervisor 使用哪种半虚拟化的“方言”?它不能简单地做出假设。早期,客户机会寻找一些微妙的线索。例如,用于识别处理器功能的 x86 CPUID 指令包含一个“hypervisor 存在”位。但依赖这种通用标志是脆弱的。为了兼容性,hypervisor 可能会隐藏该位,或者一个 bug 可能导致它在实时迁移过程中闪烁不定。
稳健的解决方案,也是今天所使用的方案,是一个明确的协商协议。客户机操作系统探测一个特殊的、保留的 CPUID 叶范围(例如,从 0x40000000 开始)。如果得到响应,它就可以读取 hypervisor 的供应商名称(例如,“KVMKVMKVM”或“XenVMMXenVMM”),然后查询另一个叶以获取支持的半虚拟化功能及其版本的位图。
这个协商过程建立了契约。一旦客户机和 hypervisor 同意使用某个功能,比如半虚拟化时钟(pvclock),该契约就必须被遵守。客户机应该继续信任并使用该功能,直到 hypervisor 通过一个明确定义的通知机制显式地撤销它。它不应仅仅因为其他一些不相关的架构标志发生变化而被放弃。这一原则确保了稳定性,尤其是在将虚拟机从一个物理主机实时迁移到另一个主机的复杂操作期间。契约一旦订立,便是事实的根源。
一个设计糟糕的契约可能比没有契约更糟。hypervisor 提供的原语必须是可证明正确的,即使在任意抢占和多处理器竞争条件等压力下也是如此。
回到我们用于自旋锁的 yield 超调用,一个简单的实现可能导致“丢失唤醒”(lost wakeup)的竞争条件。一个线程可能会检查一个锁,发现它正忙,于是决定进入睡眠。但如果 hypervisor 恰好在它执行 yield 超调用之前抢占了它,另一个线程可能会释放该锁并发出一个唤醒信号。当第一个线程最终被重新调度时,它将继续执行 yield 并进入睡眠,从而永远错过了那个唤醒调用。
为了防止这种情况,契约必须提供原子操作。一个现代的半虚拟化接口提供了一个超调用,它将检查和睡眠合并为一个不可分割的操作:“进入睡眠,但前提是这个内存位置仍然包含这个期望值。” 这是诸如 Linux 的 futex 等机制的基础,它通过消除竞争条件来保证正确性。
最后,契约必须是安全的。每一次超调用都是一个潜在的信息泄露通道,信息可能在理论上隔离的虚拟机之间泄露。一个看似无害的获取当前时间的调用,可能被攻击者用来构建宿主机调度活动的高分辨率图像,从而推断出其他虚拟机正在做什么。一个安全的半虚拟化契约通过塑造其提供的信息来缓解这个问题。对于获取一天中时间的超调用,hypervisor 可能会将返回的时间量化到一个粗糙的粒度(例如,毫秒而不是纳秒),并严格限制客户机调用它的频率。这增加了噪声并降低了旁路通道的带宽,从而在客户机的时间保持需求与系统的安全需求之间取得平衡。
归根结底,半虚拟化的发展历程是一个思想的美妙演变。它始于一个简单的合作请求,以克服陷阱-模拟模式的僵化低效。它成熟为一种丰富的语言,用于解决 I/O、内存管理 和调度中的复杂性能问题。最终,它促成了一个稳健、安全、正式的契约的建立,该契约支撑着整个现代云计算。它教给我们一个深刻的系统设计教训:有时,管理复杂系统最优雅的方式不是通过刚性强制,而是通过智能的、协作式的对话。
虽然前面的章节详细介绍了半虚拟化的机制,如超调用和共享内存,但这种方法的意义在其际应用中最为明显。半虚拟化所促成的协作式对话不仅仅是一个技术细节;它是一种合作的语言,解决了性能、资源管理和安全领域中深刻而微妙的问题。
这种对话恢复了客户机对底层机器的“感觉”,这种感知在完全虚拟化的隔离中是缺失的。通过将关系从欺骗转变为合作,半虚拟化使整个系统能够更具凝聚力、更高效地运行。本节探讨了客户机操作系统与 hypervisor 之间的这些对话如何被应用于恢复性能和智能地管理共享资源。
半虚拟化最直接、最著名的应用就是对速度的不懈追求。当您将一个操作系统放入虚拟机时,其最痛苦的盲点是输入/输出(I/O)。操作系统习惯于直接与硬件对话——网卡、磁盘控制器等等。在一个纯粹虚拟化的世界里,hypervisor 必须煞费苦心地模拟物理设备的每一个寄存器和行为。想象一下,试图通过让一个翻译向钢琴家描述每一次按键来弹奏钢琴。这既缓慢又笨拙,并且在翻译上消耗了巨大的精力。
这就是像 virtio 这样的接口形式的半虚拟化发挥作用的地方。它相当于说:“与其假装成一架特定、笨重的老式钢琴,不如我们发明一种新的、简单得多的乐器,客户机和 hypervisor 都已经知道如何演奏它。” 这种 virtio 乐器是为纯粹的效率而设计的。
其结果是,在将虚拟机连接到外部世界(例如,通过网卡)时,出现了一系列引人入胜的选择。一端是完全模拟:它速度极慢,但与任何现成的操作系统都兼容。另一端是直接硬件穿透(如 SR-IOV),这就像给客户机一张自己的物理网卡。它速度极快,但刚性强且灵活性差。半虚拟化则开辟了一个完美的中间地带。它提供的性能诱人地接近直接硬件访问,同时保留了基于软件的解决方案的灵活性。没有单一的“最佳”选择;相反,在延迟和 CPU 成本之间存在一系列最优的权衡,每种方法都有其发光的领域。
同样的原则也同样适用于存储。无论您是从磁盘读取文件还是通过网络发送数据包,模拟的根本瓶颈是相同的。像 [virtio](/sciencepedia/feynman/keyword/virtio)-blk 和 [virtio](/sciencepedia/feynman/keyword/virtio)-scsi 这样的半虚拟化存储接口提供了专门的高速队列,大幅削减了虚拟磁盘访问的开销,从而实现了可扩展的性能,这在使用简单模拟时是不可想象的。
但事情变得更加微妙。半虚拟化不仅仅是让事情变快;它还使它们变得可调。考虑一下到达虚拟机的网络数据包流。hypervisor 可以为每一个数据包中断客户机。这能给你带来最低的延迟——非常适合像在线游戏或高频交易这样的应用。但每次中断都是一次上下文切换,一次代价高昂的干扰。但如果你正在进行大规模文件下载,其中原始吞吐量才是关键,每个数据包多几微秒的延迟无关紧要呢?半虚拟化接口提供了一个调节旋钮,通常称为“中断合并”,它允许 hypervisor 等待一小段时间——比如 微秒——来收集一整批数据包,然后再发送单个中断。这极好地分摊了中断的成本。通过转动这个旋钮,您可以在最小延迟和最大吞吐量之间的平滑曲线上调整系统行为,使其完美地适应手头的工作负载。
当然,我们是怎么知道这一切的呢?我们通过测量得知。而测量本身就是一门艺术。现代计算机是一个嘈杂、混乱的地方。为了真正分离出半虚拟化的性能优势,系统工程师必须设计严谨的实验,控制诸如 CPU 频率缩放、调度器噪声和其他系统中断等混杂变量。这是科学方法的一个完美应用,证明了半虚拟化 I/O 的优雅理论能够转化为现实世界的结果。
虽然速度是一个强大的驱动力,但当我们思考如何在共享基础设施的世界中管理资源时,半虚拟化最深刻的应用便浮现出来。在这里,客户机操作系统不再只是一个蒙着眼睛的表演者;它成为一个管弦乐队的成员,而半虚拟化提示则是指挥的提示,让整个系统和谐地演奏。
时间问题
让我们从一个真正根本性的问题开始:时间本身。客户机操作系统需要一个可靠的时钟。它通常通过读取 CPU 的时间戳计数器(TSC)来获取时间,该计数器随每个处理器周期递增。但是,当宿主机为了省电而动态改变 CPU 频率时会发生什么?TSC 的速率随之改变。在启动时校准过一次时钟的客户机,现在完全迷失了方向。它的时间感被拉伸或压缩,运行得比现实更快或更慢。这不是一个性能问题,而是一个正确性问题。半虚拟化时钟以一种优美的简洁性解决了这个问题。hypervisor 与客户机共享一小块内存,其中包含一个时间的“罗塞塔石碑”:一个缩放比例和一个偏移量。每当 CPU 频率改变时,hypervisor 就会更新这些值。然后,客户机可以读取原始的 TSC,并使用这些半虚拟化值来计算正确的时间,所有这些都无需任何一次昂贵的到 hypervisor 的退出。这是一场安静、高效的对话,让客户机始终锚定于现实。
竞争问题
在一个整合的环境中,许多虚拟机争用相同的物理 CPU。这导致了一个典型的问题,即锁持有者抢占(lock-holder preemption)。想象一个客户机线程获取了一个关键锁——一个共享资源的钥匙。就在那一刻,hypervisor 决定抢占该 vCPU 并运行另一个。从客户机的角度来看,锁持有者已经凭空消失了。客户机中其他需要该锁的线程除了等待别无他法,它们通常在一个紧密的循环中“自旋”,无谓地消耗 CPU 周期。这就像一群人徒劳地敲着一扇锁着的门,而拿钥匙的人却在他们不知情的情况下被传送走了。
半虚拟化提供了对讲机。hypervisor 可以向客户机操作系统发送“抢占通知”。客户机现在意识到了情况,可以将等待的线程置于睡眠状态,而不是让它们自旋。它甚至可以提升被抢占的锁持有者的优先级,以便 hypervisor 更可能快速地将其重新调度回来。这个简单的提示将一个浪费性自旋的场景转变为智能的、协作式的等待,从而显著提高了多线程应用程序的性能。同样的批处理和通知哲学也可以驯服“千刀万剐”般的死亡,即像高精度定时器这样频繁而微小的事件风暴,否则会用无用的虚拟机退出淹没 hypervisor。
位置问题
现代服务器不是单体的;它们通常由多个处理器插槽组成,每个插槽都有自己的本地内存。这被称为非统一内存访问(NUMA)。访问本地内存速度快;访问远程插槽上的内存则明显较慢。一个对这种物理布局一无所知的客户机操作系统,可能会意外地将一个 vCPU 放在一个插槽上,而其数据却驻留在另一个插槽的内存中。结果是,这个 vCPU 大部分时间都在等待数据通过缓慢的插槽间链路传输。
再次,半虚拟化提供了地图。了解自身工作负载的客户机可以向 hypervisor 提供一个提示:“这组 vCPU 正在大量使用这个内存区域。” hypervisor 随后可以利用这个提示来智能地调度这些 vCPU,并将它们的内存分配在同一个物理插槽上。这种协同定位(co-location)极大地减少了远程内存流量,为要求苛刻的科学和数据库工作负载释放了性能。
稀缺性问题
也许这种合作哲学最复杂的应用是在管理内存压力方面。当宿主机物理内存耗尽时,它必须采取行动。一种粗糙的方法是使用“气球驱动程序”强制从客户机回收内存,就像房东突然收走你的一个房间。客户机会感到意外,并且必须手忙脚乱地去适应。一种半虚拟化的方法则优雅得多。hypervisor 可以向客户机暴露一个简单的、抽象的“压力计”——一个从 到 的值,指示宿主机上内存变得多么稀缺。它不透露任何关于其他客户机的细节;这只是一个温和的、合作的信号。一个行为良好的客户机可以看到这个压力上升,并在危机发生之前主动开始清理自己的家园——整理缓存并释放未使用的页面。这是一个分布式反馈控制系统的完美例子,其中一个简单的、低开销的提示促成了全系统的稳定性,并防止了性能崩溃。
最后,这种客户机与宿主机之间的对话对安全性具有深远的影响。hypervisor 是一个强大的实体,一个被攻破的 hypervisor 是一个可怕的想法。如果客户机需要一些基础的东西,比如用于密码学的随机数,它能信任宿主机来提供它们吗?
一个简单的设计可能是让客户机直接向宿主机请求一串随机比特。但是,一个恶意的宿主机可以提供一个完全可预测的序列,从而悄无声息地破坏客户机的所有密码学安全。半虚拟化哲学基于“深度防御”原则提供了一个更稳健的答案。一个安全的半虚拟化随机数生成器(RNG)不会向宿主机索要最终的随机数。相反,它向宿主机索要一些熵——一些不可预测性的来源。然后,客户机将这个宿主机提供的熵(它会持怀疑态度对待)与它自己收集的熵(来自鼠标移动和网络数据包时间等来源)混合在一起。通过使用密码学混合函数,客户机确保即使宿主机的贡献完全是假的,只要客户机自身的熵源是可靠的,最终结果仍然是不可预测的。半虚拟化接口成为一个协作的渠道,而不是盲目的委托,从而加固了系统以抵御被攻破的宿主机。
回首过去,我们看到半虚拟化远不止是一种性能优化技巧。它是构建分层系统的基本设计哲学。它承认抽象虽然强大,但可能产生有害的信息鸿沟。半虚拟化之美在于创建简约、优雅且高效的接口来弥合这些鸿沟。
通过这种恢复的对话,虚拟机可以保持准确的时间,高效地使用 I/O,智能地参与全系统的资源管理,甚至加强自身的安全性。这证明了一个道理:在计算中,如同在物理学中一样,理解和沟通是释放宇宙潜能的关键——即使是虚拟的宇宙。