☰
Linux内核调度器全景解析:CFS、负载均衡与调优实践
2026/10/6 4:50:11 网站建设 项目流程

1. 为什么进程会“卡住”?从调度器存在的意义说起

很多刚接触 Linux 内核的人,一上来就去翻kernel/sched/下的源码,结果被schedule()、pick_next_task()、update_curr()这些函数绕得头晕。我当年也是这么过来的,啃了一个月,感觉每个函数都认识,串在一起就不知道它们在干什么。后来才想明白一个问题:学调度器,不能从代码入手,得先从现象入手。

你想想,一台单核 CPU 的机器上跑着几十个进程,每个进程都觉得自己独占了 CPU,这怎么做到的?答案当然是时间片轮转——但“轮转”这两个字背后藏着无数细节:谁先谁后?每个进程分到多少时间?一个进程突然醒了要不要打断正在运行的进程?多核之间负载不均怎么办?

调度器解决的就是这些问题。它的核心职责一张表就能说清:

调度器要回答的问题具体体现
下一个该运行谁pick_next_task(),从运行队列里选出最合适的进程
每个进程能跑多久时间片/虚拟运行时间的计算,决定进程的运行时长
什么时候该切换抢占点判断:时钟中断、唤醒、睡眠、yield 等时机
多核之间怎么分摊负载均衡,把进程从忙核迁到闲核
紧急任务怎么插队调度类优先级,RT/DL 任务优先于普通任务

Linux 的进程调度器不是一蹴而就的。早期 2.4 内核用的是 O(n) 调度器,每次选进程都要遍历整个运行队列,进程一多性能就崩。2.6 早期换成 O(1) 调度器,用优先级位图 + 活动/过期数组,解决了大数据量下的选进程开销。再到 2.6.23 引入了 CFS(Completely Fair Scheduler,完全公平调度器),一直沿用至今——就是你今天在 Linux 上跑的任何发行版默认用的东西。

CFS 的设计理念和前辈完全不同。它放弃了“时间片 + 优先级位图”的模型,换成了一个非常优雅的思路:用虚拟运行时间(vruntime)衡量每个进程已经消耗的 CPU 时间,每次都挑 vruntime 最小的进程去运行。这个思路直接决定了后面所有代码的走向,不理解 vruntime,看 CFS 的源码就是看天书。

我在实际看代码之前,习惯先把调度器的整体框架画在脑子里:每个 CPU 上有一个运行队列(runqueue),队列里有多个调度类(sched class),每个调度类维护自己的调度实体(sched entity),进程挂在调度实体上。CPU 要选下一个进程时,按调度类优先级从高到低问一遍:你这边有没有可运行的进程?有就交给你选,没有就问下一级。这个框架搞清楚之后,再去看kernel/sched/core.c和kernel/sched/fair.c里的代码,就能做到“按图索骥”,而不是在函数海洋里瞎扑腾。

这篇文章我打算带你从头捋一遍调度器的完整逻辑链路:先讲 CFS 的核心算法(vruntime 怎么算、红黑树怎么用),再讲调度类和调度策略的叠层关系,然后深入多核负载均衡,接着讨论抢占延迟和组调度这些实际工程里高频遇到的问题,最后分享一些我平时在真实系统上观察调度行为的工具和排查经验。每一部分都会给到原理和实操的对应关系,方便你学完立刻能上手验证。

2. CFS调度器:公平不是平均主义,而是按“虚拟时间”排队

2.1 vruntime 的计算逻辑:为什么 nice 值高的进程“跑得慢”

CFS 的“公平”不是平均分配时间片,而是保证每个进程的 vruntime 尽量接近。一个进程的 vruntime 增长速度和它的权重成反比:权重高的进程,vruntime 涨得慢,于是它在红黑树里的位置不容易被追上,能获得更多的实际 CPU 时间;权重低的进程,vruntime 涨得快,很快就被“赶到”红黑树右侧,把运行机会让给别人。

先说权重怎么来的。内核里维护了一张从 nice 值到权重的映射表,在include/linux/sched/prio.h里(实际是kernel/sched/core.c里的sched_prio_to_weight[]数组),核心数值是这样的:

static const int prio_to_weight[40] = { /* -20 */ 88761, 71755, 56483, 46273, 36291, /* -15 */ 29154, 23254, 18705, 14949, 11916, /* -10 */ 9548, 7620, 6100, 4904, 3906, /* -5 */ 3121, 2501, 1991, 1586, 1277, /* 0 */ 1024, 820, 655, 526, 423, /* 5 */ 335, 272, 215, 172, 137, /* 10 */ 110, 87, 70, 56, 45, /* 15 */ 36, 29, 23, 18, 15, };

