写在前面
调度器干的事情说白了就一句话:CPU 就那么几颗,等着跑的进程一大堆,挑一个出来把上下文切过去。但这一挑背后牵扯的东西太多了。进程描述符里调度字段存哪些?实时进程和普通进程怎么排队?容器设的 CPU limit 限在哪一层?
这些问题散在 kernel/sched/ 下面几万行代码里,按文件去看容易"每个点都懂,串不起来"。这篇换个思路,按三条线走:先把调度器手里的对象看清楚,再讲这些对象各自遵循什么规则,最后跑一遍真实流程。对象、模型、运行——这三条线理清了,看源码或者排查问题就有个坐标系。
一、抽象对象
先看调度器手里有什么牌。每个进程怎么描述、运行队列怎么组织、调度类怎么抽象——这几个对象理清了,后面的模型和流程才有附着点。
task_struct:每个进程的调度身份
每个进程在内核里都有一个 task_struct,里面划了一块存调度信息。挑几个最要紧的字段看:
struct task_struct {
/* ... */
int prio; // 当前优先级,数值越小优先级越高
int static_prio; // 静态优先级,创建时确定
int normal_prio; // 基于静态优先级计算出的正常优先级
unsigned int state; // 进程状态,TASK_RUNNING 表示就绪
struct sched_entity se; // CFS 调度实体
struct sched_rt_entity rt; // RT 调度实体
struct sched_class *sched_class; // 当前所属调度类
struct cfs_rq *cfs_rq; // 所在 CFS 运行队列
struct rq *rq; // 所在 CPU 运行队列
/* ... */
};
prio 是"当前生效优先级",会因为实时继承、优先级调整等原因临时变化;static_prio 是底子,创建时定好一般不动。sched_class 直接决定进程归谁管——CFS、RT、deadline 还是 stop。每个进程同时只挂在一个 CPU 的 rq 上,多核就是每个 CPU 各管各的队列。
sched_entity / sched_rt_entity / sched_dl_entity:调度实体
不同调度算法对"进程"的建模不一样,所以内核把调度实体从 task_struct 里拆了出来。CFS、RT、deadline 各有各的实体结构。
CFS 实体 sched_entity 最重要:
struct sched_entity {
struct load_weight load; // 负载权重
unsigned long runnable_weight; // 队列总权重相关
u64 vruntime; // 虚拟运行时间,CFS 选下一个就靠它
u64 sum_exec_runtime; // 总共运行了多久
struct rb_node run_node; // 红黑树节点
/* ... */
};
vruntime 是 CFS 的灵魂。它把不同权重的进程拉到同一个尺度上比较:权重越高,同样实际运行时间带来的 vruntime 增量越小,于是更容易排到前面。后面讲 CFS 模型时会展开这个公式。
RT 实体的设计完全不同:
struct sched_rt_entity {
struct list_head run_list; // 同优先级链表
unsigned int time_slice; // RR 时间片,FIFO 不用
unsigned int rt_priority; // 实时优先级 0-99
/* ... */
};
RT 不讲公平,讲绝对优先级。rt_priority 越大越先跑,同优先级内部再分 FIFO 和 RR 两种策略。
deadline 实体又换了一套语义:
struct sched_dl_entity {
u64 runtime; // 每个周期需要跑多久
u64 deadline; // 截止时间
u64 period; // 周期长度
u64 runtime_remaining; // 当前周期剩余时间
u64 deadline_time; // 当前周期绝对截止时间
struct rb_node rb_node; // 按 deadline 挂红黑树
};
三种实体对应三种调度语义:CFS 按权重比公平、RT 按优先级抢占、deadline 按截止时间保证。sched_class 是把它们串进同一套框架的接口。
rq / cfs_rq / rt_rq / dl_rq:运行队列
每个 CPU 有一个顶层运行队列 struct rq,里面再按调度类分出各自的子队列:
struct rq {
unsigned int cpu; // 属于哪个 CPU
struct task_struct *curr; // 当前进程
struct task_struct *idle; // idle 进程
struct cfs_rq cfs; // CFS 运行队列
struct rt_rq rt; // RT 运行队列
struct dl_rq dl; // deadline 运行队列
unsigned long nr_running; // 就绪进程总数
/* ... */
};
CFS 子队列是一棵红黑树,按 vruntime 排序;RT 子队列是一个优先级数组,每个优先级挂一个链表;deadline 子队列又是一棵红黑树,按绝对截止时间排序。为什么选这些数据结构?CFS 每次要取 vruntime 最小的,红黑树最左节点就是;RT 每次要取最高优先级的,数组 O 扫到第一个非空链表就行。

