前几篇聊CFS的时候,我一直默认“调度单位是一个任务”。在桌面机上这个假设没问题,但把它搬到几十核的服务器上,尤其上面还跑着很多互相隔离的服务或容器时,问题就暴露得很明显:任务级公平并不等于组级公平。这篇文章专门讲Linux调度器里的两组机制:组调度(Group Scheduling)和带宽控制(Bandwidth Control)。前者让一组任务能像一个整体去竞争CPU,后者给这个整体加了硬性上限。看完你至少能搞懂三件事:为什么线程/进程数量多的服务总能把别的服务饿死;通过cgroup怎么把一组任务的CPU占用量压到指定水位;以及quota和period调小之后,业务为什么会出现奇怪的长尾。
这个内容适合被CPU抢占问题折腾过的运维、玩过cgroup但没搞懂“为什么限不死”的开发者,也想帮想看内核调度模块的进阶读者把轮廓勾出来。
1. 先说说问题:任务级公平在服务器上越来越不合用
1.1 CFS原本怎么给每个任务记账
CFS调度器的核心不是时间片轮转,而是把所有可运行任务放进一棵红黑树,红黑树的排序键是vruntime。vruntime是一个虚拟运行时间,每次调度周期、每次唤醒、每次tick,任务真实运行了多少时间,都会按权重折算后累加到这个值上。权重越大的任务,同样的真实运行时间折算出来的vruntime增长越慢,因此它在红黑树上的位置更靠左,更容易被选中。简单说:nice值越小,权重越大,红黑树里的位置越靠前。
这里的记账单位是task_struct,也就是一个线程。你把一个进程开了8个线程,调度器眼里就是8个独立实体,和旁边另一个只开1个线程的服务完全平等。于是线程/进程数量越多的那个整体,天然就能抢占更多CPU。台式机上这种问题没什么体感,因为大多数时间只有一个进程在跑满。放到多租户服务器上,这就是灾难的来源。
1.2 真实场景中“任务数量多”如何把别人排挤掉
我遇到过一类典型故障:某台48核的服务器,一个后台数据处理容器把宿主CPU打到接近满载,旁边在线服务的接口RT明显升高。最初怀疑是某个进程死循环,top看了一圈,发现没有任何单进程异常,所有进程的CPU占比都很平均,但整体CPU使用率就是下不来。排查到最后,结论是那个容器里的任务数量太多,每个任务的nice又都是默认0,在只认任务级的CFS调度器看来,它天然占据了绝大多数权重份额。
举个例子,租户A有300个可运行线程,租户B只有2个关键线程。在没有组调度干预时,大家权重相同,A能拿到的调度机会大约是300/302。即使你把B的所有线程nice调低,也只是让这2个线程在“单个任务权重对比”中占优,当权重完全相同的300个任务一起争夺时,数量优势会把这些个体权重压平。这就是“开线程越多,抢CPU越猛”的底层原因。
1.3 组调度的核心思路:把一组任务当成一个调度实体
组调度的想法很直接:把若干任务归进一个逻辑组,调度器先把“组”当做一个整体塞进原本的调度队列,组与组之间按组的权重去竞争;选定了某个组之后,再进入组内部,按组内任务的权重去选择具体线程。这样一个有100个线程的组和一个只有2个线程的组,如果两者group权重相同,就各拿一半CPU,组内的线程再去瓜分属于组的那一半。
组还可以嵌套,形成一棵树。比如容器平台可以给每个容器建一个组,容器内再按业务模块分小组,底层就是一棵以task_group为节点的调度树。后续把cgroup的CPU子系统挂在这棵树上,用户态就能通过cpu.weight、cpu.max这些参数去控制每个组的分额和上限。理解了“两级甚至多级天平”的模型,再看内核实现就顺了。
2. 组调度的骨架:task_group到cfs_rq的层级世界
2.1 三个核心结构体怎么挂起来
在调度器内部,每个cgroup CPU子系统的节点对应一个task_group结构。注意它不是只存一个组信息就完事,而是在每个CPU上保留两组对象:一个cfs_rq,负责本CPU上该组内部任务的排队;一个sched_entity,作为这个组在父级cfs_rq上的“代言人”。对普通任务来说,task_struct里也内嵌一个sched_entity,但它没有my_q,属于叶子节点。对组来说,这个se->my_q会指向组自己的cfs_rq。
每个CPU的根cfs_rq上挂着普通任务和各个子组的se。子组的cfs_rq上又挂普通任务或孙组的se。整个结构是一棵以“cfs_rq包含红黑树、红黑树节点包含子cfs_rq”方式递归延伸下去的调度树。为了更好理解,我用表格把这几个角色拆开:
| 结构 | 一句话角色 | 关键字段 |
|---|---|---|
task_group | 一个cgroup CPU节点对应的调度组 | se:per-cpu组代言人;cfs_rq:per-cpu组内队列 |
sched_entity(se) | 可调度实体,task或group | vruntime:排序键;my_q:若是group则指向子rq |
cfs_rq | 一个层级上的可运行队列 | tasks_timeline:红黑树;runtime_remaining:带宽控制相关 |
这种层层嵌套的结构,就是“组间公平、组内再公平”能递归实现的物理载体。
2.2 pick_next_task_fair如何“逐层下钻”
当调度器需要选下一个可运行任务时,入口是pick_next_task_fair。它会从当前CPU的根cfs_rq开始,在红黑树里选出vruntime最小的se。如果选中的se是普通任务的se,任务就找到了,直接切过去;如果选中的se是group实体,算法会顺着se->my_q进入子cfs_rq,在子红黑树里再选一个最小vruntime的se,继续重复这个过程,直到返回一个真正的任务。这个过程可以理解为“下钻”。
这里的重点是为什么要走层级,而不是把所有任务拍平到一棵树里。拍平方案会导致组内部的任务权重和外部其他组的任务权重混在一起算,一个组即使权重很高,内部某个低权重任务也可能长期得不到调度,层级结构则保证了每个层级都有独立的ber公平口径。先组间分蛋糕,再组内分蛋糕,每一层都不干扰其他层。
2.3 vruntime 在不同层级之间怎么换算
这里要稍微绕一下:每一层cfs_rq维护的是自己那一层的min_vruntime,普通任务的vruntime只跟它所在cfs_rq的其他任务比较;group se的vruntime则属于它的父cfs_rq,跟父队列里的普通任务、其他group se比较。也就是说,不同层级的vruntime不以同一把尺子衡量,但在同一层内部,尺子是一致的。
当组实体被选中、组内任务真正运行后,内核会沿着这个任务所在的所有祖先层级向上更新vruntime,既让组内任务的vruntime前进,也让group se在父队列里的vruntime前进。这样外层体现“这个组消耗了时间”,内层体现“组内这个任务消耗了时间”。组权重大的时候,该组se的vruntime增长速度慢,于是它在外层被选中的概率更高,获得的CPU时间也更多。这个权重来自哪里?在cgroup v2里是cpu.weight,v1是cpu.shares,它们和nice权重一样,最终都会折算进调度实体权重,参与vruntime计算。
3. 带宽控制:quota和period到底是怎么被执行的
3.1 quota和period的直观含义
组调度解决的是“相对份额”问题:多个组竞争,权重高的拿得多。但它管不了绝对上限:系统再空闲,一个权重很低的组也可以把CPU全部占满,因为“没人跟它抢”。很多人在这一步会踩坑,以为把cpu.weight调低就能限制容器CPU,实际观察半天还是满载。这就是把“权重”和“带宽限制”搞混了。
带宽控制单独给组设了一个硬上限。参数是quota和period两个微秒值。比如period=100000(100ms),quota=30000(30ms),含义是这个组在每100ms内,所有CPU上累计最多运行30ms,相当于0.3个核。quota是整个组跨所有CPU的合计时间,不是说每个CPU都给30ms。如果quota大于period,比如period=100000, quota=250000,意味着这个组在一个周期里最多消耗250ms CPU时间,也就是理论上最多持续使用2.5个核。
表格对比一下权重和带宽限制会很清楚:
| 维度 | cpu.weight / cpu.shares | cpu.max / cfs_quota_us |
|---|---|---|
| 作用 | 竞争时的相对份额 | 周期内的绝对上限 |
| 系统空闲时 | 权重低的组也能占满CPU | 无论如何不能超过quota |
| 类比 | 分蛋糕时你的比例 | 水表或电表的额度 |
| 适用场景 | 保证高优组获得更多机会 | 限制租户/后台任务打满CPU |
3.2 内核里怎么记账:runtime、throttle与refill
每个task_group内部有一个cfs_bandwidth结构,里面保存着quota、period、剩余runtime等状态。实际运行时,并不是每个组有一个全局计时器一直倒计时,而是把预算分配给每个CPU对应的cfs_rq。每次组内任务真实运行了,就会把相应CPU上的cfs_rq->runtime_remaining扣掉对应的真实CPU时间,同时也会向上逐级扣祖先路径上的runtime。当某个cfs_rq的runtime_remaining小于等于0,内核会调用throttle_cfs_rq把这个组se从父级cfs_rq的红黑树上删掉,等效于给整个组拉闸。组内所有任务在那一刻都被禁止继续运行。
等到下一个period周期开始,内核的hrtimer回调会做refill操作,重新补充runtime_remaining,再把组se塞回父队列。如果组内的线程数量很多,unthrottle那个瞬间所有任务一起醒来,CPU使用会突然冲高,这也是throttle次数多导致业务抖动的原因之一。需要注意的是,被throttle的是整个组的cfs_rq,不是单独某个线程。所以一个线程在限流期间睡眠,不等于它“主动让出”,它是连同整个组一起被暂时禁止调度。
3.3 为什么用“周期+配额”而不是简单的累计上限
周期配额模型最大的好处是“自动恢复”。如果只看累计时间,一个组把额度用完后就需要手动重置,不现实。周期窗口让每个窗口期都从零重新计账,既限制了持续使用量,又给了间歇性任务喘息机会。这与CPU管理里常用的令牌桶思想很接近,但实现更简单,也方便用户直观理解:“每100ms里你能用多少”。
同时,周期模型带来的副作用是任务延迟可能按period的尺度放大。quota用尽后,组内任务必须等下一个period才能恢复,如果period设置成100ms,最坏情况就是任务要等接近100ms才能重新被调度。这也是很多在线服务把quota调小后,长尾延迟明显变大的核心原因,后面实测部分会专门说。
3.4 RT带宽控制:一张调宽的CT
跟CFS带宽控制对称的,是RT调度器的带宽控制,对应cpu.rt_period_us和cpu.rt_runtime_us。这两个参数限制实时任务SCHED_FIFO/SCHED_RR在一个周期内能够占用CPU的时间,防止一个RT任务把系统其他部分饿死。内核默认情况是rt_period=1000000,rt_runtime=950000,相当于给实时任务整体留95%的CPU预算,剩下5%留给CFS任务和内核自行处理。日常不太需要动它,但是如果你的业务有大量RT级线程持续密集运算,就要特别小心,否则系统可能频繁出现“RT任务干到CPU软锁”的报警。
4. cgroup实操:从资源失控到恢复秩序
4.1 先确认cgroup版本和漏洞方向
实操要分cgroup v1和v2,因为路径和文件名不一样。第一步先确认系统用的哪个版本:
stat -fc %T /sys/fs/cgroup输出cgroup2fs就是v2,输出tmpfs一般是v1。拿到容器或服务的PID后,查看进程所在层级:
cat /proc/<PID>/cgroup路径会明确显示这个进程挂在哪个slice、哪个scope下。之后要限制哪个组,就找对应的目录。日常排查里,我通常先用top按CPU排序、再cat /proc/PID/cgroup定位,整个链路一分钟内能走完。
4.2 cgroup v2下的cpu.max与cpu.weight配置
假设已经定位到某个容器进程,目标是把它的CPU占用限制在1.5核以内。在v2下操作如下:
mkdir -p /sys/fs/cgroup/limit-demo echo "150000 100000" > /sys/fs/cgroup/limit-demo/cpu.max echo <PID> > /sys/fs/cgroup/limit-demo/cgroup.procscpu.max的语法是先quota后period,单位微秒。150000 100000表示每100ms周期内最多运行150ms,即1.5核。如果想要完全不限制,可以写成max 100000。查看效果:
cat /sys/fs/cgroup/limit-demo/cpu.statv2的cpu.stat会给出usage_usec、user_usec、system_usec、nr_periods、nr_throttled和throttled_usec。要看这个组被限流的严重程度,重点关注nr_throttled和throttled_usec。如果nr_throttled持续增长,说明它经常触顶。
提高这个组在竞争中的权重,可以用cpu.weight,范围是1到10000,默认100:
echo 800 > /sys/fs/cgroup/limit-demo/cpu.weight强调一次:weight管相对分额,cpu.max管绝对上限,两者配合才完整。
4.3 cgroup v1下的对应操作
老系统或者部分容器平台还在用v1,对应的目录是/sys/fs/cgroup/cpu:
cd /sys/fs/cgroup/cpu mkdir limit-demo echo 150000 > limit-demo/cpu.cfs_quota_us echo 100000 > limit-demo/cpu.cfs_period_us echo 2048 > limit-demo/cpu.shares echo <PID> > limit-demo/cgroup.procscpu.cfs_quota_us默认是-1,表示不限制;cpu.cfs_period_us默认100000。设置cpu.cfs_quota_us=150000、cpu.cfs_period_us=100000时,含义和v2的150000 100000一样。v1中cpu.shares是权重参数,默认1024,值越大权重越高。
v1有个明显容易踩坑的点:很多容器平台会自动创建一系列cgroup层级,你手工改的目录未必是容器实际所在的层级。如果发现改了没反应,优先检查/proc/PID/cgroup里的完整路径,然后从根目录逐级找下去,确认最终生效的是哪个cpu.cfs_quota_us。
4.4 手算配额:把“核数”换算成quota
带宽控制的quota表达的是CPU时间,不是百分比也不是“核数”,所以换算要绕一下。假设period统一取100000us(100ms),想限制最多N核,直接用:
quota_us = N * 1000002核就是200000,0.5核(半核)就是50000。把quota=50000, period=100000丢给一个8线程的组,直观现象是:8个线程抢总共50ms的周期额度,平均每个线程单周期只能跑6.25ms左右,然后整个组被throttle到下一周期。因此限制的是组的总体CPU时间,而不是“把0.5核平均分给8个线程”。
这里有个很容易误解的点:一台机器明明有32核,设置quota=10000, period=100000,并不代表“整个机器10%的CPU”,它代表的是“绝对CPU时间10ms,相当于0.1个核的持续占用”。因为全部CPU的用量都会累计到这个组的quota里,线程再多也不能绕开这个总和限制。
5. 实测与踩坑:带宽控制是双刃剑
5.1 一组简单的压测数据
我在一个8核虚拟机上做过一组简单压测。压测程序是8个线程做整数运算,持续60秒。三种配置下结果差异很明显:
| 配置方式 | 实际CPU总使用率 | 循环完成量 | 现象 |
|---|---|---|---|
| 不限制 | 约790% | 基准值 | 8核基本打满 |
cpu.max=40000/100000 | 约40% | 约5%的基准量 | 大量throttle,明显断崖 |
cpu.max=200000/100000 | 约200% | 约25%的基准量 | 稳定约2核 |
注意实际完成量不是线性比例,因为8个线程被同时限流后,在线程调度、唤醒、等待上还会额外损失时间。也就是说,把quota限制到0.4核之后,完成量并不是0.4/8=5%,实际上还要更低一点或略高,取决于线程同步模型。不过这已经足以说明,带宽限制是硬性的:即使系统很空闲,CPU也不会超过quota。
5.2 坑一:quota太小,在线业务长尾爆炸
第一次调带宽限制时,我犯过一个典型错误:为了把某个容器死死按在0.5核以内,设了quota=50000, period=100000,结果容器里的服务p99延迟从20ms跳到200ms以上。原因是该服务本身有12个线程,每个周期只有50ms总预算,几十个业务请求一来,预算瞬间耗尽,全组被throttle。等于每100ms里只有前50ms能干活,后50ms所有请求都在排队。对在线服务来说,这不是“变慢50%”,而是出现周期性的完全停摆。
解决办法是:一是提高quota到合理水位,比如0.8核甚至1核;二是利用较新内核支持的cpu.max.burst(v2)或cpu.cfs_burst_us(v1),允许在短时间窗口内借用未来额度,平滑峰值。但不能指望burst兜底,长期均值超限照样被限。线上调整时,我会持续观察cpu.stat里的throttled_usec变化,如果高位持续增长,说明quota确实不够。
5.3 坑二:多核分配不均导致单核被throttle
带宽控制的quota是整个组跨所有CPU的合计,但实际运行时,每个CPU上的cfs_rq是从全局预算里申请本地runtime的。如果组内线程被绑核或者调度器负载均衡没跟上,会出现某个核上的线程先把自己那个CPU的本地runtime耗尽,于是这个CPU将整个组se一并throttle,哪怕其他核还有剩余预算。现象是CPU平均水位不高,但单核使用率到100%,throttled_periods还在涨。
遇到这种情况,先检查组内有没有CPU绑核配置。很多容器平台会同时设置cpuset,把容器限制在固定几个核上,这时候带宽的分配粒度就跟这几个核绑在了一起,更要注意quota与可用核数的匹配。必要时可以临时清掉绑核观察,如果没有绑核但还是单核高,再考虑是不是任务本身由于锁竞争集中在一个核上。
5.4 坑三:误以为权重能当上限用
另一个高频误区是把cpu.weight或cpu.shares调低,以为就能限制CPU。我在7.x内核环境中做过测试,把一个高负载组的cpu.weight从100调到10,系统空闲时它的CPU使用率依然是满载。因为权重只决定“多个组抢CPU时怎样分配”,当系统没有其他可运行任务时,唯一在跑的任务自然能用满所有资源。所以如果你的目标是“别让它把别人饿死”,权重是有效的;如果你的目标是“不管别人在不在,这个组都不能超过X核”,就必须用cpu.max或cpu.cfs_quota_us。
更合理的思路是两者一起用:后台批任务组给低权重加硬上限,比如cpu.weight=50、cpu.max=1核;在线服务组给高权重加宽松上限,比如cpu.weight=200、cpu.max=8核。高权重保证抢资源时不吃亏,上限兜底防止极端失控。
5.5 建议组合拳
- 后台批处理任务:低
weight,配合必要的cpu.max,限制在1~2核,避免干扰在线业务。 - 在线服务:高
weight,cpu.max按峰值的80%预留,同时打开burst缓解瞬时尖刺。 - 容器类负载:优先确认容器运行时自己创建的cgroup路径,不要在根目录乱改,否则下次重启就被覆盖。
- 每次调整后观察至少15分钟,不只盯CPU平均值,还要看
nr_throttled和业务延迟分布。
6. 理解内核的几个关键入口(工程排查视角)
6.1 结构关系速览
如果你要读内核源码,先抓住三件套的关系:
struct task_group { struct sched_entity **se; /* per-cpu,父级rq中的group实体 */ struct cfs_rq **cfs_rq; /* per-cpu,本group的直接rq */ struct cfs_bandwidth cfs_bandwidth; /* 带宽控制状态 */ }; struct sched_entity { struct rb_node run_node; u64 vruntime; struct cfs_rq *cfs_rq; /* 当前挂在哪条rq上 */ struct cfs_rq *my_q; /* 如果是group,指向子级rq */ struct sched_entity *parent; };任务级别的task_struct内嵌的se是叶子,没有my_q。group级别的se则通过my_q指向子cfs_rq。很多文章把group se和cfs_rq混着说,抓住这一点就不会乱。
6.2 关键路径:入队、选任务、记账、限流
完整的数据流大致是这样的:
- 任务唤醒或创建,走
enqueue_task_fair,把它挂到所属cfs_rq,并沿着祖先路径逐级更新父队列的负载信息。 - 调度器选择任务时,走
pick_next_task_fair,从根cfs_rq开始逐层下钻,最终选中某个叶子任务。 - 任务真正运行后,
update_curr更新当前se和cfs_rq的vruntime,同时调用account_cfs_rq_runtime扣除runtime。 - 每次tick或调度点检查
check_cfs_rq_runtime,如果当前cfs_rq的runtime_remaining耗尽,就触发throttle_cfs_rq,把group se从父rq摘除。 - 周期定时器到点后,
do_sched_cfs_period_timer做refill,并把被throttle的cfs_rq重新加回调度树。
排查时如果怀疑某个组被限流,重点看第4和第5步。生产环境可以通过ftrace或bpftrace挂throttle_cfs_rq入口,统计限流触发的频率和CPU分布,比反复看cpu.stat更快定位是“整体额度不够”还是“单个CPU分配不均”。
6.3 现场排查工具箱
/proc/sched_debug:能看到每个cfs_rq上的任务数量、load与min_vruntime,也会出现throttle相关字段,适合确认组是否被限流。/proc/<PID>/sched:查看单个任务调度统计。/sys/kernel/debug/sched/features:调度特性开关,一般不建议生产环境乱关。cpu.stat:最直接的用户态观测窗口。v1下的字段是nr_periods、nr_throttled、throttled_time;v2下多了usage_usec等,注意字段名差异。
我在处理类似问题时有个固定动作:先看cpu.stat的nr_throttled增量,如果增量大,再决定是调quota还是调period。永远不要一上来就去翻内核参数。绝大多数线上问题,其实是配额和业务特征不匹配,不是调度器本身有问题。
最后再分享一个小技巧:在自己机器上准备一个8线程的压测程序,手动调cpu.max观察完成量和延迟变化,比看任何文档都更能建立直觉。你会很快发现,带宽控制不是简单的“限制多少就用多少”,这里面的调度损耗、线程同步、unthrottle抖动,才是真正影响业务体验的细节。理解了组调度和带宽控制,以后再看到“CPU没满但延迟高”的诡异监控图,至少能少走很多弯路。