注意看,nice 0 的权重是 1024,每降低一级 nice(提高优先级),权重大约乘以 1.25;每升高一级 nice(降低优先级),权重大约除以 1.25。也就是说,nice 值每差一级,CPU 时间分配比例大约差 25%。这是一个工程近似值,2 的 1/3 次方大概是 1.26,内核取整后用了 1.25 这个比,目的是让相邻 nice 级别之间的权重差距恒定且计算简单。

一个进程的实际运行时间累加到 vruntime 上的公式是这样的:

vruntime += delta_exec * NICE_0_LOAD / se->load.weight

其中delta_exec是真实运行的时间(纳秒),NICE_0_LOAD是 nice 0 的权重 1024,se->load.weight是当前进程的权重。这个除法用整数运算实现,内核里做了近似处理(__calc_delta()函数)。翻译成人话就是:一个 nice 0 的进程运行了 10 毫秒,vruntime 增加 10 毫秒;一个 nice -5 的进程运行 10 毫秒,权重是 3121,vruntime 只增加约 3.28 毫秒;一个 nice 5 的进程权重是 335,运行 10 毫秒 vruntime 要增加约 30.6 毫秒。于是 nice -5 的进程在红黑树里“下沉”得慢,能更久地留在左侧被选中,占到更多 CPU。

这个设计有一个精妙之处:vruntime 是全局可比较的。不管进程在哪个 CPU 上运行过,只要把它的 vruntime 放到同一个红黑树里比较,谁“欠”的 CPU 时间多(vruntime 小),谁就优先跑。这天然解决了“迁移”的问题——进程从 CPU0 迁到 CPU1,不需要重新计算什么历史积分,直接把它的 vruntime 拿来和新队列里的进程比较就行。

2.2 红黑树选进程:O(log n) 的“自动排好队的优先队列”

CFS 的运行队列里用了一棵红黑树来组织所有可运行的调度实体(cfs_rq->tasks_timeline)。红黑树的 key 就是 vruntime,左侧最小,右侧最大。选下一个进程的操作极其简单:取树的最左节点。这是 O(1) 的——因为红黑树的最左节点可以通过rb_first()直接拿到,不需要遍历。

很多人会问:在红黑树里插入和删除不是 O(log n) 吗?没错,但这只影响“进程进入可运行状态”或“进程被切走”时的开销。而pick_next_task()本身是 O(1),因为它只是取左端点。进程数量再多,选取的开销也不变。这也是 CFS 能支撑数千线程压力的底气所在。

红黑树的平衡性保证树高在 log n 量级,插入、删除、查找的复杂度稳定。内核里对节点插入做了“左旋转/右旋转”维护,这些代码在kernel/sched/fair.c的enqueue_entity()/dequeue_entity()流程中触发,具体维护逻辑封在lib/rbtree.c里。

实际操作中还有一个容易忽略的点:CFS 并不是让每个进程都无休止地在树里插拔。当前正在运行的进程,如果它仍然可运行且没被抢占,它就不在树里,而是单独记在cfs_rq->curr上。只有它被抢占、睡眠、或者时间片耗尽需要重新排队时,才把它重新插入红黑树。这种“当前运行实体与等待队列分离”的设计,减少了频繁的树操作,也是从真实性能数据里抠出来的优化。

2.3 调度周期与最小粒度:防止进程饿死,也防止切换开销爆炸

光有 vruntime 还不算完,还得回答一个问题:到底什么时候该切换进程?如果一个进程永远 vruntime 最小,其他进程岂不是永远等不到 CPU?为了解决这个问题,CFS 引入了调度周期(sched period)的概念。

内核有一个目标延迟(targeted latency),在kernel/sched/fair.c里对应sysctl_sched_latency,默认是 6 毫秒。它表示:在理想情况下,所有可运行进程应该在一个周期内至少被调度一次。于是每个进程可以获得的 CPU 时间大约是调度周期 / 可运行进程数。

但这会引出另一个问题:如果系统里有 1000 个可运行线程,按照“周期除以进程数”的算法,每个线程分到的运行时间大约是 6 微秒——切换一次进程的开销(上下文切换、缓存失效、TLB 刷新)都不止 6 微秒,这么干系统就废了。所以内核又引入了一个最小粒度(sysctl_sched_min_granularity,默认 0.75 毫秒)。实际计算时,每个进程至少运行这个最小粒度才能被抢占。当进程数量多到按周期平分的份额小于最小粒度时,调度周期会被动态拉长:

实际调度周期 = max(sysctl_sched_latency, nr_running * sysctl_sched_min_granularity)

举个例子:系统里只有 2 个可运行进程,周期是 6ms,每个进程分到 3ms;如果有 16 个进程,6ms 分给每个进程只有 0.375ms,小于 0.75ms 的最小粒度,于是实际调度周期自动扩展到 16 * 0.75 = 12ms,每个进程跑 0.75ms 再切换。

