CPU调度器原理与Linux CFS 虚拟机调度详解


1. CPU 调度器基础概念

CPU 调度器(Scheduler)是操作系统内核的核心组件,负责决定”哪个可运行任务在哪个 CPU 核心上运行多久”。其目标通常包括:

  • 公平性(Fairness):每个任务都能获得与其权重相称的 CPU 时间。
  • 吞吐量(Throughput):单位时间内完成尽可能多的工作。
  • 响应延迟(Latency):交互式任务应尽快获得响应。
  • CPU 利用率:尽量减少 CPU 空闲。
  • 可扩展性:在多核、多路(NUMA)系统上仍保持高效。

这些目标彼此冲突——公平性可能牺牲吞吐量,低延迟可能牺牲整体效率,调度器设计的本质就是在这些目标之间做权衡。

调度器面对的任务通常分为两类:

  • CPU 密集型(CPU-bound):如科学计算、编译,倾向于长时间占用 CPU。
  • I/O 密集型 / 交互式(I/O-bound):如 shell、GUI、网络服务,频繁睡眠等待 I/O,一旦被唤醒希望能立刻抢占执行。

虚拟机的 vCPU 线程比较特殊:它对宿主机而言看起来像一个 CPU 密集型任务(虚拟机内部一直在”跑”东西),但其内部实际上混合了大量的空闲等待(vCPU 执行 HLT/MWAIT 让出物理 CPU)和突发计算,这也是本文后半部分要重点讨论的地方。


2. Linux 调度器架构演进

阶段调度器时间复杂度主要问题
2.4 及以前简单轮询调度器O(n)每次选任务要遍历所有任务,多核/多任务下性能差
2.6.0 ~ 2.6.22O(1) 调度器(Ingo Molnar)O(1)用优先级数组实现常数时间选任务,但交互式任务识别启发式复杂且经常判断失误
2.6.23 至今CFS(Completely Fair Scheduler)O(log n)彻底抛弃”时间片”概念,用红黑树 + 虚拟运行时间实现”完全公平”
6.6+(2023 底引入,逐步默认化)EEVDF(Earliest Eligible Virtual Deadline First)O(log n)解决 CFS 在等待/延迟保证上的理论缺陷

CFS 由 Ingo Molnar 在 2007 年提出,其设计哲学受到”理想的、精确的多任务 CPU”(ideal multitasking CPU)模型启发:假设有一个理想处理器可以让 N 个任务同时以 1/N 的速度并行执行,CFS 的目标就是用现实中只能串行执行的物理 CPU 去逼近这个理想模型。


3. CFS 核心原理

3.1 虚拟运行时间(vruntime)

CFS 不再使用固定时间片轮转,而是为每个可运行任务维护一个 vruntime(虚拟运行时间)。核心思想:

谁的 vruntime 最小,谁就最应该被调度。

vruntime 的增长速度与任务的权重(由 nice 值决定)成反比:

vruntime += 实际运行时间 × (NICE_0_WEIGHT / 任务权重)

  • nice 值越低(优先级越高,如 -20),权重越大,vruntime 增长越慢,因此能获得更多实际 CPU 时间。
  • nice 值越高(优先级越低,如 19),权重越小,vruntime 增长越快,越容易被其他任务超过、让出 CPU。

内核用一张 nice_to_weight 表将 -20~19 的 nice 值映射为权重(nice=0 时权重为 1024,nice 每 +1,大约衰减 10%)。

3.2 红黑树(Red-Black Tree)

CFS 用一棵按 vruntime 排序的红黑树(cfs_rq->tasks_timeline)管理所有可运行任务:

  • 树的最左节点(leftmost)永远是 vruntime 最小的任务,也就是下一个该被调度的任务,选择复杂度为 **O(1)**(有缓存指针)。
  • 任务入队/出队(插入/删除节点)复杂度为 **O(log n)**。
  • 正在运行的任务不在树中,被抢占后会重新插入。

这种结构天然实现了公平性:一个任务运行得越多,vruntime 越大,就会被排到树的右侧,让位给 vruntime 更小(运行得少)的任务。

