恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Linux内核调度器源码深度解析:从CFS到EEVDF的演进与实战调优
首页
资讯中心
/
Linux内核调度器源码深度解析:从CFS到EEVDF的演进与实战调优
Linux内核调度器源码深度解析:从CFS到EEVDF的演进与实战调优
发布时间:2026/10/22 4:20:09
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.cCFS完全公平调度器的全部实现最高sched.h核心数据结构定义task_struct相关、rq运行队列最高rt.c实时调度器SCHED_FIFO/SCHED_RR实现中deadline.cDL调度器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_classdl_sched_classrt_sched_classfair_sched_classidle_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。默认情况下这个周期是6mssysctl_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_classSCHED_DEADLINE基于截止时间的调度用于硬实时场景。rt_sched_classSCHED_FIFO和SCHED_RR实时调度优先级1-99。fair_sched_classSCHED_NORMAL和SCHED_BATCH普通任务就是我们平时写的绝大多数程序。idle_sched_classSCHED_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寄存器。跳转到下一个任务上次被切换出去的位置继续执行。这里的关键是每个任务都有自己的内核栈。任务在用户态运行时用用户栈进入内核态后切换到内核栈。上下文切换只发生在内核态所以切换的是内核栈。内核栈的大小默认是16KBTHREAD_SIZE在x86_64上是4个页。我踩过的一个坑有次写内核模块在中断处理里递归调用导致内核栈溢出表现就是随机的oops和死机。后来用CONFIG_DEBUG_STACK_USAGE打开栈使用统计才发现栈快满了。这个经历让我意识到内核栈是稀缺资源任何在内核态执行的代码都要控制栈深度。调度器本身在__schedule()里的栈使用是经过精心优化的但如果你在task_tick回调里加了自己的逻辑就要小心了。5.3 上下文切换开销的量化与观测手段上下文切换的开销到底有多大我实测过一组数据不同硬件会有差异仅供参考切换类型典型开销同进程线程切换1-2微秒跨进程切换同CPU3-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.weightcgroup v2或cpu.sharesv1组内任务的权重分配。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之后引入了PELTPer-Entity Load Tracking用指数衰减的方式跟踪负载对突发负载的响应更好。5.0之后fair.c里的部分功能拆到了pelt.c代码结构更清晰。5.8之后引入了SCHED_COREcore scheduling用于解决SMT超线程场景下的侧信道安全问题。6.xEEVDF调度器开始进入主线6.6CFS逐渐被替代。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的演进感兴趣的话可以跟着一起读源码。