sched_class:调度类抽象
Linux 调度器不是一个大函数,而是一组可插拔的调度类。调度类用链表串起来,按优先级从高到低排:
struct sched_class {
struct sched_class *next;
void (*enqueue_task)(struct rq *rq, struct task_struct *p);
void (*dequeue_task)(struct rq *rq, struct task_struct *p);
void (*check_preempt_curr)(struct rq *rq, struct task_struct *p);
struct task_struct *(*pick_next_task)(struct rq *rq);
/* ... */
};
每个调度类只要实现这套接口,核心调度框架就不用动。加新调度类时把它插到链表里正确的位置即可。
task_group:组调度对象
容器 CPU 隔离的根基是组调度。内核里每个任务组对应一个 task_group:
struct task_group {
#ifdef CONFIG_FAIR_GROUP_SCHED
struct sched_entity **se; // 每个 CPU 一个组调度实体
struct cfs_rq **cfs_rq; // 每个 CPU 一个组运行队列
unsigned long shares; // 组权重
#endif
#ifdef CONFIG_CFS_BANDWIDTH
struct cfs_bandwidth cfs_bandwidth; // 带宽控制
#endif
struct task_group *parent; // 父组
struct list_head children; // 子组列表
/* ... */
};
**se 和 **cfs_rq 是二级指针,因为每个任务组在每个 CPU 上都有独立的调度实体和运行队列。组调度不是全局一把锁,而是每个 CPU 上各算各的,跨 CPU 靠负载均衡。这是组调度和普通进程调度最大的区别。
namespace:调度隔离不归 namespace 管
跟调度沾边的 namespace 有两个:PID namespace 和 time namespace。它们做的事情很明确:
- • PID namespace:隔离进程 ID 和进程视图。容器里 PID 从 1 开始,看不到外面进程,发信号也只能发给同 namespace 的。
- • time namespace:隔离返回给用户空间的时间。容器改时间改的是偏移量,不影响内核真实时间。
这两个 namespace 都不碰调度器本身。进程最终还是要进某个 CPU 的 rq,调度器不会因为你属于某个 PID namespace 就优待或劣待你。这一点后面"运行"部分会再展开。
二、模型
对象摆在那儿了,接下来看它们按什么规则动。Linux 调度器里同时跑着好几种模型,互不冲突,靠调度类优先级链把它们串起来。
多核模型:每个 CPU 一个 rq
Linux 调度器把运行队列按 CPU 拆分,每个 CPU 一个 rq,进程要跑到哪个 CPU 上就挂在哪个 rq 里。好处是锁争用少,多核扩展性好;代价是单个进程只能在一个 CPU 上排队,要做负载均衡就得把进程从一个 rq 搬到另一个。
别误解——"每个 CPU 一个 rq" 不等于 "进程只能跑在一个 CPU"。默认情况下普通进程可以在多个 CPU 间迁移,负载均衡会主动搬。只有显式绑核(taskset、cpuset)或者某些实时场景才会把进程钉死在某颗核上。
调度类优先级链:stop > dl > rt > cfs
调度类按优先级串成链表:
stop_sched_class ; CPU hotplug / migration
dl_sched_class ; deadline / EDF
rt_sched_class ; FIFO / RR
fair_sched_class ; CFS (普通进程)
pick_next_task 从最高优先级开始往下找,第一个有就绪进程的调度类胜出,低优先级的根本不会被看到。只要系统里有 deadline 进程在等,RT 和普通进程就得等着;只要有 RT 进程在等,CFS 进程只能干瞪眼。
设计取舍:这条链是硬编码的,语义清晰、实现简单,代价是用户没法调整优先级顺序。实际场景中几乎没人需要改这个顺序。
CFS 模型:权重 + vruntime 的公平分配
CFS 的核心就一句话:让所有进程按权重比例分享 CPU 时间。它用 vruntime 把不同权重的进程拉平到一个维度上比较,每次选 vruntime 最小的进程运行。
vruntime 的增长公式:
vruntime_delta = delta_exec * NICE_0_LOAD / weight
权重和 nice 值的对应关系是预计算好的数组。nice -20 对应权重 88761,nice 0 对应 1024,nice +19 对应 15。每差一个 nice 值权重大约除以 1.25,所以 nice -20 的进程分到的时间是 nice 0 的 80 多倍。这是按权重公平,不是按人头平均。
CFS 没有固定时间片。调度周期 sysctl_sched_latency 默认 6ms,会被展开成当前所有就绪进程的切片。进程越多每个进程拿到的越短,但公平性不变。为了不让上下文切换把 CPU 吃光,内核加了 sched_min_granularity_ns(默认 0.75ms),保证每个进程至少跑这么久才可能被切走。比如 8 个 nice 0 进程,每个分到 6ms × 1024/ = 0.75ms,正好等于最小粒度。