3.3 调度周期与最小粒度

CFS 通过两个参数控制”公平”与”切换开销”之间的平衡:

  • sched_latency_ns(默认约 24ms,随核数动态调整):一个”理想调度周期”内,系统应尽量让所有可运行任务都被调度一次。
  • sched_min_granularity_ns(默认约 3ms):每个任务在一次调度周期中的最小运行时间,防止任务过多时每个任务分到的时间片过短导致上下文切换开销过大。

实际每个任务分配的时间片 = sched_latency_ns × (自身权重 / 就绪队列总权重),但不会低于 sched_min_granularity_ns

3.4 抢占时机

CFS 并不依赖固定时钟中断做时间片耗尽检查那么简单,抢占发生在以下场景:

  1. 周期性 tickscheduler_tick() 中检查当前任务运行时间是否超过其应得的时间片,超过则设置 TIF_NEED_RESCHED
  2. 任务唤醒(wakeup preemption):新唤醒的任务如果 vruntime 明显小于当前运行任务(差值超过 sched_wakeup_granularity_ns),会抢占当前任务。这对交互式/I/O 密集型任务(如 vCPU 被中断唤醒)非常关键——它们通常睡眠久、vruntime 积累少,一旦唤醒能快速抢到 CPU。
  3. 任务主动让出:调用 sched_yield()、阻塞在锁/I/O 上等。

3.5 睡眠补偿

任务睡眠时不会累积 vruntime。为避免任务睡眠归来后 vruntime 过小从而”霸占” CPU 很长时间,CFS 在任务被唤醒重新入队时会做 vruntime 补偿:

新 vruntime = max(任务原 vruntime, cfs_rq->min_vruntime - 一定补偿量)

即把刚醒来的任务的 vruntime 调整到接近当前运行队列的最小 vruntime,而不是无限追平,这样既给它一定的”唤醒优先权”,又不会让长期睡眠的任务一醒来就无限期抢占其他任务。


4. 调度类与优先级体系

Linux 调度器采用模块化的调度类(scheduling class)架构,按优先级从高到低依次尝试:

stop_sched_class   → 用于停机/热插拔等特殊场景(最高优先级)
    ↓
dl_sched_class     → SCHED_DEADLINE(EDF,硬实时)
    ↓
rt_sched_class     → SCHED_FIFO / SCHED_RR(软实时)
    ↓
fair_sched_class   → SCHED_NORMAL / SCHED_BATCH / SCHED_IDLE(即 CFS,绝大多数进程)
    ↓
idle_sched_class   → CPU 空闲时运行的 idle 任务(最低优先级)

核心调度函数 pick_next_task() 会按上述顺序依次询问每个调度类”你有没有可运行任务”,一旦找到就返回,因此实时任务(RT/Deadline)永远优先于 CFS 管理的普通任务。QEMU/KVM 的 vCPU 线程默认属于 fair_sched_class(CFS 管理),但在需要低延迟场景下也常被显式配置为 SCHED_FIFO 或结合 SCHED_DEADLINE 使用(见第 8 节)。


5. 组调度(cgroup)机制

CFS 支持层级化的组调度(Group Scheduling),这是理解虚拟机调度公平性的关键。

  • 每个 cgroup(或容器)在 CFS 看来不是一堆离散的任务,而是一个”任务组”,组内再有自己的一棵红黑树。
  • 调度决策变成两层:先在顶层选出 vruntime 最小的,再进入该组内部选出 vruntime 最小的任务/子组
  • 这保证了组间公平:一个开了 100 个线程的 cgroup 不会因为线程多就抢到更多 CPU,因为调度器先按组分配公平的份额,组内部再分。

这对多虚拟机共存的宿主机非常重要:如果每个虚拟机(及其所有 vCPU 线程)被放入独立的 cgroup(libvirt/QEMU 默认行为),那么”虚拟机 A 开 32 个 vCPU、虚拟机 B 只开 2 个 vCPU”不会导致 A 抢走远超其应得份额的物理 CPU 时间——两台虚拟机仍会按 cgroup 权重(cpu.shares / cpu.weight)大致公平分配,然后各自内部再去分配给自己的 vCPU 线程。


