最近在啃进程调度这块源码,越往细了读越觉得有意思。很多人对调度器的理解停留在“多任务轮流用CPU”这个层面,但CFS(Completely Fair Scheduler)真正解决的是“怎么轮、轮多久、凭什么你先跑”这一连串问题。这篇内容想把CFS从设计动机、核心数据结构到实际排查手法完整串一遍,适合对Linux内核有一定基础、想深入进程调度实现的人,也适合遇到CPU分配不均衡、交互延迟抖动这些问题却不知道从哪里下手的开发者。
CFS从内核2.6.23开始成为默认调度器,取代了O(1)调度器。它最核心的贡献是把“优先级”这个人为定义的东西,转成了一套可量化的虚拟时钟模型。理解这个转变,才能理解后面所有代码逻辑。
1. 为什么会有CFS:从O(1)调度器的痛点说起
1.1 优先级数组调度器的本质问题
在CFS之前,Linux用的是基于优先级数组的O(1)调度器。每个CPU维护两个队列,active队列和expired队列,队列里有140个优先级链表。调度时从高优先级链表往下找,找到第一个非空链表就取进程。进程用完时间片就挪到expired队列,active队列空了就交换两个队列。
这套设计在当时的硬件条件下已经不错了,但有一个很难调和的问题:如何判断一个进程是交互型的还是批处理型的。O(1)调度器为此维护了一套启发式规则,根据进程睡眠时间、运行时间等一堆指标给进程打分,分数决定它进哪个优先级链表。问题在于启发式规则终究是拍脑袋定的,线上真实负载千奇百怪,误判经常发生——一个交互进程一旦被当成CPU密集型的批处理进程,它就会长时间得不到调度,表现出来就是界面卡顿、鼠标飘。
还有一个问题,也是我实际用下来感受很深的:时间片大小与优先级的关系很难调。时间片给大了,交互进程响应慢;给小了,CPU密集任务频繁切换,吞吐量掉得厉害。O(1)调度器里这个时间片是静态算好的,没法根据系统负载动态变化。CFS的设计者后来在邮件列表里说过一句大意如此的话:与其维护一堆拍脑袋的启发式规则,不如老老实实按“理想公平”来算,把“谁该多跑一点”这件事交给数学模型。
1.2 CFS的模型切换:从“分配时间片”到“追赶虚拟时间”
CFS把问题从“每个进程分多少时间片”换成了另一个等价问题:假设有一台无限快的理想CPU,N个进程能同时跑,那么每个进程应该获得CPU时间的1/N。真实CPU只有一个,所以调度器要做的是决定谁在某个时刻占有CPU,使得在一段足够长的时间内,每个进程获得的时间比例接近各自应得的份额。
这个模型有个天然优势:交互进程天然符合“少占用CPU、但需要及时响应”的特征,不需要什么启发式判断。交互进程大部分时间在睡眠,醒来时会发现自己的虚拟运行时间远远落后于别人,CFS自然会让它排到前面。IO密集进程和CPU密集进程混跑时,IO进程不会吃亏,因为CFS只统计进程在CPU上实际运行的时间,不统计它睡眠等待的时间。
CFS的公平不是平均主义。它引入权重(weight)的概念,权重对应nice值。nice值越低,权重越大,进程的虚拟时钟走得越慢,于是它在真实CPU上能跑的时间就越长。这个设计把“优先级”从离散的、静态的时间片映射,变成了连续的、动态的虚拟时间速率,思想层面确实比O(1)干净得多。
2. vruntime这笔账:CFS最核心的计算逻辑
2.1 红黑树和调度实体:理解CFS的数据结构
CFS给每个可调度对象都抽象了一个struct sched_entity,这个“可调度对象”既可以是一个线程,也可以是一组任务(在容器场景下尤其重要,后面讲到组调度时会细说)。每个调度实体上挂着两个关键字段:vruntime和load.weight。
所有可运行的调度实体都被挂在一棵红黑树上,排序的键就是vruntime。红黑树的根节点(严格说是最左节点)就是vruntime最小的进程,也就是“虚拟时间最落后、最该被补偿”的进程。调度器每次选任务,直接__pick_first_entity()取最左节点,时间复杂度O(1);插入和删除是O(log n)的红黑树操作。用红黑树而不是普通链表,是因为CFS需要频繁地找最小vruntime节点,且插入删除操作很频繁,红黑树在树平衡和操作复杂度之间取得了很好的平衡。
struct sched_entity { struct load_weight load; struct rb_node run_node; struct list_head group_node; unsigned int on_rq; u64 exec_start; u64 sum_exec_runtime; u64 vruntime; u64 prev_sum_exec_runtime; ... };这里要特别提一下load.weight。它不是简单的nice值,而是经过内核查表映射出来的整数权重。例如nice 0对应1024,nice 5对应335,nice 10对应110,nice 19对应15。这个表放在kernel/sched/core.c的sched_prio_to_weight数组里。为什么权重要设计成这样?因为Linux内核认为nice值每变化1,CPU时间分配应该相差大约10%到20%左右,但实在的调度精度又要求不能只用一个很小的整数来表示,所以才用了一组递增的整数权重。注意load.weight还有个继承的问题,进程所属的调度组、cgroup的权重都要一起折算进去,后面组调度部分再展开。
2.2 vruntime怎么算:虚拟时钟的更新公式
调度器真正干活的函数是update_curr(),每次tick、进程入队出队、唤醒抢占时都会调用它来更新当前运行进程的vruntime。核心逻辑非常简单:
vruntime += delta_exec * NICE_0_LOAD / se->load.weight;其中delta_exec是当前进程从上次更新到现在的真实运行时间,NICE_0_LOAD就是1024。这个公式的意思很好理解:进程真实运行了delta_exec这么久,但计入虚拟时钟的只有delta_exec除以它的权重的比例。权重越大的进程,vruntime增长越慢;权重越小,vruntime增长越快。CPU选人的原则是选vruntime最小的,所以权重大的进程反而被选中更多次,跑更久。
举个例子。两个CPU密集型进程竞争一个核心,一个nice 0(权重1024),一个nice 5(权重335)。假设两个进程同时开始,初始vruntime相同。在全公平模型下,nice 0进程每真实运行100ms,它的vruntime增加100ms;nice 5进程每真实运行100ms,vruntime增加约305ms。为了让两者的vruntime最终趋向一致,nice 5进程获得的真实CPU时间大约只有nice 0的三分之一。这就把“nice值影响CPU占比”这件事算得非常精确,不再是拍脑袋的时间片倍数。
2.3 新进程和唤醒进程的vruntime放置策略
这里有个细节很容易踩坑:新fork出来的进程,vruntime初始值应该设多少?如果直接设成0,而当前所有老进程的vruntime已经几十万毫秒了,新进程就会一直霸占最左节点,老进程全面饥饿。所以内核的place_entity()函数专门处理这个问题:新进程的vruntime会被设到当前运行队列的最小vruntime附近,相当于把它插到队头但又不至于甩开所有人太远。这样新进程能尽快得到调度,又不至于制造饥饿。
唤醒进程的情况也类似。一个睡眠了很久的进程被唤醒,它的vruntime还停留在睡眠前的旧值,而其他进程的vruntime早就涨上去了,两者差距巨大。如果直接让它入队,它也会长期霸占最左节点,剥夺别的进程的调度机会。内核的做法是:对于长时间睡眠的进程,重新标定其vruntime,让它的起步位置贴近当前运行队列的min_vruntime,而不是直接用历史旧值。至于“多久算久”,内核会根据进程睡眠时间、当前系统负载等做一个综合判断,所以我们在线上看到的唤醒行为远比教科书复杂,但设计意图始终没变:既要给睡眠进程响应优待,又不能让它制造新的不公平。
3. 调度器是怎么跑起来的:从tick到schedule的完整链路
3.1 主调度路径:__schedule与pick_next_task
调度器真正干活的入口是__schedule()。它首先关闭内核抢占,然后从上一次被调度走的进程(prev)的rq上拿下来,如果prev还在可运行状态就重新入队,接着调用pick_next_task()选下一个进程。
pick_next_task()不是直接去CFS的红黑树里取节点,而是按照调度类优先级依次询问:stop调度类、deadline调度类、实时调度类、CFS调度类、idle调度类。每个调度类有自己的pick_next_task回调,CFS这边就是pick_next_task_fair()。这里的设计体现了调度器结构上的优雅:CFS只管CFS的任务,实时任务仍然走RT调度,两者互不干扰。如果你在系统里用chrt给某个线程设置了实时优先级,它根本不会进CFS的红黑树,而是走实时调度类的队列。很多人在测试nice值时发现没效果,常常就是因为没有检查这个线程是不是其实已经变成了RT任务。
CFS的pick_next_task_fair()会先从当前cfs_rq(CFS运行队列)里取出最左节点作为候选进程,然后处理组调度的情况,可能需要在多个层级之间下探,最终返回一个真正的task。现代内核里这个函数还做了不少优化,比如在只有单个任务时直接返回当前任务,省去红黑树操作。
3.2 周期性调度:tick中断里如何检查抢占
除了主动调度,内核还有一个周期性的检查路径。每个时钟tick都会调用scheduler_tick(),CFS这边对应的是task_tick_fair(),它会进入entity_tick(),最终调用check_preempt_tick()。
这个函数做一件关键的事:判断当前进程是否已经运行了“够久了”。这里的“够久”不是固定时间,而是根据当前可运行进程数量动态算出来的。内核有两个关键参数:sysctl_sched_latency(调度延迟,默认6ms)和sysctl_sched_min_granularity(最小调度粒度,默认0.75ms)。当运行队列里的进程数不超过sysctl_sched_latency / sysctl_sched_min_granularity(通常是8个)时,每个进程的理想运行时间就是sched_latency / nr_running,保证一轮下来所有进程都能在一个调度延迟内跑一遍。如果进程数超过8个,理想的运行时间就变成nr_running * min_granularity,也就是每轮调度周期变长,但保证每个进程至少能跑0.75ms,不至于切换得太频繁把时间都耗在上下文切换上。
check_preempt_tick()的核心逻辑是:如果当前进程实际运行时间已经超过了它本轮该占用的理想时间,同时它的vruntime不是所有进程里最小的,就设置TIF_NEED_RESCHED标志,等合适的时机切换到下一个进程。如果它虽然超时了但仍然是vruntime最小的(也就是别的进程更“占便宜”),那就不抢,让它继续跑,直到它追赶到合理位置。这个判断很微妙,它保证了公平性不会以牺牲整体效率为代价。
3.3 唤醒抢占:wakeup路径上的调度判断
进程从睡眠状态被唤醒时,会进入try_to_wake_up(),并最终走到check_preempt_wakeup()。这个函数比较唤醒进程和当前运行进程的vruntime。如果唤醒进程的vruntime明显小于当前正在运行进程的vruntime,说明唤醒进程已经很“落后”了,那就可以触发抢占,让唤醒进程立刻上CPU。这就是交互进程响应快的原因——它睡眠了很久,vruntime落后,一醒来就能抢到CPU。这里有个阈值:只有vruntime差距超过sysctl_sched_wakeup_granularity(默认1ms左右)时才会抢占,否则即使稍微落后一点,也等它自然切换到下个tick再说。这是为了避免过度抢占造成系统颠簸。
3.4 组调度和CFS带宽控制:容器场景下的公平
到了容器时代,CFS面对的不只是线程,还有一组一组的进程。如果只按线程算vruntime,A容器里有100个线程,B容器里只有1个线程,同样权重下B容器分到的CPU会少得可怜。所以内核引入了task_group,把调度实体分成两层:上一层是组级调度实体,挂在父cfs_rq的红黑树上;下一层是组内线程的调度实体,挂在组自己的cfs_rq上。调度时先在组间按vruntime公平选择组,再进组内选线程。这样CPU资源先在组之间分,然后在组内分,组间隔离和组内公平就都有了。
和组调度配套的是CFS带宽控制,也就是cgroup的cpu.max(cgroup v2,对应v1的cpu.cfs_quota_us和cpu.cfs_period_us)。它对每个cgroup设定一个周期的CPU预算,比如每个100ms周期最多用50ms。这个机制不是CFS天然自带的,而是通过cfs_bandwidth结构在调度器主路径上加了限流逻辑。如果组内的CPU用量超过预算,调度器会把整个组限流,直到下一个周期。要注意的是,限流是“组”级别的,组内某些线程可能仍然抢占得到CPU,但整个组的CPU天花板被卡住了。线上容器CPU超过配额但没有进一步限制时,经常会看到这个机制在工作。
4. 实际排查中的常见问题和调优心得
4.1 进程nice设置“失效”的排查思路
遇到过好几回,开发者在线上反馈“我给进程设置了nice值,但是top看CPU占用几乎没变化”。这里要排查的点很多。
第一,确认进程是否真的是CFS调度。用ps -eo pid,psr,pri,ni,cmd | grep <pid>看,再用chrt -p <pid>看实时优先级。如果你看到pri很高,或者它属于实时调度策略,那nice值根本不参与调度。
第二,确认是不是有多核环境下被其他负载掩盖了。nice只影响CPU时间分配比例,如果系统里只有一个进程在跑,那这个进程不管nice多少都占满一个核,top里的%CPU当然都是接近100%。想验证nice的效果,必须让多个同权重的进程抢同一个CPU,或者用taskset绑核后观察。
第三,检查是否被cgroup权重覆盖了。cgroup v2的cpu.weight和nice有类似的功能,如果进程在一个设置了cpu.weight的cgroup里,组间分配按cgroup权重走,nice只在组内生效。很多容器平台都会在cgroup层面统一设置CPU份额,这时候你测单个进程的nice自然看不到预期效果。我自己调试时习惯是先把cgroup的影响排除掉,放在根cgroup或者一个单独测试目录里跑。
4.2 交互卡顿和CPU延迟尖峰
之前调过一台测试服务器,现象是容器里有个长任务在跑,同时有另一个服务不定期产生突发小请求,请求的延迟时不时飙到几十毫秒。当时首先怀疑网络,抓包看了一圈没发现问题,最后才想到调度器。用sysctl kernel.sched_min_granularity_ns查看参数,发现被调得特别小(只有0.1ms),也就是说内核在极端情况下会非常频繁地切换进程。长任务和突发小请求进程互相抢占,切换成本被放大,延迟自然就上去了。
排查这种问题最好用的工具其实是ftrace。我当时的操作步骤大概是这样的:
# 切换到tracefs目录 cd /sys/kernel/debug/tracing # 清空已有事件 echo > trace # 开启调度器相关事件 echo 'sched_switch' >> set_event echo 'sched_wakeup' >> set_event echo 'sched_wakeup_new' >> set_event # 开始追踪 echo 1 > tracing_on # 复现一段时间后关闭 sleep 10 echo 0 > tracing_on # 看输出 cat trace | head -200sched_switch事件会记录每个CPU上的进程切换,从中能看到prev进程和next进程的pid与comm。如果发现某一对进程在频繁地来回切换,切换间隔只有几百微秒,那基本就是调度粒度太细了。把min_granularity调回0.75ms左右,延迟抖动立刻缓解。这种问题本质上不是bug,而是参数和负载模型不匹配。
4.3 用bpftrace直接观察vruntime的变化
有时候光看宏观现象不够,我想更直观地看vruntime的变化速率。有BPF环境的话,可以直接在update_curr()上放一个kprobe:
bpftrace -e ' kprobe:update_curr { $cfs = (struct cfs_rq *)arg0; $se = $cfs->curr; if ($se != 0) { printf("pid=%d comm=%s vruntime=%llu delta=%llu\\n", $se->vruntime >> 32 ? 0 : 0, comm, $se->vruntime, $se->exec_start); } }'这个示例只是示意性的,实际运行时对结构体字段的读取要考虑内核版本和内核BTF信息,最好在开启了BTF的内核上直接用struct cfs_rq的字段名。我在实验环境里用这种方式对比过nice 0和nice 5两个进程的vruntime增长速度,结果和理论计算几乎完全吻合。这种“看到虚拟时钟在走”的感觉,比任何文档都更能帮你建立对CFS的直觉。
4.4 vruntime会不会溢出:一个很多人担心的边界问题
vruntime是一个64位无符号整数,理论上确实会溢出。但内核在比较两个进程的vruntime时,用的是entity_before()这类函数,内部做的是有符号差值比较,而不是简单的a < b。也就是说即使发生了回绕,只要两个值的差不超过2^63纳秒(大约292年),比较结果仍然是正确的。这个技巧和TCP序列号比较是同一个思路,属于教科书级别的细节。实际使用中完全不用操心溢出问题,真正值得关心的是会不会出现某个进程的vruntime比其他进程大太多,导致它在红黑树上被“扔”得太远。这正是前面说的长时间睡眠进程重新标定要解决的场景。
4.5 参数速查和故障定位表
最后把调试时经常用到的参数和问题汇总一下,方便拿到现场直接对照。
| 常见现象 | 可能原因 | 排查/调整手段 |
|---|---|---|
| nice值不生效 | 实为RT调度 / cgroup权重覆盖 | chrt -p <pid>确认调度策略;先移出cgroup再测 |
| CPU密集容器互相拖慢 | 组间没有限流 / 同cgroup线程数不等 | 检查cpu.max或share权重;按容器设置cpu.weight |
| 延迟尖峰、上下文切换频繁 | min_granularity或wakeup_granularity过小 | sysctl kernel.sched_min_granularity_ns适当调大 |
| 睡眠进程醒来卡顿 | 唤醒抢占阈值过大 | 调小kernel.sched_wakeup_granularity_ns,或检查是否被RT任务压制 |
| 一个进程完全饿死 | 进程权重过小且系统长期满负载 | 检查nice值和cgroup权重,必要时用组调度隔离负载 |
有几个sysctl参数值得记住:kernel.sched_latency_ns是调度延迟,kernel.sched_min_granularity_ns是最小调度粒度,kernel.sched_wakeup_granularity_ns是唤醒抢占阈值。这三个参数共同决定了CFS的调度周期和抢占激烈程度。线上如果不想动代码,微调这三个参数就能显著改变调度行为。但要注意,它们是全局参数,调整影响整个系统,不要在生产环境上随手改,最好先在测试机上复现问题再验证。
在我看来,CFS最值得学习的不是红黑树那一堆代码,而是“用模型代替规则”的思路。与其不断打补丁去猜某个进程是不是交互型,不如定义一套连续、可量化的公平标准,让睡眠和唤醒自然地体现在vruntime的差里。这个设计思路放到今天看依然很有启发。如果你也想深入这一块,我建议直接从kernel/sched/fair.c的update_curr()读起,顺着它把entity_tick()、check_preempt_tick()、place_entity()这几个函数串起来,再配合ftrace或bpftrace去验证自己的理解。读调度器代码没有捷径,但一旦把那层“虚拟时钟”的窗户纸捅破,后面就是一马平川了。