wakeup 抢占模型:交互响应靠什么保证
交互进程(敲键盘唤醒的 shell、点鼠标拉起来的浏览器)要求响应快。CFS 在新进程唤醒时,会比较新进程的 vruntime 和当前进程的 vruntime,差值超过 sched_wakeup_granularity_ns(默认 1ms)就标记 TIF_NEED_RESCHED 触发抢占。
这个阈值是吞吐和响应之间的旋钮。桌面系统倾向调小,让交互更跟手;服务器调大,减少无谓的上下文切换。/proc/sys/kernel/ 下这几个参数都是可写的,sched_latency_ns、sched_min_granularity_ns、sched_wakeup_granularity_ns、sched_migration_cost_ns,根据场景调。
RT 模型:绝对优先级抢占
RT 调度不讲公平,只保证高优先级先跑。两种策略:
- • SCHED_FIFO:同优先级先进先出,一个进程不主动放弃 CPU 就一直跑。
- • SCHED_RR:同优先级轮询,每个进程有固定时间片,用完轮到下一个。
RT 的坑很直接:高优先级 RT 进程死循环,低优先级进程包括普通系统进程全部饿死。所以 RT 默认需要 CAP_SYS_NICE 才能设置,容器里一般也会把 RT runtime 限得很死。
deadline 模型:EDF 截止时间保证
deadline 调度比 RT 更严格。每个任务有三个参数:runtime(每周期需要跑多久)、deadline(多久之前必须跑完)、period(周期多长)。所有就绪 deadline 任务按绝对截止时间挂红黑树,每次取截止时间最近的跑。
EDF(最早截止时间优先)有一个理论保证:只要总利用率不超过 100%,所有任务都能满足截止时间。这也是为什么 deadline 调度类比 RT 优先级还高——它的需求更硬。
组调度模型:先组间公平,再组内公平
没有组调度的时候,用户 A 开 100 个进程,用户 B 开 1 个,A 能抢到 100 倍的 CPU。组调度把进程按组聚合,先让各组之间按权重分 CPU,再让组内进程按权重分。
这个模型是递归的。根组下面有 A(weight=100)和 B(weight=200),A 下面又有 A1(weight=100)和 A2(weight=100):
- 2. 第二层:A 拿到的 1/3 在 A1 和 A2 之间再分,各拿 1/6。
每一层只看自己的兄弟组,嵌套多少层规则都一样。K8s 里 Pod 多个容器的 CPU 分配就是靠这个嵌套模型。

带宽控制模型:quota 是硬上限
组调度解决了公平分配,但还需要硬上限。CFS 带宽控制给每个任务组设 quota 和 period,比如 cgroup v2 的 cpu.max = "50000 100000" 表示每 100ms 最多用 50ms,也就是 0.5 核。
实现上每个 CPU 独立扣减这个组的 quota。进程运行 tick 时扣额度,扣到 0 就把这个组所有进程 throttle 掉,等下个周期定时器重置额度再放回来。有个坑要注意:单线程进程最多只能跑满一个 CPU 的 local quota,即便整体 quota 是 2 核也用不满——因为它没法同时跑在两个 CPU 上。
调度隔离模型:视图隔离 ≠ 调度隔离
容器的调度隔离经常被误解。PID namespace 和 time namespace 只隔离"看到的东西",不隔离"调度实体":
- • 真隔离:PID 视图、进程信号目标、用户空间读到的时间。
- • 假隔离:nice 值、调度类(SCHED_RT / SCHED_DEADLINE)、运行队列归属。这些都是全局的,容器里改的 nice 真的会影响宿主机调度。
真正的 CPU 调度隔离靠 cpuset cgroup 把进程绑到指定 CPU 上;CPU 使用量限制靠 cgroup CPU controller。namespace 不负责这一块。还有一个容易踩的坑:从来没有 CPU namespace 进过主线内核,不要信过时的文档。