6. KVM 虚拟机的调度模型

理解”CFS 如何调度虚拟机”,首先要理解 KVM 的用户态/内核态协作模型:

QEMU 进程
 ├─ vCPU 线程 0  (ioctl KVM_RUN → 进入 Guest Mode 执行 vCPU0 指令)
 ├─ vCPU 线程 1  (ioctl KVM_RUN → 进入 Guest Mode 执行 vCPU1 指令)
 ├─ ...
 ├─ I/O 线程 / iothread(处理磁盘、网络等模拟设备)
 └─ 主线程(QEMU 主循环,处理监控命令等)

关键事实:从 Linux 调度器的视角看,每一个 vCPU 就是宿主机上一个普通的(用户态)线程。 没有任何”虚拟机专属调度器”——CFS 完全不知道、也不关心某个线程是不是在跑一个 Guest OS,它只是按 vruntime 公平地调度这些线程,就像调度任何普通进程线程一样。

vCPU 线程的运行模式:

  1. 线程调用 ioctl(vcpu_fd, KVM_RUN, ...) 陷入内核。
  2. KVM 内核模块通过硬件虚拟化扩展(Intel VT-x 的 VMLAUNCH/VMRESUME,AMD-V 的 VMRUN)切换 CPU 进入 Guest Mode,此时 Guest OS 的指令直接由物理 CPU 执行,不经过宿主机内核,性能接近裸机。
  3. 发生 VM Exit(Guest 执行了敏感指令、触发缺页、外部中断到达、执行 HLT/MWAIT 等)时,CPU 退出 Guest Mode,控制权交还给 KVM 内核代码处理。
  4. KVM 处理完 exit 原因后,要么立即 VMRESUME 继续跑 Guest,要么返回用户态 QEMU 处理(如模拟 I/O 设备),处理完再次 ioctl(KVM_RUN)
  5. 如果 Guest 执行了 HLT(Guest 内核认为自己空闲了,等价于物理机的 idle),vCPU 线程会调用 kvm_vcpu_block(),通过 schedule() 让出 CPU 并进入睡眠——此时它就是一个真正阻塞态的 Linux 线程,会被 CFS 从运行队列摘除,直到有中断/事件需要唤醒它(如虚拟时钟中断、virtio 设备完成 I/O)。

这就是为什么”vCPU 线程”对 CFS 而言是一个时而突发计算、时而睡眠等待的普通线程,其调度行为完全遵循第 3 节所述的 CFS 通用规则。


7. CFS 如何调度 vCPU 线程

结合第 3、5、6 节,vCPU 线程在 CFS 下的完整调度画像:

7.1 加入运行队列与 vruntime 竞争

  • 宿主机上所有可运行线程(包括所有虚拟机的所有 vCPU 线程、宿主机自身进程、其他容器进程)共同竞争物理 CPU,通过 vruntime 大小排队。
  • 若未做 CPU 绑定(pinning),vCPU 线程可以被负载均衡器(load balancer,见 7.3 节)迁移到宿主机任意空闲核心上运行,Linux 并不保证同一个 vCPU 总在同一物理核上跑。
  • vCPU 线程的 nice 值默认与普通线程相同(0),因此默认情况下宿主机上的一个 vCPU 线程和宿主机上跑的其他任意进程,在 CFS 眼里”同等重要”,会按权重公平瓜分 CPU——这也是为什么”CPU 超配(overcommit)”的宿主机上,普通业务进程可能被虚拟机挤占 CPU 的原因之一。

7.2 唤醒抢占与中断响应延迟

虚拟机对调度延迟(scheduling latency)非常敏感:

  • Guest 内部一次时钟中断、网络包到达、磁盘 I/O 完成,都要先在宿主机上唤醒对应的 vCPU 线程(kvm_vcpu_wake_up()try_to_wake_up()),vCPU 线程被唤醒后要等待 CFS 重新把它调度上 CPU,才能把这个”虚拟中断”注入给 Guest。
  • 这中间的等待时间称为 调度延迟(scheduling latency),对于 Guest 内部运行的实时/低延迟应用(如 Guest 里的实时任务、低延迟交易系统)是关键指标。
  • 得益于 CFS 的唤醒抢占机制(3.4 节),刚从阻塞唤醒的 vCPU 线程 vruntime 通常较小,容易抢占当前运行的其他线程,这在一定程度上降低了延迟,但并不能提供硬实时保证——如果宿主机严重超配、大量任务竞争,vCPU 唤醒后仍可能要排队。

