在上一章中,我们深入剖析了进程的创建、状态转换与生命周期管理。但一个关键问题尚未回答:当一个进程处于 TASK_RUNNING 状态时,它何时能获得 CPU?运行多久?谁来决定下一个运行的是谁?这些问题的答案,就藏在 Linux 的进程调度器中。
调度器是内核中最核心的子系统之一——它直接决定了系统的响应速度、吞吐量和公平性。一个设计糟糕的调度器,会让桌面卡顿、服务器吞吐量骤降、实时任务错过截止时间。Linux 的调度器经历了从 O(1) 到 CFS 再到 EEVDF 的漫长演进,每一次迭代都折射出对”调度本质问题”的更深层理解。
本章将从调度的根本矛盾出发,带你逐层拆解 Linux 调度器的设计哲学、核心数据结构与算法实现。
一、调度的本质问题:公平性 vs 响应性 vs 吞吐量
在深入 Linux 调度器的实现之前,需要先理解调度器面临的根本矛盾。任何调度器都无法同时最优化以下三个目标:
公平性(Fairness):每个进程应该获得公平的 CPU 时间份额。一个低优先级的批处理任务不应该被完全饿死,一个高优先级的交互进程也不应该独占 CPU。公平性保证了多用户、多任务环境下的资源分配正义。
响应性(Responsiveness):交互式任务(如键盘输入、鼠标点击)需要极低的延迟。用户按下按键后,系统应该在毫秒级内响应,否则用户会感知到明显的”卡顿”。响应性要求调度器能快速将 CPU 分配给刚被唤醒的交互进程。
吞吐量(Throughput):系统在单位时间内完成的总工作量。批处理任务(如编译内核、视频编码)希望尽可能少地被中断,因为每次上下文切换都有开销——寄存器保存/恢复、TLB 刷新、缓存失效。吞吐量要求调度器减少切换次数,让进程运行更长的时间片。
这三个目标之间存在深刻的张力:
- 公平性 vs 吞吐量:越公平地分配 CPU,就需要越频繁地切换进程,上下文切换开销越大,吞吐量越低。
- 响应性 vs 吞吐量:为了快速响应交互任务,需要抢占当前运行的批处理任务,但这会打断其缓存热状态,降低吞吐量。
- 公平性 vs 响应性:严格公平意味着交互进程不能获得额外优待,但交互场景恰恰需要优先调度。
操作系统教科书通常将调度目标分为面向用户的指标(响应时间、公平性)和面向系统的指标(CPU 利用率、吞吐量)。Linux 调度器的设计哲学是:在保证公平性的前提下,通过启发式方法提升交互响应,而不是牺牲公平性来换取响应性。
Linux 的 CFS 调度器选择了一条独特的道路:以公平性为第一原则,通过虚拟运行时间(vruntime)实现精确的公平分配,同时利用唤醒抢占、最小粒度等机制兼顾响应性。这种设计哲学与传统的优先级驱动调度器(如 FreeBSD 的 ULE、Windows 的多级反馈队列)形成了鲜明对比。
二、Linux 调度器演进史:O(1) → CFS → EEVDF
Linux 调度器并非一蹴而就,它经历了多次重大重构,每次演进都解决了前一代的核心缺陷。
2.1 O(1) 调度器(2.4 - 2.6 早期)
O(1) 调度器由 Ingo Molnar 在 2.4 内核中引入,其核心设计是优先级数组 + 活跃/过期双队列:
- 维护 140 个优先级队列(0-139,0 最高),每个优先级对应一个链表
- 活跃队列(active)存放有时间片剩余的进程,过期队列(expired)存放时间片耗尽的进程
- 选择下一个进程只需找到活跃队列中最高优先级的非空链表,时间复杂度 O(1)
- 时间片耗尽后,进程从活跃队列移到过期队列;活跃队列为空时,交换两个队列的角色
O(1) 调度器的致命问题在于交互进程的启发式检测:它通过分析进程的睡眠时间来猜测”这是否是交互进程”,并给予额外优待。这套启发式规则极其复杂,容易出错,且在特定负载下会导致严重的公平性问题——一个”伪装成交互进程”的批处理任务可以获取远超公平份额的 CPU 时间。
2.2 CFS 完全公平调度器(2.6.23 - 6.5)
2007 年,Ingo Molnar 用 CFS(Completely Fair Scheduler)彻底替换了 O(1) 调度器。CFS 的设计灵感来自理想公平调度器的理论模型:
如果有一个无限精确的处理器,可以在无穷小的时间片内将 CPU 同时分配给所有 runnable 进程,那么每个进程都能获得完全公平的 CPU 份额。
CFS 不会去猜测”谁是交互进程”,而是通过**虚拟运行时间(vruntime)**精确追踪每个进程获得的 CPU 时间,并始终选择 vruntime 最小的进程运行——这就是”完全公平”的含义。
CFS 的核心优势:
- 消除启发式:不再需要复杂的交互进程检测逻辑
- 理论可证明的公平性:vruntime 的差异在数学上有界
- 简洁优雅:核心逻辑不到 1000 行代码(
kernel/sched/fair.c)
2.3 EEVDF 调度器(6.6+)
2023 年,Peter Zijlstra 提出了 EEVDF(Earliest Eligible Virtual Deadline First)调度器,在 6.6 内核中作为默认调度器替代 CFS。EEVDF 解决了 CFS 的几个长期痛点:
- 延迟可预测性:CFS 的唤醒抢占可能导致延迟不可预测,EEVDF 通过虚拟截止时间(virtual deadline)提供有界的调度延迟
- 更好的实时行为:EEVDF 的 Eligibility 机制可以更精确地控制进程的调度时机
- 权重与带宽的统一:EEVDF 将 nice 值的语义从”权重比”扩展为”带宽比”,更符合直觉
EEVDF 是 CFS 的演进而非革命——它保留了 vruntime 和红黑树的核心设计,但改变了选择下一个进程的策略。理解 CFS 是理解 EEVDF 的前提。本文以 CFS 为主进行讲解,在关键差异处会标注 EEVDF 的变化。
三、CFS 核心设计:虚拟运行时间与红黑树
3.1 sched_entity:调度的基本单位
在 CFS 中,调度的基本单位不是 task_struct,而是 sched_entity。这个设计使得 CFS 可以同时调度单个进程和进程组(cgroup),实现了调度的层级化:
struct sched_entity { struct load_weight load; // 权重,由 nice 值决定 struct rb_node run_node; // 红黑树节点 unsigned int on_rq; // 是否在运行队列中
u64 vruntime; // 虚拟运行时间(核心字段!) u64 sum_exec_runtime; // 实际运行总时间 u64 prev_sum_exec_runtime; // 上次迁移时的实际运行时间 // ...};每个 task_struct 内嵌了一个 sched_entity:
struct task_struct { // ... const struct sched_class *sched_class; // 调度类 struct sched_entity se; // CFS 调度实体 struct sched_rt_entity rt; // 实时调度实体 struct sched_dl_entity dl; // Deadline 调度实体 int prio; // 动态优先级 // ...};3.2 vruntime:虚拟运行时间
vruntime 是 CFS 的灵魂。它的含义是:考虑权重后,进程获得的”虚拟”CPU 时间。
对于一个 nice 值为 0(权重 1024)的进程,vruntime 的增长速度等于实际运行时间。对于更高优先级的进程(nice < 0,权重 > 1024),vruntime 增长更慢,因此在红黑树中会靠左,更容易被选中;对于更低优先级的进程(nice > 0,权重 < 1024),vruntime 增长更快,在红黑树中会靠右,被选中的机会更少。
vruntime 的更新发生在每次时钟中断和上下文切换时:
// kernel/sched/fair.c(简化)static void update_curr(struct cfs_rq *cfs_rq){ struct sched_entity *curr = cfs_rq->curr; u64 now = rq_clock_task(rq_of(cfs_rq)); u64 delta_exec;
delta_exec = now - curr->exec_start; // 本次运行的实际时间 curr->sum_exec_runtime += delta_exec; // 累加实际运行时间
// 核心公式:vruntime 增量 = 实际时间 × (NICE_0_LOAD / 权重) curr->vruntime += calc_delta_fair(delta_exec, curr);}calc_delta_fair 的核心计算:
static u64 calc_delta_fair(u64 delta, struct sched_entity *se){ if (unlikely(se->load.weight != NICE_0_LOAD)) delta = __calc_delta(delta, NICE_0_LOAD, &se->load); return delta;}当权重等于 NICE_0_LOAD(1024)时,vruntime 增量等于实际时间增量;否则按比例缩放。
3.3 红黑树:O(log n) 的进程选择
CFS 使用红黑树来组织所有 runnable 进程,以 vruntime 为键值:
- 插入:进程变为 runnable 时,以其 vruntime 为键插入红黑树,O(log n)
- 选择:取红黑树最左节点(vruntime 最小),O(1)
- 删除:进程阻塞或被抢占时,从红黑树中删除,O(log n)
上图中,进程 D 的 vruntime 最小(10),位于红黑树最左节点,CFS 将选择它作为下一个运行的进程。进程 B 的 nice=-5(高优先级),其 vruntime 增长较慢,因此更容易保持在树的左侧。
3.4 cfs_rq:CFS 运行队列
每个 CPU 都有自己的运行队列 rq,其中包含一个 CFS 运行队列 cfs_rq:
struct cfs_rq { struct rb_root_cached tasks_timeline; // 红黑树根节点(含最左节点缓存) unsigned int nr_running; // 队列中的进程数 u64 min_vruntime; // 队列的最小虚拟运行时间 struct sched_entity *curr; // 当前正在运行的进程 struct sched_entity *next; // 下一个待运行的进程(缓存提示) u64 exec_clock; // 执行时间时钟 // ...};rb_root_cached 是关键优化——它在红黑树根节点之外额外缓存了最左节点指针,使得”选择 vruntime 最小的进程”这一最频繁的操作从 O(log n) 降为 O(1)。
四、CFS 的公平性保证:min_vruntime、时间片与权重
4.1 min_vruntime:防止新进程饿死老进程
min_vruntime 是 CFS 运行队列的单调递增计数器,它追踪队列中所有进程 vruntime 的最小值。它的核心作用是防止新创建或新唤醒的进程通过设置极小的 vruntime 来抢占大量 CPU 时间。
当一个新进程加入 CFS 运行队列时,其初始 vruntime 不是 0,而是设置为 min_vruntime:
// kernel/sched/fair.c(简化)static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial){ u64 vruntime = cfs_rq->min_vruntime;
if (initial) { // 新创建的进程:给一点"优惠",但不会太多 vruntime += sched_vslice(cfs_rq, se); } else { // 唤醒的进程:给少量优惠以提升响应性 vruntime -= sysctl_sched_wakeup_granularity; if (vruntime < cfs_rq->min_vruntime) vruntime = cfs_rq->min_vruntime; }
se->vruntime = max(se->vruntime, vruntime);}min_vruntime 的更新规则:
static void update_min_vruntime(struct cfs_rq *cfs_rq){ u64 vruntime = cfs_rq->min_vruntime;
if (cfs_rq->curr) vruntime = cfs_rq->curr->vruntime;
if (cfs_rq->rb_leftmost) { struct sched_entity *se = __node_2_se(cfs_rq->rb_leftmost); vruntime = min(vruntime, se->vruntime); }
// 单调递增:只增不减 cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);}4.2 时间片计算:sched_period 与 sched_slice
CFS 没有传统意义上的”固定时间片”。它使用**调度周期(sched_period)**的概念:在一个调度周期内,所有 runnable 进程都至少运行一次。
调度周期的计算:
static u64 __sched_period(unsigned long nr_running){ if (unlikely(nr_running > sched_nr_latency)) return nr_running * sysctl_sched_min_granularity; else return sysctl_sched_latency;}- 当 runnable 进程数 ≤
sched_nr_latency(默认 8)时,调度周期 =sched_sched_latency(默认 6ms) - 当 runnable 进程数 >
sched_nr_latency时,调度周期 = 进程数 ×sched_min_granularity(默认 0.75ms)
每个进程的时间片则按权重比例分配:
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se){ unsigned int nr_running = cfs_rq->nr_running; u64 slice;
slice = __sched_period(nr_running + !!se->on_rq);
// 按权重比例分配 slice = __calc_delta(slice, se->load.weight, &cfs_rq->load);
return max_t(u64, slice, sysctl_sched_min_granularity);}CFS 的时间片不是固定的——它取决于当前 runnable 进程的数量和各进程的权重。进程越多,每个进程分到的时间片越短;权重越高(nice 值越低),分到的时间片越长。
4.3 nice 值与权重的关系
nice 值的范围是 -20 到 19,默认 0。nice 值与权重的映射关系定义在 prio_to_weight 数组中:
const int sched_prio_to_weight[40] = { /* -20 */ 88, 110, 138, 172, 215, /* -16 */ 270, 339, 425, 534, 671, /* -11 */ 842, 1058, 1331, 1673, 2102, /* -6 */ 2641, 3315, 4162, 5227, 6568, /* -1 */ 8192, 10240, 12800, 16000, 20000, /* +4 */ 25000, 31250, 39062, 48828, 61035, /* +9 */ 76294, 95367, 119209, 149012, 186264, /* +14 */ 232830, 291038, 363798, 454747, 568434, /* +19 */ 710551,};关键规律:nice 值每降低 1,权重增加约 10%。这意味着:
- nice 值差 1 的两个进程,CPU 时间比约为 1.1 : 1
- nice 值差 5 的两个进程,CPU 时间比约为 1.6 : 1
- nice 值差 10 的两个进程,CPU 时间比约为 2.5 : 1
- nice 值差 19 的两个进程,CPU 时间比约为 8092 : 1
nice 值的”1”并不代表 1% 的差异!nice 值差 1 ≈ 10% 的 CPU 时间差异,差 5 ≈ 60% 的差异。很多初学者误以为 nice +1 只减少 1% 的 CPU 时间,这是错误的。
五、调度类:分层的调度策略
Linux 调度器采用**调度类(scheduling class)**的分层架构,每个调度类封装了独立的调度策略和实现。调度类按优先级从高到低排列:
调度类的核心接口定义在 sched_class 结构体中:
struct sched_class { const struct sched_class *next; // 下一个更低优先级的调度类
void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags); void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags); struct task_struct *(*pick_next_task)(struct rq *rq);
void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags); void (*put_prev_task)(struct rq *rq, struct task_struct *p); void (*set_curr_task)(struct rq *rq); void (*task_tick)(struct rq *rq, struct task_struct *p, int queued); // ...};调度类的选择逻辑:当需要选择下一个运行的进程时,内核从最高优先级的调度类开始,依次调用 pick_next_task()。如果当前调度类没有 runnable 进程,则尝试下一个调度类。这意味着:
- stop 类的进程永远优先(用于内核内部停机操作,如 CPU 热插拔)
- deadline 类的进程优先于所有实时和普通进程
- rt 类的进程优先于所有普通进程
- fair 类的进程在无实时任务时才运行
- idle 类的进程只在 CPU 完全空闲时运行
stop_sched_class 不对用户空间暴露,仅用于内核内部的”停机”操作(如 CPU 热插拔、RCU 同步等)。它运行一个迁移线程(migration/X),优先级高于一切,且不可被抢占。
六、实时调度:SCHED_FIFO 与 SCHED_RR
实时调度策略用于有严格时序要求的任务,如音频处理、工业控制、高频交易。Linux 提供了两种 POSIX 实时调度策略:
SCHED_FIFO:先进先出
SCHED_FIFO 的规则非常简单:
- 实时优先级范围 1-99,数值越大优先级越高
- 同优先级的进程按 FIFO 顺序运行
- SCHED_FIFO 进程没有时间片——它会一直运行,直到:
- 主动调用
sched_yield()让出 CPU - 阻塞在 I/O 操作上(如等待锁、读磁盘)
- 被更高优先级的实时进程抢占
- 调用
sched_setscheduler()改变调度策略
- 主动调用
// 设置 SCHED_FIFO 调度策略struct sched_param param = { .sched_priority = 50 }; // 实时优先级 50sched_setscheduler(getpid(), SCHED_FIFO, ¶m);SCHED_RR:时间片轮转
SCHED_RR 与 SCHED_FIFO 的唯一区别是:同优先级的 SCHED_RR 进程按时间片轮转。时间片长度可通过 /proc/sys/kernel/sched_rr_timeslice_ms 查看和设置(默认 100ms):
# 查看 SCHED_RR 时间片cat /proc/sys/kernel/sched_rr_timeslice_ms# 输出:100
# 设置为 50msecho 50 | sudo tee /proc/sys/kernel/sched_rr_timeslice_ms实时调度的危险性
SCHED_FIFO 进程如果不主动让出 CPU,会永久霸占该 CPU,导致系统完全无响应!这是实时调度最常见的”自杀式”用法。
Linux 通过 /proc/sys/kernel/sched_rt_runtime_us 和 /proc/sys/kernel/sched_rt_period_us 两个参数限制实时任务的 CPU 占用:
# 默认配置:在 1 秒周期内,实时任务最多占用 0.95 秒cat /proc/sys/kernel/sched_rt_period_us # 1000000 (1秒)cat /proc/sys/kernel/ssched_rt_runtime_us # 950000 (0.95秒)这保证了即使实时任务全速运行,也有 5% 的 CPU 时间留给普通进程,避免系统完全锁死。
SCHED_DEADLINE:截止时间调度
SCHED_DEADLINE 是三种实时策略中最强的一种,基于 EDF(Earliest Deadline First)算法。它需要指定三个参数:
- runtime:在一个周期内需要的最大 CPU 时间(微秒)
- period:任务周期(微秒)
- deadline:绝对截止时间(通常等于 period)
// 设置 SCHED_DEADLINEstruct sched_attr attr = { .size = sizeof(attr), .sched_policy = SCHED_DEADLINE, .sched_runtime = 10 * 1000000, // 10ms 运行时间 .sched_deadline = 30 * 1000000, // 30ms 截止时间 .sched_period = 30 * 1000000, // 30ms 周期};sched_setattr(getpid(), &attr, 0);SCHED_DEADLINE 的带宽保证:只要所有 deadline 任务的带宽之和不超过 100%(即 Σ(runtime/period) ≤ 1),内核就能保证每个任务在其截止时间前获得足够的 CPU 时间。
七、调度时机:何时发生调度
调度不是随时都能发生的——它需要特定的触发条件。Linux 调度的触发时机可以分为以下几类:
1. 时钟中断(主动抢占)
时钟中断是调度器最核心的驱动力。每个时钟 tick(通常 1ms 或 4ms),内核会调用 scheduler_tick():
void scheduler_tick(void){ int cpu = smp_processor_id(); struct rq *rq = cpu_rq(cpu); struct task_struct *curr = rq->curr;
update_rq_clock(rq); curr->sched_class->task_tick(rq, curr, 0); // 调用当前调度类的 tick 处理
// 检查是否需要重新调度 if (test_tsk_need_resched(curr)) set_tsk_need_resched(curr); // ...}对于 CFS,task_tick_fair() 会检查当前进程的运行时间是否超过其应得的时间片,如果超过则设置 TIF_NEED_RESCHED 标志。
2. 自愿让出(主动调度)
进程主动调用以下函数让出 CPU:
schedule()— 直接让出 CPU,等待下次被调度sched_yield()— 让出 CPU,将自己重新加入运行队列- 阻塞操作 — 如
mutex_lock()、wait_event()、read()等会间接调用schedule()
3. 唤醒抢占(被动抢占)
当一个高优先级进程被唤醒时(如 I/O 完成、信号到达),内核会检查是否应该抢占当前运行的低优先级进程:
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags){ struct task_struct *curr = rq->curr; // ... if (curr->sched_class != p->sched_class) { // 不同调度类:高优先级调度类总是抢占低优先级 if (p->sched_class > curr->sched_class) resched_curr(rq); return; } // 同调度类:调用具体调度类的 check_preempt 方法 curr->sched_class->check_preempt_curr(rq, p, wake_flags);}4. 抢占检查点
即使设置了 TIF_NEED_RESCHED,调度也不会立即发生——它要等到抢占检查点:
- 用户抢占:从内核态返回用户态时(系统调用返回、中断返回),检查
TIF_NEED_RESCHED - 内核抢占:如果启用了
CONFIG_PREEMPT,在内核代码的抢占安全点也会检查
// arch/x86/entry/entry_64.S(简化)// 从内核态返回用户态时的抢占检查ENTRY(ret_from_fork) // ... call schedule_tail // 检查 TIF_NEED_RESCHED testl $_TIF_NEED_RESCHED, TI_flags(%r12) jnz schedule // ...八、上下文切换的内核实现
当调度器决定切换进程时,context_switch() 是实际执行切换的核心函数。它完成两件事:地址空间切换和处理器状态切换。
context_switch() 的完整流程
// kernel/sched/core.c(简化)static __always_inline struct rq *context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next, struct rq_flags *rf){ // 第一步:切换地址空间(mm_struct) switch_mm_irqs_off(prev->active_mm, next->mm, next);
// 第二步:切换处理器状态(寄存器、栈指针) switch_to(prev, next, prev);
// 第三步:屏障,确保编译器不会重排 barrier();
return finish_task_switch(prev);}switch_mm():地址空间切换
switch_mm() 负责将页表从旧进程切换到新进程:
// arch/x86/mm/tlb.c(简化)void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk){ // 加载新进程的页表基址到 CR3 寄存器 write_cr3(next->pgd);
// 刷新 TLB(Translation Lookaside Buffer) // 现代 x86 使用 PCID 减少不必要的 TLB 刷新 // ...
// 更新 CPU 的 active_mm this_cpu_write(cpu_tlbstate.active_mm, next);}内核线程没有自己的 mm_struct(tsk->mm == NULL),它借用前一个进程的 active_mm,避免不必要的页表切换。这是内核线程的重要优化——因为内核空间在所有进程间共享,切换页表是浪费。
switch_to():寄存器与栈切换
switch_to() 是架构相关的汇编宏,负责保存旧进程的寄存器并恢复新进程的寄存器:
// arch/x86/include/asm/switch_to.h(简化)#define switch_to(prev, next, last) \do { \ /* 保存旧进程的 RSP、RBP 等寄存器 */ \ save_regs(prev); \ /* 切换栈指针到新进程的内核栈 */ \ __switch_to(prev, next); \ /* 恢复新进程的寄存器 */ \ restore_regs(next); \} while (0)x86_64 的 __switch_to() 汇编实现(极度简化):
__switch_to: /* 保存 callee-saved 寄存器到 prev 的栈帧 */ push %rbp push %rbx push %r12 push %r13 push %r14 push %r15
/* 保存 prev 的栈指针到 task_struct->thread.sp */ movq %rsp, TASK_thread_sp(%rdi)
/* 加载 next 的栈指针 */ movq TASK_thread_sp(%rsi), %rsp
/* 恢复 next 的 callee-saved 寄存器 */ pop %r15 pop %r14 pop %r13 pop %r12 pop %rbx pop %rbp
/* 更新 GS 基址(per-CPU 数据) */ swapgs wrmsr
ret上下文切换的开销主要包括:
| 开销来源 | 典型耗时 | 说明 |
|---|---|---|
| 寄存器保存/恢复 | ~0.1-0.5μs | 直接 CPU 指令开销 |
| TLB 刷新 | ~1-10μs | 切换地址空间时,TLB 失效导致后续访存变慢 |
| 缓存失效 | ~1-100μs | 新进程的代码和数据不在 L1/L2 缓存中,需要从内存加载 |
| 调度器计算 | ~0.1-0.3μs | 选择下一个进程、更新运行队列 |
上下文切换的最大开销不是寄存器保存/恢复,而是缓存失效。一个”缓存热”的进程(刚运行过)和一个”缓存冷”的进程(长时间未运行)的性能差距可能高达 10 倍以上。这也是为什么调度器不应过于频繁地切换进程。
九、多核调度:负载均衡与调度域
在多核/多处理器系统上,调度器需要解决一个额外的问题:如何将进程合理地分配到各个 CPU 上?
调度域(Scheduling Domain)
Linux 用**调度域(sched_domain)**来描述 CPU 的拓扑层次结构。每个调度域包含一个或多个调度组(sched_group),形成树状结构:
系统调度域层次(以 2-socket × 4-core × 2-thread 为例):
NUMA 域(socket 间)├── MC 域(socket 0)│ ├── SMT 域(core 0:CPU 0, CPU 8)│ ├── SMT 域(core 1:CPU 1, CPU 9)│ ├── SMT 域(core 2:CPU 2, CPU 10)│ └── SMT 域(core 3:CPU 3, CPU 11)└── MC 域(socket 1) ├── SMT 域(core 4:CPU 4, CPU 12) ├── SMT 域(core 5:CPU 5, CPU 13) ├── SMT 域(core 6:CPU 6, CPU 14) └── SMT 域(core 7:CPU 7, CPU 15)调度域的层次从底到顶:
- SMT 域(Simultaneous Multithreading):超线程兄弟,共享 L1/L2 缓存和执行单元
- MC 域(Multi-Core):同一 socket 内的物理核心,共享 L3 缓存
- NUMA 域:不同 socket,通过互连总线通信,访问远端内存延迟更高
负载均衡策略
负载均衡的核心原则:尽量在低层域内均衡,减少跨域迁移。因为:
- 同一 SMT 域内迁移:几乎无开销(共享缓存)
- 同一 MC 域内迁移:L3 缓存可能失效
- 跨 NUMA 域迁移:L3 缓存必然失效,且内存访问延迟增加
负载均衡的触发时机:
- 周期性负载均衡:由
load_balance()在每个调度域的sched_balance_trigger中定期执行 - 空闲均衡:当 CPU 进入 idle 时,主动从其他 CPU “拉”任务
- fork/exec 均衡:新进程创建时,选择负载最轻的 CPU
- 主动迁移:由迁移线程(migration/X)执行,处理极端不均衡
亲和性:进程绑核
用户可以通过 sched_setaffinity() 将进程绑定到特定 CPU:
#define _GNU_SOURCE#include <sched.h>
cpu_set_t mask;CPU_ZERO(&mask);CPU_SET(2, &mask); // 绑定到 CPU 2CPU_SET(3, &mask); // 和 CPU 3sched_setaffinity(getpid(), sizeof(mask), &mask);命令行工具 taskset 提供了更便捷的接口:
# 将进程 1234 绑定到 CPU 0 和 CPU 2taskset -pc 0,2 1234
# 在 CPU 1 上启动新进程taskset -c 1 ./my_program十、cgroup v2 对调度的限制
cgroup v2 的 CPU 控制器允许对进程组的 CPU 使用进行细粒度限制,这是容器资源隔离的基础。
cpu.max:带宽限制
cpu.max 控制一个 cgroup 在给定周期内最多能使用的 CPU 时间:
# 格式:max_quota period# 示例:在 100ms 周期内最多使用 50ms CPU 时间(即 50% CPU)echo "50000 100000" > /sys/fs/cgroup/mygroup/cpu.max
# 不限制(默认)echo "max 100000" > /sys/fs/cgroup/mygroup/cpu.maxcpu.max 的实现原理:cgroup 作为一个调度实体参与 CFS 调度,当其累计运行时间超过 quota 时,整个 cgroup 被限流(throttled),直到下一个周期开始。
cpu.weight:相对权重
cpu.weight 控制多个 cgroup 之间的 CPU 时间分配比例(范围 1-10000,默认 100):
# 设置 cgroup A 的权重为 200,cgroup B 的权重为 100echo 200 > /sys/fs/cgroup/A/cpu.weightecho 100 > /sys/fs/cgroup/B/cpu.weight# 结果:A 获得 2/3 的 CPU 时间,B 获得 1/3cpu.weight 只在 CPU 未饱和时生效——如果 CPU 有空闲,所有 cgroup 都能获得足够的 CPU 时间。cpu.max 则是硬限制,无论 CPU 是否空闲都生效。
cgroup v2 CPU 控制器的实际应用
# 创建一个受限的 cgroupmkdir /sys/fs/cgroup/container1echo $$ > /sys/fs/cgroup/container1/cgroup.procs # 将当前 shell 加入echo "20000 100000" > /sys/fs/cgroup/container1/cpu.max # 限制为 20% CPU
# 验证:运行一个 CPU 密集型任务,观察 CPU 使用率dd if=/dev/zero bs=1M | md5sum &# 在另一个终端用 top 观察,CPU 使用率应被限制在 ~20%十一、nice 值、renice 与 chrt 命令详解
nice:启动时设置 nice 值
# 以 nice 值 10 启动命令(降低优先级)nice -n 10 ./my_batch_job
# 以 nice 值 -5 启动命令(提高优先级,需要 root)nice -n -5 ./my_important_taskrenice:修改运行中进程的 nice 值
# 将 PID 1234 的 nice 值改为 5renice -n 5 -p 1234
# 将用户 alice 的所有进程 nice 值改为 10renice -n 10 -u alice
# 将进程组 5678 的 nice 值改为 -3renice -n -3 -g 5678chrt:修改调度策略与实时优先级
chrt 是操作实时调度策略的核心工具:
# 查看进程的调度策略和优先级chrt -p 1234
# 以 SCHED_FIFO 优先级 50 运行chrt -f -p 50 ./realtime_task
# 以 SCHED_RR 优先级 30 运行chrt -r -p 30 ./round_robin_task
# 以 SCHED_DEADLINE 运行(runtime=10ms, deadline=30ms, period=30ms)chrt -d --runtime 10000000 --deadline 30000000 --period 30000000 ./dl_task
# 将运行中的进程改为 SCHED_FIFO 优先级 80chrt -f -p 80 1234
# 将进程改回普通调度策略chrt -o -p 0 1234十二、动手实践
实践一:查看进程调度信息
/proc/[pid]/sched 文件包含了进程调度的详细信息:
# 查看当前 shell 的调度信息cat /proc/$$/sched输出示例及关键字段解读:
bash (1234, #threads: 1)-------------------------------------------------------------------se.exec_start : 1234567890.123456 # 上次执行开始时间se.vruntime : 567890.123456 # 虚拟运行时间se.sum_exec_runtime : 1234.567890 # 实际运行总时间se.load.weight : 1048576 # 权重(nice 0 = 1048576)nr_switches : 456 # 上下文切换总次数nr_voluntary_switches : 345 # 自愿切换次数nr_involuntary_switches : 111 # 非自愿切换次数prio : 120 # 动态优先级(120 = nice 0)policy : 0 # 调度策略(0=SCHED_NORMAL)nr_voluntary_switches 高说明进程经常主动阻塞(如等待 I/O),这是交互进程的典型特征。nr_involuntary_switches 高说明进程经常被抢占,可能是 CPU 密集型任务或时间片耗尽。
实践二:nice 值对 CPU 时间分配的影响
# 终端 1:启动两个 CPU 密集型任务,一个 nice 0,一个 nice 10nice -n 0 dd if=/dev/zero bs=1M | md5sum &nice -n 10 dd if=/dev/zero bs=1M | md5sum &
# 终端 2:观察 CPU 使用率top -H# 你会发现 nice 0 的进程获得的 CPU 时间约为 nice 10 进程的 ~2.5 倍
# 清理killall dd md5sum实践三:chrt 实时调度实验
# 实验:SCHED_FIFO 进程的不可抢占性
# 终端 1:以 SCHED_FIFO 优先级 1 运行一个死循环(危险!)sudo chrt -f 1 bash -c 'while true; do :; done'# 注意:此时该终端会被完全占用,系统可能变慢但不会完全卡死# 因为 Linux 的 RT throttling 会保留 5% CPU 给普通进程
# 终端 2:查看 RT throttling 状态cat /proc/sys/kernel/sched_rt_runtime_uscat /proc/sys/kernel/sched_rt_period_us
# 终端 3:以更高优先级抢占sudo chrt -f 2 bash -c 'echo "I can preempt!" && sleep 1'# 这个进程会抢占优先级 1 的 SCHED_FIFO 进程实践四:taskset 绑核实验
# 将进程绑定到 CPU 0taskset -pc 0 $$cat /proc/$$/status | grep Cpus_allowed# 输出:Cpus_allowed: 00000001
# 启动一个 CPU 密集型任务并绑定到 CPU 1taskset -c 1 dd if=/dev/zero bs=1M | md5sum &
# 观察:只有 CPU 1 的使用率接近 100%mpstat -P ALL 1
# 清理killall dd md5sum实践五:cgroup v2 CPU 限制实验
# 确保 cgroup v2 已挂载mount | grep cgroup2
# 创建测试 cgroupsudo mkdir /sys/fs/cgroup/test-cpuecho $$ | sudo tee /sys/fs/cgroup/test-cpu/cgroup.procs
# 限制为 20% CPUecho "20000 100000" | sudo tee /sys/fs/cgroup/test-cpu/cpu.max
# 运行 CPU 密集型任务dd if=/dev/zero bs=1M | md5sum &
# 在另一个终端观察 CPU 使用率top -p $!# CPU 使用率应被限制在 ~20%
# 清理kill %1sudo rmdir /sys/fs/cgroup/test-cpu实践六:观察上下文切换
# 使用 perf 观察调度事件sudo perf sched record -- sleep 5sudo perf sched latency# 输出各进程的调度延迟统计
# 使用 vmstat 观察上下文切换频率vmstat 1# cs 列显示每秒上下文切换次数
# 使用 /proc/stat 观察全局上下文切换计数cat /proc/stat | grep ctxt# ctxt 123456789
# 使用 pidstat 观察特定进程的上下文切换pidstat -w -p ALL 1十三、核心源码导航
| 文件路径 | 核心内容 |
|---|---|
kernel/sched/core.c | 调度器核心:schedule()、context_switch()、pick_next_task() |
kernel/sched/fair.c | CFS 实现:enqueue_entity()、pick_next_entity()、update_curr() |
kernel/sched/rt.c | 实时调度:enqueue_task_rt()、pick_next_task_rt() |
kernel/sched/deadline.c | Deadline 调度:EDF 算法实现 |
kernel/sched/sched.h | 调度器内部数据结构定义 |
include/linux/sched.h | task_struct、sched_entity 等核心结构 |
kernel/sched/topology.c | 调度域与负载均衡 |
kernel/sched/cpufreq.c | 调度器与 CPU 频率调节的交互 |
参考资料
书籍
- 《Linux 内核设计与实现》 第 4 章 — Robert Love 对 Linux 调度器的经典讲解,涵盖 CFS 的设计哲学与实现
- 《深入理解 Linux 内核》 第 7 章 — Daniel P. Bovet 等对调度器数据结构与算法的详细剖析
- 《Systems Performance》 第 5 章 — Brendan Gregg 对调度性能分析与调优的实践指南
- 《操作系统导论》(OSTEP) — Remzi H. Arpaci-Dusseau,调度理论的最佳入门教材
内核文档
- CFS Design — CFS 官方设计文档
- EEVDF Scheduler — EEVDF 调度器文档
- Real-Time Scheduling — 实时调度文档
- Cgroup v2 CPU Controller — cgroup v2 CPU 控制器文档
手册页
man 1 chrt— 实时调度策略操作工具man 2 sched_setscheduler— 设置调度策略的系统调用man 2 sched_setaffinity— 设置 CPU 亲和性man 7 sched— Linux 调度概述man 1 taskset— CPU 亲和性设置工具
在线资源
- Bootlin Elixir Cross Referencer — 在线阅读内核调度器源码
- LWN.net: CFS — CFS 合并内核时的详细报道
- LWN.net: EEVDF — EEVDF 调度器的引入报道
从本章中,理解了 Linux 调度器如何通过 vruntime 和红黑树实现”完全公平”的 CPU 分配,如何通过调度类层次支持实时与普通进程的共存,以及上下文切换、多核负载均衡、cgroup 限制等关键机制。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






