第 5 章 调度与 CPU 虚拟化
一个物理核要假装成多个 CPU,靠的是"谁该在下一纳秒上 CPU"这个决策的连续正确性。
学习目标
读完本章,你应当能够:
- 论证吞吐、延迟、公平三者不可能同时最优,并指出任意调度算法至少牺牲哪一项。
- 说出 FCFS/SJF/优先级/轮转/MLFQ 各自的致命场景,以及 MLFQ 的防欺骗规则在机制上封住了什么。
- 用
vruntime更新公式和nice → weight表,手算多进程场景下的 CPU 份额比与各自时间片。 - 解释新唤醒进程的
vruntime为什么必须被拉到min_vruntime附近,以及不这么做会导致哪两种灾难。 - 区分
SCHED_NORMAL/BATCH/IDLE/FIFO/RR/DEADLINE,说明实时进程如何饿死普通进程。 - 解释 CPU 缓存亲和为什么常常比负载均衡更重要,并说出
sched_domain与 NUMA 感知调度的分工。 - 拆解一次上下文切换的开销构成,用
vmstat的cs与pidstat -w区分自愿/非自愿切换。 - 说明 CPU 虚拟化的两条技术路线与 KVM 依赖的硬件辅助,以及除 CPU 外还有哪些资源必须虚拟化。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
| cache 层次、cache line、主存访问延迟量级 | 见第 1 章 1.1 节 |
fork/clone、进程状态、内核栈与用户栈的区分 | 见第 4 章 4.1、4.2 节 |
| 用户态/内核态切换与时钟中断的基本路径 | 见第 9 章 9.1、9.3 节 |
| 页表与 TLB 的作用 | 见第 8 章 8.2 节(本章只用到"换页表会失效 TLB"这一结论) |
5.1 调度的三个目标与根本矛盾
调度器要在每个可运行实体(runnable entity,Linux 中即 task_struct)之间分配稀缺资源:CPU 时间,而它同时被三个目标拉扯。
| 目标 | 度量口径 | 极端最优策略 | 代价 |
|---|---|---|---|
| 吞吐(throughput) | 单位时间完成的任务数 | 任务尽量跑完再切换(切换有成本) | 先到的长任务占住 CPU,短任务全部等待 |
| 延迟(latency) | 就绪到首次上 CPU、以及完成时间的分布(p50/p99) | 谁快到期限谁先跑(近似 SJF/SRTF) | 必须预知未来;长任务无限延后 |
| 公平(fairness) | 各任务 CPU 占比是否与权重成比例 | 严格按权重轮转,粒度越细越公平 | 时间片趋近 0 时全部时间花在切换上 |
三者的矛盾是结构性的:一次上下文切换约 1~5 μs(口径:vmstat 的 cs 与 perf sched 统计,含直接开销),时间片缩到与之同量级,CPU 就有一半时间在保存/恢复现场;把长任务一次跑完吞吐最高,但排在后面的短任务延迟爆炸(FCFS 的护航效应);严格公平又会让交互任务每次只拿到几毫秒,用户感知仍然抖动。
重要
不存在完美调度器,因为最优性依赖任务未来行为(下次何时就绪、要跑多久),而它在运行前不可知。所有实用调度器都是"用可观测的历史去近似未来",每一个都有专门的对抗负载(adversarial workload)。面试时若答"硬件不够快所以做不到完美",就直接跑偏了。
抢占式(preemptive)vs 非抢占式(non-preemptive):判据只有一条——运行中的任务能否被外力夺走 CPU。非抢占式下任务自己让出(终止、阻塞、主动 sched_yield),实现简单,但一个死循环任务能锁死整个系统。抢占式下内核在中断返回点检查 TIF_NEED_RESCHED 标志,必要时调用 schedule() 换人;触发源有三类:时钟中断(时间片耗尽)、更高优先级任务唤醒时 try_to_wake_up 中的可抢占判断、用户态信号的投递点。CONFIG_PREEMPT_NONE/VOLUNTARY/PREEMPT/PREEMPT_RT 的差异只在于内核态代码是否也可被抢占;用户态任务在 Linux 上始终可抢占。
注意
追问点:“抢占的检查点为什么放在中断返回路径,而不是每条指令之间?“因为抢占需要一次内核态入口,中断返回是已有的、开销固定的检查窗口;逐指令比对优先级需要硬件在每个指令边界介入,收益远不抵成本。
5.2 经典调度算法速查:每个算法的致命场景
下面五种算法本科都讲过,本节只保留面试官用来区分"背过"还是"懂"的那一列。
| 算法 | 一句话机制 | 致命场景(面试必答) | 是否抢占 |
|---|---|---|---|
| 先来先服务(first-come first-served, FCFS) | 按到达顺序排队,队头不完成不换人 | 护航效应:100 ms 的 CPU 密集任务后面排着 100 个 1 ms 任务时,平均等待被队头拉爆;平均周转时间对顺序极敏感 | 否 |
| 最短作业优先(shortest job first, SJF) | 每次选剩余运行时间最短的 | 要求预知未来;只能用历史值预测,任务突然变长就失准;持续到达的短任务会无限推迟长任务 | 否 |
| 最短剩余时间优先(shortest time-to-completion first, STCF) | SJF 的抢占版:新任务剩余更短就换人 | 同样依赖预知;短任务密集到达时长任务几乎拿不到 CPU | 是 |
| 优先级调度(priority scheduling) | 选优先级最高者 | 饥饿:持续有高优先级任务到达时低优先级永不运行。解法只有老化(aging),但老化速度又要调参 | 两者皆可 |
| 轮转(round robin, RR) | 按到达顺序轮流,每人固定时间片 q | q 是死结:太大退化成 FCFS;太小时切换占比约 c/(q + c)(c 为切换成本);响应时间也不随 q 缩小而线性变好 | 是 |
| 多级反馈队列(multi-level feedback queue, MLFQ) | 多级队列 + 按行为动态调优先级 + 周期性提升 | 可以欺骗:在时间片快耗尽前主动让出 CPU,就能长期留在高优先级队列 | 是 |
MLFQ 的五条规则(能说出编号与"每条堵的是什么洞”,层次立刻不同):
| |
规则 4b 是欺骗入口:纯 CPU 密集进程只要在时间片耗尽前做一次无关 I/O,就能永久霸占高优先级队列。防欺骗方案是记账改造:不看"是否在时间片内放弃”,而是记录每层累计消耗的 CPU 时间(total accounting),累计到阈值就降级。
警告
MLFQ 最常见的错误回答是"高优先级时间片短、低优先级时间片长"。时间片长度确实是参数,但不是机制核心;核心是"用观测到的行为反推任务类型,再用优先级编码这个推断"。被追问"MLFQ 与 CFS 的本质差别"时,落点应是:MLFQ 靠离散队列与启发式分类(猜测),CFS 靠连续权重与精确的虚拟时间记账(按比例分配)。
5.3 CFS 完整机制:vruntime 与红黑树
完全公平调度(Completely Fair Scheduler, CFS)是 SCHED_NORMAL 的实现。它的出发点是反问:“如果 CPU 是能被无限细分的资源,公平应该长什么样?” 理想模型是 N 个任务各以 1/N 速度并行推进;单核只能用极小粒度轮流逼近,而这会撞上切换开销。CFS 的解法是不分配时间片,只维护虚拟时间。
5.3.1 vruntime 更新公式
| |
CPU 时间严格正比于权重,不需要额外的比例控制逻辑——比例是记账方式的副作用。
5.3.2 红黑树:最左节点就是下一个
vruntime 是单调递增的键,就绪队列只需"快速取最小 + 快速插入 + 快速删除任意节点"。CFS 选红黑树(cfs_rq.tasks_timeline):
| |
5.3.3 时间片由 sched_latency、min_granularity、nr_running 共同决定
CFS 不存固定时间片,每次调度时现算:
| |
sched_latency是目标周期:可运行任务不多时,希望这段时间内每个任务至少被调度一次,从而控制响应量级。nr_running一旦使nr_running × min_granularity > sched_latency,周期就被撑大,因为不能让任何任务的时间片小于min_granularity。min_granularity是切换开销的护栏:保证时间片显著大于一次切换(约 1~5 μs),代价是任务数极大时周期拉长、延迟变差。
提示
心算口诀:时间片 = 周期 × 我的权重 / 总权重。被追问"200 个任务同时可运行时呢",先算周期 200 × 0.75 ms = 150 ms,再按权重分。这解释了为什么满载机器上单任务响应明显变差:问题不在切换本身,而在调度周期被 min_granularity 撑到了上百毫秒。
5.3.4 nice → weight 换算表与算例
内核用预计算表 sched_prio_to_weight 把 nice 映射到权重,规则是每级约 1.25 倍,且 nice 0 ⟹ 1024:
| nice | weight | nice | weight | nice | weight |
|---|---|---|---|---|---|
| −20 | 88761 | −5 | 3121 | 3 | 526 |
| −15 | 29154 | −4 | 2501 | 5 | 335 |
| −10 | 9548 | −3 | 2004 | 10 | 110 |
| −1 | 1277 | −2 | 1605 | 19 | 15 |
| 0 | 1024 | 1 | 820 | — | — |
由表可直接验证必须记住的三个倍数:
| nice 差 | 权重比(低 nice 方 : 高 nice 方) | 通俗表述 |
|---|---|---|
| 1(0 对 1) | 1277 / 1024 = 1.247 | 低 nice 一方约多拿 25% |
| 19(−20 对 19) | 88761 / 15 = 5917 | 近乎独占 |
| 5(0 对 5) | 1024 / 335 = 3.057 | “差 5 约 3 倍” |
| 10(0 对 10) | 1024 / 110 = 9.31 | “差 10 约一个数量级”(约 9.3 倍) |
完整算例:某 CPU 上有三个可运行任务 A(nice 0, weight 1024)、B(nice 0, weight 1024)、C(nice 5, weight 335)。
| |
重要
加分点在于用"C 每跑 1 ms 真实时间,vruntime 就跑 3.057 ms"来解释 C 的时间片为何只有 A 的约 1/3。只会答"nice 小的权重大所以分时间多",说明只知结论;能写出 Δvruntime = 实际时间 × 1024/weight 并算出三者步长相等,才算摸到机制。
5.4 min_vruntime:新唤醒进程的 vruntime 从哪来
vruntime 只在任务占用 CPU 时增长,睡眠期间不增长。于是必须回答:刚 fork 出来或刚睡醒的任务,vruntime 初值设为多少? 两个直觉答案各有灾难:取 0(或任意极小值)时它是队列最小值,会被连续选中直到追平队列——每秒 fork 上千次的短连接服务因此可独吞 CPU,把老任务饿死,这是 fork bomb 的调度版本;取"队列当前最大值"看似安全,却既惩罚了真正交互的新任务,又没解决根本问题——睡眠任务沿用它入睡时的旧 vruntime(睡眠期间被冻结),醒来时早已远小于 min_vruntime。Linux 的做法:每个 cfs_rq 维护 min_vruntime(队列上 vruntime 的最小值,随最左节点前移单调上升),新唤醒/新创建任务的 vruntime 被设置为不低于 min_vruntime——place_entity() 先按 sched_vslice() 算出该任务应得的时间片并折算成 vruntime 补贴,再取补贴结果与 min_vruntime 的较大者。
| |
注意
min_vruntime 是高频考点,因为它用同一个动作解决两个相反方向的问题:防止新任务饿死老任务(不让新任务的 vruntime 远小于队列最小值),也防止长期睡眠任务醒来独占 CPU(不让它历史遗留的小 vruntime 生效)。注意"睡眠任务醒来后 vruntime 落后"是必然的(睡眠期间不记账),必须由 place_entity() 主动修正。追问下一层:“那唤醒补贴不是又让它插队了吗?"——补贴量由 sched_vslice() 按权重给出,但仍以 min_vruntime 为下界,因此最多让高权重任务少等一轮,不会形成独占。
5.5 调度类与优先级:实时进程如何饿死普通进程
Linux 按"调度类(scheduling class)“分层,每层是独立的调度器实现,高层的类只要队列非空,低层就完全没机会。
| 调度类 | 策略常量 | 优先级 | 机制要点 | 典型用途 |
|---|---|---|---|---|
stop_sched_class | — | 最高(内部) | 用于 CPU 热插拔、停机 | 内核内部 |
dl_sched_class | SCHED_DEADLINE | 最高(用户可见) | 按 (runtime, deadline, period) 做 CBS/EDF 带宽预留,每周期最多用 runtime,超了被 throttle | 硬实时、音视频流水线 |
rt_sched_class | SCHED_FIFO/SCHED_RR | 1~99(越大越优先) | 按实时优先级位图选最高队列;FIFO 不吃时间片,RR 吃固定时间片后轮转到同优先级队尾 | 中断下半部线程、低延迟采集 |
fair_sched_class | SCHED_NORMAL/SCHED_BATCH/SCHED_IDLE | nice −20~19 | CFS;BATCH 标记为非交互、不做唤醒抢占;IDLE 权重极低,只在 CPU 空闲时才跑 | 普通进程、批处理、后台任务 |
idle_sched_class | — | 最低 | 每 CPU 一个 idle 线程 | 省电 |
nice 与实时优先级是两套独立编号:nice 是 −2019(越小越优先),实时优先级是 199(越大越优先),且任意实时进程都优先于任意 nice 进程。
| |
“实时进程会把普通进程饿死"不是 bug,是语义:RT 类队列非空时 CFS 队列根本不被查询,一个优先级 1 的 SCHED_FIFO 死循环线程就能让该核上的普通任务永久得不到调度。两个实际后果:设置 RT 优先级需要 CAP_SYS_NICE(或 RLIMIT_RTPRIO),否则普通用户即可瘫痪整台机器;RT 任务默认受带宽限制保护,sched_rt_runtime_us(默认 950000 μs)与 sched_rt_period_us(默认 1000000 μs)意味着 RT 任务每 1 s 最多占用 950 ms,留 50 ms 给普通任务,设为 -1 才是真正不限制——这是排查"RT 任务为何偶尔卡一下"时该看的第一个旋钮。
小心
常见答崩方式:把"优先级"当成单一维度,说”SCHED_FIFO 优先级是 0,所以排在 nice 后面”。实时优先级数值越大越优先,与 nice 方向相反,两套编号也不通用;被追问”nice -20 与 SCHED_FIFO 优先级 1 谁先跑"答错,就暴露没用过 chrt。
SCHED_DEADLINE 单独记一句:它不是"更高优先级的 RT",而是带宽预留。参数须满足 runtime ≤ deadline ≤ period;内核用 EDF 在 dl 任务间排序,并在 runtime 用尽时强制 throttle——所以它会"跑着跑着被停",与 SCHED_FIFO 的"跑到自己让出为止"是两种不同的保证。
5.6 多核调度:亲和性、负载均衡与 sched_domain
多核引入了单核不存在的问题:任务放在哪个 CPU 上跑。两条原则相互冲突。CPU 亲和性(CPU affinity):任务在某核跑过后,其数据与指令大概率还在该核私有 cache(L1/L2)里;迁移意味着私有 cache 作废并重新填充——L1 命中约 1 ns、L3 约 15 ns、主存约 100 ns(口径:单次访存延迟量级),一次迁移省下的均衡收益很容易被 cache 冷启动吃掉,这就是实践中缓存亲和收益常大于负载均衡收益的原因。负载均衡(load balancing):又不能因为亲和就把 8 个任务全堆一核、让其余 7 核空转。调和工作交给层次化的调度域(sched_domain):均衡只在域内进行,且范围越大触发越稀疏。
| |
load_balance() 触发时机 | 触发点 | 特点 |
|---|---|---|
| 周期性均衡 | 调度 tick 上按 sched_domain 的 balance_interval 检查(idle 的 CPU 更频繁) | 慢速、逐步拉平,避免抖动 |
| 新建任务时 | fork 后 select_task_rq_fair() 按 wake_affine 等启发式选核 | 从源头避免不均 |
| 唤醒任务时 | try_to_wake_up → select_task_rq | 优先放回上次运行的核(保 cache),除非该核明显过载 |
| 空闲核主动拉取 | idle 的 CPU 进入 newidle_balance() 偷任务 | 解决"一核忙死、一核空转",不打断忙核 |
| 拉取失败时 | 退回 active_balance,向目标 CPU 发 IPI 让它自己把任务推出去 | 跨核迁移必须由源/目标 CPU 在安全上下文执行 |
重要
高频追问:“为什么不让忙的 CPU 直接把任务推给空闲 CPU,而非要空闲 CPU 来拉?“因为忙 CPU 并不知道哪个核真空闲,逐个试探要发 IPI(跨核中断,成本高且打断对方 cache 局部性);空闲 CPU 最清楚自己空闲,由它发起可把探测成本降到最低,还天然收敛为"N 个空闲核不会同时抢同一个任务”。
NUMA 感知调度(NUMA-aware scheduling):多路机器上每个 NUMA 节点有本地内存,跨节点访存延迟约为本地的 1.5~2 倍。调度器做两件事:NUMA 层均衡设更高门槛,且迁移时同步改掉任务的"首选节点”,否则它在远端跑还要访问本地内存;自动 NUMA balancing(/proc/sys/kernel/numa_balancing)周期性把部分页设为 PROT_NONE,用缺页捕捉任务真正访问哪块内存,再把任务或页迁到同一节点(缺页路径见第 8 章 8.3 节)。
| |
提示
高并发服务的常见调优是"一个 worker 线程固定绑一核 + 关闭该核的自动 NUMA balancing",目的是压掉迁移带来的尾延迟抖动(p99),平均吞吐往往没有明显变化。把这个 trade-off 说清楚——绑核优化的是延迟的稳定性,不是吞吐——比背 taskset 语法有价值得多。
5.7 上下文切换:开销构成与量化
上下文切换(context switch)是"内核保存一个任务的可恢复执行状态,并恢复另一个任务的同类状态"。它贵不在切换这个动作,而在切换导致的环境失效。
| |
| 指标 | 典型量级 | 观测方式 |
|---|---|---|
| 直接开销(保存/恢复 + 栈切换) | 约 1~3 μs | perf stat -e context-switches、perf sched latency |
| 含 TLB/cache 间接影响的端到端 | 约 3~5 μs;大工作集或跨 NUMA 可到 10 μs 以上 | 微基准(lmbench 类测试的 lat_ctx) |
| 与系统调用的对比 | 系统调用约 100 ns ~ 1 μs,即切换比"进一次内核"还贵数倍 | perf stat、strace -c |
| 线程 vs 进程切换 | 同进程内线程共享 mm_struct,不换 CR3,省掉 TLB 冲刷与页表遍历 | /proc/[pid]/status 的 voluntary_ctxt_switches |
注意
现代 CPU 用 PCID(process-context identifier,CR3 中的地址空间标识,见 Intel SDM 对 CR3 的描述)缓解 TLB 冲刷:给地址空间打标签,切换时不必清空 TLB,只失效被复用的标签。这是"线程切换比进程切换便宜"被削弱的原因;答"进程切换慢因为要换页表"不算错,但不知道 PCID 就答不出"慢多少取决于 TLB 覆盖了多少工作集"。
自愿切换 vs 非自愿切换,是排查中区分"进程在等"和"进程被抢"的分水岭:
| 类型 | 产生原因 | 观测字段 | 高值意味着什么 |
|---|---|---|---|
| 自愿切换(voluntary) | 主动阻塞:等 I/O、等锁、sleep、read 无数据 | /proc/[pid]/status 的 voluntary_ctxt_switches | 时间花在等待上,瓶颈很可能在 I/O 或锁竞争,而非 CPU |
| 非自愿切换(involuntary) | 时间片耗尽被抢占,或被更高优先级任务插队 | nonvoluntary_ctxt_switches;pidstat -w 的 nvcswch/s | 任务想跑却抢不到 CPU:CPU 饱和、被 RT 任务压制、或频繁被中断打断 |
| |
5.8 CPU 虚拟化:敏感指令与 KVM 的硬件辅助
CPU 虚拟化要在一台物理机上同时跑多个操作系统实例,且每个实例都以为自己独占 CPU。难点非常具体:guest 内核里的特权指令怎么办? 一类是特权指令(privileged instruction),如 lgdt、mov cr3、hlt,在用户态执行会触发异常,可让 guest 内核陷入(trap)到 hypervisor 由其模拟——陷入-模拟(trap-and-emulate),前提是硬件必须在非特权模式下执行它们时陷入;另一类是敏感指令(sensitive instruction),会读取或改变系统状态,其中一部分在传统 x86 上用户态执行不陷入(如 sgdt/sidt 直接返回真实值),这类"敏感但非特权"的指令构成虚拟化漏洞(virtualization hole),使纯陷入模拟在 x86 上不可能实现,必须靠二进制翻译或半虚拟化绕过。
| 路线 | 机制 | 代价 | 代表 |
|---|---|---|---|
| 陷入模拟 | 敏感指令在非特权态执行触发异常,hypervisor 捕获后模拟;对能陷入的指令(hlt、I/O 端口访问)效率高 | 每条被模拟的指令都是一次 VM exit,成本比系统调用高一到两个数量级 | KVM 对多数特权指令的处理 |
| 二进制翻译 | 扫描 guest 内核代码,把"敏感但不陷入"的指令就地改写成能陷入的形式并缓存翻译块 | 首次执行要扫描与改写,难处理自修改代码;好处是不改 guest | 纯软件全虚拟化思路 |
| 半虚拟化(paravirtualization) | 改 guest 内核源码,把敏感操作换成显式 hypercall | 必须能改 guest 内核(闭源系统不行),性能最好 | virtio 系列设备驱动 |
| 硬件辅助 | CPU 原生提供两种运行模式,敏感指令与特权访问自动触发 VM exit | 需要 CPU 支持,VM exit 本身仍有固定成本 | Intel VT-x、AMD-V |
KVM 的定位:KVM 只是 Linux 内核模块,提供 /dev/kvm 与 KVM_RUN ioctl,把 QEMU 之类用户态程序变成 VMM。guest 的 vCPU 就是宿主上的一个线程;KVM_RUN 让物理 CPU 进入非根模式(non-root operation)直接执行 guest 代码,遇到必须由 hypervisor 处理的事件(EXIT_REASON)才退出到根模式。KVM 不模拟 CPU、也不逐条翻译指令,只做"硬件虚拟化能力的转发与调度",因此 guest 性能上限由 VM exit 次数与单次处理成本决定。
| 扩展 | 虚拟化的对象 | 解决的问题 |
|---|---|---|
VT-x(VMX)与 EPT | CPU 本身与内存地址翻译 | 提供根/非根模式与 VMCS(虚拟机控制结构),存放 guest/host 状态与退出原因;EPT(extended page tables)让"guest 物理地址 → 宿主物理地址"的翻译由硬件完成,避免每次访存都 VM exit,也消除了"影子页表"的软件同步开销 |
| VT-d / IOMMU | 设备直通(passthrough)时的 DMA | 没有 IOMMU 时直通设备的 DMA 用真实物理地址,可绕过 hypervisor 读写任意内存;VT-d 提供设备侧地址翻译与中断重映射,使直通在安全前提下可用 |
重要
“CPU 是唯一被虚拟化的资源吗?“答案必须是否定的,且要逐类说清主体与陷阱:
内存靠 EPT 做二级翻译,坑在超分 + 气球驱动(ballooning)+ KSM 页合并,
共享物理页必须写时复制,否则一个 guest 的写入会污染另一个(机制见第 4 章 4.2 节);
I/O 最贵:全模拟设备每次访问都 VM exit,virtio 用共享内存环(virtqueue)把"每次一个 exit”
降到"批量几个 exit”,直通(VFIO)几乎零开销但依赖 VT-d,SR-IOV 把数据面下放到硬件;
中断需要虚拟中断控制器,注入要经 VM exit,直通设备的中断重映射依赖 VT-d;
时间必须虚拟化:TSC 可直通,但 vCPU 被调度出去的时间在 guest 内不可见,
定时器与真实时间会偏离,这是"虚拟机里时间不准"的根因;
带宽/IOPS 的多租户争抢表现为邻居噪声(noisy neighbor),靠 cgroup 配额约束,见第 10 章 10.4 节。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 三个目标无法同时最优;抢占的判据只有一条 | 最优依赖未来行为而未来不可知,每个算法都有对抗负载;抢占看的是运行中的任务能否被外力夺走 CPU | 答"用更好的算法就能兼顾";把"优先级调度"等同于"抢占式调度" |
vruntime 是加权虚拟时间 | vruntime += 实际时间 × 1024/weight,公平的对象是虚拟时间 | 说 CFS 给高优先级进程固定更长的时间片,忽略"每次现算" |
红黑树 + rb_leftmost | O(1) 取下一个,O(log n) 插入与删除任意节点 | 答"用堆因为取最小 O(1)",说不出堆删除任意键的短板 |
min_vruntime 双向保护 | 新唤醒/新建任务的 vruntime 被拉到队列最小值附近 | 认为"睡眠久了醒来独占 CPU"是正常现象而非需要修正的问题 |
| 实时进程按语义饿死普通进程 | RT 类队列非空时 CFS 不被查询,带宽由 sched_rt_runtime_us 限制 | 把 RT 优先级与 nice 混为一谈,或说 RT 数值越小越优先 |
| 缓存亲和常胜于负载均衡;切换贵在间接开销 | 迁移作废私有 cache;直接开销 1~3 μs,TLB/cache/分支预测器失效才是主要损失 | 认为"均衡越彻底越好";只答"保存和恢复寄存器" |
| CPU 不是唯一被虚拟化的资源 | 内存靠 EPT、I/O 靠 virtio/直通、时间靠 TSC 与中断注入 | 答"是的,其他资源都是模拟的",说不出各资源主体与陷阱 |
自测题
Q1(计算)某单核 CPU 有 4 个可运行任务,nice 分别为 0、0、5、10;sched_latency = 6 ms、min_granularity = 0.75 ms。求调度周期与各任务时间片,并验证 nice 10 的任务其 vruntime 步长是否与其他任务一致。
答案与解析
权重:nice 0 → 1024,nice 5 → 335,nice 10 → 110,Σ weight = 1024 + 1024 + 335 + 110 = 2493;周期 = max(6 ms, 4 × 0.75 ms) = 6 ms。
| |
错在哪:把 CFS 当成"给高优先级进程更长固定时间片",忽略了时间片每次现算、且公平的对象是虚拟时间。
面试官会怎么追问:①"把 min_granularity 改成 1 ms、任务数变成 100 个会怎样"——周期被撑到 100 ms,最坏情况下一个任务要等 100 ms 才轮到自己,这是就绪队列过长导致响应变慢的直接原因。②"nice 10 改成 19 呢"——权重 15,时间片约 6 × 15/2398 = 37.5 μs,已接近一次上下文切换(1~5 μs)的 10 倍以内,切换占比不再可忽略,这正是 min_granularity 存在的意义。
Q2(推导)说明为什么把新任务或刚唤醒任务的 vruntime 设为不低于 min_vruntime,既避免饿死老任务又避免睡眠进程醒来独占 CPU;去掉这一修正,两个方向分别发生什么?
答案与解析
前提事实:vruntime 只在任务占用 CPU 时增长,睡眠期间冻结,所以队列最小值 min_vruntime 单调上升,而长期睡眠任务的 vruntime 停在入睡那一刻。
不修正、新任务 vruntime 取极小值(极端为 0):新任务是队列最小值,调度器只看最左节点,于是连续选中它直到追平队列——每秒 fork 大量短命进程的服务,每个新进程都插到最前面,老任务被无限推后,新任务饿死老任务。不修正、唤醒任务沿用旧 vruntime:交互任务(睡 1 s、跑 1 ms)的 vruntime 增长极慢,长期远小于持续运行的任务,醒来后就会"补偿式"独占 CPU,而它永远补不上这 1 s(每轮只该拿 1 ms),于是反复以小 vruntime 领先,睡眠任务反复独占 CPU。
面试官会怎么追问:①"那睡眠任务岂不是白白损失应得 CPU"——是的,CFS 保证长期稳态下的比例份额,不承诺补发;交互任务要的是低延迟而非大份额。②"min_vruntime 为什么必须单调不减"——回退会让新入队任务又落到过小位置,修正失效,且单调性保证"最左节点前移"不会形成调度回环。③"每秒 fork 的程序为什么更占便宜"——短命进程几乎不消耗 CPU,真正的问题是创建频率,解法是线程池/epoll 或限制 fork 速率,而不是调调度器。
Q3(辨析)“线程切换比进程切换快,因为进程切换要换页表、刷新 TLB,线程切换不用。“这句话对吗?
答案与解析
方向对,但表述不完整,“快多少"还依赖硬件特性。 机制上正确的部分:同进程线程共享 mm_struct,切换时 CR3 不变,不需要换页表;进程切换必须改 CR3。不完整之处有三点:不换页表 ≠ 不失效 TLB——若切到另一个 CPU 上运行,该 CPU 的 TLB 里没有这个地址空间的条目,仍要重新走页表遍历,只有"同一 CPU、同一地址空间"的切换才真正免掉 TLB 损失;PCID 削弱了差距——现代 x86 的 TLB 条目可携带地址空间标签,切换 CR3 时不必全局刷新,此时进程切换的额外成本主要变成"新任务工作集不在 cache 里”;成本大头常不在 TLB 而在 cache 与分支预测器——即使 TLB 保留,新任务的数据与指令也不在 L1/L2。
错在哪:把"快不快"简化成"要不要换 CR3”。加分答法:分三种情形——同 CPU 同地址空间(最省)、同 CPU 不同地址空间但有 PCID(次省)、跨 CPU 甚至跨 NUMA(最贵,cache 冷启动主导)。工程上与其纠结线程还是进程,不如先解决跨核迁移。
Q4(设计)一个 8 核服务:用户态 CPU 使用率仅约 40%,但 vmstat 的 cs 高达 30 万/秒,pidstat -w 显示各工作线程 nvcswch/s 很高而 cswch/s 不高。给出判断与排查步骤,并说明从哪入手压掉这个开销。
答案与解析
判断:nvcswch/s(非自愿)高而 cswch/s(自愿)不高,说明线程不是在等 I/O 或等锁,而是想跑却抢不到 CPU——被抢占。用户态使用率只有 40%,说明 CPU 容量不是被业务逻辑吃掉的,上下文切换本身就是消耗方。按可能性排查:① 就绪队列过长(vmstat 的 r 远大于 8);② 实时任务周期性抢占(ps -eLo pid,tid,cls,rtprio 看 cls 是否为 FF/RR);③ 高频唤醒风暴(忙轮询 + sched_yield、每毫秒唤醒几十个线程);④ 中断集中在一核(若 in 也高,查 /proc/interrupts 分布与 RSS/RPS);⑤ 若 cswch/s 其实也不低,应怀疑 futex 竞争导致的自愿切换(见第 6 章 6.3 节)而非调度器。定位工具:pidstat -w -t 1、perf sched latency、perf record -e sched:sched_switch -a -- sleep 5。
压掉开销:把 runnable 线程数与核数对齐(CPU 密集取 nproc,I/O 密集取 nproc × (1 + 等待/计算));消除忙轮询与过频 sched_yield;关键线程绑核并用 isolcpus 摘出通用均衡;核查 RT 任务的优先级与 sched_rt_runtime_us 带宽;网卡中断按队列分散到多核。面试官会怎么追问:"cs 高但 us 不高,怎么排除系统调用引起?“看 sy 与 in;若 sy 也低,说明 CPU 花在调度路径的"轻量但高频"工作上,此时 perf top 通常会看到 schedule、finish_task_switch、try_to_wake_up 排在前面。
Q5(判断)逐条判断下列说法是否正确并说明理由:(1) x86 上最早无法用陷入-模拟实现全虚拟化,是因为有些敏感指令在用户态执行不会陷入;(2) KVM 会模拟 guest 的每一条指令,所以 guest 性能主要取决于 KVM 的翻译效率;(3) VT-d 是用来加速 guest 内存地址翻译的。
答案与解析
(1) 正确。 这正是虚拟化漏洞的定义:敏感指令中有一部分在 x86 用户态执行时不触发异常(如直接读取描述符表寄存器),hypervisor 无从捕获,只能靠扫描并改写 guest 代码的二进制翻译(或半虚拟化)绕过,直到 VT-x/AMD-V 提供强制陷入的硬件机制。
(2) 错误。 KVM 基于硬件虚拟化,不逐条模拟 guest 指令。vCPU 是宿主线程,KVM_RUN 让物理 CPU 进入非根模式直接执行 guest 代码,绝大多数指令以原生速度运行,只有必须由 hypervisor 介入的事件才 VM exit。所以瓶颈是 VM exit 的频率与单次成本,KVM 根本没有翻译环节。“模拟每条指令"描述的是无 KVM 时 QEMU 的 TCG 模式。
(3) 错误。 VT-d 虚拟化的是 I/O 侧:为设备 DMA 提供地址翻译与中断重映射,是设备直通的安全前提。没有 VT-d,直通设备的 DMA 用真实物理地址,可绕过 hypervisor 读写任意内存。负责 guest 内存地址翻译的是 EPT(Intel)/ NPT(AMD),属 CPU 侧扩展。
错在哪:三条踩的是同一个坑——把"某类资源"与"某项技术"混为一谈,没区分 CPU 侧(VT-x/EPT)、I/O 侧(VT-d/IOMMU)与软件层(KVM 的调度、QEMU 的设备模型)。面试官会怎么追问:①"既然有 EPT,为什么还出现过影子页表”——影子页表是 EPT 之前的软件方案,为每个 guest 维护"guest 虚拟地址 → 宿主物理地址"的合成页表,guest 每次改页表都要同步;EPT 把这一步交给硬件,代价是 TLB miss 时多走一级,所以虚拟化环境里大页收益更明显。②"虚拟机里 sleep(1) 真睡满 1 秒吗”——不一定,vCPU 被调度出去的时间在 guest 内不可见,定时器精度取决于虚拟时钟源(如 kvm-clock),这也是虚拟机上延迟敏感业务必须做时钟同步的原因。
面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 为什么说不存在完美的调度器?请从调度目标出发说明。 | 矛盾的结构性而非工程性 | 错误回答:“硬件性能有限,CPU 更快就能做到完美。” 这是把结构性问题当算力问题;根因是最优策略依赖任务未来行为(何时就绪、要跑多久),而未来不可知,故每个算法都有对抗负载 | 5.1 |
CFS 的 vruntime 是怎么算的?为什么它能实现按权重分配 CPU? | 公式与比例分配的内在联系 | 错误回答:“高优先级进程时间片更长,所以分到更多 CPU。” 这只是现象;应写出 vruntime += 实际时间 × (1024/weight),并推出两任务 vruntime 追平时 t_A/t_B = weight_A/weight_B | 5.3.1 |
| 为什么 CFS 用红黑树而不是堆来组织就绪队列? | 数据结构复杂度的实际取舍 | 错误回答:“红黑树取最小值更快。” 二叉堆取根也是 O(1);真正理由是删除任意键(阻塞、唤醒重入队、改优先级、迁移)在堆上要 decrease-key,而红黑树 O(log n) 且成熟,取最小靠 rb_leftmost 缓存同样是 O(1) | 5.3.2 |
一个进程睡了很久才醒来,它的 vruntime 很小,会不会独占 CPU? | min_vruntime 与 place_entity() 的作用 | 错误回答之一:“会,所以 CFS 不公平。“之二:“不会,vruntime 会随时间增长。” 两句都错——睡眠期间 vruntime 不增长,正因如此才需要 place_entity() 把它拉到 min_vruntime 附近 | 5.4 |
一个 CPU 密集线程 nice -20,另一个 nice 19,它们各占多少 CPU? | nice→weight 换算与比例计算 | 错误回答:"−20 的独占,19 的饿死。” CFS 没有绝对优先级,两者按权重比分配(88761 : 15 ≈ 5917 : 1),19 那一方仍拿到非零份额 | 5.3.4 |
我在一个核上跑 SCHED_FIFO 的死循环,会发生什么? | 实时类与 CFS 的优先级语义 | 错误回答:“它和普通进程按优先级抢,普通进程还能跑一点。” 只要 RT 类队列非空,CFS 队列根本不被查询;但答"整机必然死"也不对,默认 sched_rt_runtime_us/sched_rt_period_us = 950000/1000000 会给普通任务留 5% 带宽 | 5.5 |
| 多核机器上,为什么有时把线程绑核比让它自由迁移更快? | CPU 缓存亲和 vs 负载均衡 | 错误回答:“绑核避免上下文切换。” 绑核不减少切换次数,它减少的是跨核迁移造成的 TLB 冲刷与私有 cache 冷启动;加分点是补充绑核主要优化尾延迟稳定性而非平均吞吐 | 5.6 |
上下文切换到底贵在哪一步?vmstat 的 cs 很高、us 却不高,你怎么判断原因? | 开销构成与自愿/非自愿切换的区分 | 错误回答之一:“就是保存和恢复寄存器。"(漏掉 TLB 冲刷、L1/L2 污染、分支预测器与流水线前端冲刷)之二:“说明 CPU 不够。"(未区分 nvcswch/s 高指向就绪队列过长或被 RT 抢占、cswch/s 高指向 I/O 等待或 futex 竞争) | 5.7 |
| KVM 是全虚拟化还是半虚拟化?它是怎么执行 guest 指令的? | 虚拟化路线与 KVM 的准确定位 | 错误回答:“KVM 逐条翻译 guest 指令。” KVM 依赖 VT-x,KVM_RUN 让物理 CPU 进入非根模式原生执行 guest,仅 VM exit 时回到 hypervisor;“逐条翻译"是纯软件模拟(TCG)的行为 | 5.8 |
| CPU 是虚拟化里唯一需要虚拟化的资源吗?还有哪些? | 虚拟化的完整视图 | 错误回答:“是的,内存和磁盘都是模拟的设备。” 需逐类说清主体与陷阱:内存(EPT + 超分 + KSM 的 COW)、I/O(virtio 共享环 / VFIO 直通 / SR-IOV)、中断(虚拟中断控制器 + VT-d 重映射)、时间(TSC 直通与 vCPU 让出造成的时钟偏差) | 5.8 |
延伸阅读
- Linux 内核源码:
kernel/sched/fair.c(update_curr()、place_entity()、sched_slice()、pick_next_task_fair())、kernel/sched/core.c(__schedule()、try_to_wake_up())、kernel/sched/rt.c、kernel/sched/deadline.c。 - Linux 内核文档:
Documentation/scheduler/sched-design-CFS.rst、sched-deadline.rst、sched-nice-design.rst。 man 7 sched(各调度策略语义与sched_setattr)、man 1 taskset、man 8 numactl、man 1 chrt、man 5 proc(/proc/[pid]/sched、/proc/[pid]/status的voluntary_ctxt_switches字段)。- Intel SDM Volume 3C:VMX(非根操作、
VMCS、VM exit 原因)与 EPT 章节;Intel VT-d 规范(DMA 重映射与中断重映射)。 - Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau,《Operating Systems: Three Easy Pieces》第 7~10 章(调度与 MLFQ)、第 23 章(虚拟化:VT-x 与 KVM 的实现讨论)。