
用Linux这么多年最让我抓狂的问题永远是“跑不满”。机器配置不低进程也没写错CPU利用率却二十几个点上下跳或者某个线程卡得毫无规律。查到最后十有八九都落在进程调度上。搞明白Linux进程调度不只是应付面试更是排查系统卡顿、优化响应延迟、给容器/嵌入式系统做性能调优的一项底子功夫。这篇文章我按“调度器设计思路 - 内核机制拆解 - 常用命令实操 - 真实故障复盘 - 个人调优经验”的顺序来写尽量避免教科书式的堆概念重点把为什么这样设计、实际上手怎么做、碰到问题怎么看给出清楚。不论你是做服务器运维、嵌入式开发还是刚接触Linux想系统搞懂调度这篇文章都值得认真看两遍。1. 调度器在解决什么问题从内核演进看设计思路1.1 调度目标与多任务权衡所谓进程调度就是内核里决定“下一个谁用CPU、用多长时间、什么时候被换下来”的一整套规则。听起来简单但真实场景里CPU资源永远不够分十几个进程同时想跑有人要延迟低有人要吞吐量大有人只是后台默默干活。内核调度器存在的意义就是在这堆互相矛盾的需求里找平衡点。具体来说调度器要满足四个目标公平性所有进程都该有机会运行不能有进程被长期饿死。响应性用户敲个键盘、点个鼠标交互进程要立刻被唤醒。高吞吐系统整体每秒能完成的任务量要足够大。实时约束某些任务必须在确定的时间窗口内执行完比如工业控制、音视频推流。这四件事天然冲突如果追求绝对公平那每个人各跑1ms轮流来交互式程序就会觉得卡顿如果让交互进程总是优先后台任务可能永远排不上。Linux调度器十几年来的演进史本质上就是怎么在这个矛盾里反复横跳。1.2 从O(1)调度器到CFS再到EEVDF早期Linux用的是O(1)调度器名字就来自它在选择下一个任务时的时间复杂度是O(1)跟系统里进程数量无关。它维护了140个优先级的就绪队列数组普通进程优先级100~139实时进程0~99。每次调度直接看队列里有没有任务。这个设计在当时很先进但问题也很明显调整nice值会改变时间片大小而时间片的计算方式过于机械导致交互进程和批处理进程的身份切换很不自然。这个阶段有个尴尬场景一个进程的nice值从0调到1时间片可能从100ms直接跳到95ms看起来合理但实际手感变化剧烈。公平性不够平滑。到了2.6.23CFS完全公平调度器的合并彻底改变了思路。CFS不搞“分配固定时间片”那一套逻辑而是维护一个虚拟运行时间vruntime。它用红黑树把所有就绪进程按vruntime排序调度器每次都挑最小的——也就是“累计虚拟运行时间最少、最该被公平照顾”的那个进程。这个思路妙在哪它完全放弃了“时间片被穷尽才切换”的旧模型改用“每个进程都该按比例获得CPU时间”的比例公平模型。权重高的进程vruntime增长慢意味着它能获得更多实际运行时间nice值高优先级低的进程vruntime增长快自然被排到后面。一切变得平滑且可控。再到内核6.6左右主线引入了EEVDF功耗感知的虚拟截止时间调度准确说是“最早虚拟截止时间优先”。它把CFS的vruntime从“平均公平”进一步细化成“延迟公平”。EEVDF会计算每个进程的虚拟截止时间让你不仅能保证CPU时间的分配比例还能控制单次调度的延迟分配更均匀。具体到内核配置里CFS的几个核心参数仍然有效kernel.sched_latency_ns控制调度周期kernel.sched_min_granularity_ns控制每个任务运行的最小时间粒度。EEVDF落地后新内核在交互式场景下的卡顿体感改善非常明显我前阵子把一台24核开发机升到6.8内核后编译加桌面操作同时进行明显比5.15顺滑。1.3 调度类与优先级层次现代Linux调度器已经不是一个单独的调度算法而是按优先级从上到下排列的一堆调度类它像插线板一样越上面的类越有发言权调度类优先级调度策略适用场景stop最高强制停止CPU内核内部停机/热迁移deadline第二SCHED_DEADLINE硬实时、对延迟确定性要求极高realtime第三SCHED_FIFO、SCHED_RR软实时任务、需要抢占普通进程fair第四SCHED_NORMAL 即 CFS/EEVDF绝大多数普通应用idle最低SCHED_IDLE空闲任务这个层级设计是保底逻辑实时任务再牛也不能抢stop类否则cpu热插拔会出问题普通进程优先级再低也比idle任务强。实际工作时绝大多数进程都落在fair类里所以大部分优化都围在CFS/EEVDF转。2. 核心机制逐项拆解你需要理解的调度细节2.1 nice值、优先级与权重换算排查调度问题时你第一个看到的东西往往是nice值和PR优先级。很多人只知道“nice越小越优先”但不知道换算关系普通进程的静态优先级 20 nice范围100~139。注意top里的PR字段一般直接显示这个值数值越小越优先。nice的范围是-20到19换算后正好覆盖100~139。这之间的权重映射不是线性的而是指数曲线。内核里维护了一张权重表nice值为0的进程权重是1024nice值每增加1权重大概乘以0.8每减少1权重大概乘以1.25。这意味着nice降到-20的进程获得CPU时间是nice0进程的几十倍。实操经验是别把nice随便拉到-20这让普通进程饿到窒息。我遇到过同事把Nginx的worker nice设成-20结果一个日志刷新任务长期被饿得超时整个磁盘服务都跟着卡。2.2 CFS的虚拟时间和调度周期CFS的核心是个数学公式vruntime 实际运行时间 * 1024 / 进程权重体重越大权重越高vruntime增长越慢于是它会被红黑树放在更靠前的位置获得更多CPU。体重越小vruntime像坐火箭一样暴涨很快就会被判定为“已经跑够了”让出CPU。调度周期也不固定。内核把一次完整“让所有就绪进程至少跑一次”的时间称为调度周期默认值是6mskernel.sched_latency_ns 6000000。如果进程数太多6ms不够分内核会启动最小粒度限制默认kernel.sched_min_granularity_ns 3000000左右。也就是说每个进程至少跑3ms才会被强制换出。这个参数对交互延迟影响很大。比如内核态下快速切换的开销是微秒级的要降低交互延迟可以适当调小调度周期但要付出的代价是切换次数变多cache命中率下降。我一般在低延迟网关这种场景会把sched_latency调到3ms普通Web服务保持默认即可别盲改。唤醒抢占也值得注意进程从睡眠中醒来后如果它的vruntime比当前运行进程更小内核会触发thundering herd式的抢占。这里有个补偿机制休眠进程会被赋予一定的vruntime补偿避免它们一醒来就被立刻抢占。这本来是照顾交互进程的但很多时候会导致唤醒进程过度抢占表现就是后台线程频繁被搅动。2.3 多核负载均衡与NUMA单核调度好写多核才是噩梦的开始。每个CPU有自己的运行队列任务在队列之间搬来搬去。内核定时器会触发负载均衡当一个队列明显比另一个队列更忙时调度器会从最忙的队列迁移任务到空闲CPU。负载均衡的决策并不只看CPU占用率还要看cache亲和。一个进程从一个核迁到另一个核所有L1/L2 cache都失效重新预热成本很高。所以内核有wakeup和idle两种均衡时机进程创建或唤醒时可以迁移到更空闲的CPUCPU空闲时也可以主动去忙队列拉活但都设置了一些门槛避免频繁迁移。NUMA非统一内存访问架构让事情更复杂。内存访问时间和CPU所在位置强相关进程在node 0的CPU上访问node 1的内存会比本地内存慢20%~30%。内核默认开启自动NUMA平衡会周期性把进程页表迁移到正在运行node上。查看/sys/kernel/debug/sched/domains等可确认节点信息。实际遇到最多的现象是服务刚启动时性能很好跑了一天后变慢。排掉内存泄漏后往往发现内核把线程迁到了远端node。解决手段要么用numactl绑定内存要么调整kernel.numa_balancing0关掉自动迁移让分配更稳定。2.4 组调度与cgroup的CPU控制别把进程调度理解成只针对单个进程容器时代看的都是进程组。cgroup把一组进程作为整体参与调度CFS在组层面同样有权重概念。在cgroup v2里cpu.weight的范围是1到10000默认100cpu.max则直接限制组内进程能使用的CPU配额。组调度最典型的价值是解决“多个业务混部”的场景。比如一个物理机上既跑核心交易服务又跑日志采集Agent如果不用组限制Agent可以抢占大量CPU交易服务的延迟会跟着抖动。我为组分别设置不同的cpu.weight后资源分配立刻变得可控。3. 现场实操观察、调整、验证进程调度3.1 用常用命令查看当前调度状态排查第一步永远是用肉眼观察现状。查看所有进程的优先级和当前所在CPU最常用的就是toptop -c在top界面按f可以选中P最后使用的CPU字段和NInice、PR优先级。键盘按j还能把当前运行核列出来。想看某个进程所有线程的分布用ps -eLf | grep 进程名STAT列里的R表示runningD表示不可中断睡眠S表示可中断睡眠。D状态进程很多时候让人误以为死锁但通常是在等待磁盘IO或内核锁。更详细的信息在/proc/pid/sched里cat /proc/1234/sched里面能看到se.sum_exec_runtime累计运行时间、se.vruntime、se.load.weight等调度器内部数据这个是内核给调度器自己看的排查“为什么这个进程排这么后”时信息密度非常高。查看整个系统调度统计可以直接读/proc/schedstatcat /proc/schedstat各字段包括CPU编号、调度次数、运行时间、队列延迟。或者确认内核构建了调度调试信息直接:cat /proc/sched_debug这个文件很大但能看到每个runqueue的详细状态、当前正在运行的任务、vruntime排序后的树结构基本等于把调度器的内脏摊开给你看。3.2 调整优先级、绑定CPU与设置实时策略修改普通进程优先级最顺手的是nice和renice# 以nice-5启动进程 nice -n -5 ./simulator # 修改已有进程PID 1234nice值改到-5 renice -n -5 -p 1234注意普通用户只能调高nice值变“更不优先”只有root能调成负数。一个常见误区是看到进程PR高就说“进程有问题”其实PR高只代表它优先级低不代表它在等待锁或IO。给实时任务设置FIFO/RR策略用chrt# 查看策略和优先级 chrt -p 1234 # 设置SCHED_FIFO优先级50 chrt -f -p 50 1234 # 设置SCHED_RR优先级30 chrt -r -p 30 1234实时优先级范围1~99越大越优先。你敢把某个线程设成99它就基本可以独霸CPU核心连SSH都可能超时。所以我强烈建议生产环境用实时策略前先做个压测确认它不会饿死关键管理进程。绑定CPU用taskset# 查看进程允许使用的CPU taskset -pc 1234 # 把PID 1234绑定到2、3号核 taskset -pc 2,3 1234 # 启动时绑定到0号核 taskset -c 0 ./simulator绑定核能显著降低调度抖动但注意别把两个网络中断密集的物理核心都绑给一个高并发应用否则si软中断消耗会大量抢占业务线程。3.3 用perf sched和trace-cmd做延迟分析当系统没有“跑不满”但就是“响应很慢”时普通top看不出问题需要用调度事件级工具。perf sched是一把最趁手的扳手# 采集一段时间调度事件 perf sched record -- sleep 10 # 生成调度延迟汇总 perf sched latency --sort max输出会展示每个进程的平均调度延迟、最大调度延迟。如果某个关键线程最大延迟动不动在几十毫秒以上说明它可能在等待锁、没有及时被唤醒或者被更高优先级的任务抢占。trace-cmd更细粒度。抓一下sched_switch事件可看到每个CPU上的具体切换序列trace-cmd record -e sched_switch -e sched_wakeup sleep 5 trace-cmd report输出的每一行都包含任务何时进入、何时离开、唤醒者是哪个进程。我曾用它抓到过一个诡异现象一个消费线程总在等不到数据实际上它切换出去后没有及时被唤醒追查后发现是生产者线程被cgroup限流了数据一直没生产出来。4. 故障排查实录生产过程遇到的调度问题4.1 场景一CPU跑不满线程全挤在一个核有次客户反馈一台32核机器部署Java服务后整体负载不高但业务RT一直上不去。我用top看发现进程的线程几乎全跑在2号核上其他核闲着。第一反应是线程根本没发到多核上。查了taskset -pc pid发现没有绑定限制再看/proc/pid/status里的Cpus_allowed_list也是0-31。问题出在glibc的线程创建策略早期线程库默认继承了创建者的affinity而又因为Java的GC线程把整个进程卡到了某个核上后续线程全都出生在这个核。解决方案很简单启动进程前显式把整个进程的affinity放开taskset -pc 0-31 pid或者在程序启动时用sched_setaffinity主动设置。跑了两小时后负载均匀了RT也降了。事后反思多线程服务巨卡时第一眼必须先看Cpus_allowed_list然后看每个线程运行在哪个核。很多时候不是调度器问题而是线程亲和性设置错误。4.2 场景二实时任务把系统“锁死”另一个案例很吓人一台嵌入式设备跑着实时采集线程用chrt -f -p 99设置后一旦任务密集整个系统Ping都超时SSH连不上。排查时我看到设备上top显示的采集线程占用99%CPU而其他普通进程基本拿不到CPU时间。这是实时调度的正常副作用FIFO优先级99意味着它抢占其他所有普通进程而普通进程永远排在它后面。Linux为了保护系统不至于被实时任务彻底饿死内置了实时进程带宽控制cat /proc/sys/kernel/sched_rt_period_us # 默认 1000000 cat /proc/sys/kernel/sched_rt_runtime_us # 默认 -1实际使用如果sched_rt_runtime_us被设置为有限值实时任务在周期内最多运行该时间超限后被强制让出CPU给普通进程。默认值是-1表示不限制这就是危险来源。我建议将实时任务优先级降到50以下并显式设置rt带宽上限# 每秒钟实时任务最多占用95%带宽 echo 950000 /proc/sys/kernel/sched_rt_runtime_us另外实时线程设计上要避免自旋死等浪费要用futex或信号量挂起等待数据而不是在while(1)里空转。这样即使优先级特别高也不会白白占光CPU。4.3 场景三容器限额下负载不均线上容器化部署后经常看到Pod的CPU使用率虽然没超limit但时延抖动明显且多个容器的CPU用量不均。v2版本cgroups的CPU控制是cpu.weight和cpu.max。cpu.weight控制多个组之间的相对比例cpu.max则给一个配额格式是quota period。例如mkdir /sys/fs/cgroup/demo echo 200000 1000000 /sys/fs/cgroup/demo/cpu.max # 最多使用2个核 echo 2000 /sys/fs/cgroup/demo/cpu.weight容器超卖情况下内核会按权重分配CPU时间。但有些场景下比如Pod里运行的是IO密集加CPU密集的混合负载你看到容器刚刚达到limit就开始限流其实是CPU带宽节流导致的不是真的不够用。排查容器CPU限流可以用cat /sys/fs/cgroup/cpu.stat里面throttled_usec如果很大就说明容器经常被限制到配额上限需要先确认业务代码是否真的吃CPU还是被调度抖动干扰。若默认权重不合理调高cpu.weight比直接调大cpu.max更符合“弹性共享”的初衷。4.4 常见问题速查与工具清单问题现象检查点常用命令CPU跑不满且分布不均亲和性、numa、线程创建策略taskset -pc, numactl实时任务导致系统假死sched_rt_runtime_us限制cat /proc/sys/kernel/sched_rt_runtime_us容器经常被限流cpu.stat的throttled_useccat /sys/fs/cgroup/cpu.stat交互式程序卡顿调度延迟、唤醒延迟perf sched latency --sort max任务迟迟得不到CPU运行队列延迟是否被更高优先级抢占cat /proc/sched_debug进程始终D状态检测IO锁或内核阻塞strace -p PID, /proc/PID/stack排查优先级从小到大先看top里的R状态和CPU分布再看/proc/sched_debug确认排队必要时用perf sched做延迟归因最后才动参数。别一上来就调内核参数往往不是调度器的问题。5. 关于调优的几点个人经验5.1 默认参数适合多数场景别迷信神奇数值很多人看完网上“性能调优必改内核参数”的文章立刻把sched_latency_ns和sched_min_granularity_ns改小觉得这样进程切换更频繁、响应更快。结果往往是吞吐量下降业务更卡。原因很简单切换频繁cache丢失加剧内存访问变慢。我自己的习惯是先从业务形态判断。如果是延迟敏感的网关类服务可以适度缩短调度周期但要配合CPU绑核和降低线程数量一起做如果是计算密集型批处理保持默认甚至适当加大调度周期提升吞吐。调优必须一个变量一验证别同时改多个。5.2 多核时代先检查NUMA再谈绑定很多人和我一样早期做绑核时只想着taskset -c 0-3后来发现性能还不如不绑。原因是绑核没有考虑内存在哪个node上导致进程跑到node 0的CPU内存却在node 1访问成本激增。现在我的标准姿势如下# 先确认node拓扑 numactl --hardware # 绑定node0上的CPU numactl --cpunodebind0 --membind0 程序如果业务是低延迟加高吞吐优先用numactl而不是taskset。尤其跑数据库、Redis、Nginx这类内存访问敏感的进程NUMA感知的绑定能减少20%以上的延迟波动。5.3 调度器源码是最终的老师调优踩过太多坑之后疲劳感会推着你去看源码。读sched/fair.c里的update_curr、enqueue_task_fair、load_balance这些函数比看十篇博客都管用。比如vruntime补偿机制网上众说纷纭只有源码注释里写清楚了睡眠进程怎么获得补偿、补偿上限怎么控制。如果要入坑建议从pick_next_task_fair开始读。你顺着它就能看到红黑树怎么找最左节点if条件怎么处理实时抢占一个进程如何从等待变成运行。源码里每一个打印日志、每一个#ifdef背后都是真实线上问题的影子。用Linux这些年我对调度器的态度从“黑盒”变成“能读得懂的黑盒”。它不神秘只是信息藏在多个层级里只要愿意一层层拆绝大多数卡顿都找得到原因。希望这篇整理能帮你少走那些我走过的弯路。