7.3 负载均衡与 NUMA 感知

多核系统中,CFS 每个物理核心维护自己的运行队列(per-CPU runqueue),需要靠负载均衡器在核心间搬运任务以维持整体均衡:

  • 周期性负载均衡:每隔一定 tick,load_balance() 检查调度域(scheduling domain,按 SMT→Core→LLC(末级缓存)→NUMA Node→System 分层)内各 CPU 的负载差异,将任务从繁忙 CPU 迁移到空闲 CPU。
  • NUMA 感知:虚拟机往往被分配跨 NUMA 节点的内存和 vCPU,如果 vCPU 线程被迁移到与其访问的内存不在同一 NUMA 节点的核心上运行,会产生显著的跨节点内存访问延迟。Linux 的 NUMA Balancingkernel.numa_balancing)会尝试把任务和它常访问的内存页迁移到同一节点,但对虚拟机这种内存footprint 巨大、vCPU 拓扑需要精确暴露给 Guest 的场景,往往更推荐手动 CPU 绑定 + NUMA 绑定(见第 10 节),避免自动均衡带来的不确定性。
  • vCPU 拓扑与物理拓扑不匹配问题:如果不做绑定,Guest 内部通过 CPUID 看到的 vCPU 拓扑(哪些 vCPU 共享 L2/L3 cache、是否同一 SMT 对)可能与宿主机实际调度位置完全不符,导致 Guest 内部的调度优化(比如 Guest 自己的 CFS 也在做类似决策)产生误判。

8. 虚拟化场景下的特殊调度问题

8.1 vCPU Stealing Time(被窃取时间)

当宿主机 CPU 超配(vCPU 总数 > 物理核心数)时,一个 vCPU 线程即使 Guest 内部认为自己”应该运行”,也可能因为宿主机 CFS 把物理 CPU 分给了其他任务而被迫等待——这段时间称为 steal time

  • Guest 内核可以感知 steal time(通过 kvm-clock 半虚拟化时钟里的 steal time 字段),并在 /proc/statst 列、top%st 中展示。
  • Steal time 过高说明宿主机 CPU 严重超配,Guest 内部的应用会感受到”CPU 变慢/卡顿”,即使 Guest 内部看起来 CPU 使用率不高。
  • 这是排查”虚拟机变慢但内部看不出原因”的关键指标——需要去宿主机上看,而不是 Guest 内部。

8.2 Double Scheduling(双重调度)问题

虚拟化引入了经典的”双重调度”难题:

  • Guest 内部有自己的调度器(如 Guest 也在跑 Linux + CFS),决定哪个 Guest 内部任务运行在哪个 vCPU 上。
  • 宿主机也有调度器(CFS),决定哪个 vCPU 线程运行在哪个物理核心上,运行多久。

两层调度器互相不感知对方的决策依据,可能产生一系列问题:

  • Lock Holder Preemption(LHP,锁持有者被抢占):Guest 内部某任务持有自旋锁(spinlock)时,如果它所在的 vCPU 恰好被宿主机 CFS 换下 CPU,那么 Guest 内其他等待该锁的 vCPU 会持续空转(busy-wait)却毫无进展,直到持锁的 vCPU 重新被调度——严重浪费物理 CPU 且拉长延迟。缓解手段包括 Paravirtualized spinlock(PV spinlock):Guest 检测到自旋等待过久时主动 yield(通过 PAUSE-loop exiting 或 KVM_HC_KICK_CPU hypercall),让宿主机调度器把 CPU 让给真正持锁的 vCPU。
  • PLE(Pause-Loop Exiting):现代 Intel/AMD CPU 支持检测 Guest 内密集执行 PAUSE 指令(典型的自旋锁等待特征),触发 VM Exit 交还给宿主机,KVM 借此机会主动让宿主机调度器把物理 CPU 让给其他更需要它的 vCPU(如真正持锁的那个),缓解 LHP。

