Linux 进程调度深度实战:CFS 完全公平调度器全链路解析
Linux 内核的进程调度器是操作系统最核心的组件之一,它决定了哪个进程在何时获得 CPU 时间。自 2.6.23 内核版本以来,完全公平调度器(Completely Fair Scheduler, CFS)取代了之前的 O(1) 调度器,成为 Linux 默认的公平调度实现。CFS 以其精妙的数学模型和优雅的数据结构设计,在桌面交互、服务器高并发、容器化部署等场景中表现卓越。
本文将从 CFS 的核心设计理念出发,深入剖析其内部的 vruntime 计算机制、红黑树数据结构、实时进程支持、带宽控制、组调度以及最新的 sched_ext 扩展,并结合生产环境中的监控与调优实践,为读者构建完整的知识体系。
一、CFS 设计理念:从"时间片"到"虚拟运行时间"
传统调度器基于固定时间片轮转,存在几个固有缺陷:优先级高的进程总是获得更多时间、进程切换频率固定难以兼顾延迟与吞吐量、新进程可能遭受饥饿。CFS 革命性地摒弃了时间片的概念,转而基于一个简单而深刻的公平模型:让每个进程获得的 CPU 时间完全均等。
1.1 核心思想:理想多任务处理器
CFS 建立在"理想多任务处理器"(Ideal Multitasking Processor)的理论模型上。在理想的硬件环境中,N 个进程可以同时运行,每个进程占用 1/N 的 CPU 算力。现实中 CPU 核心有限,CFS 通过虚拟化每个进程的运行时间,让所有进程的"虚拟运行时间"保持同步增长,逼近理想状态。
这个模型的关键在于:无需给进程分配固定时间片,只需记录每个进程的实际运行时间,在每次调度决策时,选择虚拟运行时间最小的进程运行,就能在宏观上保证所有进程获得均等的 CPU 份额。
1.2 vruntime:调度决策的核心变量
虚拟运行时间(virtual runtime, vruntime)是 CFS 调度算法中每个进程最重要的调度属性,记录在 task_struct->se.vruntime 字段中:
struct sched_entity {
struct load_weight load; // 负载权重
struct rb_node run_node; // 红黑树节点
u64 exec_start; // 本次开始执行的时间
u64 sum_exec_runtime; // 累计实际运行时间
u64 vruntime; // 虚拟运行时间
u64 prev_sum_exec_runtime; // 上次累计运行时间
// ... 其他字段
};
vruntime 的计算公式为:
vruntime += (实际运行时间) * (NICE_0_LOAD / 当前进程权重)
其中 NICE_0_LOAD 是 nice 值 0 对应的基准权重(1024)。这意味着:
- Nice 值为 0 的进程:vruntime 增速等于实际时间
- Nice 值为 -20 的进程(高权重):vruntime 增速慢 → 更频繁被选中执行
- Nice 值为 19 的进程(低权重):vruntime 增速快 → 较少被选中执行
二、红黑树:高效的全局运行队列
2.1 数据结构选择
CFS 使用红黑树(Red-Black Tree)作为全局运行队列的底层数据结构,以 vruntime 作为排序键值。选择红黑树而非其他数据结构的原因是:
| 数据结构 | 插入复杂度 | 查找最小值 | 删除复杂度 | 适用性 |
|---|---|---|---|---|
| 链表 | O(1)/O(n) | O(n) | O(1) | 不适合大规模进程 |
| 最小堆 | O(log n) | O(1) | O(log n) | 仅支持全局最优,不支持中值插入 |
| 红黑树 | O(log n) | O(log n) | O(log n) | 完美支持所有操作 |
| 跳表 | O(log n) | O(1) | O(log n) | 额外内存开销大 |
红黑树的优势在于支持所有核心操作(插入、删除、查找最左节点)都在 O(log n) 时间完成,且内核已经有成熟的实现。
2.2 运行队列结构
每个 CPU 都有一个独立的 CFS 运行队列,定义在 struct cfs_rq 中:
struct cfs_rq {
struct load_weight load; // 队列总负载权重
unsigned long runnable_weight; // 可运行进程的权重总和
unsigned int nr_running; // 可运行进程数量
unsigned int h_nr_running; // 包含组调度的层级计数
u64 exec_clock; // 执行时钟
u64 min_vruntime; // 队列中最小 vruntime(单调递增)
struct rb_root_cached tasks_timeline; // 红黑树根节点(带缓存的最左节点)
struct rb_node *rb_leftmost; // 缓存的最左节点指针(O(1) 获取下一个调度目标)
struct sched_entity *curr; // 当前正在执行的进程
struct sched_entity *next; // 下一个要执行的进程(用于抢占优先级)
struct sched_entity *last; // 上次执行的进程(用于缓存预热)
};
关键优化:rb_leftmost 缓存指针使得选择下一个待调度进程的平均时间复杂度接近 O(1),仅在插入新节点或删除最左节点时才需要重新查找。
2.3 入队与出队操作
当一个进程变为可运行状态(wakeup、fork、从阻塞恢复)时,调用 enqueue_entity() 将其插入红黑树:
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED);
bool curr = cfs_rq->curr == se;
// 更新负载统计
update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
se_update_runnable(se);
update_cfs_group(se);
// 如果是新唤醒的进程,修正 vruntime 防止饥饿
if (renorm && curr)
se->vruntime += cfs_rq->min_vruntime;
// 更新进程统计
update_curr(cfs_rq);
// 入队时需要重新规范化 vruntime
if (renorm)
se->vruntime += cfs_rq->min_vruntime;
// 将节点插入红黑树(以 se->vruntime 为键值)
__enqueue_entity(cfs_rq, se);
// 检查是否需要抢占当前进程
if (!curr)
check_preempt_curr(cfs_rq, se);
}
__enqueue_entity() 执行标准红黑树插入操作,从根节点开始比较 vruntime 值,小的往左走,大的往右走,找到合适位置后插入并做红黑树再平衡。
三、调度核心流程:pick_next_task_fair
3.1 单次时钟中断的处理路径
当调度时钟触发时,CFS 执行以下核心流程:
scheduler_tick()
└─> task_tick_fair()
└─> entity_tick(cfs_rq, curr, 1)
├─> update_curr(cfs_rq) // 更新当前进程的 vruntime
└─>check_preempt_tick(cfs_rq, curr) // 检查是否应被抢占
├─> ideal_runtime = sched_period / nr_running
└─> if (curr->sum_exec_runtime > ideal_runtime)
=> resched_curr(rq) // 设置需要重新调度标志
ideal_runtime 是"理想每次连续运行时间",等于调度周期(sched_latency_ns,默认 24ms)除以最运行进程数。
3.2 抢占发生的条件
当前进程运行超过 ideal_runtime 或者有新进程的 vruntime 显著小于当前进程时,就会触发抢占:
static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
unsigned long ideal_runtime, delta_exec;
struct sched_entity *se;
s64 delta;
// 计算当前进程已运行的实际时间
delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
// 如果未达到最小粒度时间,不抢占
if (delta_exec < sysctl_sched_min_granularity) // 默认 3ms
return;
// 计算理想运行时间
ideal_runtime = sched_slice(cfs_rq, curr);
// 如果超过理想运行时间两倍,强制抢占
if (delta_exec > ideal_runtime) {
resched_curr(rq_of(cfs_rq));
clear_buddies(cfs_rq, curr);
return;
}
// 如果运行时间小于最小粒度,不抢占
if (delta_exec < sysctl_sched_min_granularity)
return;
}
3.3 选择下一个进程
当需要重新调度时,pick_next_task_fair() 从红黑树中选择最左节点:
static struct task_struct *pick_next_task_fair(struct rq *rq, struct task_struct *prev)
{
struct cfs_rq *cfs_rq = &rq->cfs;
struct sched_entity *se;
// 如果队列为空,尝试从其他 CPU 拉取任务
if (!cfs_rq->nr_running)
return NULL;
// 快速路径:使用缓存的最左节点
se = pick_next_entity(cfs_rq, NULL);
if (!se)
return NULL;
// 设置任务上下文
set_next_task_fair(rq, se->task, true);
return se->task;
}
四、睡眠进程的公平性补偿
当一个进程长时间阻塞(等待 I/O、信号量、定时睡眠等),它的 vruntime 会远小于其他 CPU 密集型进程。如果直接让它参与竞争,它将长时间抢占 CPU 导致不公平。
4.1 唤醒时的 vruntime 修正
CFS 通过调整唤醒进程的 vruntime 来解决这个问题:
static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime;
// 新创建的进程(initial=1),给予一定的 vruntime 补偿
// 防止新 fork 出来的进程饿死老进程
if (initial)
vruntime += sched_vslice(cfs_rq, se);
else
vruntime -= sysctl_sched_latency; // 给予一定的"宽限期"
// 将 vuntime 限制在 [min_vruntime, min_vruntime + latency) 范围内
// 既不让饥饿进程长时间霸占 CPU,也不让它完全没有竞争力
se->vruntime = max_vruntime(se->vruntime, vruntime);
}
4.2 sleep_avg 与交互式进程识别
CFS 还会统计进程的平均睡眠时间(sleep_avg),用于识别交互式进程:
- 高
sleep_avg(频繁短睡眠)= 交互式进程 → 唤醒时给予更大补偿 - 低
sleep_avg(长时间运行)= CPU 密集型 → 普通处理
这使得像 Vim、Shell 这样的交互工具在后台有编译任务时依然能保持流畅响应。
五、实时进程调度类
Linux 支持两种实时调度策略,它们的优先级始终高于 CFS 管理的普通进程:
5.1 SCHED_FIFO 与 SCHED_RR
| 特性 | SCHED_FIFO | SCHED_RR | SCHED_NORMAL (CFS) |
|---|---|---|---|
| 抢占规则 | 高优先级到达才抢占 | 时间片轮转+优先级 | vruntime 公平分配 |
| 时间片 | 无(运行到阻塞或有更高优先级) | 有(默认 100ms) | 动态计算 |
| 优先级范围 | 1-99(数值越大越高) | 1-99 | 100-139(nice 映射) |
| 适用场景 | 硬实时控制 | 软实时多媒体 | 通用负载 |
// 设置实时策略(需要 root 或 CAP_SYS_NICE)
struct sched_param param = { .sched_priority = 50 };
sched_setscheduler(pid, SCHED_FIFO, ¶m);
5.2 调度优先级链表
调度器按优先级顺序依次尝试各个调度类:
// 内核中的调度类优先级顺序(从高到低)
stop_sched_class // 停止类(最高优先级)
-> dl_sched_class // deadline 调度类(EDF 算法)
-> rt_sched_class // 实时调度类(FIFO/RR)
-> fair_sched_class // CFS 完全公平调度
-> idle_sched_class // 空闲调度类(最低优先级)
当调度发生时,内核按此顺序调用每个调度类的 pick_next_task(),选择第一个非空结果。这意味着实时进程总是优先于 CFS 进程运行。
六、CFS Bandwidth:CPU 配额控制
6.1 设计目的
在容器化和多租户环境中,CFS Bandwidth Control(CFS BC)提供了一种硬上限机制,限制特定进程组在固定周期内可使用的 CPU 时间。
6.2 配额模型
period = 可配置周期(默认 100ms) → cpu.cfs_period_us
quota = 周期内可用的 CPU 时间(默认 -1 即无限制) → cpu.cfs_quota_us
nr_cpus = 可用的 CPU 核心数
例如:在一个 4 核系统上,要限制某组进程使用不超过 2 个核心:
# 设置周期 100ms,配额 200ms(等价于 2 颗 CPU)
echo 100000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_period_us
echo 200000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_quota_us
6.3 带宽节流机制
// 带宽会计核心逻辑
static int do_sched_cfs_period_timer(struct cfs_bandwidth *cfs_b)
{
// 周期结束,补充配额
cfs_b->runtime = cfs_b->quota; // 重置为配额值
return 0; // 不再 throttle
}
static int do_sched_cfs_slots_timer(struct cfs_bandwidth *cfs_b)
{
// 配额耗尽,开始节流
throttle_cfs_rq(cfs_rq); // 将该运行队列从活跃列表中移除
return 1; // 继续在非活跃列表中
}
当配额耗尽时,相关进程进入 throttled 状态,不会参与调度,直到下一个周期开始才补充配额。
七、组调度(Group Scheduling)
7.1 为什么需要组调度
在多用户桌面环境或容器平台中,我们需要将 CPU 资源按组分配——每个用户/容器获得固定份额,组内进程再公平分享这个份额。没有组调度时,进程数量多的用户会获得不成比例的 CPU。
7.2 两级调度架构
CFS 组调度的实现分为两层:
组间调度:struct task_group -> 每个组有独立的 struct cfs_rq
│
├─ 用户A (获得 50% CPU份额)
│ ├─ 进程A1 (在组内红黑树中)
│ └─ 进程A2
│
└─ 用户B (获得 50% CPU份额)
├─ 进程B1
├─ 进程B2
└─ 进程B3
每个 task_group 维护自己的 cfs_rq 层级,在全局调度时先决定哪个组获得 CPU,再在组内决定哪个进程获得 CPU。
7.3 cgroup 层级配置
# 创建子 cgroup
mkdir /sys/fs/cgroup/cpu/team_a
mkdir /sys/fs/cgroup/cpu/team_b
# 设置组间 CPU 权重(默认 1024)
echo 2048 > /sys/fs/cgroup/cpu/team_a/cpu.shares # team_a 获得 2/3
echo 1024 > /sys/fs/cgroup/cpu/team_b/cpu.shares # team_b 获得 1/3
# 将进程加入组
echo $PID_A1 > /sys/fs/cgroup/cpu/team_a/cgroup.procs
八、NUMA 感知调度
8.1 非一致性内存访问的挑战
在 NUMA 架构的服务器上,内存访问延迟因 CPU 和内存的拓扑关系不同而有显著差异。跨 NUMA 节点的内存访问延迟可能是本地访问的 2-3 倍。
8.2 AutoNUMA 平衡机制
从 Linux 3.8 开始引入的 AutoNUMA(Automatic NUMA Balancing)通过以下步骤工作:
- 扫描阶段:内核定期扫描进程的页表,标记被远程节点访问的页面
- 统计阶段:根据访问频率识别需要迁移到本地节点的页面
- 迁移阶段:将页面迁移到访问它的 CPU 所在的 NUMA 节点
- 任务迁移:如果某个进程主要在远程节点运行,迁移整个进程到该节点
- 游戏引擎:主渲染线程优先,IO 线程紧随其后
- AI 推理服务:批量请求合并执行,避免频繁上下文切换
- 5G 基站:严格控制调度延迟在微秒级
- 云原生平台:更精细的 QoS 分层和隔离策略
# 查看 NUMA 平衡状态
cat /proc/sys/kernel/numa_balancing # 0=禁用, 1=启用
# 查看当前进程的 NUMA 策略
numactl --show
# 绑定进程到特定 NUMA 节点
numactl --cpunodebind=0 --membind=0 ./my_program
8.3 NUMA 与 CFS 的协同
CFS 在调度时会考虑 NUMA 亲和性:当进程被唤醒时,调度器优先选择它上次运行的 CPU 所在的 NUMA 节点上的空闲核心,尽可能减少跨节点调度:
// NUMA 感知的进程选择逻辑
static int select_task_rq_fair(struct task_struct *p, int prev_cpu, int wake_flags)
{
// 1. 尝试上次运行的 CPU(L1/L2 缓存热度)
// 2. 尝试同 NUMA 节点内的空闲 CPU
// 3. 考虑 wake_affine(两个有通信关系的进程放同节点)
// 4. 全局范围内找最优空闲 CPU
}
九、sched_ext:BPF 调度器扩展
9.1 为什么需要可定制调度器
尽管 CFS 在通用场景表现优异,但在特定领域(如游戏服务器的高优先级实时线程、AI 推理的批量任务调度、HPC 的科学计算)中,需要更加定制化的调度策略。传统做法是修改内核代码,但升级和维护成本极高。
9.2 sched_ext 架构
Linux 6.12 引入的 sched_ext(Scheduler Extensibility)允许用户空间通过 BPF 程序动态加载自定义调度器,无需修改内核源码:
// BPF 调度器示例:简单的全局 FIFO 调度
SEC("struct_ops/sched_ext_ops")
int BPF_PROGS(sched_ext_select_cpu, struct task_struct *p, int prev_cpu, u64 wake_flags)
{
// 自定义 CPU 选择逻辑
return bpf_get_smp_processor_id();
}
SEC("struct_ops/sched_ext_ops")
int BPF_PROGS(sched_ext_enqueue, struct task_struct *p, u64 enq_flags)
{
// 自定义入队逻辑:添加到 BPF 维护的队列
return 0;
}
schedext 调度器以模块方式存在,可热插拔,且不会与 CFS 共存——需通过 sysfs 切换回默认调度器。
9.3 典型应用场景
十、生产环境监控与调优
10.1 关键性能指标
# 查看进程的 vruntime 和调度延迟
cat /proc/<PID>/sched
# 输出包含:se.vruntime, se.sum_exec_runtime, nr_migrations, nr_migrations_cold
# 全局调度延迟统计
cat /proc/schedstat
# 包含:CFS 运行队列的等待时间、调度延迟分布
# 实时监控上下文切换率
vmstat 1 # cs 列显示每秒上下文切换次数
sart -w 1 # 更详细的上下文切换分析
# 查看进程在哪些 CPU 上运行过(迁移历史)
grep "migration" /proc/<PID>/sched
10.2 关键内核参数
# 调度周期(默认 24ms)。值越小响应越快但切换开销越大
sysctl kernel.sched_latency_ns # 默认 24,000,000 ns (24ms)
# 最小时间片(默认 3ms)。保证进程至少运行这么长时间
sysctl kernel.sched_min_granularity_ns # 默认 3,000,000 ns (3ms)
# 唤醒抢占粒度(默认 4ms)。控制新唤醒进程的抢占积极性
sysctl kernel.sched_wakeup_granularity_ns # 默认 4,000,000 ns (4ms)
# 迁移成本阈值(微秒)。低于此值的迁移被认为不划算
sysctl kernel.sched_migration_cost_ns # 默认 500,000 ns (500us)
# NUMA 平衡开关
sysctl kernel.numa_balancing # 默认 1 (启用)
# 自动 NUMA 平衡扫描周期(毫秒)
sysctl kernel.numa_balancing_scan_period_min_ms
10.3 场景化调优指南
场景一:交互式桌面/终端服务器
目标:降低输入延迟,提高响应速度
# 缩短调度周期,让进程更快获得 CPU
sysctl -w kernel.sched_latency_ns=12000000 # 12ms(默认的一半)
# 降低最小粒度,让时间片分配更细
sysctl -w kernel.sched_min_granularity_ns=1500000 # 1.5ms
# 减少唤醒抢占的阈值,让新唤醒进程更早抢占
sysctl -w kernel.sched_wakeup_granularity_ns=2000000 # 2ms
场景二:高吞吐量批处理/编译服务器
目标:减少上下文切换,提高吞吐量
# 增大调度周期,减少切换频率
sysctl -w kernel.sched_latency_ns=48000000 # 48ms(默认的两倍)
# 增大最小粒度,防止频繁切换
sysctl -w kernel.sched_min_granularity_ns=6000000 # 6ms
# 关闭 NUMA 平衡(减少统计开销)
sysctl -w kernel.numa_balancing=0
场景三:低延迟实时交易系统
目标:保证关键线程的确定性延迟
# 将关键进程设为 SCHED_FIFO 实时策略
chrt -f 99 ./trading_engine
# 使用 CPU 隔离避免其他进程干扰
# 内核启动参数:isolcpus=2,3,5
# 将中断绑定到非关键 CPU
echo 7 > /proc/irq/<IRQ>/smp_affinity # 绑定到 CPU 0,1,2
# 启用 Full dynticks 模式(减少时钟中断干扰)
# 内核启动参数:nohz_full=2,3,5 rcu_nocbs=2,3,5
10.4 诊断工具链
# perf sched:调度器性能分析器
perf sched record -- sleep 1 # 记录 1 秒调度事件
perf sched latency # 显示每个进程的最大/平均调度延迟
perf sched map # 可视化 CPU 迁移热力图
# ftrace:跟踪内核调度函数
echo "sched_switch sched_wakeup" > /sys/kernel/debug/tracing/set_event
cat /sys/kernel/debug/tracing/trace_pipe
# BPF 跟踪:使用 bpftrace 实时分析
bpftrace -e 'tracepoint:sched:sched_switch { @[comm] = count(); }'
# CFS Bandwidth 监控
cat /sys/fs/cgroup/cpu/cpu.stat # 显示 nr_periods, nr_throttled, throttled_time
十一、总结:CFS 调度器核心要点
CFS 的设计哲学和工程实践为我们提供了几个关键启示:
公平性的数学建模。CFS 不是简单地分配时间片,而是通过 vruntime 这个虚拟维度,将"公平"从定性概念转化为可量化的调度目标,这是其优雅的根本原因。
数据结构驱动性能。红黑树 + 最左节点缓存使得调度决策接近 O(1),即使运行队列中有数千个进程也不会成为瓶颈,这种优化思路在系统设计中普遍适用。
分层与模块化。从调度类(dl → rt → fair → idle)的优先级链,到组调度的两级架构,再到 sched_ext 的可扩展机制,CFS 体现了清晰的分层思想,使得不同需求可以独立组合满足。
场景化的调优平衡。没有一组固定的参数适合所有场景——低延迟、高吞吐、实时性之间存在固有的 trade-off。理解 vruntime、调度周期、最小粒度等核心参数的物理意义,是做出正确调优决策的前提。
CFS 自诞生以来已经演进近二十年,但核心的"理想多任务处理器"模型依然有效。随着 sched_ext 的引入,Linux 调度器正在从静态的通用算法走向可编程的、面向特定场景的调度平台,这将是未来几年内核调度子系统最重要的演进方向。
*本文涵盖 CFS 核心算法、红黑树数据结构、vruntime 计算、睡眠补偿机制、实时进程支持、CFS Bandwidth 配额控制、组调度架构、AutoNUMA 平衡、sched_ext BPF 调度器扩展、生产环境监控方法论、10 个内核参数详解、3 种典型场景调优方案、4 套诊断工具链使用指南,共计约 4500 字。*

发表评论 取消回复