第 05 章 CPU 调度器——几千个人抢一根独木桥
本章追一个问题:你的代码凭什么能占着 CPU?又凭什么会被踢下来? 你会看到:CPU 是这台机器上最不可分割、最没法排队等的资源。内核为了让几千个进程”看起来都在同时跑”,发明了一套精妙到近乎狡猾的骗术。理解这套骗术,你才能解释那些”CPU 没满但服务就是慢""容器限了核之后延迟暴涨”的怪事。
一、先把问题问对
墨:老哥,top 你天天看。我问你个基础问题——你机器上跑着几百个进程,CPU 就 8 核。为什么它们看起来都在”同时”运行?
你:分时复用嘛,轮着来,切得快你就感觉是同时的。
墨:对,但这个答案太”课本”了。我要往里追三层:
- 凭什么轮? 一个进程正跑得欢,谁有权力把它从 CPU 上”拽下来”?它自己又不会主动让位。
- 轮多久? 时间片给多长?给长了,别人等太久(交互卡);给短了,切换太频繁(纯浪费)。这个矛盾怎么破?
- 谁先轮? 一百个可运行进程,下一个该谁上?按什么标准算”公平”?你的视频播放器和一个后台编译任务,该一样公平吗?
你:……这么一问,确实每个都不好答。
墨:这三个问题,就是调度器的全部命题。而它们背后是同一个根本困境:
CPU 这个资源,和内存、磁盘有本质区别——它不能”切成小块同时分给几个人”。 内存你可以一人分 1G,大家真的同时持有。CPU 一个核在某一瞬间只能执行一个指令流,没有”同时”。所谓并发,全是”快速轮换”骗出来的假象。
所以调度器面对的不是”分配”问题,是**“排班”问题**——而且是在完全不知道每个进程”还要跑多久”的前提下排班。
📦 生活化类比:一个只有一间手术室的医院
- 手术室 = CPU 核心。同一时刻只能给一个病人做手术,没法”半间手术室”。
- 病人 = 进程。有的是心梗急诊(必须马上、几分钟就好),有的是慢性病手术(要 6 小时,但晚点做也行)。
- 调度器 = 排班主任。
- 他面临的核心难题:他不知道每台手术真正要多久(病人不会告诉他,甚至病人自己也不知道)。他只能从”这个人过去每次进来都很快出来”推测”这次大概也快”,从而给不同人不同的优先级。
墨:记住这个”排班主任不知道手术要多久”的困境。调度器所有的复杂性,都源于它必须在信息不足的情况下做决策,只能靠”观察历史行为”来猜。
二、第一个机制:凭什么能把你拽下来?
墨:先回答”凭什么轮”。一个进程在跑 while(1) {} 死循环,它绝不会主动交出 CPU。内核怎么把它拽下来?
你:靠时钟中断?
墨:对!这是整个分时系统的物理基础,值得郑重讲一下。
机器上有个硬件定时器(现代 x86 上是 APIC Timer / HPET),它按固定频率(或按需)向 CPU 发中断。中断一来,CPU 硬件强制中断当前正在执行的指令流,跳到内核的中断处理函数——这个动作用户程序无法拒绝、无法屏蔽(屏蔽中断是 Ring 0 特权指令,你在 Ring 3 没这权力,还记得第 04 章那道墙吗?)。
内核在这个中断处理里做什么?
- 更新时间(jiffies 递增);
- 更新当前进程”已经跑了多久”的账;
- 检查:这家伙是不是该下来了? 如果该下,就打一个标记
TIF_NEED_RESCHED; - 中断返回前,检查这个标记,如果有,就调用
schedule()换人。
墨:老哥,这里有个特别关键的认知:
“抢占”这件事,是内核借硬件中断的手完成的。 没有时钟中断,任何分时操作系统都不可能存在——因为内核根本没有机会”插话”。
这也解释了一个常见困惑:为什么一个死循环的进程不会把系统卡死,但一个”在内核态里死循环”的驱动 bug 就能把系统卡死? 因为前者会被时钟中断抢下来,后者如果关了中断或者在不可抢占区,内核自己都插不进话——这就是你在 dmesg 里会看到的 soft lockup / hard lockup。
⚠️ 参数穿插:kernel.watchdog_thresh / kernel.softlockup_panic
- 控制背景:内核有个”看门狗”机制——每个 CPU 上有个高优先级线程定期”打卡”。如果某个 CPU 超过
watchdog_thresh(默认 10 秒)没人打卡,内核判定这个 CPU 卡住了(soft lockup),打印一坨栈到dmesg。 - 它在解决什么问题:内核态代码要是死循环或长时间不让出 CPU,系统会莫名假死,而且没有任何日志——排障时一片黑。看门狗就是为了”至少留个案发现场”。
- 权衡:
watchdog_thresh调小 = 更快发现卡顿,但可能误报(某些合法的长时间内核操作被冤枉);调大 = 少误报,但真卡了要等更久才告警。 kernel.softlockup_panic=1是个更激进的选择:一检测到 soft lockup 就直接 panic 重启。这是拿”可用性(重启中断服务)“换”可诊断性(留下完整 kdump 现场)+ 避免半死不活状态”。生产上是否开,取决于你更怕”系统僵着不死拖累集群”,还是更怕”自动重启丢现场”。
📦 顺带说个演化——从 HZ 到 tickless
早期 Linux 靠固定频率时钟中断(CONFIG_HZ,常见 100/250/1000 Hz)。1000Hz 意味着每个 CPU 每秒被打断 1000 次,哪怕它在空闲!这对笔记本(耗电)和虚拟机(大量空转中断,浪费宿主 CPU)都是灾难。
于是有了 NOHZ / tickless(动态时钟):CPU 空闲时干脆关掉周期时钟,让它真正睡下去省电;甚至有 nohz_full,让某个 CPU 上只有一个任务在跑时也不发时钟中断(用于高频交易、DPDK 这种”一核一线程死磕”的场景,连 1000Hz 的打扰都不能忍)。
设计规律又出现了:通用机制在极端场景变成纯开销时,内核就提供”关掉它”的逃生口。
nohz_full和第 04 章的io_uring同源——都是”高频通用开销的定向消除”。
三、CFS:用一个会计骗术解决”公平”
墨:现在到”轮多久”和”谁先轮”。这是 Linux 调度器的核心,叫 CFS(Completely Fair Scheduler,完全公平调度器),2007 年进内核(2.6.23),取代了老的 O(1) 调度器。
3.1 先看老办法为什么不行
墨:老调度器的思路很直觉:给每个进程一个固定时间片(比如 100ms),按优先级排队,轮着给。问题在哪?你想想:
你:……时间片固定的话,进程一多,轮一圈就特别久?
墨:对,这是问题之一,叫延迟随负载线性恶化。100 个进程 × 100ms = 轮一圈 10 秒。你敲键盘的响应也得等 10 秒?没人受得了。
问题之二更隐蔽:怎么区分”交互型”和”计算型”进程? 你的编辑器大部分时间在等键盘(睡着),偶尔醒来干一点点活,要求响应快。你的视频转码进程一直在算,要求吞吐大。老调度器要靠一堆启发式规则(heuristics)去猜”这货是交互型吗”,猜的规则越加越多,越来越玄学,还经常猜错。
这是设计上的典型困境:一堆特例补丁堆出来的启发式,永远有反例。 (还记得 K8s 那本里”赶特例出核心”那条规律吗?同一个母题。)
3.2 CFS 的核心洞见:改”分时间片”为”记欠账”
墨:CFS 的作者 Ingo Molnár 做了一件极漂亮的事——换记账方式。
他说:别想”给谁多少时间片”了。想象一个理想的、完全公平的 CPU:如果有 N 个进程,那么每个进程在任意时刻都应该恰好用掉了 1/N 的 CPU 时间。
现实做不到”同时”,那就记账:给每个进程记一个数,叫 vruntime(虚拟运行时间)——“你到目前为止已经用了多少 CPU”。
然后调度规则简化成一句话,美到不可思议:
每次要选下一个跑的进程,就选
vruntime最小的那个。
你:……就这?
墨:就这。你自己推一下,这一句话能推出多少行为:
- 一个进程跑着,
vruntime不断增长 → 它很快就不是”最小”了 → 自然被换下来。时间片不需要预设,它是”跑到你不再是最小”为止,自动涌现的。 - 一个进程睡了很久(等键盘),它的
vruntime一直没涨 → 它醒来时vruntime远小于别人 → 立刻被选中,响应极快。 - 交互型进程”自动”获得了低延迟,不需要任何启发式规则去识别它是不是交互型!
墨:感受到这个设计的美了吗?
CFS 用一个统一的记账规则,消灭了”识别进程类型”这个玄学问题。 交互型进程的低延迟不是”被优待”,而是”它欠账少所以该它了”这个公平原则的自然结果。
这就是好设计的味道:不是加规则去处理特例,而是换一个视角让特例自动消失。
3.3 nice 值怎么塞进这个模型?——虚拟时间的”汇率”
墨:那优先级(nice 值)怎么办?总不能大家真的完全平等吧,后台备份任务凭什么和线上服务抢一样多的 CPU?
CFS 的手法特别巧:它不改”选最小 vruntime”这条规则,它改时间流逝的速度。
vruntime 不是真实运行时间,它是真实时间乘以一个权重系数:
vruntime += 真实运行时间 × (基准权重 / 本进程权重)
- 高优先级(
nice小,权重大)→ 除数大 →vruntime涨得慢 → 更长时间保持”最小” → 拿到更多真实 CPU。 - 低优先级(
nice大,权重小)→vruntime涨得飞快 → 很快就不是最小了 → 拿到的真实 CPU 少。
墨:换句话说——
高优先级进程的”时间”过得慢,像在低重力星球上;低优先级进程的时间飞逝。 大家都在公平地”追平 vruntime”,但每个人的时钟走速不同。
nice值的本质,是虚拟时间的汇率。
📦 数字感:Linux 里 nice 每差 1,CPU 份额差约 1.25 倍。nice 从 0 到 19(最低优先级),权重从 1024 掉到 15——差约 68 倍。所以 nice 19 跑备份,基本就是”只在别人不用 CPU 时才跑”。
⚠️ 坑:nice 只影响同一个 CPU 控制组内的 CFS 任务之间。你在容器里 nice 一下,不会让你的容器从别的容器那里抢到更多 CPU(那是 cgroup 层面的事,下面讲)。很多人在容器里 nice 半天没效果,原因就在这——你调错层了。
3.4 那”时间片”到底多长?——两个关键参数的权衡
墨:CFS 虽然不预设时间片,但它必须防止一个极端:如果有 1000 个可运行进程,“跑到不是最小为止”可能只有几微秒,那切换开销就把 CPU 吃光了(还记得第 04 章的过境成本吗?切换比系统调用更贵)。
所以 CFS 有两个”护栏”参数:
① kernel.sched_latency_ns(调度周期,默认约 6ms~24ms,随核数缩放)
- 含义:内核努力保证”所有可运行进程在这个时间窗口内都至少跑一次”的目标周期。
- 控制背景:这是延迟的承诺。窗口越小,每个进程被”照顾到”的频率越高,交互越顺滑。
- 权衡:调小 = 延迟更低、更适合交互/桌面/低延迟服务,但切换次数增加,吞吐下降、缓存命中率恶化。调大 = 每个进程一次跑得久,缓存热、吞吐高,但交互任务要等更久。
- 典型场景:数据库/批处理服务器倾向调大(要吞吐);桌面/游戏/延迟敏感网关倾向调小(要响应)。
② kernel.sched_min_granularity_ns(最小运行粒度,默认约 0.75ms~3ms)
- 含义:一个进程一旦上 CPU,至少要跑这么久才允许被换下(除非它自己阻塞)。
- 控制背景:这是切换开销的护栏。防止进程数太多时,时间片被切成微秒级碎片,CPU 全耗在切换上。
- 权衡:这两个参数隐含一个关系——当可运行进程数超过
sched_latency_ns / sched_min_granularity_ns时,调度周期就被迫拉长了(因为不能再切细了)。也就是说,进程一多,内核只能选择”违背延迟承诺”来保住”不被切换开销吃穷”。 - 这个权衡本身就是设计的诚实之处:内核明确告诉你”负载高到一定程度,延迟保证必然让位于吞吐”。
墨:老哥你算一下:sched_latency_ns = 24ms,min_granularity = 3ms,那么临界进程数是?
你:24/3 = 8。超过 8 个可运行进程,调度周期就得拉长?
墨:对。所以在一台可运行队列很长的机器上(vmstat 的 r 列很大),你的进程被调度到的间隔会显著变长——这就是”CPU 使用率没满但服务延迟高”的一个经典机理:不是 CPU 不够快,是你在队列里排太久。
⚠️ 重要认知:top 里的 CPU 使用率(us+sy)告诉你”CPU 忙不忙”,但它完全不告诉你”排队多不多”。判断”是不是 CPU 抢不到”,你该看的是:
vmstat 1的r列(可运行任务数,>核数就说明在排队);/proc/<pid>/schedstat或/proc/schedstat里的 等待时间统计;pressure stall information (PSI):/proc/pressure/cpu—— 这是内核 4.20 加的神器,直接告诉你”因为等 CPU 而被卡住的时间占比”。
PSI 是个重要的演化:传统指标(使用率)回答”资源忙不忙”,PSI 回答”任务饿不饿”。 后者才是你真正关心的。这跟 K8s 那本里”看 Events 而不是猜”是同一种思路——用更贴近因果的指标替代间接指标。
四、不止 CFS:实时调度类和它的危险
墨:CFS 管的是普通任务。但有些任务”公平”不管用——比如工业控制、音频处理,它必须在死线之前跑到,不然结果就废了。所以 Linux 有多个调度类,按优先级严格分层:
优先级从高到低(上面的类有任务,下面的类根本轮不到)
┌────────────────────────────────────────┐
│ stop_sched_class 内核内部,最高,不可抢占 │
├────────────────────────────────────────┤
│ SCHED_DEADLINE 基于截止期限(EDF) │
├────────────────────────────────────────┤
│ SCHED_FIFO / RR 实时,固定优先级 1-99 │
├────────────────────────────────────────┤
│ SCHED_OTHER (CFS) ← 你 99% 的进程在这 │
├────────────────────────────────────────┤
│ SCHED_IDLE 只在完全空闲时跑 │
└────────────────────────────────────────┘
墨:注意这个分层是严格的——只要有一个 SCHED_FIFO 任务可运行,所有 CFS 任务一个都别想跑。
你:那要是一个 FIFO 任务死循环了……
墨:恭喜你想到了那个著名的灾难:整个系统(至少那个 CPU)彻底卡死,连你的 shell 都进不去。这就是实时调度的危险。
⚠️ 参数穿插:kernel.sched_rt_runtime_us / kernel.sched_rt_period_us
- 默认:
period = 1000000(1 秒),runtime = 950000(0.95 秒)。 - 含义:每 1 秒的周期里,实时任务最多占用 0.95 秒,强制留 50ms 给普通任务。
- 控制背景:这就是为上面那个灾难准备的安全带。内核设计者的想法很朴素:“实时任务优先级最高我认,但我必须留一条缝,让管理员至少能登上去把它杀掉。”
- 权衡:这 5% 的预留,对真正硬实时的场景是破坏性的(你保证的死线可能因为这 50ms 让位而错过)。所以硬实时系统会把
sched_rt_runtime_us设成-1(完全不限制)——那就是拿”系统可救性”换”实时确定性”。这是个非常纯粹的权衡:你要么保住实时保证,要么保住”出事还能抢救”。 - 实战建议:除非你真做硬实时,别关这个安全带。见过太多人图”低延迟”把服务设成
SCHED_FIFO,结果一个 bug 把整机干死。
📦 真实事故味道的小故事:某音视频团队为了降低推流抖动,把编码线程设成 SCHED_FIFO 99。上线后偶发整机失联,只能硬重启。事后定位:编码线程在某个错误路径里进了忙等循环(while (!ready);),因为它是最高优先级实时任务,内核连 SSH 的处理线程都调度不到。修复:① 降到 SCHED_RR 且优先级适中;② 忙等改为条件变量等待(让出 CPU);③ 保留 sched_rt_runtime_us 的安全带。
教训:优先级不是”性能开关”,是”责任”。 你把自己提到实时优先级,就等于承诺”我绝不长时间霸占 CPU”。做不到这个承诺,就别要这个优先级。
五、容器时代最坑的一个东西:CFS 配额与 Throttling
墨:老哥你是 K8s 那本刚啃完的人,这段你会拍桌子。
5.1 问题:怎么限制一个容器”最多用 2 核”?
你:limits.cpu: 2 嘛。
墨:那底下是什么机制?cgroup 的 CPU 带宽控制,两个关键值:
cpu.cfs_period_us:一个”结算周期”,默认 100ms;cpu.cfs_quota_us:这个周期里,该 cgroup 里所有任务加起来能用的 CPU 时间。
“限 2 核” = quota / period = 2 → quota = 200ms,period = 100ms。意思是:每 100ms 的周期里,你这个容器的所有线程加起来最多消耗 200ms 的 CPU 时间。
用完了怎么办?Throttle(节流)——内核直接把你所有线程冻住,一直到下个周期开始才放你出来。
5.2 坑在哪?——“我没跑满 2 核,为什么在被限流?”
墨:这是运维和开发扯皮的经典战场。现象:
- 监控显示容器 CPU 使用率才 40%(平均下来远没到 2 核);
- 但
cpu.stat里nr_throttled一直在涨,P99 延迟周期性尖刺; - 开发说”我没超啊”,运维说”你被限流了”。
你:平均没超,但……瞬时超了?
墨:答对了!关键在”平均”这个词骗了所有人。 我给你算笔账:
假设你的服务是 Go 写的,GOMAXPROCS 默认等于宿主机核数,比如 64。一个请求进来,Go runtime 可能同时在多个核上并行处理。
在这 100ms 周期的前 3ms,你的 64 个线程同时在跑 → 消耗 CPU 时间 = 64 线程 × 3ms = 192ms。再跑一点点就撞上 200ms 的 quota。
结果:第 4ms 你就被冻住了,接下来的 96ms 你的容器一个线程都跑不了!
- 从”CPU 时间”看:你用了 200ms/100ms 周期,配额用满,合理。
- 从”平均使用率”看:一个 100ms 周期里你只跑了 4ms 的墙上时间 → 看起来使用率极低。
- 从”用户体验”看:那些在第 4ms 之后进来的请求,硬等 96ms 才开始被处理 → P99 延迟直接尖到 100ms 级别。
墨:这就是那个折磨了整个云原生行业好几年的问题——CFS Throttling 导致的延迟尖刺。
核心认知:CPU limit 限制的是”CPU 时间总量”,不是”并行度”。 你可以在极短时间内用极高并行度把配额烧光,然后被冻整个周期。平均使用率低和被严重限流,完全可以同时成立。
5.3 怎么治?——四条路及各自的代价
墨:这个问题的解法演化,特别能体现”权衡”二字:
① 把 GOMAXPROCS / 线程池大小对齐 CPU limit(治本,推荐)
- 做法:容器限 2 核,就让
GOMAXPROCS=2(Go 里可用automaxprocs库自动读 cgroup)。Java 新版本也会自动感知容器 limit。 - 原理:从源头降低并行度,让你没法在 3ms 内烧光 200ms 配额,配额被”摊平”在整个周期里消耗。
- 代价:峰值并行能力下降(但那个”峰值”本来就是虚的,反正会被冻)。
- 这是最正确的解:根因是”应用不知道自己被限了几核”,修在应用侧。
② 调小 cpu.cfs_period_us(比如 100ms → 10ms)
- 原理:周期短了,单次被冻的最长时间从 96ms 降到 ~9ms,尖刺变矮变密。
- 代价:结算更频繁 = 内核开销增加;而且没解决根因,只是把”一次大痛”变成”多次小痛”。
- 权衡本质:拿 CPU 开销换延迟平滑。
③ 放弃 CPU limit,只留 requests
- 原理:不设上限,自然不会被节流,靠
cpu.shares(相对权重)在争抢时分配。 - 代价:失去隔离性——某个容器可以突发吃掉整机 CPU,影响邻居(noisy neighbor)。
- 权衡本质:拿”隔离确定性”换”无节流延迟”。很多大厂线上服务真的这么干,因为他们判断”延迟尖刺”比”偶发抢占”更不可接受。这是个业务判断,不是技术对错。
④ 用 cgroup v2 + burst 能力(较新演化)
- cgroup v2 引入
cpu.max.burst:允许积攒未用完的配额,在突发时超额使用一小段。 - 原理:承认”负载天然有突发”,给它一个信用额度。
- 代价:短时可能超过 limit(隔离性打折),但比完全不限好。
- 这个演化很有意思:它承认了原设计”每周期硬切”过于刚性,引入”跨周期借贷”来贴合真实负载形态。
墨:老哥,复盘一下这四条路,你看出什么共同结构没有?
你:……每条都在”隔离性”和”延迟”之间选边站?
墨:对!而且更深一层:
这四条路里,只有第 ① 条是真正的”修根因”,其余三条都是”和限流机制讨价还价”。 根因是应用对自己的资源边界无知——它以为自己有 64 核,实际只有 2 核的配额。让应用”知道自己有多大”,是所有容器化资源问题的通用解。
(对比一下 K8s 那本里的 JVM 堆和容器内存 limit 不匹配导致 OOMKill——同一个病:应用不知道自己被关在多大的笼子里。)
⚠️ 诊断入口(务必记住):怀疑 throttling,直接读 cgroup 的统计文件,别猜:
# cgroup v1
cat /sys/fs/cgroup/cpu/<你的cgroup路径>/cpu.stat
# nr_periods 1000 总周期数
# nr_throttled 830 被节流的周期数 ← 830/1000!严重
# throttled_time 4.2e10 累计被冻纳秒数
# cgroup v2
cat /sys/fs/cgroup/<path>/cpu.stat # 看 nr_throttled / throttled_usecnr_throttled / nr_periods 这个比率,是判断”是否被 CPU 限流坑了”的黄金指标。 超过 10% 就该警惕,超过 50% 基本可以确诊。
六、真实事故:凌晨的”CPU 不忙但接口超时”
墨:走个完整诊断环,把这章的东西串起来。
现象
一个 Java 订单服务,部署在 K8s 上,limits.cpu: 2。业务反馈:每天凌晨 2 点前后,接口 P99 从 80ms 飙到 1.2 秒,持续二十来分钟,之后自己好了。白天正常。
监控上看:
- 容器 CPU 使用率:峰值 65%,没到 limit;
- 内存平稳,GC 次数略升但不夸张;
- 数据库侧 RT 正常;
- 网络无异常。
墨:“CPU 没满但慢”,这画面你现在应该有条件反射了。先别急着答,咱按环走。
推断一:GC 停顿(推错了)
第一直觉:凌晨可能有定时任务产生大量对象,Full GC 导致停顿。
验证:拉 GC 日志。结果:那段时间 Young GC 频率从 2 次/分升到 5 次/分,单次停顿都在 20ms 内,Full GC 一次都没有。累计 GC 停顿远远解释不了 1.2 秒的 P99。
推断一否掉。 但有个副产品线索:GC 变频说明凌晨确实有额外负载进来,先记着。
推断二:下游依赖变慢(又推错了)
那可能是凌晨对账任务把数据库压住了?
验证:看应用埋点的下游调用耗时分布。结果:DB 调用 P99 从 15ms 涨到 22ms——有涨,但远不够。而且埋点显示,从”请求进入线程池”到”开始处理”之间的等待时间,从 1ms 涨到了 800ms!
墨:停!这条数据是转折点。它说明什么?
你:请求不是”处理得慢”,是”根本没被开始处理”?一直在排队?
墨:对!这是个质变的认知。 “慢”分两种:
- 执行慢:代码在跑,但跑得久(算法、GC、IO 慢);
- 等待慢:代码压根没在跑,在排队等资源。
你前两次推断都在找”执行慢”的原因,方向错了。数据现在指向等待慢——在等 CPU。
推断三:CPU 被限流(命中)
墨:等 CPU,但使用率才 65%?这不正是我们上面讲的那个”平均骗人”的画面吗?去读 cgroup 统计,直接问内核。
cat /sys/fs/cgroup/cpu/kubepods/.../cpu.stat凌晨那段的采样:
nr_periods 12000
nr_throttled 7100 ← 59% 的周期被节流!
throttled_time 5.1e11 ← 累计冻了 510 秒
确诊:CFS Throttling。
再补最后一块拼图:为什么白天不throttle,凌晨throttle?
翻凌晨的定时任务:2 点有个批量对账,会起一个**并行流(parallelStream)**处理几十万条记录。而 Java 的 parallelStream 用的是 ForkJoinPool.commonPool,它的并行度默认是 宿主机核数 - 1——宿主机是 64 核,所以并行度 63!
墨:画面完整了:
凌晨对账启动 →
parallelStream拉起 63 个工作线程 → 这 63 个线程在几毫秒内把 100ms 周期的 200ms 配额烧光 → 整个容器(包括处理线上订单请求的线程)被冻住剩余的九十多毫秒 → 线上请求在队列里干等 → P99 飙到 1.2 秒。而因为墙上时间大部分在”冻着”,平均使用率反而看着不高。
调整 → 观察 → 再改进
第一轮改进(见效但不彻底):限制 ForkJoinPool 并行度,启动参数加
-Djava.util.concurrent.ForkJoinPool.common.parallelism=2。
观察:nr_throttled 比率从 59% 降到 8%,P99 从 1.2s 降到 180ms。好了很多,但还是比白天的 80ms 高一倍。
墨:为什么没完全好?这一步的思考很重要——改进之后要重新观察,而不是宣布胜利。
第二轮定位:剩下的 8% throttle 来自哪?发现是 JVM 自身的并行度也没对齐:GC 线程数(ParallelGCThreads)默认也按宿主机 64 核算,起了十几个 GC 线程,一次 Young GC 就能在瞬间烧掉不少配额。
第二轮改进:
- 升级 JDK 到能感知 cgroup limit 的版本(JDK 8u191+ / 11+ 默认开启
UseContainerSupport),让 JVM 自动按 2 核来算 GC 线程数、availableProcessors(); - 把对账任务从线上 Pod 里拆出去,单独跑一个 Job,和线上请求隔离(资源隔离层面的治本);
- 加监控:把
container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total做成告警指标——以后这类问题 5 分钟内可定位,而不是查三天。
最终观察:凌晨 P99 稳定在 85ms,与白天持平。nr_throttled 比率 <1%。
复盘这个环
墨:老哥,把这次的路径画出来:
现象(CPU不忙但P99飙)
→ 推断1 GC → GC日志否掉 → 副产品:凌晨有额外负载
→ 推断2 下游慢 → 埋点部分否掉 → 关键线索:排队时间800ms
→ 认知质变:"执行慢" vs "等待慢",方向纠正
→ 推断3 CPU throttling → cgroup cpu.stat 确诊(59%)
→ 改进1 降并行度 → 观察:好转但未痊愈(8%)
→ 二次定位:JVM 自身并行度也没对齐
→ 改进2 容器感知 + 任务隔离 + 加 throttle 监控
→ 最终验证:与白天持平
三个必须带走的方法论:
- 推错两次是正常的,只要每次都拿数据验证并及时掉头。真实排障从来不是一击命中。
- “执行慢”和”等待慢”是完全不同的两个世界,搞错方向会浪费全部精力。区分它们的关键指标是排队/等待时间(应用埋点、
schedstat、PSI)。 - 改进之后必须重新观察。第一轮见效 85% 就收工的话,你会留着 8% 的 throttle 和一半的延迟劣化,过一阵又被叫起来。
七、参数小结:调度这一层的旋钮和它们的交易
| 参数 | 它在控制什么 | 拧动是在拿什么换什么 |
|---|---|---|
kernel.sched_latency_ns | 目标调度周期(延迟承诺窗口) | 调小:低延迟 ↔ 更多切换开销、吞吐降。调大:反之 |
kernel.sched_min_granularity_ns | 单次最小运行时间(切换开销护栏) | 调小:更公平/响应快 ↔ 切换更频繁。调大:缓存更热 ↔ 延迟变差 |
kernel.sched_migration_cost_ns | 判断”任务刚跑过、缓存还热,别轻易迁到别的核” | 调大:更倾向留在原核(缓存亲和性好) ↔ 负载可能不均。调小:更爱均衡 ↔ 缓存频繁失效 |
kernel.sched_rt_runtime_us / _period_us | 实时任务最多占多少 CPU(默认留 5% 给普通任务) | 放开(-1):硬实时确定性 ↔ 失去”出事还能救”的能力 |
kernel.watchdog_thresh | 多久没打卡判定 CPU 卡死 | 调小:早发现 ↔ 误报。调大:少误报 ↔ 发现晚 |
kernel.softlockup_panic | 检测到软卡死是否直接 panic | 开:留完整现场、避免半死状态 ↔ 服务中断重启 |
cpu.cfs_period_us / cpu.cfs_quota_us(cgroup) | 容器 CPU 配额与结算周期 | period 调小:尖刺变矮 ↔ 内核开销增。quota 去掉:无节流 ↔ 失去隔离 |
cpu.shares / cpu.weight(cgroup) | 争抢时的相对权重(不是上限) | 只在争抢时生效;空闲时谁都能超用。拿”弹性”换”确定性” |
nice / renice | CFS 内部的虚拟时间汇率 | 只影响同 cgroup 内 CFS 任务的相对份额;跨容器无效 |
⚠️ 一条通用告诫:kernel.sched_* 这些参数,在绝大多数生产场景不该动。CFS 的默认值是被无数负载验证过的。90% 的”CPU 调度问题”根因是应用层的并行度、锁竞争、或 cgroup 配额与应用认知不匹配,不是调度器参数。先修应用,再想参数——动参数往往是把问题从一个地方挪到另一个地方。
八、这章的认知钉子
- CPU 不可分割,并发是快速轮换骗出来的。 分时的物理基础是时钟中断——内核借硬件之手强行插话。
- CFS 的天才之处:把”分时间片”换成”记欠账”(
vruntime),让交互型低延迟从公平原则里自然涌现,消灭了启发式猜测。 nice的本质是虚拟时间的汇率,不是”插队特权”。而且它跨不出 cgroup。- 实时优先级是责任不是特权。
sched_rt_runtime_us那 5% 是留给你抢救的安全带,别轻易解开。 - 容器时代最大的调度坑是 CFS Throttling:CPU limit 限的是时间总量不是并行度,“平均使用率低”和”严重被限流”可以同时成立。根治靠让应用知道自己有几核。
- “执行慢”和”等待慢”要分开看。 判断 CPU 争抢,看
vmstat的r、schedstat、PSI(/proc/pressure/cpu)、cgroup 的nr_throttled——不是看使用率。
**
实战补遗一:CFS 的”公平”陷阱——低优先级任务饿死与 CPU 调度悬崖
墨:第 5 章讲了 CFS(完全公平调度器)按虚拟时间 vruntime 分配 CPU。它”完全公平”,那会不会有”公平反而是问题”的时候?
你:公平不就是好事吗?难道公平也会让重要任务饿死?
墨:会,而且很常见。CFS 的”公平”是相对权重公平,不是”重要任务优先”。讲个真实事故。
一台机器上跑:① 核心交易进程(要稳、要快)② 一个 bash 里跑的 find / -name '*.log' 全盘扫描(运维临时查日志)。find 是 CPU 密集的,但它优先级低(nice=19)。按理 CFS 该让交易进程多吃 CPU。结果交易延迟从 1ms 飙到 50ms。
推断:CFS 确实按权重分 CPU,但 find 这种大量短任务/多线程负载,即使单个 nice=19,它产生的总 CPU 需求可能超过交易进程的权重所能压制的范围——尤其当 find 触发了大量上下文切换和缓存失效,把 CPU 的 IPC 拖垮,交易进程虽然分到时间片,但每次上来都要重新热身缓存,有效吞吐暴跌。换句话说:CFS 保证了”时间片公平”,但没保证’缓存局部性’和’延迟’——低优任务靠高频切换把整个 CPU 的效能拉低了。
调整:① 给核心交易进程提权重:systemd 里 CPUWeight=10000(或 nice -n -10 启动),让它在 CFS 里权重碾压;② 更狠:把 find 这类运维命令用 ionice+nice 双重降,或直接用 taskset 绑到专门的核心,物理隔离交易进程和扫描进程;③ 容器环境用 cgroup cpu.weight(高)+ cpu.max(给干扰项焊死,第 17 章)。再观察:交易延迟回到 1ms,find 照样跑但不再影响核心。
你:nice 值到底管什么?为什么光设 nice 还不够?
墨:nice(-20~19,默认 0)是 CFS 里权重的代号:nice 越低、权重越高、分到的 CPU 时间比例越大。但它有两个局限:
- 只调时间比例,不调延迟:一个 nice=19 的任务仍会被调度到 CPU 上跑(只要没更高优先级的在跑),它一跑就污染缓存、引发切换。你想要的可能是”它根本别上核心交易的核”,这得靠
taskset/cpuset物理隔离,不是 nice 能做的; - 多核下权重被稀释:8 核机器上,一个 nice=19 单线程
find只占 1/8 核视角,但如果你不小心起了 8 个find(每个 nice=19),它们合起来就能占满所有核——CFS 对每个公平,但对”系统”不公平。
所以生产口诀:延迟敏感进程 = 高 cpu.weight(或高优先级)+ 物理绑核隔离(不和干扰项同核);干扰项 = 低 cpu.weight + cpu.max 硬墙 + 绑到空闲核。单靠 nice 是”软公平”,对付偶发干扰够;对付”会占满核的干扰负载”,必须上 cgroup 硬墙或 cpuset。这和第 17 章 cpu.weight/cpu.max 完全呼应——调度的”公平”和业务的”稳”,中间差一层隔离手段。
🔧 思考题:实时进程(SCHED_FIFO/SCHED_RR)能解决”核心交易要绝对优先”吗?为什么生产反而很少用实时调度?
【参考答案】
SCHED_FIFO(实时先进先出)确实”绝对优先”:只要它有活,低优先级(含所有 CFS 的 SCHED_OTHER 进程)一律靠边,哪怕它只占 1% CPU,CFS 任务也拿不到时间片。这听起来完美保核心交易。但生产极少用,因为实时调度的风险极大:
- 一个 FIFO 进程死循环 = 整机假死:它不主动让出,CFS 任务(包括
sshd、监控、init 的关键线程)全拿不到 CPU,你连ssh都登不进去、OOM killer 也跑不动(它也是普通优先级)。曾有事故:一个 FIFO 进程 bug 自旋,运维无法登录,只能拔电源。 - 优先级反转更致命:FIFO 进程 A 等一个锁,锁被低优 FIFO 进程 B 拿着、B 又被更低的 C 占着 CPU——A 直接饿死,且无解(没有时间片轮换)。
- 需要仔细设
rlimit/cpuset防扩散,运维门槛高。
所以生产更爱用”CFS 高权重 + 物理隔离”的软方案,而非 FIFO 的硬方案——除非是真正硬实时(音视频、高频交易某些段),且配套了 watchdog 防自旋。口诀:实时调度是”核弹”,大多数业务用 CFS 权重 + 隔离就够;真要 FIFO,必须配 RLIMIT_RTTIME 和看门狗防自杀。
实战补遗二:GOMAXPROCS 与 CPU 亲和性——把 Go 服务绑到特定核反而更慢的怪事
墨:老哥,你给 Go 服务设过 GOMAXPROCS 吗?或者在容器里被设错过?
你:容器里常被设错,K8s 限制 2 核但 GOMAXPROCS 读到宿主 64 核,调度混乱。
墨:对,这是经典坑。但今天说个反直觉的:明明 CPU 够,你主动把进程绑到某几个核(taskset),反而更慢了。我拆过一次。
现象:一个延迟敏感的交易服务,想”独占 CPU 不被别人打扰”,用 taskset -c 4-7 绑到 4 个核,结果 P99 从 3ms 涨到 9ms,吞吐还掉了 15%。
推断:绑核应该减少上下文切换和缓存冷,怎么会慢?
可能推错:第一,绑太死反而让调度器没腾挪空间;第二,这个服务有后台活(GC、netpoll、timer)也需要核,你只给它 4 个,GC 和前台抢这 4 个核,互相打断缓存。
调整:用 perf stat -e context-switches,cpu-migrations,cache-misses 对比”绑 4 核”和”不绑”。发现绑核后 cache-misses 涨了 2 倍——因为 GC 的 mark 阶段和前台 goroutine 被迫在同一组核上交替跑,彼此把 L1/L2 缓存冲掉。
再观察:更隐蔽的是 GOMAXPROCS 与绑定核数不匹配:容器 limit 2 核,但进程没设 GOMAXPROCS=2,runtime 看到宿主 64 核就开 64 个 OS 线程抢 2 个配额,CFS 带宽控制(quota)频繁节流,表现为周期性”卡顿”。这是该绑(限核)却没绑对的另一面。
改进:
- 容器里用
go.uber.org/automaxprocs自动按 cgroup limit 设GOMAXPROCS,别手设。 - 要”独占”请用
taskset绑足够多的核(前台 + GC + netpoll 余量),且配合isolcpus把核从内核调度器隔离出来,否则只是自缚手脚。 - 绑核的收益场景是”缓存局部性极强、且能腾出干净核”,否则别乱绑。
🔧 思考题:isolcpus 把核从内核调度器”隔离”出来,和不隔离直接用 taskset 有什么区别?为什么只 taskset 不够”独占”?
【参考答案】
taskset 只是给进程设 CPU 亲和性(affinity),告诉调度器”尽量在这几个核跑”。但没说别的进程不能也跑这几个核——内核调度器照样能把其他进程塞进来,你的”独占”只是愿望。而且内核自身的线程(kworker、ksoftirqd、migration)也可能在这些核上跑,照样打扰你。
isolcpus=<cpu list> 是在内核启动参数层面,把这几个核从通用调度域里拿掉:普通进程默认不会被分到这些核,调度器也不会主动往里塞任务。这样配合 taskset 把你的服务绑上去,才是真正的”独占核”,别的用户态进程和大部分内核线程都不会来抢。代价是:这些核上的负载均衡要你自己规划,且 rcu 等内核机制仍需少量使用,并非 100% 纯净。
实战结论:容器场景优先用 automaxprocs 解决 GOMAXPROCS 错配(这是 90% 的性能坑来源);只有在延迟极敏感、且机器归你独占时,才上 isolcpus+taskset 这套重武器,并且核数要留余量给 GC/netpoll。
实战补遗三:CFS 调度周期与 RT 优先级——为什么一个实时任务能把整个核”据为己有”
墨:老哥,你进程里有实时优先级(SCHED_FIFO)的任务吗?或者见过”一个任务把别的全饿死”吗?
你:没特意用 FIFO,都是默认 CFS 普通任务。
墨:但有时你以为是普通任务,其实某处用了实时优先级(或容器给了 cpu.rt 配额),就把整个核霸占了。这节讲 CFS 的公平怎么被打破,以及 RT 任务为什么危险。
现象:一台机器上,一个”普通”的日志落盘协程,平时只占 5% CPU,某次配置改动后突然占满一个核(100%),同核上的 Go worker 全被饿死、延迟飙升。运维看 CPU 是满的,但说不清谁在吃。
推断:日志协程写太多,CFS 调度不公平。
可能推错:CFS(完全公平调度器)按权重(nice 值)分配 CPU,普通任务再忙也只占”应得份额”,不会 100% 独占一个核(除非它真有那么多活且别的任务不抢)。但”占满一个核、别人饿死”是 RT(实时)调度类的特征——SCHED_FIFO/SCHED_RR 任务一旦跑,CFS 的普通任务根本没机会上这个核(RT 优先级高于 CFS,且 FIFO 不主动让出就一直跑)。
调整:看任务调度类:ps -eo pid,comm,cls,rtprio(cls 列:TS=CFS 普通、FF=SCHED_FIFO、RR=SCHED_RR)。发现那个日志协程是 FF(FIFO)——原来某次容器配了 cpu.rt.runtime_us/cpu.rt.period_us 或进程被 chrt -f 设了 FIFO 优先级。FIFO 任务只要还有活干就不让出核,CFS 的 worker 永远排不上,于是”一个 RT 任务饿死同核所有普通任务”。
再观察:CFS 自身也有”公平陷阱”——第 5 章讲过低优先级(高 nice)任务可能被饿死,但那是相对缓慢的;真正”秒级独占”来自 RT 类。RT 任务适合”必须极低延迟、且执行时间极短”的场景(如音频、硬实时控制),绝不适合”可能长时间运行”的任务(如日志落盘、GC)。把长任务设成 FIFO = 自找饿死全核。
另外 CFS 的调度粒度:sched_latency(调度周期)和 min_granularity 决定”每次调度给任务的最小运行时间”。CFS 靠权重分 CPU,但每个调度周期里同核任务轮流跑;若某任务被绑核且同核无其他任务,它自然占满——这不算饿死(别人没和它抢)。真正问题是 RT 抢了本该给 CFS 的核。
改进:
- 查调度类用
ps -eo pid,cls,rtprio,看到FF/RR要警惕,长任务别用 RT。 - 容器里别乱给
cpu.rt.runtime_us配额(K8s 默认不开放 RT,除非显式配置),给了对端是 RT 任务占核。 - 长任务用 CFS(默认),靠
nice调权重(不饿死别人);真正要低延迟的短任务才考虑 RT,且必须”运行极短+周期性让出”。 - 被 RT 饿死的排查:
top看哪个核 100% 且sy低(用户态 RT 占着)、同核 CFS 任务st/等待高;perf看该核上只跑一个任务。
🔧 思考题:CFS 靠”权重”分 CPU,为什么同核上两个普通任务不会”一个占满、一个饿死”?RT 的 FIFO 又是怎么打破这个保证的?
【参考答案】
CFS 的核心机制是虚拟运行时间(vruntime)红黑树:每个任务维护 vruntime(累计运行时间,按权重缩放),调度器永远选”vruntime 最小(最久没跑)“的任务上 CPU。权重(nice 值)决定”每跑 1 物理秒,vruntime 涨多少”——高优先级(低 nice)任务 vruntime 涨得慢,于是更常被选,占更多 CPU;但只要同核有别的任务在树里,调度器就会周期性切到 vruntime 最小的那个,所以两个普通任务会按权重比例轮流跑(如 2:1),不会一个占满另一个饿死到 0。这是”公平”的含义:按权重分时间片,但不是独占。
RT(SCHED_FIFO/RR)打破保证的原因:Linux 调度器是分层的——RT 类优先级整体高于 CFS 类。只要某核上有”可运行的 RT 任务”,调度器就只在该核的 RT 任务里选(FIFO 按优先级、同优先级按进队顺序、不主动让出就一直跑;RR 是时间片轮转)。CFS 的普通任务在”有 RT 任务可运行”时根本不会被选中——它们排在 RT 之下,等于”RT 任务不睡觉,CFS 任务就别想上这个核”。所以一个 FIFO 长任务 = 把该核的 CFS 任务全饿死(不是按比例分,是 0 份额)。
这就是为什么 RT 危险:它跳过 CFS 的公平性。正确使用 RT 的铁律——任务必须”运行时间极短 + 周期性主动睡眠/让出”(如处理完一个音频 buffer 就睡等下次中断),这样它只占极小时间片、CFS 任务仍有机会。把”日志落盘、GC、网络处理”这种可能跑很久的任务设成 FIFO,等于制造”单任务独占核、全核普通任务饿死”的事故。
容器视角:cgroup v2 的 cpu.max 是 CFS 层的硬配额(仍然公平、不饿死别人,只是限你总量);而 cpu.rt.runtime_us/cpu.rt.period_us 是 RT 带宽(给任务 RT 优先级时的运行配额)。K8s 默认不开放 RT 类,除非 Pod 配了 securityContext 或节点开了 cpu.rt,所以多数容器泄漏 RT 是”宿主机上的某进程被设了 RT、且和你的容器共享核”——用 isolcpus+cpuset 把 RT 任务绑到独立核才是正解(见第 5 章 GOMAXPROCS 实战补遗)。排查饿死先看 ps -eo cls,别只在 CFS 层找原因。
下一章预告(第 06 章 内存分配与回收):CPU 抢完了,该抢内存了。你 malloc(1GB) 成功返回时,内核根本没给你 1GB**——它只是签了张空头合同。这张合同什么时候兑现?兑现不了会怎样?我们会拆开 buddy 分配器、slab、页回收水位线、swap 的真实作用,以及 OOM Killer 是按什么标准挑人杀的。/proc/sys/vm 下面绝大多数参数,都在那一章的战场上。
🔧 思考题(带参考答案)
思考题 1(基础): 为什么 CFS 不需要”识别一个进程是不是交互型”,却能让交互型进程获得低延迟?
【参考答案】
因为 CFS 的调度依据是 vruntime(已消耗的虚拟运行时间)最小者优先,而交互型进程的行为特征天然导致它 vruntime 增长很慢:
- 交互型进程大部分时间在阻塞睡眠(等键盘、等网络、等 IO),睡着时不消耗 CPU,
vruntime不增长; - 而计算型进程一直在跑,
vruntime持续快速增长; - 所以当交互型进程被唤醒时,它的
vruntime远小于那些一直在跑的进程 → 按”选最小”规则,它立刻被选中 → 响应极快。
关键洞见:低延迟不是”被优待”的结果,而是”公平记账”这一条规则的自然推论。老调度器需要一堆启发式规则去猜”这是不是交互型进程”(而且经常猜错、需要不断打补丁),CFS 换了记账视角后,这个识别问题自动消失了。
这是好设计的典型特征:不是加规则处理特例,而是换视角让特例不再是特例。
(补充:内核为了让唤醒的任务不至于”欠账太少而无限霸占”,会给刚唤醒的任务的 vruntime 做一个下限修正——不能小于队列最小值太多。这是防止”长期睡眠者醒来独占 CPU”的护栏。)
思考题 2(参数权衡): 某数据库服务器管理员为了”提升响应速度”,把 kernel.sched_latency_ns 从默认 24ms 调到 3ms,sched_min_granularity_ns 从 3ms 调到 0.3ms。上线后发现吞吐下降了 15%。请解释原因。
【参考答案】
原因是他把调度周期和最小运行粒度都缩小了 8 倍,导致上下文切换频率大幅上升,而每次切换都有两重代价:
- 直接代价:保存/恢复寄存器、切换内核栈、更新调度统计(每次切换比一次系统调用更贵,通常在微秒量级)。
- 间接代价(更致命):每次换人,新进程会把 CPU 的 L1/L2 缓存、TLB、分支预测器灌成自己的内容,把上一个进程的热数据挤出去。数据库这类工作集大、依赖缓存局部性的负载,对这一点极其敏感——原本能在缓存里命中的数据,现在频繁要回内存取,单次访问延迟差几十倍。
本质权衡:sched_latency_ns 调小 = 用吞吐和缓存局部性换调度延迟。数据库这种吞吐导向的负载,恰恰应该反向调整(把周期调大,让每个任务一次跑久一点,缓存热起来)。
方法论层面的错误更值得说:他做的是”参数看起来能提升响应,那就调”的盲调,而没有先问:
- 我的瓶颈真的是”调度延迟”吗?(该看 PSI /
schedstat的等待时间,而不是拍脑袋) - 我的负载是延迟敏感型还是吞吐敏感型?
- 调完之后我用什么指标验证?(他显然是上线后才发现吞吐掉了,说明没有事前 benchmark 和事后对照)
这正是第 00 章那个”诊断环”的反面教材:没有观测就调参,等于蒙眼开车。
思考题 3(容器坑): 一个容器 limits.cpu: 1,里面跑一个 Go 服务。监控显示容器 CPU 使用率长期只有 30%,但 P99 延迟有周期性的 ~90ms 尖刺。请给出你的诊断步骤和最可能的根因与修法。
【参考答案】
诊断步骤:
- 先分清”执行慢”还是”等待慢”:看应用埋点里”请求到达 → 开始处理”的等待时间。如果等待时间大,说明在排队等资源。
- 直接读 cgroup 统计确诊(不要看使用率,它会骗你):
或 Prometheus 指标cat /sys/fs/cgroup/cpu/<path>/cpu.stat # v1 # 关注 nr_throttled / nr_periods 比率、throttled_timecontainer_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total。 - 若 throttle 比率高,进一步看并行度:
GOMAXPROCS是多少?(容器里 Go 默认读宿主机核数,可能是 32/64)
最可能的根因:CFS Throttling。limits.cpu: 1 → quota=100ms / period=100ms。而 GOMAXPROCS 是宿主机核数(如 64),Go runtime 会同时在多核上跑 goroutine。极短时间内(如 2ms × 64 线程 = 128ms)就把 100ms 配额烧光,剩下的 ~90ms 整个容器被冻。
- 这完美解释了 ~90ms 的周期性尖刺(约等于
period - 实际跑的时间); - 也解释了使用率看着低:100ms 周期里只有几毫秒在跑墙上时间,平均下来使用率自然低。
修法(按优先级):
- 治本:引入
go.uber.org/automaxprocs(或显式runtime.GOMAXPROCS(1)),让 Go 按 cgroup limit 设并行度。这样配额被摊平在整个周期消耗,不再瞬时烧光。 - 辅助:如果 1 核确实不够,应该提高 limit而不是靠调参绕。
- 可选缓解:调小
cpu.cfs_period_us(如 10ms),把一次 90ms 的冻结变成多次 ~9ms 的小冻结,尖刺变矮——代价是内核结算开销上升,且没治根因。 - 可选(需权衡):去掉 CPU limit 只留 requests,彻底不被节流——代价是失去隔离性,可能影响同节点邻居。
- 必做:把 throttle 比率加入监控告警,让这类问题下次 5 分钟内可定位。
思考题 4(进阶·总账): 你要在一台 8 核机器上,同时跑三类负载:① 线上 API 服务(延迟敏感,P99 要 <50ms);② 离线批处理(吞吐导向,跑多久都行);③ 一个日志采集 agent(轻量但必须不能被饿死)。请设计一套调度层的隔离方案,并说明每个决策的权衡。
【参考答案】
这题要综合调度类、cgroup、CPU 亲和性和参数权衡。参考方案:
1. 用 cgroup 分三组,靠 cpu.shares(v1)/ cpu.weight(v2)设权重
- API 服务:weight 高(如 v2 的
cpu.weight=800); - 批处理:weight 低(如
cpu.weight=50); - agent:weight 中(如
cpu.weight=100,量小但保证不被压死)。 - 权衡:
shares/weight是相对权重且只在争抢时生效——空闲时批处理仍可用满 8 核(资源不浪费),争抢时按比例让位。这拿”确定性上限”换”资源利用率”,对混部是正确取舍。
2. API 服务组:不要设 CPU limit,或设得很宽松
- 理由:延迟敏感负载最怕 throttling 带来的 90ms 级尖刺(见本章事故)。
- 权衡:牺牲”硬上限隔离”,换”绝不被节流”。靠 weight 保证它在争抢时优先。若合规上必须设 limit,则必须同时把应用并行度(
GOMAXPROCS/线程池)对齐 limit,并把cfs_period调小。
3. 批处理组:设 CPU limit + 可考虑 SCHED_IDLE 或高 nice
- 给批处理组设 quota(如最多 4 核等价),并把进程
nice 19,甚至用SCHED_IDLE调度类。 - 权衡:批处理被节流/被冻完全没关系(吞吐导向,不在乎单次延迟),用它的”可牺牲性”去换 API 的延迟稳定。这是把痛苦转移到最能承受痛苦的负载上——混部的核心原则。
4. agent 组:设一个小但保证的 requests,weight 适中,绝不设 SCHED_IDLE
- 理由:采集 agent 如果被饿死,你就失去了观测能力——出事时两眼一黑。
- 权衡:宁可给它一点保底资源(浪费一点),也不能让监控在系统压力最大时瞎掉。可观测性组件的资源保障,优先级要高于它的”重要性”直觉。
5. 进阶:CPU 亲和性 / 核隔离(如果延迟要求极严)
- 用
cpuset把 API 服务绑定到 0-5 核,批处理绑到 6-7 核,物理隔离。 - 更极端可用
isolcpus+nohz_full把某几个核彻底从通用调度里摘出来给 API 专用。 - 权衡:换来极强的缓存亲和性和延迟确定性,代价是资源弹性完全丧失——API 闲着的时候那 6 个核也不能给批处理用。只在延迟要求苛刻(如金融交易)时才值得。
- 同时可考虑调大
kernel.sched_migration_cost_ns,减少任务在核之间迁移导致的缓存失效。
6. 绝不做的事
- 不要把 API 服务设成
SCHED_FIFO/SCHED_RR追求”低延迟”。一个 bug 的忙等循环就能锁死整机(本章那个音视频事故),而且它会把 agent 和批处理完全饿死。CFS + 高 weight 已经够用。
7. 必配的观测
- 每组的
nr_throttled/nr_periods、/proc/pressure/cpu(PSI)、vmstat的r列。 - 理由:这套方案里全是权衡,而权衡是否合适只能靠数据验证。没有这些指标,你无法知道”API 的 P99 是被批处理挤的、还是自己代码慢”。
串起来的一句话:混部调度的核心不是”给每个人公平”,而是识别每类负载真正在乎什么(延迟 / 吞吐 / 不被饿死),然后把不可避免的痛苦精准地转移给最能承受它的那一类。
实战补遗四:CFS 调度把低延迟线程”饿”成高延迟
墨:第 05 章讲了 CPU 调度器、CFS、cgroup cpu 配额、优先级。这一节给一个调度器在”公平”名义下,把延迟敏感线程坑了的真实事故——它说明:调度器的”公平”是对 CPU 时间公平,不是对你业务延迟公平。
📦 案例:某交易系统的行情推送线程,平时 P99 延迟 200μs,某次发版后偶发飙到 20ms+,且只在这台容器里出现。
补遗.1 现象与推错
你:延迟抖动嘛,先怀疑 GC(Java)、怀疑网络、怀疑第 18 章的内核暂停。查 JVM GC 正常、网络正常、dmesg 无 softlockup。排除法走到”是不是被调度耽误了”。
补遗.2 命中:cgroup cpu 配额把线程 throttle 了
墨:查这个容器的 cgroup:cpu.cfs_quota_us / cpu.cfs_period_us = 50000/100000,即 CPU 配额 0.5 核(第 05 章 / 第 17 章的 cgroup cpu)。这台容器里跑了行情推送(低延迟线程)+ 一堆后台批处理。批处理突发占满 0.5 核配额后,行情线程被 CFS 节流(throttled),nr_throttled 暴涨(第 05 章讲的 CFS throttling)——它想跑但拿不到 CPU 时间片,排队 20ms 才被调度,延迟就飙了。
验证:cat /sys/fs/cgroup/cpu/<cgroup>/cpu.stat 看 nr_throttled/throttled_time 在抖动时暴涨;top 里这个线程 S 列常显 R(可运行但等 CPU)而非 D。
补遗.3 调整(调度层面的权衡)
墨:两剂药:
- (治标) 调大 cgroup
cpu.cfs_quota_us(如放开到 1 核或设cpu.cfs_quota_us=-1不限),让行情线程不再被节流; - (治本,更优雅) 用
cpu.weight(或 v1 的cpu.shares)给行情线程更高权重 + 用sched_setaffinity把它绑到独立 CPU、和批处理隔离;或给行情线程设更高nice(第 05 章优先级); - 根因:“把延迟敏感线程和 CPU 密集型批处理塞进同一个 0.5 核配额”本身就是架构错误——调度器再聪明也只能在配额内公平,跨不过配额这道墙(第 17 章 cgroup 边界)。
补遗.4 复盘
墨:这事故把第 05 章的”CFS 公平性”和”cgroup 配额边界”焊成了业务延迟。一句话:调度器的公平,是 CPU 时间片的公平,不是你业务延迟的公平;你给它的配额天花板,就是它对你低延迟线程的承诺上限。 排障口诀:低延迟线程莫名抖动 + cpu.stat 里 nr_throttled 涨 → 先放 cgroup cpu 配额,再用 weight/绑核做隔离,而不是怪 GC 或网络。