8.3 SCHED_FIFO / SCHED_DEADLINE 用于 vCPU 线程

对延迟极度敏感的场景(电信 NFV、金融低延迟、实时音视频),常见做法是让 vCPU 线程完全脱离 CFS 管理:

  • 将 vCPU 线程设置为 SCHED_FIFO(实时调度类,优先级高于 CFS,见第 4 节),配合独占绑核(isolcpus/cpuset)+ nohz_full(关闭该核心的周期性时钟中断)+ RCU offload,实现接近裸机的确定性延迟。
  • 更严格的方案使用 SCHED_DEADLINE(基于 EDF + CBS 带宽控制算法),为 vCPU 显式声明 (runtime, deadline, period) 三元组,内核提供数学上可证明的时限保证。这类配置常见于 libvirt 的 <cputune><vcpusched vcpus='0' scheduler='fifo' priority='1'/></cputune>

代价是:一旦脱离 CFS 的公平调度,这些 vCPU 线程会抢占几乎所有 CFS 管理的普通进程(包括宿主机自身的管理进程),必须谨慎规划、通常需配合 CPU 隔离一起使用,否则可能”饿死”宿主机上的其他关键服务。


9. 虚拟机 CPU 资源限制与隔离

在生产环境(尤其是云平台/多租户场景),通常需要对虚拟机的 CPU 使用做硬性限流份额分配,这一层建立在 cgroup(v1 的 cpu/cpuset 子系统,或 v2 的统一层级)之上,libvirt/QEMU 会自动为每个虚拟机创建对应的 cgroup:

机制cgroup v1 参数cgroup v2 参数作用
份额(权重)cpu.sharescpu.weight决定多个 cgroup 竞争 CPU 时的相对比例分配(软限制,CPU 空闲时不受限)
硬限流(Bandwidth Control)cpu.cfs_quota_us / cpu.cfs_period_uscpu.max在每个周期(默认 100ms)内,该 cgroup 最多能使用的 CPU 时间总量,超过则被节流(throttle),即使还有空闲 CPU 也不能用
CPU 绑定cpuset.cpuscpuset.cpus(在 cpuset 控制器下)限定该 cgroup 内线程只能在指定物理核心集合上运行,配合 virsh vcpupin 实现 vCPU 到 pCPU 的精确绑定
内存节点绑定cpuset.memscpuset.mems限定只能分配指定 NUMA 节点内存,配合 CPU 绑定避免跨节点访问

CFS Bandwidth Control(quota/period)的工作原理:调度器给 cgroup 分配一个”CPU 时间银行”,每个 period(如 100ms)开始时账户被充值到 quota,cgroup 内所有线程消耗这个共享额度,额度耗尽后组内所有可运行任务会被节流(throttled)——从运行队列摘除,即使物理 CPU 空闲也不会被调度,必须等到下一个 period 重新充值。这解释了云平台上”虚拟机 vCPU 使用率明明没到 100% 却感觉卡顿”的常见原因:可能不是 vruntime 排队慢,而是配额耗尽被强制节流(可通过 nr_throttled/throttled_time 观测,见第 11 节)。


10. 性能调优实践

