最近在啃进程调度这块源码越往细了读越觉得有意思。很多人对调度器的理解停留在“多任务轮流用CPU”这个层面但CFSCompletely 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把问题从“每个进程分多少时间片”换成了另一个等价问题假设有一台无限快的理想CPUN个进程能同时跑那么每个进程应该获得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对应1024nice 5对应335nice 10对应110nice 19对应15。这个表放在kernel/sched/core.c的sched_prio_to_weight数组里。为什么权重要设计成这样因为Linux内核认为nice值每变化1CPU时间分配应该相差大约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增加100msnice 5进程每真实运行100msvruntime增加约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_rqCFS运行队列里取出最左节点作为候选进程然后处理组调度的情况可能需要在多个层级之间下探最终返回一个真正的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面对的不只是线程还有一组一组的进程。如果只按线程算vruntimeA容器里有100个线程B容器里只有1个线程同样权重下B容器分到的CPU会少得可怜。所以内核引入了task_group把调度实体分成两层上一层是组级调度实体挂在父cfs_rq的红黑树上下一层是组内线程的调度实体挂在组自己的cfs_rq上。调度时先在组间按vruntime公平选择组再进组内选线程。这样CPU资源先在组之间分然后在组内分组间隔离和组内公平就都有了。和组调度配套的是CFS带宽控制也就是cgroup的cpu.maxcgroup 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()上放一个kprobebpftrace -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去验证自己的理解。读调度器代码没有捷径但一旦把那层“虚拟时钟”的窗户纸捅破后面就是一马平川了。