这两个参数在/proc/sys/kernel/sched_latency_ns和/proc/sys/kernel/sched_min_granularity_ns里可以直接读出来。我在压测低延迟服务时调整过它们,对交互式应用的响应时间影响很直接。不过要提醒一句:sched_latency_ns调小会增加上下文切换频率,CPU 开销上升;调大则降低切换频率但进程间的响应变慢。生产环境不要盲目调,先看pidstat -w的上下文切换数据再决定。

3. 从实时到普通:调度类与调度策略的优先级叠层

3.1 四个调度类的“上下级关系”:stop > dl > rt > cfs > idle

Linux 内核的调度器不是只有 CFS 一个。在kernel/sched/core.c里维护了一个调度类链表,按优先级从高到低依次是:

  1. stop_sched_class:停止调度类,优先级最高,用于 CPU 热插拔、迁移任务等内核内部紧急操作,普通进程永远接触不到。
  2. dl_sched_class:Deadline 调度类,实现 EDF(Earliest Deadline First)算法,用于有硬实时截止时间要求的任务。
  3. rt_sched_class:实时调度类,对应 SCHED_FIFO 和 SCHED_RR 策略,优先级范围 0~99。
  4. fair_sched_class:CFS 调度类,对应 SCHED_NORMAL / SCHED_BATCH / SCHED_IDLE,优先级范围 100~139。
  5. idle_sched_class:空闲调度类,CPU 上没有任何可运行任务时运行 idle 线程。

当 CPU 要选下一个任务时,调度核心会从 stop 类开始依次问下去,高优先级的调度类只要还有可运行任务,低优先级的就没机会。这个设计保证了周期性实时任务能够严格抢占普通进程。

这个“叠层巡检”的机制在pick_next_task()里体现得很清楚。早期版本的实现是逐个调用每个调度类的pick_next_task(),性能不佳;后来内核做了 fast-path 优化:如果当前运行任务和运行队列都属于 CFS,就直接走 CFS 的快路径,不再询问其他调度类——因为绝大多数系统里,绝大多数时间跑的进程都是普通进程。你可以去读kernel/sched/core.c里的pick_next_task(),现在前面一大段 if 判断都是在走这个 fast path。

3.2 实时调度策略:FIFO 和 RR 的行为差异

rt_sched_class管理的是 SCHED_FIFO 和 SCHED_RR 这两种实时策略。理解它们的区别对写实时程序非常关键,因为选错了策略,任务的延迟表现会差好几个数量级。

SCHED_FIFO(First In First Out):严格按优先级执行。一个 SCHED_FIFO 进程一旦开始运行,它会一直运行到阻塞、退出,或者被更高优先级的实时进程抢占。同优先级的 FIFO 进程之间不会因为时间片而轮换。这意味着一个写死循环的 SCHED_FIFO 进程可以永久占住 CPU,把其他所有进程(包括内核线程)都饿死。

SCHED_RR(Round Robin):和 FIFO 类似,但同优先级的 RR 进程会在时间片耗尽后轮流执行。它本质上就是“有轮转的 FIFO”,适合多个同优先级实时任务需要交替执行的场景。

我在做音频采集程序时测过这两者的差异:使用 SCHED_FIFO 的采集线程配合mlockall()锁页,中断延迟和调度延迟都能压到几十微秒级别;但如果优先级设置不当,一个忙等的 FIFO 线程会拖垮整个系统的 UI 响应。给实时线程设置优先级时,务必从高到低规划好几层,并把非关键实时线程的优先级错开,不要让所有实时线程都挤在同一个优先级上互踩。

设置实时调度策略和优先级不需要写内核模块,用chrt命令或sched_setscheduler()系统调用就行:

# 把 PID 1234 设置为 SCHED_FIFO,优先级 80 chrt -f -p 80 1234 # 以 SCHED_RR 策略启动一个程序,优先级 50 chrt -r 50 ./my_real_time_app

注意,非 root 用户默认只能把优先级设到 0,需要ulimit -r放开限制或者用 root 来设置。容器环境下还要看 Docker/CGroup 的cpu.rt_runtime_us限制,默认值往往是 0,意味着容器内的实时任务根本跑不起来。

3.3 SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE 的微妙区别

