前几篇聊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节点对应的调度组seper-cpu组代言人cfs_rqper-cpu组内队列sched_entityse可调度实体task或groupvruntime排序键my_q若是group则指向子rqcfs_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.weightv1是cpu.shares它们和nice权重一样最终都会折算进调度实体权重参与vruntime计算。3. 带宽控制quota和period到底是怎么被执行的3.1 quota和period的直观含义组调度解决的是“相对份额”问题多个组竞争权重高的拿得多。但它管不了绝对上限系统再空闲一个权重很低的组也可以把CPU全部占满因为“没人跟它抢”。很多人在这一步会踩坑以为把cpu.weight调低就能限制容器CPU实际观察半天还是满载。这就是把“权重”和“带宽限制”搞混了。带宽控制单独给组设了一个硬上限。参数是quota和period两个微秒值。比如period100000100msquota3000030ms含义是这个组在每100ms内所有CPU上累计最多运行30ms相当于0.3个核。quota是整个组跨所有CPU的合计时间不是说每个CPU都给30ms。如果quota大于period比如period100000, quota250000意味着这个组在一个周期里最多消耗250ms CPU时间也就是理论上最多持续使用2.5个核。表格对比一下权重和带宽限制会很清楚维度cpu.weight / cpu.sharescpu.max / cfs_quota_us作用竞争时的相对份额周期内的绝对上限系统空闲时权重低的组也能占满CPU无论如何不能超过quota类比分蛋糕时你的比例水表或电表的额度适用场景保证高优组获得更多机会限制租户/后台任务打满CPU3.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_period1000000rt_runtime950000相当于给实时任务整体留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默认100echo 800 /sys/fs/cgroup/limit-demo/cpu.weight强调一次weight管相对分额cpu.max管绝对上限两者配合才完整。4.3 cgroup v1下的对应操作老系统或者部分容器平台还在用v1对应的目录是/sys/fs/cgroup/cpucd /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_us150000、cpu.cfs_period_us100000时含义和v2的150000 100000一样。v1中cpu.shares是权重参数默认1024值越大权重越高。v1有个明显容易踩坑的点很多容器平台会自动创建一系列cgroup层级你手工改的目录未必是容器实际所在的层级。如果发现改了没反应优先检查/proc/PID/cgroup里的完整路径然后从根目录逐级找下去确认最终生效的是哪个cpu.cfs_quota_us。4.4 手算配额把“核数”换算成quota带宽控制的quota表达的是CPU时间不是百分比也不是“核数”所以换算要绕一下。假设period统一取100000us100ms想限制最多N核直接用quota_us N * 1000002核就是2000000.5核半核就是50000。把quota50000, period100000丢给一个8线程的组直观现象是8个线程抢总共50ms的周期额度平均每个线程单周期只能跑6.25ms左右然后整个组被throttle到下一周期。因此限制的是组的总体CPU时间而不是“把0.5核平均分给8个线程”。这里有个很容易误解的点一台机器明明有32核设置quota10000, period100000并不代表“整个机器10%的CPU”它代表的是“绝对CPU时间10ms相当于0.1个核的持续占用”。因为全部CPU的用量都会累计到这个组的quota里线程再多也不能绕开这个总和限制。5. 实测与踩坑带宽控制是双刃剑5.1 一组简单的压测数据我在一个8核虚拟机上做过一组简单压测。压测程序是8个线程做整数运算持续60秒。三种配置下结果差异很明显配置方式实际CPU总使用率循环完成量现象不限制约790%基准值8核基本打满cpu.max40000/100000约40%约5%的基准量大量throttle明显断崖cpu.max200000/100000约200%约25%的基准量稳定约2核注意实际完成量不是线性比例因为8个线程被同时限流后在线程调度、唤醒、等待上还会额外损失时间。也就是说把quota限制到0.4核之后完成量并不是0.4/85%实际上还要更低一点或略高取决于线程同步模型。不过这已经足以说明带宽限制是硬性的即使系统很空闲CPU也不会超过quota。5.2 坑一quota太小在线业务长尾爆炸第一次调带宽限制时我犯过一个典型错误为了把某个容器死死按在0.5核以内设了quota50000, period100000结果容器里的服务p99延迟从20ms跳到200ms以上。原因是该服务本身有12个线程每个周期只有50ms总预算几十个业务请求一来预算瞬间耗尽全组被throttle。等于每100ms里只有前50ms能干活后50ms所有请求都在排队。对在线服务来说这不是“变慢50%”而是出现周期性的完全停摆。解决办法是一是提高quota到合理水位比如0.8核甚至1核二是利用较新内核支持的cpu.max.burstv2或cpu.cfs_burst_usv1允许在短时间窗口内借用未来额度平滑峰值。但不能指望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.weight50、cpu.max1核在线服务组给高权重加宽松上限比如cpu.weight200、cpu.max8核。高权重保证抢资源时不吃亏上限兜底防止极端失控。5.5 建议组合拳后台批处理任务低weight配合必要的cpu.max限制在1~2核避免干扰在线业务。在线服务高weightcpu.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_timev2下多了usage_usec等注意字段名差异。我在处理类似问题时有个固定动作先看cpu.stat的nr_throttled增量如果增量大再决定是调quota还是调period。永远不要一上来就去翻内核参数。绝大多数线上问题其实是配额和业务特征不匹配不是调度器本身有问题。最后再分享一个小技巧在自己机器上准备一个8线程的压测程序手动调cpu.max观察完成量和延迟变化比看任何文档都更能建立直觉。你会很快发现带宽控制不是简单的“限制多少就用多少”这里面的调度损耗、线程同步、unthrottle抖动才是真正影响业务体验的细节。理解了组调度和带宽控制以后再看到“CPU没满但延迟高”的诡异监控图至少能少走很多弯路。
阅读完成 · 觉得有帮助?