Linux内核学习轨迹第四部:CFS完全公平调度器深度拆解(第四小节)
CFS(Completely Fair Scheduler,完全公平调度器)是Linux 2.6.23版本至今的默认调度器,负责管理系统中99%的普通非实时进程,也是整个调度体系中最核心、工程场景最复杂的调度类。很多工程师对CFS的认知停留在「用红黑树管理进程、按vruntime调度」的表层概念,却不理解其公平性的数学本质、min_vruntime维护机制、组调度与带宽控制的底层逻辑,最终在遇到系
6. CFS完全公平调度器深度拆解
6.1 CFS的核心设计思想与解决的历史痛点
- 交互式进程识别依赖复杂的启发式算法:O(1)调度器通过「睡眠时间」判断进程是否为交互式进程,给其奖励优先级和额外时间片,算法极易被用户态程序绕过,导致桌面场景下用户操作响应卡顿,边界条件极多,维护成本极高;
- 公平性无法数学化保证:时间片分配基于优先级线性映射,多进程场景下低优先级进程极易出现饿死,无法保证CPU时间的按权重公平分配;
- 多核扩展性差:时间片计算依赖全局变量,多核场景下锁竞争严重,进程迁移逻辑复杂,负载均衡效果差;
- 不支持层级化资源分配:无法实现进程组级别的CPU资源隔离,为后续容器技术的发展埋下了障碍。
- 单CPU上有2个相同nice值(权重相同)的进程A和B,CFS会给它们各分配50%的CPU时间,保证它们的vruntime始终完全同步;
- 如果进程A的nice值为-10(权重是B的10倍),CFS会给A分配90.9%的CPU时间,B分配9.1%的CPU时间,保证相同物理时间内,两者的vruntime增长完全一致,实现按权重的绝对公平。
- 数学化的绝对公平:用虚拟时间公式替代启发式算法,保证CPU时间按权重分配的可预测性;
- 极致的交互式响应性:睡眠唤醒的进程能快速抢占当前运行的进程,降低用户操作的响应延迟;
- 优秀的多核扩展性:基于per-CPU就绪队列设计,最小化锁竞争,层级化负载均衡机制;
- 原生支持层级化资源分配:通过组调度实现进程组级别的CPU资源隔离,为cgroup和容器技术奠定了内核基础;
- 极简的代码实现:核心逻辑清晰,边界条件少,易于维护和扩展。
6.2 核心概念:nice值、权重与虚拟时间vruntime
|
nice值 |
对应权重 |
相对CPU时间占比(与nice=0对比) |
|
-20 |
88761 |
86.7倍 |
|
-10 |
10240 |
10倍 |
|
0 |
1024 |
1倍(基准值) |
|
10 |
102 |
1/10倍 |
|
19 |
15 |
1/68倍 |
内核的映射公式:
// 权重计算公式:weight = 1024 / (1.25^nice)
// 1.25是经验值,保证nice值每变化10,权重刚好变化10倍,匹配用户对CPU时间的对数级感知
为什么用指数级映射?因为用户对CPU时间的感知是对数级的:给一个占用10%CPU的进程增加10%的CPU时间,用户能明显感知到性能提升;但给一个占用90%CPU的进程增加10%的CPU时间,用户几乎感知不到变化。指数级映射完美匹配了用户的感知模型。
6.2.2 虚拟时间vruntime的核心计算公式
- NICE_0_LOAD是nice=0的进程的权重,固定为1024,作为整个系统的权重基准;
- 进程权重越大(nice值越低),vruntime增长越慢,在红黑树中越靠左,越容易被调度器选中;
- 进程权重越小(nice值越高),vruntime增长越快,在红黑树中越靠右,被调度的概率越低。
- 进程A:nice=0,权重1024,实际运行1ms,vruntime增加 1ms * 1024 / 1024 = 1ms;
- 进程B:nice=-10,权重10240,实际运行1ms,vruntime增加 1ms * 1024 / 10240 = 0.1ms;
- 进程C:nice=10,权重102,实际运行1ms,vruntime增加 1ms * 1024 / 102 = 10ms。
6.2.3 vruntime的关键规则与边界处理
1.fork子进程的vruntime继承规则
核心源码片段(kernel/sched/fair.c):
void task_fork_fair(struct task_struct *p)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se, *curr;
struct rq *rq = this_rq();
raw_spin_lock(&rq->lock);
cfs_rq = task_cfs_rq(current);
curr = ¤t->se;
// 子进程vruntime继承父进程的当前vruntime
se->vruntime = curr->vruntime;
// 对齐到cfs_rq的min_vruntime,避免vruntime过小
place_entity(cfs_rq, se, 0);
if (sysctl_sched_child_runs_first && curr->vruntime < se->vruntime) {
// 交换父子进程的vruntime,让子进程优先运行
swap(curr->vruntime, se->vruntime);
resched_curr(rq); // 触发抢占,让子进程先运行
}
raw_spin_unlock(&rq->lock);
}
2.睡眠唤醒后的vruntime补偿规则
- 内核解决方案:进程唤醒时,它的vruntime会被设置为「就绪队列的min_vruntime」和「原来的vruntime」两者的最大值。这样既保证了睡眠进程唤醒后能快速得到调度,又不会出现vruntime过度落后的问题。
- 核心源码片段(kernel/sched/fair.c的enqueue_entity()函数):
if (entity_is_task(se) && se->in_iowait) {
// IO等待唤醒的进程,额外补偿vruntime,提升交互式响应性
vruntime = max_vruntime(se->vruntime, cfs_rq->min_vruntime - sysctl_sched_wakeup_granularity);
} else {
vruntime = max_vruntime(se->vruntime, cfs_rq->min_vruntime);
}
se->vruntime = vruntime;
3.跨CPU迁移的vruntime对齐规则
6.2.4 调度边界:最小粒度与最大偏差限制
- 调度最小粒度sysctl_sched_min_granularity:进程被调度后,至少能运行的最小时间,默认值0.75ms。哪怕进程的vruntime已经超过了其他进程,只要它的运行时间还没达到最小粒度,就不会被抢占,避免频繁的上下文切换。
- 调度最大延迟sysctl_sched_latency:所有就绪进程完成一轮调度的最大时间,默认值6ms。当就绪进程数量少于8个时,调度周期等于最大延迟;当就绪进程数量超过8个时,调度周期 = 进程数量 * 最小粒度,保证每个进程至少能运行一个最小粒度的时间。
- 唤醒抢占粒度sysctl_sched_wakeup_granularity:唤醒的进程要抢占当前运行的进程,它的vruntime必须比当前进程小至少一个唤醒粒度,默认值1ms。避免频繁的唤醒抢占导致的上下文切换开销。
6.3 CFS就绪队列cfs_rq全字段深度解析
struct cfs_rq {
// 1. 红黑树管理核心字段
struct rb_root_cached tasks; // 带缓存的红黑树根节点
unsigned int nr_running; // 就绪队列中的可运行进程数量
unsigned int h_nr_running; // 层级化可运行进程数(组调度用)
// 2. 虚拟时间基准字段
u64 min_vruntime; // 就绪队列的最小虚拟时间,整个队列的vruntime基准
u64 exec_clock; // 就绪队列的执行时钟,纳秒级
// 3. 负载与权重统计字段
struct load_weight load; // 就绪队列的总负载权重
unsigned long runnable_weight; // 可运行的总权重
unsigned int nr_numa_running; // NUMA相关可运行进程数
unsigned int nr_uninterruptible; // 不可中断睡眠的进程数
// 4. 带宽控制相关字段(cgroup CPU限流核心)
u64 runtime_remaining; // 剩余的CPU运行配额
u64 runtime_expires; // 配额过期时间
int throttled; // 当前队列是否被限流
struct list_head throttled_list; // 被限流的队列链表
// 5. 组调度相关字段
struct sched_entity *curr; // 当前运行的调度实体
struct sched_entity *next; // 下一个要运行的调度实体
struct sched_entity *last; // 上一个运行的调度实体
struct rq *rq; // 所属的CPU全局就绪队列
struct task_group *tg; // 所属的任务组(cgroup用)
// 6. 统计与调试字段
unsigned int nr_spread_over; // 负载均衡相关统计
u64 avg_scan_cost; // 红黑树扫描平均开销
#ifdef CONFIG_SCHED_DEBUG
u64 nr_wakeups; // 唤醒次数统计
u64 nr_migrations; // 进程迁移次数统计
#endif
};
1.struct rb_root_cached tasks
- 红黑树的排序规则:以调度实体的vruntime为key,vruntime越小,节点越靠左;vruntime越大,节点越靠右。
- 调度器选下一个进程时,直接取缓存的最左节点即可,不需要遍历红黑树。
2.min_vruntime
核心作用:
- 作为进程睡眠唤醒、跨CPU迁移时的vruntime补偿基准;
- 保证不同CPU的就绪队列之间的vruntime基准一致,实现跨CPU的公平调度;
- 避免红黑树的vruntime值溢出,保证红黑树的排序正确性。
工程意义:min_vruntime是CFS公平性的核心锚点,绝大多数CFS的公平性异常问题,都和min_vruntime的更新异常相关。
带宽控制相关字段
组调度相关字段
6.4 CFS调度核心流程全链路源码解析
CFS实现了struct sched_class接口,定义在kernel/sched/fair.c中,核心接口对应了调度的全生命周期:进程入队、出队、tick更新、选核、抢占检查、主动放弃CPU。我们拆解每个核心流程的源码实现与工程逻辑。
6.4.1 调度器tick:task_tick_fair()
每个时钟tick(默认1ms)到来时,内核会调用调度类的task_tick接口,更新进程的vruntime,检查是否需要触发抢占,这是CFS驱动调度的核心入口。
核心源码流程:
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &curr->se;
// 层级遍历组调度的所有调度实体
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
// 更新当前进程的vruntime和统计信息
update_curr(cfs_rq);
// 检查是否需要触发抢占
if (cfs_rq->nr_running > 1)
check_preempt_tick(cfs_rq, se);
}
}
update_curr(cfs_rq):更新当前运行进程的vruntime,这是CFS最核心的函数:
- 计算当前进程从上一次更新到现在的实际运行物理时间delta_exec;
- 按权重公式,把delta_exec转换为虚拟时间增量,加到进程的vruntime上;
- 更新就绪队列的min_vruntime,保证它单调递增;
- 更新进程的CPU时间统计、负载统计。
check_preempt_tick(cfs_rq, se):检查是否需要抢占当前进程:
- 计算当前进程的理想运行时间(调度周期 * 当前进程权重 / 队列总权重);
- 如果当前进程的实际运行时间超过了理想运行时间,设置TIF_NEED_RESCHED标志,中断返回时触发调度;
- 同时检查就绪队列中最左节点的vruntime,如果比当前进程的vruntime小超过一个唤醒粒度,也会触发抢占
6.4.2 进程入队:enqueue_task_fair()
- 层级遍历组调度的所有调度实体,把每个调度实体加入对应的cfs_rq;
调用enqueue_entity(),完成进程的入队操作:
- 更新进程的vruntime,做睡眠唤醒的补偿对齐;
- 把进程的调度实体加入红黑树,更新红黑树的最左节点缓存;
- 增加队列的nr_running计数,更新队列的总负载权重;
如果新入队的进程可以抢占当前运行的进程,设置TIF_NEED_RESCHED标志,触发抢占。
6.4.3 进程出队:dequeue_task_fair()
- 层级遍历组调度的所有调度实体,把每个调度实体从对应的cfs_rq中移除;
调用dequeue_entity(),完成进程的出队操作:
- 更新当前进程的vruntime和队列的min_vruntime;
- 把进程的调度实体从红黑树中移除,更新红黑树的最左节点缓存;
- 减少队列的nr_running计数,更新队列的总负载权重。
6.4.4 选核逻辑:pick_next_task_fair()
核心源码流程:
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
struct cfs_rq *cfs_rq = &rq->cfs;
struct sched_entity *se;
struct task_struct *p;
// 优化:如果队列中没有可运行的进程,直接返回NULL
if (!cfs_rq->nr_running)
return NULL;
// 层级遍历组调度,选择vruntime最小的调度实体
do {
se = pick_next_entity(cfs_rq, NULL);
set_next_entity(cfs_rq, se);
cfs_rq = group_cfs_rq(se);
} while (cfs_rq);
// 从调度实体中获取对应的进程
p = task_of(se);
// 如果切换的进程和当前进程不同,触发上下文切换
if (prev != p)
put_prev_task(rq, prev);
return p;
}
- 调用pick_next_entity(),从红黑树中获取最左节点(vruntime最小的调度实体),直接使用缓存的最左节点,O(1)时间复杂度;
- 调用set_next_entity(),把选中的调度实体设置为当前运行的实体,更新队列的统计信息;
- 如果是组调度,层级遍历子任务组,最终找到要运行的进程;
- 返回选中的进程,调度器会触发上下文切换。
6.4.5 唤醒抢占:check_preempt_curr_fair()
- 更新当前运行进程和唤醒进程的vruntime;
- 比较唤醒进程的vruntime和当前进程的vruntime,如果唤醒进程的vruntime比当前进程小超过一个sysctl_sched_wakeup_granularity,设置TIF_NEED_RESCHED标志,触发抢占;
- 对于IO等待唤醒的进程,会额外降低抢占的阈值,让它更容易抢占,提升交互式进程的响应速度。
6.5 CFS组调度与CPU带宽控制
6.5.1 组调度的设计思想
6.5.2 组调度的内核实现
- 系统启动时,会创建一个根任务组,对应系统的所有进程;
- 每创建一个cgroup CPU控制组,就会创建一个对应的子任务组,挂载在根任务组下;
- 调度器选核时,先在同级的任务组之间做公平调度,选中一个任务组后,再在该任务组的子任务组/进程之间做公平调度,层级遍历直到找到最终的进程。
6.5.3 CPU带宽控制:quota、period与burst
|
参数 |
内核对应字段 |
含义 |
容器对应参数 |
|
period |
cfs_period_us |
限流周期,单位微秒,默认100000us(100ms) |
--cpu-period |
|
quota |
cfs_quota_us |
每个周期内,任务组能使用的CPU时间,单位微秒 |
--cpu-quota |
|
burst |
cfs_burst_us |
允许累积的突发CPU配额,用于应对突发流量 |
--cpu-burst |
- 每个周期开始时,内核会把任务组的runtime_remaining重置为quota值;
- 任务组内的进程每运行1us,runtime_remaining就减1us;
- 当runtime_remaining减到0时,任务组会被设置为throttled=1,组内的所有进程都会被从就绪队列中移除,无法被调度,直到下一个周期开始,配额重置;
- 开启burst后,未使用的配额会累积到burst池中,进程可以使用burst池中的配额应对突发流量,避免限流抖动。
- docker run --cpus 2:等价于period=100ms,quota=200ms,表示最多可以使用2个CPU核心;
- docker run --cpu-shares 1024:等价于设置任务组的权重为1024,实现CPU资源的按权重软限制,系统CPU空闲时可以突破限制;
- docker run --cpuset-cpus 0-1:等价于设置进程的CPU亲和性,只能在0和1号CPU上运行。
6.6 CFS核心调优参数全解析
|
参数名 |
默认值 |
核心含义 |
调优场景 |
|
sched_min_granularity_ns |
750000ns(0.75ms) |
进程调度的最小运行粒度 |
高并发场景下,上下文切换过多时,可以调大这个值,减少调度频率;低延迟场景下,可以调小,提升响应性 |
|
sched_latency_ns |
6000000ns(6ms) |
所有就绪进程的一轮调度最大延迟 |
高并发场景下,就绪进程很多时,可以调大这个值,保证每个进程有足够的运行时间;交互式场景下,可以调小,提升响应速度 |
|
sched_wakeup_granularity_ns |
1000000ns(1ms) |
唤醒抢占的最小粒度 |
交互式场景(桌面、移动端),可以调小这个值,让唤醒的进程更容易抢占,提升响应性;服务器场景下,可以调大,减少频繁的抢占和上下文切换 |
|
sched_child_runs_first |
0 |
fork后子进程是否优先运行 |
fork后子进程立即exec的场景,可以开启这个参数,减少写时复制的开销 |
|
sched_migration_cost_ns |
500000ns(0.5ms) |
进程迁移的成本阈值 |
进程频繁在CPU之间迁移时,可以调大这个值,减少进程迁移,提升缓存命中率;负载不均衡时,可以调小,提升负载均衡的灵敏度 |
|
sched_nr_migrate |
32 |
一次负载均衡最多迁移的进程数 |
多核负载不均衡时,可以调大这个值,提升负载均衡的速度;高并发场景下,可以调小,减少锁竞争 |
1.高并发服务器场景(数据库、Web服务)
目标:最大化吞吐量,减少上下文切换开销;
调优方案:
2.低延迟交互式场景(桌面、移动端、实时交易)
目标:最小化响应延迟,提升唤醒抢占速度;
调优方案:
3.容器化场景
目标:保证CPU资源隔离的公平性,减少限流抖动;
调优方案:
优先使用cgroup v2,相比v1,带宽控制的精度更高,burst功能更稳定;
对于有突发流量的业务,开启cpu-burst功能,避免不必要的限流;
对于延迟敏感的业务,设置cpu-shares软限制,而非硬quota限制,系统空闲时可以使用更多CPU资源;
绑定容器到固定的NUMA节点和CPU核心,减少跨NUMA访问的延迟。
6.7 CFS工程实践与避坑指南
1.nice值调优的指数级陷阱
最佳实践:调整nice值时,每次只调整1-2个等级,观察CPU占用变化,不要盲目设置极低的nice值;关键业务进程的nice值建议设置在-1~-5之间,不要低于-10,除非是绝对核心的系统进程。
2.容器CPU限流的坑点
- 限流是按周期统计的,比如period=100ms,quota=100ms,进程在10ms内用完了100ms的quota,剩下的90ms都会被限流,哪怕平均CPU使用率只有10%;
- 解决方案:开启cpu-burst功能,允许累积未使用的配额,应对突发流量;调小period值,比如设置为10ms,减少限流的持续时间。
- 另一个坑:容器内的进程数很多时,调度器会把进程分散到多个CPU上,每个CPU的运行时间都会计入quota,比如8个进程在8个CPU上各运行10ms,quota会消耗80ms,而不是10ms。
4.IO密集型进程的调度优化
最佳实践:给IO密集型进程设置稍低的nice值(比如-2),提升它的权重,唤醒后更容易抢占CPU;开启IO等待补偿,内核默认已经对IO唤醒的进程做了vruntime补偿,不需要额外修改;绑定进程到固定的CPU核心,提升缓存命中率。
4.CPU密集型进程的调度优化
最佳实践:给CPU密集型进程设置稍高的nice值(比如+2),避免它抢占交互式进程的CPU;绑定进程到固定的CPU核心,减少跨CPU迁移;调大调度最小粒度,减少调度频率。
5.红黑树的性能问题
解决方案:分散进程到多个CPU核心,减少单个CPU的就绪队列长度;使用cgroup限制每个任务组的进程数量;对于超大规模的并发场景,使用线程池复用线程,减少就绪进程的数量。
更多推荐

所有评论(0)