除了实时策略,CFS 调度类内部还分三种调度策略,它们的调度实体都挂在同一棵红黑树上,但行为有细微差别。

  • SCHED_NORMAL(SCHED_OTHER):标准策略,绝大多数进程用的就是它。按 vruntime 公平调度。
  • SCHED_BATCH:面向批处理任务。它和 NORMAL 最大的区别是调度器会尽量避免抢占正在运行的 SCHED_BATCH 进程,让批处理任务运行得更久、减少切换开销。但这不代表它真的不会被抢占——当一个 SCHED_BATCH 进程的 vruntime 大到一定程度,或者有实时任务出现时,它照样会被切走。实际测试中,SCHED_BATCH 对 CPU 密集型任务能提升约 2%~5% 的吞吐,但对交互应用毫无帮助。
  • SCHED_IDLE:名字有迷惑性,它并不是 idle 线程,而是“低优先级调度”策略。SCHED_IDLE 进程的权重非常低(大概相当于 nice 值 19 的进程还要低),只有在系统上没有任何其他可运行普通进程时,它才能获得 CPU。适合跑后台不计较延迟的批处理任务。

用schedtool或chrt也能切换这些策略:

# 设置进程为 SCHED_BATCH chrt -b -p 0 5678 # 以 SCHED_IDLE 运行命令 chrt -i -p 0 ./low_priority_task

这里有一个常见的认知误区:SCHED_IDLE 不等于 nice 19。nice 19 的进程仍然会参与公平竞争,只是权重低,但系统只有它一个进程时它会跑满 CPU;而 SCHED_IDLE 进程在系统有任何其他可运行任务时基本处于“让路”状态,这是两种完全不同的语义。选错了会导致后台任务迟迟得不到执行。

4. 多核时代的难题:负载均衡是怎么把进程“搬”到空闲CPU上的

4.1 从“一个队列”到“每 CPU 一个队列”:隔离与均衡的博弈

单核时代,调度器只需要维护一个全局运行队列,选进程、算 vruntime 都很简单。多核普及后,内核面临一个两难:全局只有一个队列的话,每次选进程都要加锁,多核并发访问会打成狗脑袋;给每个 CPU 独立队列的话,CPU 之间负载不均,有的核忙死、有的核闲死。

Linux 的折中方案是每 CPU 一个运行队列(per-CPU runqueue),然后通过周期性的负载均衡机制在 CPU 之间迁移进程。这个设计牺牲了绝对的全局最优,换来了极高的并发度——每个 CPU 在绝大多数情况下只需要操作自己的队列,不用和其他 CPU 争锁。rq->lock的竞争成为调度器的主要锁开销之一,但已经是经过大量优化之后的结果。

每个 CPU 的运行队列有主队列和各个调度类的子队列。主队列struct rq里记录了nr_running(可运行任务数)、cpu_load(历史负载指数)、curr(当前任务)等信息。CFS 子队列是struct cfs_rq,每个调度实体可以挂载到某个 CPU 的 cfs_rq 上。这套“一核一队列”的结构,决定了你看到的所有负载均衡动作——本质上就是把进程从一个 CPU 的队列挪到另一个 CPU 的队列。

4.2 负载均衡的三种触发途径:周期、唤醒、新进程

内核在kernel/sched/fair.c里实现了负载均衡的三种触发路径:

周期性负载均衡(periodic load balance):每个 CPU 每隔一段时间(由sched_balance_interval控制,通常几毫秒到几十毫秒)触发一次软中断(SCHED_SOFTIRQ),检查当前 CPU 的负载是否和系统平均负载偏离过大。如果偏离超过阈值,就会从最忙的调度域里拉取任务到当前 CPU。这个动作叫idle balance(空闲 CPU 主动拉任务)或nohz idle balancer(在 NO_HZ 模式下,空闲 CPU 不频繁接收时钟中断,由忙 CPU 在 tick 里代为判断是否需要把任务推给空闲 CPU)。

唤醒负载均衡(wakeup balancing):当一个进程被唤醒(比如等待的 I/O 完成了),调度器会找到一个合适的 CPU 让它跑。现代内核(尤其是 EAS,Energy-Aware Scheduling)会综合考虑 CPU 当前负载、任务缓存亲和性、能耗等因素,选出一个“最划算”的 CPU。传统wake_affine逻辑会优先判断 task 上次运行的 CPU 和 waker 所在 CPU,如果缓存热(cache hot)就倾向于不迁移。

新进程/exec 负载均衡:fork()出一个新进程时,调度器会调用select_task_rq_fair()找一个负载最轻的 CPU。早期内核用find_idlest_cpu()找最空闲的 CPU;现在的代码逻辑复杂得多,会先把 CPU 分成不同调度域(sched domain)和调度组(sched group),分层寻找。

这三种路径的触发时机和决策依据不同,但目标一致:尽可能让系统所有 CPU 的负载(runnable time 的加权值)趋于均衡,同时控制迁移开销。

4.3 调度域与调度组:CPU 拓扑如何影响负载均衡