三、运行
对象和模型都清楚了,走一遍真实的调度流程。调度入口主要有三条触发路径:进程主动阻塞、周期 tick 中断、新进程唤醒抢占。三条路最终都汇聚到 __schedule()。
调度入口:__schedule
__schedule() 是调度器的统一入口,骨架很简单:
__schedule()
├─ 拿到当前 CPU 的 rq
├─ pick_next_task(rq) // 从最高优先级调度类开始选
│ ├─ stop 类有没有就绪?有就选
│ ├─ dl 类有没有就绪?有就选
│ ├─ rt 类有没有就绪?有就选
│ └─ 最后 fallback 到 CFS
├─ 如果选中的是当前进程,不用切换,直接返回
└─ context_switch(prev, next) // 切换页表、寄存器、栈
└─ 新进程从上次切出的地方继续跑
唤醒入队->检查抢占-> __schedule -> pick_next_task-> 上下文切换->新进程运行。不同调度类在这个骨架上填自己的细节。
CFS 调度生命周期
一个普通进程在 CFS 上的完整调度周期:
- 1. 唤醒入队:
try_to_wake_up 把进程状态设成 TASK_RUNNING,调 enqueue_task_fair。后者更新 vruntime、更新队列 min_vruntime,把 sched_entity 按 vruntime 插入红黑树。新进程的 vruntime 不是从 0 开始——它继承父进程的 vruntime,否则新进程一进来就霸占 CPU。休眠进程唤醒时 vruntime 会对齐到 min_vruntime,防止睡了很久的进程一醒就狂跑。 - 2. 抢占检查:新进程
vruntime 足够小的话,check_preempt_curr 设置 TIF_NEED_RESCHED。 - 3. 选进程:
pick_next_task_fair 取红黑树最左节点,就是 vruntime 最小的进程。 - 4. tick 更新:进程运行期间每个 tick 调
task_tick_fair,累计实际运行时间并更新 vruntime。当前进程 vruntime 超过下一个进程太多就标记需要调度。 - 5. 出队/重入队:进程主动
schedule() 或被抢占时,put_prev_task_fair 把它放回红黑树;阻塞或退出则出队。
RT 与 deadline 的差异
RT 进程唤醒后入队到对应优先级的链表头。新进程优先级比当前高就直接抢占。选进程时从高到低扫优先级数组,第一个非空链表的第一个进程胜出。SCHED_RR 时间片到了把进程放到同优先级链表尾,SCHED_FIFO 不会。
deadline 进程入队时按绝对截止时间插红黑树。新进程截止时间比当前进程的早就触发抢占。运行时消耗 runtime_remaining,当前周期额度用完就出队,等下周期重置后再回来。
组调度 + cgroup CPU limit 怎么生效
容器 CPU limit 的生效路径:
- 1. 用户在 cgroup v2 下创建子目录,写
cpu.max = "50000 100000" 和 cpu.weight = 200。 - 2. 内核创建对应的
task_group,初始化 cfs_bandwidth,每个 CPU 上分配独立的组调度实体和组运行队列。 - 3. 把进程 PID 写入
cgroup.procs,进程从根组出队,入到新组的 cfs_rq。 - 4. 调度器选进程时,先在顶层组之间按权重公平选,再递归进入组内。运行 tick 中扣减当前 CPU 的 local quota,扣完 throttle 全组进程。
常见坑: 容器 CPU limit 设 2 核,但单线程进程最多用 1 核。这不是 bug,而是单线程只能跑在一个 CPU 上,而带宽控制是按 CPU 独立扣减的。
常见问题排查
| |
|---|
| 有 RT/FIFO 进程死循环占满 CPU?ps -eo pid,pri,cls,comm 看调度类。 |
| sched_wakeup_granularity_ns |
| 进程是不是单线程?cpu.max 每个 CPU 独立扣减,单线程只能用一个 CPU 的配额。 |
| 被组调度父组的 shares 或者 cgroup quota 限住了。 |
| top 读 /proc/stat,容器里是宿主机 bind mount 过来的。用 cgroup.stat 读真实数据。 |
容易踩的坑
- • CFS 不是绝对平均,是按权重公平。nice 不同分到的 CPU 时间就不同。
- • RT 不是跑得更快,指令执行速度一样,只是能优先抢到 CPU。RT 进程死循环照样饿死别人。
- • deadline 优先级比 RT 高。很多人以为 RT 最高,实际上 stop 下面是 deadline。
- • 容器里改 nice 是全局生效。namespace 不隔离调度优先级,容器里
renice 真的影响宿主机。 - • CPU shares 是相对值不是绝对值。weight 100 和 200 是 1:2 分配,但如果没有别的组争,weight 1 也能跑满整个 CPU。
- • 没有 CPU namespace,从来没进过主线内核。CPU 隔离靠 cgroup 和 cpuset。
最后
回到开头那条线:对象、模型、运行。
对象:task_struct 是进程的调度身份,sched_entity 等是调度实体,rq 及其子队列是容器,sched_class 是插件接口,task_group 是组调度容器。namespace 只隔离视图。
模型:每个 CPU 独立 rq;调度类按 stop > dl > rt > cfs 链式查找;CFS 用权重和 vruntime 保证公平;RT 绝对优先级抢占;deadline EDF 保证截止时间;组调度递归公平;带宽控制加硬上限;调度隔离靠 cgroup 不靠 namespace。
运行:调度入口是 __schedule;触发路径有主动阻塞、tick 检查、唤醒抢占;CFS/RT/deadline 各自实现入队选进程逻辑;组调度让容器 CPU limit 成为可能。
看到一段调度逻辑,先判断它属于哪个对象、哪个模型、运行流程的哪一步,就不会迷失在 kernel/sched/*.c 几万行代码里。