1. 为什么每个后端人都该啃一次调度器源码
做后端开发到一定年限,迟早会撞上一堵墙:线上服务明明CPU利用率不高,但请求延迟就是压不下去;或者某个线程莫名其妙被"饿死",日志里看不出任何异常。你翻遍应用层代码,加锁、改线程池、调JVM参数,折腾一圈发现收效甚微。这时候问题往往已经下沉到了操作系统层面——具体说,就是**Linux内核的进程调度器(sched)**在起作用。
我最初接触sched这块源码,是因为一个真实的性能问题:一个计算密集型的服务在混部环境下,P99延迟比独占环境高了将近8倍,但CPU使用率却只有40%出头。当时团队里没人能解释这个现象,直到我把kernel/sched/目录下的代码翻了一遍,才明白是CFS(完全公平调度器)的vruntime机制在作祟——计算密集线程的虚拟运行时间增长极快,导致它频繁被抢占,而每次抢占带来的上下文切换开销,在高并发场景下被放大了。
这就是我想写这个系列的原因。sched是Linux内核里最核心、也最容易被应用层开发者忽视的子系统之一。它不像文件系统、网络协议栈那样有直观的API,但它的每一个设计决策,最终都会以延迟、吞吐、抖动的形式反映在你的服务指标上。这个系列我打算从源码结构入手,把调度器的核心机制一层层拆开,讲清楚它到底怎么工作、为什么这么设计、以及我们能从中获得什么。
适合读这个系列的人:有C语言基础、了解操作系统基本概念(进程、线程、上下文切换)的后端或系统开发者。如果你只是想知道"怎么调nice值",那网上教程一大把;但如果你想搞清楚"为什么调了nice值效果不明显",那跟着源码走一遍是绕不开的路。我会尽量用生活化的类比来解释抽象概念,同时保证每个关键数据结构都落到具体的代码位置上,让你看完能自己动手去读。
2. 调度器源码的目录地图与阅读顺序
2.1 kernel/sched/ 下每个文件的职责边界
很多人第一次打开kernel/sched/目录会懵——十几个文件,名字还都挺像。我建议先别急着看代码,花十分钟把文件职责理清楚,后面读起来会顺畅很多。下面这张表是我自己整理的核心文件清单,按重要程度排序:
| 文件名 | 核心职责 | 阅读优先级 |
|---|---|---|
core.c | 调度器主框架,schedule()入口、上下文切换、抢占逻辑 | 最高 |
fair.c | CFS完全公平调度器的全部实现 | 最高 |
sched.h | 核心数据结构定义(task_struct相关、rq运行队列) | 最高 |
rt.c | 实时调度器(SCHED_FIFO/SCHED_RR)实现 | 中 |
deadline.c | DL调度器(SCHED_DEADLINE)实现 | 中 |
idle.c | 空闲调度器,CPU没事干时跑什么 | 低 |
stats.c | 调度统计信息导出 | 低 |
clock.c | 调度相关的时钟封装 | 低 |
cpufreq.c | 调度器与CPU频率调节的交互 | 低 |
wait.c | 等待队列实现 | 中 |
读的顺序我建议是:先看sched.h把数据结构混个脸熟,然后直接啃core.c里的__schedule()函数,这是整个调度器的"心脏"。搞懂它之后,再去看fair.c里CFS怎么挑选下一个任务。这个顺序的好处是,你先建立了"调度器要做什么"的全局观,再去看"具体怎么做",不会一上来就陷进红黑树的细节里出不来。
提示:不同内核版本的目录结构会有差异。比如5.x之后
fair.c里的一些功能被拆到了pelt.c(负载跟踪)和fair.c配合。读之前先确认你手上的内核版本,用uname -r看一眼,然后去对应版本的源码树里找。
2.2 从 schedule() 入口倒推调用链
schedule()是调度器对外的门面函数,但它本身逻辑很简单,核心就是调用__schedule()。真正有意思的是谁在什么时候调用了schedule()。我习惯用"倒推法"来读:先找到入口,然后往上追调用者,这样能快速理解调度器在整个内核里的位置。
主要的调用场景有这么几类:
- 主动让出:任务调用
sched_yield()或者等待某个资源(比如mutex_lock失败)时,会主动触发调度。 - 被动抢占:时钟中断到来,发现当前任务的时间片用完了,或者有更高优先级的任务被唤醒,就会设置
TIF_NEED_RESCHED标志,在中断返回用户态或内核态时触发调度。 - 唤醒抢占:一个任务被
wake_up_process()唤醒时,如果它的优先级比当前运行的任务高,会尝试抢占当前任务。
理解这三类场景很关键,因为它们对应了调度器要解决的三类问题:公平性(主动让出)、实时性(被动抢占)、响应性(唤醒抢占)。后面讲CFS的vruntime和check_preempt_*系列函数时,你会发现所有逻辑都是围绕这三类场景展开的。
2.3 读源码前必须搞清楚的几个前置概念
在正式进入代码之前,有几个概念必须先说清楚,否则后面会一直卡壳。
第一个是"运行队列"(runqueue,简称rq)。每个CPU核心都有一个独立的rq结构,里面挂着这个核心上所有可运行的任务。注意是"每个CPU一个",不是全局一个——这是Linux调度器可扩展性的基石。你可以把rq想象成一个餐厅里每个服务员手里的订单夹,每个服务员只管自己夹子里的订单,不需要去抢别人的。
第二个是"调度类"(sched_class)。Linux不是只有一种调度算法,而是把调度器抽象成了"类",每个类实现一套接口(enqueue_task、dequeue_task、pick_next_task等)。优先级从高到低依次是:stop_sched_class>dl_sched_class>rt_sched_class>fair_sched_class>idle_sched_class。调度时从高优先级类开始问"你有任务要跑吗",有就交给它,没有就问下一个。这种设计叫"调度类链",是理解整个调度框架的钥匙。
第三个是"虚拟运行时间"(vruntime)。这是CFS的核心概念,我后面会用一整节来讲,这里先记住一句话:CFS不直接看任务跑了多久,而是看任务"应该"跑多久,用权重把实际运行时间换算成一个虚拟值,谁的小谁就先跑。
3. CFS完全公平调度器的核心机制拆解
3.1 vruntime 到底在算什么:一个生活化类比
CFS全称Completely Fair Scheduler,翻译过来叫"完全公平调度器"。但"公平"这个词很容易让人误解——它不是让每个任务跑一样长的时间,而是让每个任务按权重比例分享CPU。权重高的任务分到的CPU时间多,权重低的分到的少,但所有任务在"虚拟时间"这个维度上是公平的。
我用一个类比来解释vruntime。假设有三个学生(任务)要分一块蛋糕(CPU时间),他们的"饭量权重"分别是1、2、4。如果直接按权重分,饭量4的学生拿到的蛋糕是饭量1的4倍,这看起来合理,但问题是:如果饭量1的学生先到,他先吃了一块,饭量4的学生后到,他得等多久才能吃到?CFS要解决的就是这个"等待公平性"问题。
vruntime的做法是:给每个学生记一个"虚拟等待时间"。学生实际吃了多久,除以他的饭量权重,得到虚拟时间。饭量大的学生,实际吃同样久,虚拟时间增长得慢;饭量小的学生,虚拟时间增长得快。调度器每次挑虚拟时间最小的那个学生来吃。这样,饭量小的学生虽然每次吃得少,但他虚拟时间涨得快,很快又会轮到他;饭量大的学生虚拟时间涨得慢,可以一次吃久一点。最终结果是:每个学生的虚拟时间趋于相等,而实际吃到的蛋糕量与权重成正比。
对应到代码里,vruntime的计算公式大致是:
vruntime += delta_exec * (NICE_0_LOAD / weight)其中delta_exec是实际运行时间,weight是任务权重(由nice值映射而来),NICE_0_LOAD是nice为0时的基准权重(1024)。nice值每降低1,权重增加约1.25倍;每升高1,权重减少约0.8倍。这个1.25倍不是随便定的,它保证了nice值相差1的两个任务,CPU占比大约相差10%。
3.2 红黑树:CFS 挑选下一个任务的底层结构
CFS用一棵红黑树来组织所有可运行的任务,树上的节点按vruntime排序,最左边(最小值)的节点就是下一个该跑的任务。为什么用红黑树而不是最小堆?因为红黑树的插入、删除、查找最小值都是O(log n),而且它支持高效的"删除任意节点"操作——任务被唤醒、睡眠、迁移时都需要从树上摘下来或插进去,红黑树的综合性能最均衡。
红黑树的节点是sched_entity结构,每个任务(或任务组)都有一个。关键字段包括:
vruntime:虚拟运行时间,树的排序依据。rb_node:红黑树节点,把自己挂到树上。load:权重,用于计算vruntime增量。on_rq:标记是否在运行队列上。
挑选下一个任务的函数是pick_next_task_fair(),它最终会调用__pick_first_entity(),也就是取红黑树最左边的节点。这个过程非常快,就是沿着左指针一路走到底。
但这里有个细节值得注意:新创建的任务的vruntime初始值是多少?如果设成0,那它会一直霸占CPU直到追上其他任务,这显然不公平。内核的做法是把它设成当前运行队列的min_vruntime(树中最小的vruntime),并且做一点偏移,保证新任务不会一上来就抢占,也不会被饿死。这个细节在place_entity()函数里,读的时候可以留意。
3.3 调度周期与时间片:sysctl_sched_latency 的取舍
CFS没有固定时间片的概念,它用的是"调度周期"(sched_latency)。默认情况下,这个周期是6ms(sysctl_sched_latency),意思是:如果运行队列上有n个任务,那么在6ms内,每个任务至少应该被调度一次。每个任务分到的时间片就是调度周期 / 任务数,再按权重调整。
但这里有个问题:如果任务数很多,比如100个,那每个任务只能分到60微秒,上下文切换的开销可能比实际执行时间还大。所以内核设了一个下限sysctl_sched_min_granularity,默认是0.75ms。如果算出来的时间片小于这个值,就强制设为这个值,同时调度周期也会相应拉长。
这两个参数的取舍很能体现调度器的设计哲学:
sched_latency调小:任务轮转更快,交互式任务响应更好,但上下文切换开销增大,吞吐下降。sched_latency调大:吞吐提升,但延迟敏感的任务可能等太久。min_granularity调小:任务数多时每个任务分到的时间更少,公平性更好,但切换开销大。min_granularity调大:减少切换,但任务数多时延迟会明显上升。
我在实际调优中遇到过这样一个案例:一个服务在容器里跑,容器限制了CPU配额,导致运行队列上的任务数看起来很多(因为cgroup的throttle机制),结果每个任务分到的时间片被压得很小,上下文切换率飙升。后来把min_granularity适当调大,切换率降了一半,P99延迟改善了30%多。这个经验说明,调度参数不是孤立的,它和cgroup、CPU亲和性等机制会相互影响。
4. 调度类链与优先级:谁先谁后的完整逻辑
4.1 sched_class 结构体里的函数指针表
sched_class是Linux调度框架的抽象接口,定义在kernel/sched/sched.h里。它本质上就是一张函数指针表,每个调度类实现自己的一套。核心方法包括:
struct sched_class { void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags); void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags); void (*yield_task)(struct rq *rq); struct task_struct *(*pick_next_task)(struct rq *rq); void (*task_tick)(struct rq *rq, struct task_struct *p, int queued); void (*task_fork)(struct task_struct *p); void (*task_dead)(struct task_struct *p); void (*switched_from)(struct rq *rq, struct task_struct *p); void (*switched_to)(struct rq *rq, struct task_struct *p); // ... 还有更多 };读这张表的时候,我建议重点关注三个方法:enqueue_task(任务怎么进队列)、pick_next_task(怎么挑下一个)、task_tick(时钟中断来了怎么处理)。这三个方法构成了调度器的主循环。
enqueue_task在任务被唤醒或新建时调用,负责把任务挂到对应的数据结构上(CFS是红黑树,RT是优先级链表)。pick_next_task在调度时调用,返回下一个该跑的任务。task_tick在每个时钟中断里调用,用来更新运行统计、检查是否需要抢占。
4.2 从 stop 到 idle:五个调度类的优先级顺序
调度类链的顺序是硬编码在pick_next_task()里的,从高到低依次尝试:
- stop_sched_class:最高优先级,用于CPU热插拔、迁移线程等内核内部机制。普通任务永远不会属于这个类。
- dl_sched_class:SCHED_DEADLINE,基于截止时间的调度,用于硬实时场景。
- rt_sched_class:SCHED_FIFO和SCHED_RR,实时调度,优先级1-99。
- fair_sched_class:SCHED_NORMAL和SCHED_BATCH,普通任务,就是我们平时写的绝大多数程序。
- idle_sched_class:SCHED_IDLE,最低优先级,只在CPU完全没事干时跑。
这个顺序意味着:只要有一个RT任务可运行,所有CFS任务都别想跑。这是实时性的保证,但也是很多"CPU明明很闲但我的任务不跑"问题的根源。我曾经排查过一个案例:一个服务在混部机器上延迟极高,最后发现是同一台机器上有个RT任务在周期性运行,每次运行都把CFS任务挤掉了。用chrt -p <pid>一看,那个任务的调度策略是SCHED_FIFO,优先级50。把它的策略改成SCHED_OTHER后,问题立刻消失。
注意:RT任务如果设计不当(比如死循环且不主动让出),会直接饿死同CPU上的所有CFS任务,甚至导致系统无响应。生产环境里给任务设RT策略要非常谨慎,最好配合
RLIMIT_RTTIME限制单次运行时间。
4.3 优先级继承与抢占标志的传递路径
当一个高优先级任务被唤醒时,它需要"通知"当前CPU上正在跑的任务让位。这个通知机制就是TIF_NEED_RESCHED标志。具体路径是:
- 任务A被
wake_up_process()唤醒,进入try_to_wake_up()。 - 如果A的优先级高于当前运行的任务B,调用
check_preempt_curr()。 check_preempt_curr()根据调度类调用对应的check_preempt_*函数,比如CFS的check_preempt_wakeup()。- 如果判断需要抢占,设置B的
TIF_NEED_RESCHED标志。 - B在返回用户态或内核态可抢占点时,检查到这个标志,调用
schedule()让出CPU。
这个路径里有个容易忽略的点:抢占不是立即发生的。设置标志只是"请求",真正让出要等到下一个抢占点。如果B正在内核态执行一个不可抢占的临界区(比如持有自旋锁),那它必须先把临界区执行完。这就是为什么有时候高优先级任务被唤醒后,延迟还是很高——它在等当前任务释放CPU。
理解这一点对排查延迟问题很重要。如果你发现高优先级任务唤醒后延迟高,可以检查当前任务是否长时间持有自旋锁或处于不可抢占状态。/proc/<pid>/stack和ftrace的preemptirqsoff跟踪器都能帮你定位这类问题。
5. 上下文切换的完整链路与开销来源
5.1 __schedule() 函数逐段拆解
__schedule()是调度器的核心函数,我把它拆成几个关键段落来看:
第一段:关抢占、拿运行队列锁。调用preempt_disable()禁止抢占,然后rq_lock()拿当前CPU运行队列的锁。这一步保证了调度过程的原子性。
第二段:检查是否需要调度。如果当前任务有TIF_NEED_RESCHED标志,或者处于可中断睡眠状态被唤醒,就继续;否则直接返回。这里有个优化:如果当前任务还在运行且没有抢占请求,就不做无谓的调度。
第三段:挑选下一个任务。调用pick_next_task(),从调度类链里找下一个任务。如果找不到(所有任务都在睡眠),就选idle任务。
第四段:上下文切换。调用context_switch(),这是真正"换人"的地方。它又分两步:先switch_mm()切换地址空间(如果两个任务属于不同进程),再switch_to()切换CPU寄存器状态和栈。
第五段:收尾。新任务开始运行后,会从switch_to()返回,然后释放运行队列锁、开抢占。
整个流程里,最耗时的通常是switch_mm(),因为它涉及页表切换和TLB刷新。如果两个任务属于同一个进程(比如两个线程),switch_mm()会跳过,这就是线程切换比进程切换快的根本原因。
5.2 switch_to 宏背后的寄存器与栈切换
switch_to是一个宏,不同架构实现不同。以x86_64为例,它做的事情大致是:
- 保存当前任务的callee-saved寄存器(rbp, rbx, r12-r15)到当前任务的内核栈。
- 把当前任务的栈指针保存到
task_struct->thread.sp。 - 把下一个任务的
thread.sp加载到栈指针寄存器。 - 恢复下一个任务的callee-saved寄存器。
- 跳转到下一个任务上次被切换出去的位置继续执行。
这里的关键是:每个任务都有自己的内核栈。任务在用户态运行时用用户栈,进入内核态后切换到内核栈。上下文切换只发生在内核态,所以切换的是内核栈。内核栈的大小默认是16KB(THREAD_SIZE),在x86_64上是4个页。
我踩过的一个坑:有次写内核模块,在中断处理里递归调用导致内核栈溢出,表现就是随机的oops和死机。后来用CONFIG_DEBUG_STACK_USAGE打开栈使用统计,才发现栈快满了。这个经历让我意识到,内核栈是稀缺资源,任何在内核态执行的代码都要控制栈深度。调度器本身在__schedule()里的栈使用是经过精心优化的,但如果你在task_tick回调里加了自己的逻辑,就要小心了。
5.3 上下文切换开销的量化与观测手段
上下文切换的开销到底有多大?我实测过一组数据(不同硬件会有差异,仅供参考):
| 切换类型 | 典型开销 |
|---|---|
| 同进程线程切换 | 1-2微秒 |
| 跨进程切换(同CPU) | 3-5微秒 |
| 跨CPU迁移 | 5-10微秒 |
| 带TLB刷新的切换 | 额外2-5微秒 |
这些数字看起来不大,但在高并发场景下会被放大。假设一个服务每秒处理10万请求,每个请求触发2次上下文切换,那就是20万次切换,按3微秒算就是0.6秒的CPU时间纯花在切换上。如果CPU只有4核,这0.6秒就是15%的CPU浪费。
观测上下文切换的手段主要有:
vmstat 1:看cs列,就是每秒上下文切换次数。pidstat -w -p <pid> 1:看单个进程的cswch/s(自愿切换)和nvcswch/s(非自愿切换)。perf stat -e context-switches:精确统计。ftrace的sched_switch事件:能看到每次切换的完整调用栈,适合定位"谁在频繁切换"。
自愿切换通常是任务主动等待资源(比如等IO、等锁),非自愿切换是被抢占。如果非自愿切换很高,说明CPU竞争激烈,可能需要加CPU或调整调度策略。如果自愿切换很高,说明任务在频繁等待,要检查是不是锁竞争或者IO瓶颈。
6. 从源码回到生产:几个真实的调优场景
6.1 计算密集任务被频繁抢占的排查过程
回到我开头提到的那个案例。一个计算密集型服务在混部环境下P99延迟飙升,CPU使用率却不高。排查过程是这样的:
第一步,确认现象。用pidstat -w看到非自愿上下文切换高达每秒5万次,而独占环境下只有几百次。这说明任务在被频繁抢占。
第二步,看谁在抢占。用ftrace抓sched_switch事件,发现抢占者是一批短命的线程,它们属于同机器上的另一个服务。这些线程生命周期极短(几毫秒),但数量多,频繁唤醒。
第三步,分析调度行为。计算密集任务的vruntime增长很快(因为它一直在跑),而短命线程每次唤醒时vruntime被设为min_vruntime附近,所以它们一醒来就能抢占计算任务。这就是CFS的"唤醒抢占"机制——为了保证交互式任务的响应性,新唤醒的任务会获得一定的优先级优势。
第四步,解决方案。我们试了几种方案:调大sysctl_sched_wakeup_granularity(增加唤醒抢占的门槛)、给计算任务设CPU亲和性、用cgroup隔离。最终效果最好的是cgroup隔离,把两个服务放到不同的CPU cgroup里,各自有独立的运行队列,互不干扰。
这个案例的教训是:CFS的公平性是"局部"的,它保证同一个运行队列上的任务公平,但不保证跨cgroup的公平。混部场景下,cgroup隔离往往比调调度参数更有效。
6.2 nice 值调了没效果?先看 cgroup 和 CPU 亲和性
很多人调优的第一反应是调nice值,但经常发现没效果。原因通常有两个:
一是cgroup的CPU配额限制。如果任务在cgroup里,cgroup的cpu.max会限制整个组的CPU使用,组内任务的nice值只在组内起作用。如果组本身就被限流了,组内怎么调nice都没用。检查方法:cat /sys/fs/cgroup/<path>/cpu.max。
二是CPU亲和性。如果任务被绑定到某个CPU,而那个CPU上有个RT任务在跑,那nice值再低也没用——RT任务永远优先。检查方法:taskset -p <pid>看亲和性,chrt -p <pid>看调度策略。
我的一般排查顺序是:先看调度策略(是不是RT),再看cgroup限制,最后才看nice值。这个顺序能避免很多无用功。
6.3 容器环境下调度参数的可见性与调整边界
容器环境给调度调优带来了额外的复杂性。容器里的/proc/sys/kernel/sched_*参数通常是只读的,因为它们是全局参数,改了会影响宿主机上所有容器。你能调的主要是cgroup层面的参数:
cpu.weight(cgroup v2)或cpu.shares(v1):组内任务的权重分配。cpu.max:组的CPU带宽限制。cpuset.cpus:组的CPU亲和性。
我的经验是:容器里不要试图调全局调度参数,而是通过cgroup的权重和配额来表达你的优先级需求。比如一个延迟敏感的服务,给它更高的cpu.weight和更宽松的cpu.max,比调nice值有效得多。
另外要注意,不同内核版本的cgroup接口有差异。v1和v2的调度参数名字和语义都不一样,调优前先确认你的环境用的是哪个版本。mount | grep cgroup能看出来。
7. 读 sched 源码的几个实用技巧
7.1 用 ftrace 和 perf 验证你的理解
读源码最怕的是"以为自己懂了"。我的习惯是:每理解一个机制,就用ftrace或perf去验证。比如理解了check_preempt_wakeup()的逻辑后,我会写个小程序,创建两个不同nice值的线程,用ftrace抓sched_wakeup和sched_switch事件,看实际行为是否符合预期。
ftrace的sched跟踪器特别有用,它能记录每次调度决策的细节。打开方式:
# 挂载tracefs(如果还没挂) mount -t tracefs nodev /sys/kernel/tracing # 启用sched_switch事件 echo 1 > /sys/kernel/tracing/events/sched/sched_switch/enable # 开始跟踪 echo 1 > /sys/kernel/tracing/tracing_on # 跑你的测试程序 ./your_test # 停止并查看 echo 0 > /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace输出的每一行会显示:哪个任务被切换出去、哪个任务被切换进来、在哪个CPU上、优先级是多少。对照源码看这些数据,理解会深刻很多。
7.2 版本差异:从 4.x 到 6.x 的关键变化
Linux调度器一直在演进,不同版本差异不小。几个关键变化:
- 4.14之前:CFS的负载跟踪用的是
load_avg,基于运行时间。 - 4.14之后:引入了PELT(Per-Entity Load Tracking),用指数衰减的方式跟踪负载,对突发负载的响应更好。
- 5.0之后:
fair.c里的部分功能拆到了pelt.c,代码结构更清晰。 - 5.8之后:引入了
SCHED_CORE(core scheduling),用于解决SMT(超线程)场景下的侧信道安全问题。 - 6.x:EEVDF调度器开始进入主线(6.6+),CFS逐渐被替代。EEVDF用"最早截止时间优先"的思路,解决了CFS在某些场景下的公平性问题。
如果你读的是6.6之后的内核,会发现fair.c里多了很多EEVDF相关的代码。EEVDF的核心思想是给每个任务算一个"截止时间"(deadline),按截止时间排序而不是按vruntime。这个变化对延迟敏感型任务更友好,但理解起来需要先搞懂CFS,因为EEVDF是在CFS基础上演进的。
提示:读源码时一定要对照你实际使用的内核版本。网上很多文章基于4.x或5.x,和6.x的代码差异可能很大。用
git log kernel/sched/fair.c看提交历史,能快速了解某个函数的演变过程。
7.3 调试调度问题的常用命令清单
最后整理一份我平时排查调度问题常用的命令清单,都是实战中反复用到的:
# 查看进程的调度策略和优先级 chrt -p <pid> # 查看进程的CPU亲和性 taskset -p <pid> # 查看进程的cgroup cat /proc/<pid>/cgroup # 实时监控上下文切换 pidstat -w -p <pid> 1 # 查看运行队列长度 vmstat 1 # 看r列 # 查看调度延迟(需要root) perf sched latency # 抓调度事件 perf sched record -- sleep 5 perf sched script # 查看CPU频率和调度统计 cat /proc/schedstat cat /proc/<pid>/sched/proc/<pid>/sched这个文件特别有用,它显示了单个任务的调度统计,包括se.sum_exec_runtime(总运行时间)、se.vruntime(虚拟运行时间)、nr_switches(切换次数)等。对比两个任务的这些值,能直观看出调度器是否"公平"。
我个人在实际操作中的体会是:调度器调优没有银弹,关键是理解机制后,结合具体场景去观测、假设、验证。源码是理解机制的最好途径,但光读源码不够,一定要配合工具去验证。我见过太多人背了一堆参数含义,但真遇到问题时不知道从哪下手。真正的能力是:看到现象,能快速定位到调度器的哪个机制在起作用,然后用工具去证实或证伪。这个系列后面还会继续拆解CFS的负载均衡、组调度、以及EEVDF的演进,感兴趣的话可以跟着一起读源码。