负载均衡不是在所有 CPU 之间无脑平均,因为 CPU 之间访问内存/缓存的成本不是对称的。现代 CPU 有 NUMA(Non-Uniform Memory Access)拓扑:跨 NUMA 节点访问内存比本节点慢得多;同一个物理核上的两个超线程(SMT)共享执行单元,迁移到超线程兄弟核的收益很低。

内核用**调度域(sched domain)**把 CPU 按拓扑层级组织起来,数据存在kernel/sched/topology.c里。例如一个典型的双路服务器可能有三级 domain:

  • MC 域(Multi-Core):一个物理 CPU 内的所有核。
  • NUMA 域:同一 NUMA 节点内的所有 CPU。
  • NUMA 远程域:整个机器的所有 CPU。

负载均衡是分层进行的:先在本层 domain 内找负载最轻的 CPU,如果还不行,再上升到更宽泛的 domain。每级 domain 之间通过调度组(sched group)把 CPU 分成若干组,比较组间平均负载,然后从最忙的组里“搬运”任务到最闲的组。只要组间负载差超过设定阈值就执行迁移,不会追求绝对相等——因为迁移本身有代价,过度均衡反而可能把系统搞得比不均衡更差。

你可以在内核里看到实际的拓扑层级:

# 查看 CPU 拓扑和调度域关系 cat /proc/schedstat # 各 CPU 的运行统计 ls /sys/devices/system/cpu/cpu0/topology/

调度域信息实际渲染在 debugfs 里:

mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/sched/domains

你会看到每一级 domain 的span(覆盖的 CPU 列表)、flags(如 SD_BALANCE_NEWIDLE、SD_WAKE_AFFINE)、min_interval、max_interval等参数。改动这些参数可以在 sysctl 层面对调度域做调优,但生产环境极少需要动,更多是理解机制后在写多线程程序时做出合理的位置选择——比如把计算密集型线程绑核,就能规避大量跨 NUMA 迁移。

4.4 迁移的代价:缓存亲和性 vs 负载均衡的权衡

负载均衡不是免费的。把一个进程从 CPU0 迁到 CPU1,意味着它之前在 CPU0 各级缓存(L1/L2)里的数据全部失效,下次运行要从内存重新加载。如果进程的工作集很大,这次迁移可能导致瞬时性能崩塌。

所以负载均衡不是“发现不均衡就立刻迁移”,而是有一套保护机制:

  • 缓存亲和性(cache affinity):迁移时会参考vruntime和cached_hot_time。一个刚运行过的进程被视为“cache hot”,短时间内会尽量留在原 CPU,避免迁移。
  • 迁移次数限制:每 CPU 的nr_migrations会记录迁移次数,防止进程在 CPU 间抖动(ping-pong)。抖动会带来灾难性的性能下降——比不均衡更糟。
  • idle 优先原则:只有 CPU 处于 idle 或接近 idle 时,才最积极地执行迁移。忙碌的 CPU 之间不会强行搬运任务,因为两边都在跑,迁移收益低、代价高。

一个非常典型的实践场景:多线程服务程序在 NUMA 机器上性能不达标,你拿perf stat -e cpu-migrations一看,每秒迁移上万次,那大概率是调度器在做无谓的均衡。这时候最简单有效的办法是绑核:用taskset把关键线程固定在几颗核上,让它们在同一个 NUMA 节点内运行,远程内存访问和迁移抖动同时消失。

# 将 PID 绑定到 CPU 4-7 taskset -pc 4-7 9876

我自己在大规模分布式存储服务的压测里验证过:不绑核时尾部延迟(p99)经常飙到几十毫秒,绑核后稳定在个位数毫秒。绑核牺牲了一点灵活性,但对延迟敏感型应用来说是性价比极高的优化手段。

5. 抢占、延迟与组调度:调度器如何权衡“响应快”和“吞吐高”

5.1 内核抢占模型的历史演进:从不可抢占到 FULL_PREEMPT

你写的用户态程序随时可能被切走,这不奇怪;但 Linux 内核长期以来有一个限制:内核态代码在执行关键临界区时是不能被随意抢占的。如果内核正在修改一个链表,此时发生时钟中断,调度器想把另一个任务切进来——切进来也去碰同一个链表怎么办?数据直接就乱了。

为了解决这个问题,历史上内核使用“中断返回时检查TIF_NEED_RESCHED标志”的方式来延迟抢占:中断处理完返回之前,如果发现这个标志被置位,就切换到新任务。但这里有一个细节——只有在返回用户态时才会响应这个标志,如果当前正处于内核态(比如正在执行系统调用),则返回用户态之前不会调度。这就是早期内核“一旦进内核,就一路干完再出来”的行为,一个实时任务即使优先级再高,也必须等当前系统调用返回用户态才有机会抢占。

