ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux DL调度器深度探秘:SCHED_DEADLINE硬实时原理与实战

Linux DL调度器深度探秘:SCHED_DEADLINE硬实时原理与实战 这篇继续咱们的Linux任务调度探秘系列。上一次聊到CFS、vruntime和负载均衡不少做嵌入式和实时业务的朋友追着问同一个问题我有一个任务必须每100毫秒算一段、最多占30毫秒CPU普通调度器能不能保证它不晚点答案很遗憾不能。CFS的公平是按权重分时间它不认识“截止时间”这个参数。而Linux内核里恰好有一套专为这种场景设计的调度器——截止时间调度器DL对应的调度策略名是SCHED_DEADLINE。它不跟你比优先级而是跟你签一份“时间合同”你声明每个周期要多少运行时间调度器就保证在截止时间之前把CPU配额交到你手上。从原理、接口、实测到踩坑这篇就把它完整过一遍适合想把手头任务真正做成硬实时的工程师。1. 从RT到DL为什么“优先级先跑”保证不了“按时交活”1.1 RT调度器的赌注优先级高不等于截止时间被遵守先用一个具体例子感受下RT的局限。你在一个核上跑了两个实时任务A每20毫秒需要5毫秒CPUB每50毫秒需要10毫秒CPU。你把A设成SCHED_FIFO优先级90B设成SCHED_FIFO优先级80看起来A更优先应该没问题。但实际上一旦多个实时任务挤在同一个CPU上调度器只做两件事谁优先级高谁先跑同优先级按队列顺序跑。它根本不看你声明的周期和截止时间也不检查高优先级任务是不是把CPU塞满了。这时候你可能会想那我动态调整优先级总行了吧这只是把“实时排产”的工作从调度器手里搬到你手里。任务一多、周期一乱光算哪一刻哪个任务该拿到CPU就够你写一个虚拟调度器了。更麻烦的是RT任务之间的互相阻塞没有上限A疯狂写日志把b的CPU时间挤没了B能不能在50毫秒的周期内完成完全取决于A当时的心情。这不是RT调度器“坏了”而是它从一开始就没打算管截止时间它是按优先级和排队来设计的。优先级在线程调度领域是一个有用的相对概念但要把它换算成“某任务必须在几毫秒内完成”的硬承诺中间隔着大量人工换算。1.2 DL调度器的“合同”runtime、deadline、period三个数字DL调度器把这种人工换算变成了三个明码标价的时间参数任务在创建或设置调度策略时必须提供runtime任务在一个周期内最多需要占用的CPU运行时间。deadline任务希望在一个周期开始后的多长时间内完成本轮工作。period任务以多长的周期重复执行。我给刚接触这块的同学打过一个比方period是公交车的发车间隔每100毫秒来一班runtime是这班车最多能上的乘客数假设最多30个deadline是车必须在什么时刻之前驶出站台比如最晚本周期开始后60毫秒内发车。调度器的工作就是照着每辆“班车”的三张表确保它按点进站、按量载客、按时出发。这里有一条硬性不等式内核直接校验runtime ≤ deadline ≤ period。三个值必须都是正整数而且不能倒挂。如果deadline大于period意味着你给了任务超过一个周期的交活时间那下一个周期开始后任务还挂着没干完这种周期模型本身就不成立所以sched_setattr会直接返回EINVAL。1.3 EDF的最优性与CBS的必要性DL调度器底层用的是单核场景下经典的“最早截止时间优先”策略也就是EDF。实时调度理论里有一个很漂亮的结论在单处理器上只要所有周期任务各自利用率runtime除以period加起来不超过1EDF就能保证每个任务都在截止时间前完成。这个保证比“高优先级先跑”强得多因为它给的是“算得出来、证明得了”的充分条件。但裸用EDF有个致命问题如果一个任务的实际执行时间超过它声明的runtime它就会持续超支把后面所有任务的截止时间全部挤爆。打个比方EDF像一个完全信任乘客的售票系统乘客说“我只坐30分钟”结果他一坐就是50分钟后面所有班次全乱套。所以Linux没有让EDF裸奔而是给它套了一件“带宽服务器”外套也就是CBS算法。CBS把每个任务包装成一个带固定带宽的服务器任务只能在服务器分配到的预算里运行预算耗尽就被强制节流等下一个周期再重新充值。2. CBS的账本DL调度器到底在记账本上记什么2.1 一次完整周期从充值配额到强制节流把一个DL任务放到运行队列后CBS就变成了一台严谨的账本机器。任务周期开始时调度器给任务的runtime余额充值并把任务的绝对deadline设为本周期起点加上参数里的deadline值。任务每次被调度上CPU账本就开始扣减runtime余额扣的是实际运行时间不是墙上时钟时间。当runtime余额归零时即使任务还想继续跑调度器也会强行把它从运行状态拉下来标记为“节流”丢进等待区。注意这时候哪怕整个CPU空空荡荡任务也不能继续运行必须等到下一个period边界到账预算重新充值然后更新新一轮deadline它才能恢复运行。反过来如果任务在runtime还没耗尽时就已经到达deadlineCBS同样会做一次重置——把runtime重新充满把deadline顺延到下一个周期。这里的核心思想是DL任务拿到的不是“无限CPU空档”而是“一段有严格上限的预算”。2.2 睡眠、阻塞和自旋三类特殊的时间记账理解了“按实际占用CPU扣款”这个原则就能推演出三类特殊情况。任务主动睡眠或调用阻塞式I/O等待时它没有占用CPU因此不消耗runtime余额。这一点比想象中更符合直觉CBS扣的是CPU时间不是日历时间。任务睡了一整个周期再醒来调度器会在唤醒时重新对齐deadline相当于上一轮的“车没出发班次顺延”。不少刚上手的人会误以为睡眠能“省下”预算留给后面用实际上不存在这种跨周期积累周期一结束没跑完的runtime也就作废了。第二种情况是忙等待也就是自旋。一个DL任务如果写了while(!done);这种自旋循环进程始终处于RUNNING状态CPU时间照扣不误。三毫秒的自旋就烧掉三毫秒的runtime预算很快被清零然后被强制节流。很多实时程序里“等我一会儿就能拿到锁”的坏习惯到了DL调度器下会立刻现出原形。第三种情况是主动让出CPU比如调用sched_yield。这类行为同样不会让你占便宜因为让出CPU后任务进入等待队列调度器会把它当作“主动放弃本轮配额”来处理deadline不会因此延长。所以写DL任务时原则上不要主动让出CPU让调度器按照账本节奏来。2.3 利用率公式为什么内核会拒绝你的带宽申请DL的带宽核算公式非常简单单个任务在一个CPU上的利用率是runtime / period。比如runtime30ms、period100ms利用率就是30%。如果两个这样的任务挤在同一个CPU上总利用率就是60%没问题。但如果你贪心一点把三个任务都塞上去利用率到90%还能放塞第四个120%内核就会在你的sched_setattr调用上直接返回EBUSY——带宽审计不过关。实际发生的过程是设置DL参数时内核会把目标CPU或任务允许运行的CPU集合上所有DL任务的runtime/period累计起来和该CPU的调度容量比对。一旦超过容量这次设置就会被拒绝。这个设计相当于银行给你的信用卡额度你可以自己决定怎么分配账单周期但总透支额度封死了不能让某个CPU上的DL任务总利用率超过100%。2.4 全局带宽与每CPU带宽调优前先分清两层账除了每个CPU的局部带宽外内核还留了一套全局层面的DL带宽限制相关参数挂在/proc/sys/kernel/下面主要是sched_dl_period_us和sched_dl_runtime_us。它们的作用和RT调度类的那组带宽参数类似在一个统一时间窗口里限制所有CPU上的DL任务合计能占用的CPU时间总量。理解它的意义就够用了日常调试时你没必要一上来就动它先确保单CPU账本没问题再说。局部账本是主防线全局账本负责兜底两层一起看才能解释清楚为什么某些任务迁移配置明明单核不超、系统层面却还是报错。3. 让任务进入DL调度内核配置、sched_setattr与chrt三件事3.1 先确认内核已经把DL调度器编进去DL调度器需要内核在编译时打开CONFIG_SCHED_DEADLINE。绝大多数主流发行版内核默认都开了这个选项但嵌入式裁剪内核或某些极致最小化内核不一定带。检查方法稳定可靠直接解压或者查看当前内核配置zcat /proc/config.gz | grep SCHED_DEADLINE如果/proc/config.gz不存在就去/boot/config-$(uname -r)里找同样的键值。看到CONFIG_SCHED_DEADLINEy就没问题看到m说明调度器编成了模块需要提前加载啥都搜不到就说明这块功能被裁掉了得换内核或者重新编译。另外顺手确认内核版本别太老DL调度器自Linux 3.14起才合入主线3.14之前的版本不用看后面的内容了。3.2 sched_setattr的填表细节与一段可直接抄的程序设置调度策略的系统调用名叫sched_setattr。glibc没有直接封装它要用syscall()碰到底层代码不长#define _GNU_SOURCE #include stdio.h #include string.h #include unistd.h #include sys/syscall.h #include linux/sched.h #include linux/types.h static int sched_setattr(pid_t pid, const struct sched_attr *attr, unsigned int flags) { return syscall(SYS_sched_setattr, pid, attr, flags); } int main(void) { struct sched_attr attr; memset(attr, 0, sizeof(attr)); attr.size sizeof(attr); attr.sched_policy SCHED_DEADLINE; attr.sched_runtime 30 * 1000 * 1000; /* 30ms纳秒 */ attr.sched_deadline 60 * 1000 * 1000; /* 60ms纳秒 */ attr.sched_period 100 * 1000 * 1000; /* 100ms纳秒 */ if (sched_setattr(0, attr, 0) -1) { perror(sched_setattr); return 1; } printf(set SCHED_DEADLINE ok\n); return 0; }这里的几处细节值得多说一句。attr.size必须填成sizeof(struct sched_attr)内核靠它判断这个结构体是旧版还是新版空着不填会直接EINVAL。sched_policy设为SCHED_DEADLINEsched_priority保持为0DL调度器不看传统优先级数字和nice值。三个时间字段单位全是纳秒不是微秒也不是毫秒这是DL配置里最容易踩的单位坑之一。编译时如果报struct sched_attr未定义通常是内核头文件版本太老装一下当前内核的linux-headers包再编译就好。写代码之前先想清楚你的period是不是真的能支撑这个调度频率设一个10毫秒的period试图做千赫兹级别的任务系统调度开销会先把你吃垮。3.3 不改代码的验证方式chrt一条命令如果你不想写C代码只是想试试DL调度器好不好用chrt命令从util-linux 2.33开始直接支持DL参数。它的单位是微秒和sched_setattr的纳秒刚好差一千倍使用的时候务必分清楚sudo chrt --deadline 60000 --period 100000 --runtime 30000 ./my_task这条命令的意思就是把my_task的runtime设为30ms、deadline设为60ms、period设为100ms单位是微秒也就是前面程序里那些纳秒值除以1000。命令返回后任务已经处于SCHED_DEADLINE策略你可以另开一个终端看当前策略chrt -p $(pgrep my_task)输出里会明确显示SCHED_DEADLINE。也可以直接用ps -eo pid,cls,pri,comm策略列会标注DL。chrt这种方式适合快速验证但生产环境里参数要动态控制、错误要精细处理时还是走sched_setattr更扎实。3.4 权限都不是白给的CAP_SYS_NICE、EINVAL与EBUSY设置DL调度器需要足够的权限不是任意用户都能给自己发“硬实时通行证”。普通用户直接调用会收到EPERM只有root或者具有CAP_SYS_NICE能力的进程才能成功。这也算是一种安全设计否则任何程序都能声明自己是最紧急的任务系统直接瘫痪。实际报错时三个错误码最值得记住EINVAL参数非法。优先检查runtime、deadline、period的先后顺序再看是不是有零值或负值最后看size有没有填。EPERM权限不足。确认当前进程的capabilityroot身份不代表所有情况都畅通容器里尤其容易踩这个坑。EBUSY带宽审计不通过。说明CPU上的DL任务利用率已超额试着降低runtime或者把任务绑到负载更低的CPU上。4. 实测对照同一个周期任务在CFS、RT、DL下的表现4.1 测试任务怎么设计纸上谈兵没意思我在一个4核桌面环境上做了个对照实验。测试任务是模拟常见的数据处理周期每个周期开始后做一段约30ms的计算然后检查本轮是否在deadline前完成最后睡到下一个周期边界。任务的参数固定为周期100ms、deadline 60ms、runtime 30ms连续跑500个周期。为了让场面残酷一点后台同时开了两个死循环的CPU压榨任务它们没有设任何实时属性就是纯纯地和测试任务抢CPU。整个过程不依赖复杂工具在测试任务的每个迭代里取时间戳统计超过60ms deadline的次数以及最大超出量。这样的测试足够表达意图同样是满负载CPU不同调度策略下这个周期任务的守时性区别有多大。4.2 CFS下的表现公平但不守时作为对照基准测试任务先以默认CFS策略跑。CFS会按权重把CPU时间相对公平地分摊给各个进程听起来很合理但它没有任何“下一轮必须在几点前结束”的概念。两个后台死循环任务的权重和测试任务完全一样于是三方大致各拿三分之一CPU测试任务实际分到的有效算力远不够30ms/100ms的周期需求。跑完的结果不出意外500次迭代里有相当一部分超过了60ms的deadline线最严重的几次超出量达到几十毫秒。即使我把测试任务的nice值调低比如-10也只是让它在普通任务里稍微占点便宜后台负载一波动超时照样出现。CFS保证的是“长期看大家时间公平”而这个测试考验的是“某一轮必须在精确时点前完成”两者不在一个维度上。4.3 RT下的表现优先级的优势与它的粗糙接着把测试任务设成SCHED_FIFO优先级拉到95。RT策略的实时性立竿见影后台那两个普通CFS任务根本抢不过它每次测试任务醒来都能拿到CPU迭代全部跑完deadline超出次数明显下降。这是RT在工程上的真本事也是它到今天仍是很多实时系统首选的原因。但RT的问题也在这场测试里暴露了。它只保证“这个任务想跑就能跑”却完全不检查“它跑多久算完”。如果我把另一个同样高优先级的RT任务也放进来两个RT任务之间立刻回到手动博弈优先级的状态。我甚至不需要真的放任务进来只要在理论上知道RT策略下一个贪婪的相同优先级任务完全可以让测试任务在某个周期里错过deadline而调度器根本意识不到这件事。优先级不是时间它可以决定谁先上车却决定不了谁必须在几点前下车。4.4 接入DL后的表现与一个重要发现最后把测试任务切成SCHED_DEADLINE参数就是前面写的runtime30ms、deadline60ms、period100ms。同样顶着两个后台死循环任务跑结果是500次迭代全部在deadline内完成。这不是因为DL任务跑的“比RT更快”而是因为它从根上改变了游戏规则它把“本轮我还有30ms预算”写在账本上在deadline前会通过EDF排序确保自己拿到这30ms。实验里最值得一提的反向操作是把runtime从30ms故意缩到25msdeadline和period不变。结果测试任务几乎每轮都超时而且超时模式非常规律。原因也很清晰任务实际计算要30ms但CBS每周期只给它25ms预算预算烧光后立刻被节流剩下的5ms只能等下一个周期补于是任务永远追不上自己的计划。这件事告诉我一个非常现实的经验DL调度器只认你声明的runtime它不会因为“你上一轮实际只跑了28ms”就给你发善心DB里的每一毫秒都要靠你自己量准。5. DL任务在调度类金字塔里的位置为什么连RT都得让路5.1 调度类优先级链dl_sched_class排在rt之前内核里各种调度策略不是平级的而是一个严格排序的调度类链stop dl rt fair idle。经常有搞过RT的人看到这个顺序会愣一下DL居然排在RT前面没错一个SCHED_DEADLINE任务和SCHED_FIFO任务同时就绪时DL任务会先拿到CPU这是内核写死的顺序。设计逻辑其实能想通RT承诺的是“你想跑就可以跑”DL承诺的是“你必须在这个时间点前跑完”后者对CPU响应的紧急度要求更高。两台车同时进站一台上车就走但没固定离站时间另一台还有两分钟必须开出站台调度员当然先把离开时间紧的那台放过去。所以不要把DL和RT混在一个核上互相添乱它们之间没有传统的优先级对比关系谁先跑是调度类顺序说了算。5.2 全局带宽与RT带宽的瓜分规则RT调度类长期以来有一套带宽限制参数默认在每1秒窗口内RT任务最多占用950ms CPU剩下的50ms留给普通任务防止实时任务把系统永久锁死。DL调度器也继承并扩展了这套思想它自己带的带宽变量同样存在全局层和每CPU层。实际共存时规则是这样的只要有DL任务Ready它就有能力抢占RT任务因为调度类更高而RT任务内部再怎么分优先级、再怎么卡带宽都是在DL任务排完以后才轮到的事。在多核场景下同一时刻不同CPU上可能分别跑着DL和RT任务还有CFS任务在夹缝里捡时间。判断一个系统的实时承载力时不能只看某一个核的DL利用率还要看全局DL带宽和RT带宽的联合占用。我曾经调试过一个系统单看每个核都挺健康但全局DL带宽被一个迁移频繁的任务几乎吃满某个核上一旦有新DL任务接入立刻EBUSY。算总账这件事宁可提前做也别等报错时再做。5.3 CPU亲和与中断隔离离开这层DL会被“穿云箭”射穿DL调度器的能力边界也得很清楚它管得住调度类队列却管不住设备中断。一个硬中断到来CPU会先跳去跑中断处理程序DL任务只能排在中断后面这一点无论你设多紧凑的deadline都绕不开。实战中我一般这么处理先用cpuset或taskset把DL任务绑到专用的隔离CPU上再把这个CPU从系统的普通调度域里摘出去尽量让内核负载均衡不把杂活派过来。有条件的话把网卡等高频中断绑定到其他CPU或者开启threaded IRQ降低中断对实时路径的影响。没有这层隔离DL任务哪怕参数再完美也经常被几十微秒的硬中断打出一个明显抖动。记住DL是一个优秀的调度器它不是中断控制器。6. 探秘路上躲不开的坑单位、过冲与中断6.1 单位换算内核纳秒、chrt微秒一个零就能把你带偏我见过的DL配置错误里单位搞错是最常见的也是最隐蔽的。sched_setattr的三个时间字段全部是纳秒而chrt命令的三个参数全部是微秒。同样表示30ms在C代码里是30 * 1000 * 1000纳秒在命令行里却是30000微秒。如果你把C代码里的纳秒值直接搬到chrt相当于把runtime放大了1000倍内核一校验就EINVAL倒还好怕的是某些场景下参数被曲解后勉强通过任务行为莫名其妙。判断当前进程到底拿的什么参数别只看注释用chrt -p或直接读/proc/pid/sched里的相关字段。内核在/proc/pid/sched中会暴露调度策略和CPU时间信息和你预期值对不上时先怀疑单位。6.2 runtime只约束调度器不约束你的代码第二个坑来自对DL的过度信任设好参数就以为任务一定能完成。CBS只保证“按声明的runtime把CPU配额给你”它不保证你在这段时间里写出的代码真的能在30ms内跑完。如果任务内部有一次随机的慢路径执行了35ms30ms预算烧完时调度器会毫不犹豫地节流绝不透支。解决思路也很固定先做WCET分析把代码里每条路径的最坏执行时间摸出来然后按最坏执行时间加一定余量去配置runtime。我习惯给WCET再乘1.2到1.5的安全系数具体看任务的执行路径稳定性。千万别用平均执行时间作为DL参数平均值的背后往往藏着一两个极端慢的样本它们恰恰是实时系统最致命的点。6.3 中断和调度延迟DL管得住CPU队列管不住设备敲门DL调度器可以把CFS任务、RT任务安排得明明白白但面对硬中断它也没有优先豁免权。CPU收到中断后无论当前在跑哪个调度类都会先切到中断处理程序。如果这个中断处理里还有乱糟糟的下半部处理DL任务的响应时间直接被拉长。我做网络设备上的DL任务时吃过一次亏任务声明每10ms要跑一次deadline给12ms看起来余量还行但只要网卡每毫秒来一批中断任务实际能用的CPU窗口就被切得支离破碎偶尔一次中断处理占用过长那轮的deadline就保不住。后来把网卡中断绑到另一个核开启threaded IRQ之后抖动立刻降了一个量级。这件事的教训是用DL之前先给硬件中断排好座次。6.4 从RT迁移到DL前先把这几件事做完很多老工程师的实时方案一直是RT调度手动调优先级想迁到DL还要克服不小的惯性。根据我的经验迁移前至少要把这几件事落地先把任务按“周期行为”梳理清楚固定好period和deadline的取值不要把不定期的“伪周期任务”硬塞成DL其次在单核上跑通DL配置测量实际最坏执行时间再去铺多核再次把CPU亲和性、中断绑定、带宽上限这些周边配置一起纳入变更清单而不是只改一个调度策略最后用ftrace的调度事件sched_wakeup、sched_switch抓几个周期的真实调度记录确认任务被唤醒、被调度、被节流的时间点都在预期范围内。我自己现在做硬实时改造时的习惯是先在纸上画出整个实时链路的数据流标出每个阶段的最坏执行时间和可用截止时间然后才轮到DL调度器出场。DL是很好的执行者但它只把“CPU带宽契约”履行得足够彻底解决不了算法本身的超时也解决不了中断带来的抖动。如果这篇能让你少走我当时调试那几天走错的方向那它就算没白写。下次探秘想聊哪个调度器评论区喊一声就行。
返回列表