针对延迟敏感型/计算密集型虚拟机的典型调优组合:

  1. CPU 绑定(Pinning)virsh vcpupin <domain> <vcpu> <pcpu>,将每个 vCPU 线程固定到特定物理核心,避免负载均衡器随意迁移带来的缓存失效和延迟抖动。
  2. NUMA 绑定virsh numatune 配合 vcpupin,确保 vCPU 运行的核心与其访问的内存在同一 NUMA 节点,并通过 <numa> 拓扑向 Guest 暴露真实的 NUMA 结构。
  3. CPU 隔离(isolcpus/cpuset):将一部分物理核心从内核默认调度域中排除,专供虚拟机 vCPU 使用,避免宿主机其他进程/中断抢占这些核心。
  4. 专用 housekeeping 核心:将中断处理、emulator 线程、iothread 等留在少数几个”管理核心”上,业务 vCPU 独占其余核心,减少 VM Exit 处理和中断对 Guest 计算的干扰。
  5. 关闭该核心的时钟中断(nohz_full)+ RCU offload:减少不必要的 VM Exit(每次周期性时钟中断都可能导致 VM Exit)。
  6. 绑定与关闭超线程/SMT 考量:若延迟要求极高,可考虑关闭 SMT 或至少保证同一虚拟机的 vCPU 不与其他租户共享同一物理核心的两个 SMT 线程(也是 side-channel 安全隔离的常见要求)。
  7. **合理设置 cpu.shares/cpu.weightquota/period**:避免因为限流粒度(period 太短导致突发计算被过早节流)设置不当引入人为抖动,通常建议 period 不小于 100ms,quota 按预期平均使用率设置并预留余量应对突发。
  8. virtio + vhost 卸载:将网络/磁盘 I/O 模拟从 QEMU 主线程卸载到内核态 vhost 线程(本身也是宿主机上受 CFS 调度的线程,但减少了用户态/内核态切换次数),降低 I/O 路径对 vCPU 调度的干扰。

11. 观测与调试工具

工具/接口用途
top / htop 中的 %st观测 steal time,判断宿主机是否超配
pidstat -t -p <qemu_pid>观测 QEMU 进程下各 vCPU 线程的 CPU 使用、上下文切换次数
perf sched record/latency/timehist记录并分析调度延迟、唤醒延迟、各线程实际获得的 CPU 时间分布
/proc/<pid>/task/<tid>/sched查看单个线程(vCPU 线程)的 vruntime、nr_switches、调度统计
/proc/sched_debug全局调度器状态转储,包括各 CPU 运行队列、cgroup 层级 vruntime
cat /sys/fs/cgroup/.../cpu.stat查看 cgroup 的 nr_periodsnr_throttledthrottled_time,判断是否被 quota 节流
virsh vcpuinfo <domain> / virsh vcpupin <domain>查看/设置 vCPU 与物理核心的绑定状态
numastat -c qemu查看虚拟机跨 NUMA 节点的内存访问统计
trace-cmd / ftrace (sched_switch, kvm_exit 等 tracepoint)精细追踪 VM Exit 原因分布、调度切换事件时序

12. EEVDF:CFS 的继任者

Linux 6.6(2023 年底)开始,CFS 逐步被 EEVDF(Earliest Eligible Virtual Deadline First) 取代(由 Peter Zijlstra 实现),并在后续版本中成为默认调度器。核心动机:

  • CFS 的”完全公平”是长期平均意义上的公平,短期内某个任务可能持续被别的任务插队,导致延迟不可预测;纯粹依赖唤醒抢占的启发式(sched_wakeup_granularity_ns)难以给出理论保证。
  • EEVDF 为每个任务额外维护一个 虚拟截止时间(virtual deadline) = vruntime + 请求时间片/权重,调度时优先选择”已经有资格运行(eligible,即已欠公平份额)且虚拟截止时间最早”的任务,从理论上更接近 GPS(Generalized Processor Sharing)理想模型,同时能表达”某任务想要更小时间片、更快响应”的诉求(通过 sched_setattrsched_runtime 提示)。
  • 对虚拟机调度而言,EEVDF 理论上能提供更平滑、更可预测的 vCPU 唤醒延迟,减少延迟抖动,但目前(2026 年)在生产环境的大规模虚拟化实践积累仍在增长中,具体收益需结合实际内核版本和工作负载验证。

参考资料方向(供进一步查阅)

  • 内核源码:kernel/sched/fair.c(CFS/EEVDF 实现)、kernel/sched/core.c(调度框架)
  • Documentation/scheduler/sched-design-CFS.rst
  • KVM 官方文档与 Documentation/virt/kvm/
  • libvirt 文档中的 <cputune><numatune> 元素说明
  • Red Hat《Virtualization Tuning and Optimization Guide》

发表评论