现在的 Linux 内核提供了三种抢占模型,在编译时通过CONFIG_PREEMPT_*选择:

抢占模型配置内核态可抢占点适用场景
无抢占(服务器)CONFIG_PREEMPT_NONE几乎不可抢占吞吐优先的服务器,追求最低上下文切换开销
低延迟抢占CONFIG_PREEMPT_VOLUNTARY只在显式调度点(schedule/schedule_timeout等)可抢占桌面/通用场景,兼顾吞吐和响应
完全抢占CONFIG_PREEMPT除持有锁等少数临界区外均可抢占实时性要求高的嵌入式/音频应用

判断当前系统用的是什么抢占模型:

zcat /proc/config.gz | grep CONFIG_PREEMPT # 或 grep PREEMPT /boot/config-$(uname -r)

我自己调板子上的实时系统时,通常用CONFIG_PREEMPT,配合 RT 补丁(PREEMPT_RT)进一步把锁变成可抢占的 rtmutex,能把调度延迟压到几十微秒。但对常规服务器来说,PREEMPT_NONE 反而吞吐更好——上下文切换少,cache 命中率高。所以不是越能抢占越好,得看业务目标。

5.2 唤醒抢占与调度延迟:从“置位标志”到“真正切换”的距离

当一个高优先级进程被唤醒(比如网卡收到数据包唤醒等待队列里的进程),它不可能立刻运行——中间要经历一串动作:

  1. 唤醒者(比如中断处理下半部/软中断)调用wake_up()→try_to_wake_up(),把任务放入目标 CPU 的运行队列。
  2. 检查新任务 vruntime/优先级,判断是否需要抢占当前任务。如果需要,置位TIF_NEED_RESCHED。
  3. 如果当前 CPU 处于中断上下文,无法立刻调度,只能等中断/异常返回时响应TIF_NEED_RESCHED。
  4. 从内核态返回用户态(或返回低优先级内核态代码)时,schedule()被调用,真正发生上下文切换。

这里的“唤醒到真正切换”之间的时间间隔就是调度延迟(wakeup latency)。它受很多因素影响:中断处理时间、锁的竞争、目标 CPU 是否空闲、是否在 NO_HZ 模式下等待下一次 tick 等。测量它的工具是perf sched latency,或者更精细地用 ftrace 的wakeuptracer:

# 开启 wakeup tracer,测量最高优先级任务的调度延迟 echo wakeup > /sys/kernel/debug/tracing/current_tracer echo 1 > /sys/kernel/debug/tracing/tracing_on # 跑一段时间后 cat /sys/kernel/debug/tracing/trace | head -50

实际优化时,如果你的系统有周期性硬实时任务,优先考虑SCHED_FIFO加上mlockall(),并且把中断绑到不同的 CPU,把实时任务从中断风暴里隔离出来。这套组合拳的效果远大于修改 CFS 参数。

5.3 CGroup 与组调度:CFS 带宽控制如何限制一个“进程组”

前面说的权重公平,只是进程级别的公平。但现实中的系统往往以“服务”为粒度分配资源——比如一个 Kubernetes 节点上跑着多个容器,每个容器是一个 CGroup,容器里有很多线程。如果不做限制,一个容器里的几百个线程可以用权重优势把其他容器的 CPU 份额全部抢走。

组调度(group scheduling)就是解决这个问题的:CFS 不再是直接对每个进程算 vruntime,而是在调度实体之间增加了一层“组”的概念。每个 CGroup 对应一个task_group,组内再维护自己的 cfs_rq 和红黑树。时间片的分配先发生在组与组之间,再发生在组内进程之间。这样即使一个组里有 1000 个线程,它分到的总 CPU 时间也只由这个组的权重决定,不会挤占其他组的份额。

配合cpu.max(原cpu.cfs_quota_us/cpu.cfs_period_us),还可以限制一个组在一个周期内最多使用多少 CPU 时间:

# 限制一个 CGroup 在 100ms 周期内最多使用 50ms CPU(即半核) echo "50000 100000" > /sys/fs/cgroup/cpu.max

这个机制在实现上依赖 CFS 的带宽控制逻辑:当组内所有调度实体的 vruntime 分布已经到达限额时,内核会给这个组打上“throttled”标记,暂时把整个组从父运行队列里摘除,直到下一个周期开始才重新放回。我在压测多租户平台上见过很多因为cpu.cfs_quota_us设置不当导致的奇怪现象——比如容器内 CPU 使用率明明只有 50%,但业务延迟很高,查到最后发现是 quota 周期与线程调度周期共振造成的节流抖动。这时候把period从默认的 100ms 调大(比如 1s),节流恢复的频次就会降低,稳定性明显变好。

5.4 调度器的高精度时间管理:hrtick 与高精度时钟

CFS 的时间记账依赖高精度定时器(hrtimer)。早期内核用低精度 tick(通常 100Hz~250Hz,即每 10ms 或 4ms 一次时钟中断)驱动调度;现在主流内核在CONFIG_HZ选 1000(HZ=1000,即每 1ms 一次 tick)的基础上,还可以启用SCHED_HRTICK,用 hrtimer 精确控制抢占时机,而不是等下一个 tick 才决定是否切换。

这对延迟的影响是:没有 hrtick 时,即使 CFS 计算出某个进程应该只跑 3.2ms,实际也可能拖到一个 tick 边界(比如 4ms 或 5ms)才被切走。有了 hrtick,调度器可以在精确的纳秒级别设定抢占闹钟,显著降低调度误差。但 hrtick 在虚拟化环境里要小心:如果宿主机本身 tick 很粗,guest 里的高精度定时器也会受限。

看当前系统的 HZ:

# 通常在 /boot/config 里 grep '^CONFIG_HZ=' /boot/config-$(uname -r) # 或者 cat /proc/version

对绝大多数业务来说,HZ=1000 已经是很好的折中。再往上调只会增加时钟中断开销,收益甚微。

6. 在真实系统里观察调度器:工具、指标与一次排查实例

6.1 命令行三件套:pidstat、vmstat、top 的调度信息

内核调度器内部发生了什么,单靠肉眼看并不直观。我平时用的第一层工具是这三个:

  • pidstat -w:查看每个进程/线程的上下文切换次数(cswch自愿切换,nvcswch非自愿切换)。非自愿切换高说明被抢占频繁;自愿切换高说明经常睡眠/等待 I/O。
  • vmstat 1:看r(运行队列长度)和cs(上下文切换速率)。r长期大于 CPU 核数意味着 CPU 饱和,cs过高说明调度太过频繁。
  • top的%Cpu行显示us(用户态)和sy(内核态)占比,sy过高经常是调度/锁竞争信号。

一个最基本的排查套路:如果cs每秒几十万次而上层业务吞吐却很低,拿pidstat -w -I 1看看是哪些进程在被频繁切换,然后评估是否有排查优先级/绑核/时间片参数优化空间。我遇到过一个案例:一个 16 核机器只跑了 4 个线程的数据库服务,每个线程非自愿切换每秒高达 2 万次,后来发现是线程内部用了大规模自旋锁,锁竞争导致调度器反复切入切出,绑核之后立刻缓解。

6.2 /proc 与 debugfs:调度器给你的“内部体检报告”

/proc下的调度信息虽然不像内核文档那么系统化,但拿来快速定位问题很有效:

  • /proc/schedstat:格式略晦涩,但包含了每个 CPU 的调度统计,比如sched_count(调度次数)、sched_goidle(进入 idle 次数)、sched_wait(等待时间)等。
  • /proc/<pid>/sched:单个进程的调度信息。里面有nr_switches、nr_voluntary_switches、nr_involuntary_switches,以及se.vruntime(虚拟运行时间)、se.load.weight(权重)、policy(调度策略)等。这个文件可以直接用来验证你对 CFS 参数的理解。
  • /proc/PID/stat里的prio字段是调度优先级(rt 优先级 0~99 映射到内核 0~99;普通进程的 nice 值映射到 100~139)。

看一个进程的调度信息实例:

cat /proc/1966/sched # 输出示例(节选): # se.vruntime : 2924379128 # se.load.weight : 1024 # sum_exec_runtime : 12345678.901 # nr_switches : 98765

se.vruntime是不是符合 fair 调度的规律,sum_exec_runtime和vruntime的比值是否大致等于权重比,这些都可以用来验证你对原理的理解。

debugfs 里还有更丰富的调度域/调度类信息:

mount -t debugfs none /sys/kernel/debug ls /sys/kernel/debug/sched/ cat /sys/kernel/debug/sched/features

sched/features文件里列出了内核编译时启用的一些调度特性(GENTLE_FAIR_SLEEPERS、START_DEBIT、NEXT_BUDDY等)。例如START_DEBIT表示新唤醒的进程在首次运行时会被多记一些 vruntime,防止频繁唤醒的进程把别的进程饿死;GENTLE_FAIR_SLEEPERS则调整睡眠进程的补偿逻辑。这些开关在运行时可以通过写入该文件来开启/关闭(echo NO_GENTLE_FAIR_SLEEPERS > /sys/kernel/debug/sched/features),不过生产环境慎用,先理解每个特性再决定要不要动。

6.3 一次真实的“负载不均衡”排查经过

去年我接手一个服务,现象是 32 核机器上只有部分核在忙,另一些核闲着,吞吐上不去。当时我的排查链是这样的:

第一步,top确认 CPU 使用不均:看到 CPU0~CPU7 跑满,CPU8~CPU31 几乎为 0。这不是系统调用导致的,而是任务分配出了问题。

第二步,pidstat -p 所有服务线程 -t 1,看到大量线程集中在 CPU0~7 上。用taskset -pc查看线程的 CPU 亲和性掩码,发现都是0x000000ff,明显被限制在低位 8 个核上。

第三步,翻看代码定位原因:创建线程时用了sched_setaffinity()把线程绑到了0x000000ff。这是历史遗留代码,当时绑核是为了避免 NUMA 跨节点访问,但没考虑节点上有多少核,直接写死成了前 8 个核。

第四步,修改方案:去掉硬绑,让调度器通过负载均衡自由调度;同时用numa库接口把线程和工作内存分配到同一 NUMA 节点,保留局部性的好处但不放弃全局负载均衡。

最终结果是吞吐提升约 20%,p99 延迟从 40ms 降到 9ms。这个案例的价值不在于方案多高深,而在于怀疑负载均衡失效前,先检查有没有显式亲和性设置。很多“调度器有问题”的误判,最后都是这类显式配置或 CPU 隔离(isolcpus)导致的。

6.4 perf sched 与 ftrace:延迟到底耗在哪

如果常规指标不够用,就得请出更精细的工具。

perf sched是追踪调度器行为的利器。常用两个子命令:

# 记录一段时间的调度行为 perf sched record -- sleep 5 # 查看调度延迟直方图 perf sched latency # 查看每单位时间内的调度活动和迁移 perf sched timehist

perf sched latency会列出每个任务的平均调度延迟(avg)、最大延迟(max)、最大延迟发生时刻等。如果看到某个任务的调度延迟动辄几十毫秒,优先怀疑:它是否运行在满载 CPU 上、是否频繁被从 runqueue 尾部追加(没有足够优先级抢占)、是否存在 CGroup throttle 等。

ftrace 则能直接追踪一次唤醒事件的生命周期:

echo 0 > /sys/kernel/debug/tracing/tracing_on echo function_graph > /sys/kernel/debug/tracing/current_tracer echo 'wake_up_process' > /sys/kernel/debug/tracing/set_ftrace_filter echo 1 > /sys/kernel/debug/tracing/tracing_on # 触发场景... echo 0 > /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace

通过函数调用图,你能看到从try_to_wake_up()到schedule()之间实际执行了哪些函数、在哪一步停留最长。我曾经在嵌入式板子上用这个方法定位到一次因为select_task_rq_fair()里的find_idlest_group_cpu()耗时过长导致的调度延迟激增——那个板子的 CPU 拓扑被 ACPI 表描述成 4 个 NUMA 节点,但实际只有 1 个物理节点,导致调度域遍历开销夸大。

6.5 让它更稳:基于仿真和基准的调度参数调优心得

最后说点参数调优的经验。调度参数没有“标准答案”,但可以给一套我常用的评估方法:

  1. 先建立基线:记录调参前的吞吐(TPS/QPS)、延迟分位数(p50/p99)、上下文切换速率。
  2. 一次只调一个参数,调完跑同样的基准测试,对比三组数据。
  3. 决定调优方向时,先判断业务是“延迟敏感型”还是“吞吐敏感型”:前者优先降低调度延迟(提高 HZ、启用 PREEMPT、减小sched_min_granularity_ns),后者优先降低切换开销(增大粒度、考虑PREEMPT_NONE、使用 SCHED_BATCH)。
  4. 用turbostat或perf stat -e cycles,instructions,cache-misses观测实际硬件行为,防止“调度参数带来了延迟改善、但缓存命中率崩了”这种隐形恶化。
  5. 生产环境改参数前,先在压测环境跑 48 小时,并监控/proc/schedstat和软中断分布。

我个人的经历是:在一次高并发网关调优中,把 HZ 从 250 改到 1000,p99 延迟下降了约 35%,代价是 CPU 利用率上升了约 2%。这个交换在某些场景是划算的,在另一些场景可能正好相反。所以调优唯一靠谱的依据是真实压测数据,而不是网上任何一篇“最佳配置”文章。

最后再分享一个小习惯

写这篇东西的起因,是我这些年见过太多人一上来就扎进代码细节里出不来。如果你也正在啃调度器源码,我给你一个建议:先不急着读schedule()的实现,先去运行一个多线程程序,用pidstat观察它的上下文切换,用/proc/<pid>/sched看它的 vruntime 涨落,用perf sched latency看调度延迟分布。把这些真实数据和你从原理里推导出的预期对照起来,你会发现内核调度器其实特别“讲道理”——每一个看起来晦涩的机制,都是在一个具体的现实约束下长出来的。理解了约束,代码就自然能读懂了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询