前 20 篇给边界、调用、内存都定了价,这一篇给时间 定价:同样一个 10 ms 的周期任务,交给不同的调度架构,它到底能不能准时到?本机真跑五组实验、拿到可以复算的数字:① 四种周期原语的交付精度差 1550 倍 ------自旋忙等区间误差 p99 3.7 µs (但白烧 98.7% 的一个核),线程睡眠 p99 729.3 µs ,事件等待 p99 5664.5 µs ,协作式事件循环 p99 5734.0 µs 。② 真正致命的不是"慢一点",而是跳拍 :事件等待 53.97% 的周期被量化成两拍(实测最大迟到 10.30 ms ),协作式 50.21% ,而自旋与睡眠是 0% 。③ 一次 50 ms 的纯 CPU 阻塞写进事件循环,4 个 10 ms 任务 p50 迟到 11.03 ms 、p99 49.29 ms ,28.9% 的周期迟到超过 20 ms;把同一个阻塞卸载到线程池,p99 降到 33.13 ms (-32.8% )、超 20 ms 的比例降到 12.5% (-56.5% )------账没消失,只是 GIL 帮你分期 。④ 30 个随机任务集(33 483 个作业)上:EDF 错过 0 个,RMS 错过 43 个(0.13% ),而"不抢占、谁先来谁先跑"的协作式 FIFO 错过 5920 个(17.68% ,是 RMS 的 137 倍 ),单集最差 47.86% ;而且 FIFO 在 U=0.5 就开始掉作业 。⑤ 抖动税只对快对象 收:把实测抖动序列回放进闭环,τ=0.02 s 时闭环直接发散 ,τ=0.05 s 时 IAE 翻 2.01 倍 ,τ≥0.2 s 时 ×1.00 (一分钱都不收)。⑥ 过载后教科书结论反转 :U=1.4 时 EDF 错过 15.98% ,反而比 RMS 的 9.40% 差 70%。
1. 为什么"调度"必须有一张账单
架构19 我们把"一次调用值多少微秒"量了出来,架构20 把"一块内存值多少碎片"量了出来。但真实的车载/工控软件里,更常见的抱怨不是"这个函数太慢",而是:
- "这个 10 ms 任务有时候跑了 20 ms 才回来";
- "偶发丢帧,复现不了";
- "我们在 PC 上跑得好好的,上车就抖"。
这些抱怨的共同点是:平均值完全正常,尾部一塌糊涂。而架构决策恰恰只影响尾部:
| 你可能选的架构 | 平均值看起来 | 尾部真实表现 |
|---|---|---|
| 主循环 + 一堆 if 轮询(协作式) | 一模一样 | 取决于"最长的那个分支什么时候返回" |
| 每任务一线程 + sleep(抢占式) | 一模一样 | 取决于 OS 定时器粒度与唤醒路径 |
| 单线程事件循环(异步) | 一模一样 | 取决于"上一段回调有没有让出" |
这就是本篇要付的三笔账:精度账 (能不能按点交付)、阻塞账 (一段长活会不会连坐别人)、过载账 (顶不住的时候谁先死)。三笔账都不是"平均值"能表达的,所以下面所有结论一律用 p50 / p99 / max / 错过率 报,不用均值。

本篇全部数据来自本机真跑:Windows 10 专业版 / Intel i5-10400(6 核 12 线程)/ 32 GB / Python 3.12.9 ,实验脚本 scripts/a21_exp.py,原始数据 scripts/a21_exp.json,日志 scripts/a21_exp.log,时间戳 2026-10-17 10:13:34。凡是不能在本机复现的数字,本文不写。
2. 四把秒表:实验设置与口径
2.1 E1:同一件事的四种交付方式
被测对象只有一句话:每 10 ms 干一次活,跑 240 拍。四种实现:
- 自旋忙等(spin) :
while perf_counter() < next: pass------CPU 打满,但绝不让出。 - 线程睡眠(sleep) :
time.sleep(next - now)------线程真挂起,由 OS 唤醒。 - 事件等待(event) :
threading.Event().wait(next - now)------走可等待对象的内核路径。 - 协作式事件循环(asyncio) :单个循环里
await asyncio.sleep(...),全进程只有一个线程。
口径很重要,先说清楚三件事,否则数字会被误读:
- 区间误差:相邻两次实际唤醒的间隔减去 10 ms,单位 µs。它衡量"这一拍有没有按时到",是控制回路的真实输入。
- 迟到(lateness) :
实际时刻 - 计划时刻,只统计正值。它衡量"这一次晚到多久"。 - 跳拍率 :区间 > 1.5×周期 的比例。跳拍意味着这一拍合并进了下一拍,对控制/采样类任务等于丢一个样本。
2.2 E2~E5 的分工
| 实验 | 问的问题 | 手段 |
|---|---|---|
| E1 | 四种原语谁准时? | 真机 240 拍实测 |
| E2 | 一次长阻塞会连坐几个人? | 4 个 10 ms 任务 + 一个 50 ms 纯 CPU 阻塞,对照"同循环"与"线程池卸载" |
| E3 | 30 个任务集上,三种调度策略谁掉作业? | 离散事件仿真(0.1 ms 步长),UUniFast 生成随机利用率 |
| E4 | 抖动传到闭环控制值多少钱? | 把 E1 的实测区间误差序列回放进 PI 闭环 |
| E5 | 顶不住的时候谁先死? | U 从 0.6 扫到 1.4 |
E2 用"纯 CPU 忙循环"是故意的:这是最难卸载的一种阻塞(GIL 仍然要切),如果连它都能改善,真实的 I/O 阻塞只会更好。E3/E5 用非调和周期(从 5/7/11/13...97 ms 里随机取 6 个),因为调和周期会让固定优先级调度白捡便宜------这一点下面第 6 节会用数据说明。
2.3 本篇不测什么(边界先说清)
有三件事本文没有测,读到后面的数字时需要一起考虑:
- 没有测真正的线程优先级 。Python 在 Windows 上无法从标准库设置线程优先级(要用
ctypes调SetThreadPriority),而且 CPython 的 GIL 会让"优先级"在多线程里基本失效。所以 E1 的"抢占式"实际是"OS 定时器抢占 + 时间片轮转",反映的是定时器精度,不是优先级抢占延迟。真实的 RTOS 上这个数字通常好一个数量级(几十 µs 量级)。 - 没有测多核迁移。所有实验都在单进程里跑,i5-10400 的 12 个逻辑核基本是空的。核心迁移带来的缓存失效、跨 NUMA 的抖动,本文不覆盖。
- 没有测"优先级反转"。共享锁 + 不同优先级的经典病,需要真优先级才能构造,留到后面单独一篇。
把边界写出来的好处是:本文所有"谁比谁贵多少倍"的相对结论是稳的,"绝对微秒数"要按平台打折------第 10 节给了具体的放缩建议。
3. E1:四种原语的交付精度差 1550 倍
先看最直观的一张表。
表 1 10 ms 周期的交付精度(240 拍,本机实测,单位 µs)
| 原语 | 区间误差均值 | p99 | max | 迟到 p50 | 迟到 p99 | CPU 占用 | 能否按点交付 |
|---|---|---|---|---|---|---|---|
| 自旋忙等 | 2.1 | 3.7 | 202.9 | 0.3 | 37.0 | 98.7% | 能,但烧一个核 |
| 线程睡眠 | 256.4 | 729.3 | 916.0 | 246.3 | 532.9 | 0.0% | 基本能,亚毫秒级偏 |
| 事件等待 | 6862.4 | 5664.5 | 9997.8 | 5048.0 | 10135.2 | 0.0% | 不能,半拍量级 |
| 协作式事件循环 | 6872.3 | 5734.0 | 9998.8 | 5582.3 | 11262.3 | 0.0% | 不能,半拍量级 |
这张表里最容易被误读的是"事件等待和协作式几乎一样"。它们内部机制完全不同,但都被同一件事钉死:定时器粒度的量化。看它们的区间误差分布就明白了:
表 2 跳拍才是真凶(区间 > 1.5×周期 计为跳拍)
| 原语 | 跳拍次数/总拍数 | 跳拍率 | 最大迟到 | 偏置(平均偏差) |
|---|---|---|---|---|
| 自旋忙等 | 0/239 | 0.00% | 203.5 µs | 1.2 µs |
| 线程睡眠 | 0/239 | 0.00% | 548.5 µs | 31.7 µs |
| 事件等待 | 129/239 | 53.97% | 10 303.0 µs | 2369.8 µs |
| 协作式事件循环 | 120/239 | 50.21% | 11 504.9 µs | 3557.9 µs |
这就是为什么"平均 10 ms"毫无意义:事件等待下,一半的间隔是 20 ms 左右,另一半是几百微秒------平均下来正好 10 ms ,但控制回路已经丢了一半样本。这也是现场"偶发丢帧、偶发超时"最典型的成因:不是慢,是节拍被吞了。

三条可以直接搬进设计的结论:
- "CPU 越忙越准时"是反直觉的真话 。自旋忙等的 p99 是 3.7 µs,是睡眠的 197 倍精度提升;代价是 98.7% 的一个核。它只该用在"错过一拍等于功能失效"的地方(如电流环 PWM 更新、硬实时采样)。
- 睡眠是可以用的 。Python 3.11+ 在 Windows 上走的是高精度可等待定时器,10 ms 周期的 p99 落在 0.73 ms 以内,跳拍率 0%。绝大多数 10~100 ms 的周期任务,睡眠完全够用。
- 事件/协作式不是"不准",而是"不可预测地准" 。两者都在半拍量级,而且跳拍率过半。这类架构适合"对时延不敏感、但对吞吐/并发敏感"的场景(通信收发、事件分发、UI),不适合当控制回路的时间基。
先解释一个容易困惑的现象:既然事件等待一半的间隔是 20 ms、一半是几百微秒,为什么平均下来还是 10 ms? 因为实现写的是 next += T(绝对时间表),不是"睡满 T"。当一次唤醒晚了 10 ms,下一拍的剩余时间就变成负值,循环立即执行------看起来"追上了",实际上是那一拍的两个样本被合并成一个 。这就是跳拍的本质:用平均值的正确,掩盖了样本的缺失 。工程上的推论很硬:判断一个周期任务是否健康,必须数样本数,不能看平均周期。一条可用的门禁:连续 10 s 内,实际样本数必须等于理论样本数,少一个就告警。
顺带一个工程细节:自旋忙等的 max 是 202.9 µs,而 p99 只有 3.7 µs------尾部的 200 µs 不是定时器的错,是 OS 抢走了 CPU(GC、中断、其它线程)。所以"自旋"也不是万能药:你只能保证"我一直在跑",不能保证"我没被打断"。
把这笔 CPU 账换算成钱,更能帮助决策:2.9 GHz 的一个核被打满,按 240 拍、每拍 10 ms 算,600 个周期里这个线程空转了约 590 ms 的 CPU 时间 ;同一段时间里,睡眠版只花了不到 1 ms。也就是说,用 590 ms 的 CPU 换来了约 0.73 ms 的 p99 改善------只有当"这 0.73 ms 会影响功能正确性"时这笔交易才成立。这也是为什么硬实时控制环里常见"自旋 + 提前量"的写法,而通信/业务任务里几乎见不到。
4. E2:一次 50 ms 阻塞,连坐 4 个任务
协作式架构的拥趸常说:"只要大家都不阻塞,就没问题。"E2 就是来量"不阻塞"这条纪律值多少钱的。
实验设置:一个 asyncio 循环里跑 4 个 10 ms 周期任务,外加一个"每 100 ms 来一次、每次干 50 ms 纯 CPU"的负载(模拟一次同步解析、一次大 JSON 序列化、一次 CRC/校验、一次 NVM 写入)。两种架构:
- 同循环 :
busy(50)直接写在协程里; - 卸载 :
await loop.run_in_executor(EX, busy, 50)丢到线程池。
表 3 4 个 10 ms 任务的迟到(单位 ms,样本 636/640)
| 架构 | p50 | p99 | max | 迟到 >10 ms | 迟到 >20 ms | >20 ms 占比 |
|---|---|---|---|---|---|---|
| 阻塞写在同一循环 | 11.03 | 49.29 | 49.88 | 340 | 184 | 28.93% |
| 阻塞卸载到线程池 | 8.98 | 33.13 | 43.15 | 284 | 80 | 12.50% |
| 改善 | -18.6% | -32.8% | -13.5% | -16.5% | -56.5% | -56.8% |
注意三个细节,它们是这段实验最有价值的部分:
- p99 几乎等于阻塞时长本身(49.29 ms ≈ 50 ms) 。这不是巧合:协作式的迟到上限 = 你最长的那一段不让出的时间。这条可以写成架构约束:协作式架构里,"最长不可让出段"就是你的 p99 下界。
- 卸载只削掉了 32.8% 的尾巴,没有归零 。因为纯 Python 忙循环在子线程里仍然持 GIL,解释器每 5 ms 才会切换一次(
sys.setswitchinterval),所以主循环仍被"分期扣款"。这正好说明:"丢线程池"不是免死金牌,GIL/全局锁一类的共享资源会把延迟税再收一遍。 - p50 只从 11.03 降到 8.98 ms 。也就是说,只要系统里存在一个周期 100 ms、时长 50 ms 的负载,其它任务的 p50 迟到就必然 在 9~11 ms 量级------这是负载本身的性质(占用 50% 的 CPU),换调度架构只能改变"谁来承担抖动",不能改变"有 50% 的 CPU 被吃掉"这个事实。
还有一个常被忽略的连带成本:队列深度 。如果那 4 个任务不是"到点就跑",而是"到了就先排队",那么 50 ms 阻塞期间会积压 5 个周期/任务。队列深度=5 时,最后一帧的数据已经是 50 ms 前 的------比迟到本身更危险,因为它不报错。因此协作式架构里必须同时回答两个问题:队列有多深?满了之后丢谁? 本文的 E2 只量了"无队列"的迟到,已经能看到 49 ms 的尾巴;加上队列只会更差。

用架构语言翻译:协作式不是"没有调度",而是把调度决策权下放给了每个开发者,由"谁先让出"决定优先级;当任何一个模块违反纪律,惩罚由全体共同承担。抢占式则把决策权集中到调度器,代价是每次切换的开销和优先级配置的复杂度。
5. E3:30 个任务集,EDF 0 掉队、FIFO 掉 17.68%
E1/E2 是单任务的微观账,现在换成"一个系统里有 6 个任务抢一个核"的宏观账。
生成方式(可复算,不玩花活):
- 任务数 n=6,周期从 {5,7,11,13,17,23,29,37,41,47,53,59,67,73,79,83,89,97} ms 中随机取 6 个互不相同的,非调和;
- 总利用率 U 从 0.50 扫到 0.95,每个 U 取 3 个种子;
- 每个任务的最坏执行时间由 UUniFast 分配到各任务(这是实时系统文献里的标准做法,保证 ∑Cᵢ/Tᵢ=U 且长尾分布);
- 仿真步长 0.1 ms,时间窗 4 s,共 33 483 个作业。
三种策略:
- RMS:固定优先级,周期越短优先级越高,可抢占;
- EDF:动态优先级,绝对截止期最近者优先,可抢占;
- FIFO:不抢占,谁先释放谁先跑,跑到完(协作式的极端形态)。
表 4 30 个任务集的合计错过(33 483 个作业)
| 策略 | 错过作业 | 错过率 | 有掉队的任务集 | 相对 RMS |
|---|---|---|---|---|
| EDF | 0 | 0.00% | 0/30 | --- |
| RMS | 43 | 0.13% | 6/30 | 1× |
| FIFO(协作式) | 5920 | 17.68% | 30/30 | 137× |
再按利用率分层看:
表 5 错过率随利用率的变化(每个 U 取 3 个任务集平均)
| U | RMS | EDF | FIFO | 备注 |
|---|---|---|---|---|
| 0.50 | 0.00% | 0.00% | 3.95% | U 才一半,FIFO 已经在丢作业 |
| 0.55 | 0.00% | 0.00% | 5.62% | |
| 0.60 | 0.00% | 0.00% | 17.14% | |
| 0.65 | 0.00% | 0.00% | 10.65% | |
| 0.70 | 0.00% | 0.00% | 13.97% | |
| 0.75 | 0.00% | 0.00% | 20.52% | |
| 0.80 | 0.00% | 0.00% | 27.46% | |
| 0.85 | 0.00% | 0.00% | 27.36% | 单集最差 47.86% |
| 0.90 | 0.83% | 0.00% | 9.67% | RMS 开始掉,EDF 仍完美 |
| 0.95 | 0.84% | 0.00% | 25.79% | EDF 在 U<1 时理论最优 |
三条必须记住的结论:
① RMS 的 0.7348 界是"充分不必要",实测到 U=0.85 仍然 0 掉队。 n=6 时 Liu & Layland 界是
U_{rms} \\le n\\left(2\^{1/n}-1\\right) = 6\\left(2\^{1/6}-1\\right) \\approx 0.7348
表 4/5 里 U=0.75~0.85 的 RMS 错过率全是 0 ,说明这个界保守 。真实工程含义:如果你按 0.7348 卡预算,你会浪费 10~20% 的 CPU;反过来,如果你按 0.85 排任务,你必须逐个任务集做响应时间分析(RTA),不能只报一句"U<1 就能跑"。
② 同一个 U 下,不同周期组合的差距可以到 47 个百分点。 U=0.85 时三个种子的 FIFO 错过率分别是 12.68% / 21.54% / 47.86% ;最差的那个周期组合是 T={5,7,53,79,89,97},C={0.44,0.02,9.44,32.61,14.77,0.24}------一个 32.61 ms 的长作业把 5 ms 任务按在地上。这个单集值得单独解剖一次,因为它把"非抢占"的病理暴露得最干净:
- 5 ms 任务的截止期是 5 ms,而最长作业 32.61 ms 一旦开始就必须跑完;
- 32.61 / 5 = 6.5 ,也就是说这一个作业横跨 5 ms 任务的 6~7 个周期 ,这 6~7 个作业必然全部超期;
- 该任务集 4000 ms 内共有 800 次 5 ms 释放,漏掉 383 次 = 47.86% ,和上面的推算同量级------一个数字就能解释这个任务的掉队率。
同一副"显微镜"也可以用来读 RMS。对固定优先级做响应时间分析(RTA),从最高优先级开始迭代:
R_i = C_i + \\sum_{j \\in hp(i)} \\left\\lceil \\frac{R_i}{T_j} \\right\\rceil C_j
迭代到收敛即得最坏响应时间,只要 R_i ≤ T_i 就可调度。注意它和"U ≤ 0.7348"的差别:RTA 是精确判据(必要且充分,对固定优先级而言),Liu & Layland 界只是它的一个充分条件 。这也是为什么表 5 里 U=0.85 的 RMS 仍然 0 掉队------界保守,而真实系统刚好都被 RTA 判过。工程建议是:预算阶段用界(快),放行阶段用 RTA(准)。
这解释了一个常见困惑:"我们的 CPU 占用才 60%,为什么还会超时?"答案是:利用率是标量,可调度性是张量,周期/截止期的相对大小关系比总利用率更致命。
③ 协作式 FIFO 的失效不是"过载才发生",而是"一直都在"。 U=0.5 时它已经丢 3.95% 的作业。因为非抢占意味着任何一次长作业都会推迟所有高优先级任务,哪怕 CPU 一半是空的。这就是"主循环 + 一堆 if"在台架上永远看不出问题、一上量就崩的根本原因。

6. E4:抖动税只对"快对象"收
到这里有个自然的反驳:"我的系统是控制回路,抖动几十微秒无所谓,反正被控对象有大惯性。"
这句话对了一半,而"另一半"是有边界的。E4 就是把这个边界量出来。
做法 :把 E1 里实测 的区间误差序列(不是假设分布,是真实采样)一帧一帧回放进一个 PI 闭环,含一拍执行延迟 (算出来的 u 下一拍才作用于对象),增益按 IMC 随对象时间常数 τ 整定(Kp=τ/(λ+Ts), Ki=Kp/τ, λ=max(0.2τ, Ts)),对比"理想定周期"的 IAE。
表 6 抖动传导到控制质量:IAE 相对理想定周期的倍数(τ 为对象时间常数)
| τ | 理想 IAE | 自旋 | 睡眠 | 事件等待 | 协作式 |
|---|---|---|---|---|---|
| 0.02 s | 0.0439 | ×1.00 | ×1.02 | ×10 249 336(发散) | ×639 779(发散) |
| 0.05 s | 0.0387 | ×1.00 | ×1.01 | ×2.01 | ×1.95 |
| 0.10 s | 0.0327 | ×1.00 | ×1.01 | ×0.95 | ×0.94 |
| 0.20 s | 0.0500 | ×1.00 | ×1.00 | ×1.00 | ×1.00 |
| 0.50 s | 0.1100 | ×1.00 | ×1.00 | ×1.00 | ×1.00 |
读法:
- τ=0.02 s(50 Hz 带宽的对象) :半拍量级的抖动(约 5~10 ms,也就是周期的 0.5~1 倍)直接让闭环发散 。注意这不是数值噪声,
max超调到 10⁸ 量级------采样周期和对象时间常数同一量级时,抖动就是稳定性问题,不是精度问题。 - τ=0.05 s :IAE 翻 2.01 倍、超调从 38.5% 涨到 54.3%、调节时间从 0.07 s 拖到 0.12 s。这是"能用但验收不过"的典型区间。
- τ≥0.2 s :所有架构 ×1.00 ,一模一样。也就是说,对于大惯性对象,花 98.7% 的 CPU 去换自旋精度,一分钱回报都没有------这是本篇最省钱的一条结论:先算对象的时间常数,再决定要不要为调度精度付钱。
- τ=0.10 s 时事件/协作式反而 ×0.95(变好了) :抖动改变了采样相位,恰好更利于抑制超调。这个"侥幸"不能当成设计依据,但它说明一件事:抖动不是单调地坏,不能用一句"抖动有害"做架构决策,必须用具体对象 + 具体增益去算。

工程化建议(可直接写进架构约束):
- 控制回路的时间基不要建立在"谁先让出"的协作式语义上;哪怕用事件循环,也要把控制任务放到独立的、可抢占的执行体里。
- 判断"要不要为抖动付钱"的公式很朴素:抖动 σ 与采样周期 T 的比值,以及 T 与对象 τ 的比值。σ/T>0.3 且 T/τ<0.2 时,抖动就是稳定性风险。
- 反过来说,对于 τ≥0.2 s 的对象,抖动账可以完全不算------别在架构评审里为"要不要上 RTOS"吵三个小时,先看对象。
7. E5:过载之后,教科书结论会反转
实操中最危险的不是"U 算错了",而是"设计时 U=0.7,量产后 U=1.2"。E5 把 U 从 0.6 扫到 1.4。
表 7 过载扫描(每点 3 个随机任务集平均,4000 ms 仿真)
| U | RMS | EDF | FIFO | 谁在赢 |
|---|---|---|---|---|
| 0.60 | 0.00% | 0.00% | 12.99% | RMS=EDF |
| 0.80 | 0.00% | 0.00% | 23.40% | RMS=EDF |
| 0.90 | 0.38% | 0.00% | 30.36% | EDF |
| 1.00 | 3.14% | 2.51% | 37.45% | EDF |
| 1.10 | 7.10% | 8.59% | 44.58% | RMS 反超 |
| 1.20 | 8.57% | 12.17% | 50.62% | RMS |
| 1.30 | 9.06% | 13.68% | 55.52% | RMS |
| 1.40 | 9.40% | 15.98% | 60.53% | RMS(差 70%) |
这张表有三层信息:
- U<1 时 EDF 是理论与实测双料赢家(0 掉队)。如果你的系统保证不过载,EDF 是利用率最高的选择。
- U>1 时 EDF 崩溃得更快 。这是 EDF 的经典病:一旦来不及,它会优先跑"截止期最近"的那个,于是所有任务都在赶最近的死线,长作业被反复插队、永远做不完 。RMS 则因为优先级固定,低优先级任务集体饿死,但高优先级任务仍然准时 ------从系统安全角度看,这是更想要的失效模式:关键是"谁可以死"由架构决定,而不是由负载决定。
- FIFO 一路线性雪崩 ,从 12.99% 到 60.53%,没有拐点。它既不保高优先级,也不保截止期,只保"先来后到"。在过载下,协作式调度等于放弃了所有的失效可控性。
所以架构评审里该问的问题不是"用 RMS 还是 EDF",而是:
- 过载时,你要保谁? 保安全(刹車/看门狗) → 固定优先级,把安全任务钉在最高优先级;
- 能不能保证不过载? 能(有准入控制/降级策略)→ EDF 拿满利用率;
- 过载概率极低但要求软实时 → RMS + 服务器/带宽保留(本文未覆盖,留给下一篇)。
配套的"三个护标",可以直接写成需求和测试用例:
- 利用率护标 :U 的实测上限(不是设计值)要留 20~30% 余量;超过就在 CI 里告警,而不是等上车。
- 单作业护标:任何一个作业的 C 不得超过它自己周期的一半,否则非抢占场景必然连坐(本文 32.61 ms 的例子就是 C/T=0.62)。
- 过载护标:人工把 U 扫到 1.2,看"谁先丢"是不是你想要的那个任务。这个实验在仿真里一下午就能做完,上车后却是血。
8. 六种调度架构的选型表
把五组实验压成一张能被评审直接用的表。
表 8 调度架构选型:按"你要付哪种税"来选
| 架构形态 | 交付精度(p99) | CPU 成本 | 失效形态 | 过载行为 | 适用落点 |
|---|---|---|---|---|---|
| 自旋忙等(裸循环计数) | 3.7 µs | 98.7%/核 | 被 OS 抢 CPU,尾部 200 µs | 无队列,直接漏拍 | 电流环/硬实时采样 |
| 抢占式 + 定时器(RTOS/线程) | 729.3 µs | ~0% | 稳态偏移 + 偶发迟到 | 低优先级饿死,高优先级保住 | 大多数周期任务 |
| 事件等待(semaphore/Event) | 5664.5 µs | ~0% | 跳拍 54% | 队列积压 | 事件驱动、非周期敏感 |
| 单线程事件循环(asyncio) | 5734.0 µs | ~0% | 跳拍 50% + 长回调连坐 | 全线迟到,无优先级 | 通信/IO/UI |
| 协作式主循环(if 轮询) | 取决于最长段 | ~0% | U=0.5 就丢 3.95% | 线性雪崩到 60% | 极简固件/单功能 |
| 混合:抢占时间基 + 协作业务 | 由时间基决定 | 低 | 若边界划错则退化 | 取决于时间基策略 | 推荐的默认形态 |
关于最后一行,给一句可直接落地的判据:
时间基必须抢占,业务可以协作。 把"每 10 ms 必须发生的事"放进抢占式执行体,把"来了就处理、晚一点没关系"的事放进事件循环,两者之间用深度受限的队列 连接,并在队列水位超限时显式降级(丢最旧/丢最低优先级),而不是让它无限积压。
8.1 四步选型流程
把上面八种形态压成一个可以拿着需求直接走的流程:
- 先定 τ,定需不需要精度。 τ≥0.2 s:表 6 显示四种架构 ×1.00,直接选"最省心"的(抢占 + 睡眠),不该为抖动花钱。τ≤0.05 s:必须进入下一步。
- 再定 T,定能容忍多少跳拍。 σ/T>0.3 就不可接受。此时可选解只有两个:缩小 T(提高采样率,但 U 同比例上升),或者把 σ 压到 100 µs 以内(自旋 / 硬件定时器 / 独立抢占线程)。
- 然后算"最长不可让出段"。 这个数字直接决定了你能不能用协作式:它约等于该架构的 p99 下界。超过 T 就不行,超过 0.5T 就要写风险条目。
- 最后定过载策略。 U>1 时的失效形态必须由架构指定(保安全 vs 保利用率),并用 E5 式的过载扫描做一次"压力验收"。
9. 五条可迁移的方法论
- 报尾不报均值。 本文所有"灾难"在均值上都看不出来:E1 的四种原语平均间隔都是 10 ms;E2 的 p50 是 11 ms 而 p99 是 49 ms;E5 的 FIFO 在 U=0.5 时"平均响应"也很好看。架构评审要求 p99 + 错过率,这是最低门槛。
- 先找"最长不可让出段",再谈调度。 协作式的 p99 等于它;抢占式的 p99 等于"最长不可抢占段 + 切换开销"。把这个数字写进架构文档,比写"采用实时调度"有用一万倍。
- 利用率是标量,可调度性是张量。 同 U=0.85,错过率从 12.68% 到 47.86%,差 35 个百分点。永远拿真实周期组合去算,不要拿"CPU 占用 60%"交差。
- 抖动只在它和对象同量级时才收钱。 τ≥0.2 s 的对象,四种架构 ×1.00;τ=0.02 s 的对象,半拍抖动直接发散。判断顺序:先 τ,再 T,再 σ。
- 过载行为必须被设计,而不是被发现。 EDF 在 U=1.4 时错过率是 RMS 的 1.7 倍,但它在 U<1 时是 0。要么保证不过载(准入控制),要么保关键任务(固定优先级),两者不能都想。
10. 本篇的可复现性
所有数字都可以在本机五分钟内重跑一遍。
| 文件 | 作用 |
|---|---|
scripts/a21_exp.py |
E1~E5 全部实验,输出 JSON + 日志 |
scripts/a21_exp.json |
原始数据(含每拍的区间误差序列、每个任务集的周期与 C) |
scripts/a21_exp.log |
运行时的人类可读日志(含时间戳与 Python 版本) |
scripts/a21_stat.py |
把 JSON 压成上表用的汇总打印 |
scripts/a21_fig.py |
由 JSON 出 5 张白底配图(21-01..21-05) |
复现步骤:
bash
cd F:/flexflowwork/wzAPP/csdn/软件架构设计实战
python scripts/a21_exp.py # 约 30 s,产出 a21_exp.json / a21_exp.log
python scripts/a21_stat.py # 打印本文所有表格的数字
python scripts/a21_fig.py # 产出 images/21-01..21-05.png
口径与容差(必须一起读,否则数字对不上会以为跑错了):
- E1 的绝对值随机器负载浮动 。同一台机器空载跑,自旋 p99 在 2~5 µs、睡眠 p99 在 300~900 µs 之间波动;但排序关系稳定:自旋 ≪ 睡眠 ≪ 事件 ≈ 协作式,且后两者的跳拍率始终在 45%~55%。
- E2 依赖 GIL 的切换间隔 (默认 5 ms)。把
sys.setswitchinterval(0.001)调小后,卸载的改善幅度会明显变大------这本身也是一个可验证的推论。 - E3/E5 是确定性仿真 ,只要种子和 U 不变,数字逐位可复现(不涉及真实时间)。
- E4 的"发散"判据是 IAE 相对理想值 >10 倍 ;τ=0.02 s 时实际放大到 10⁶~10⁷ 量级,是真实的闭环不稳定,不是数值噪声(可以用
T_end加长再验证,IAE 会继续单调增长)。 - 本机基线:Windows 10 专业版 / i5-10400(6C12T)/ 32 GB / Python 3.12.9 。换 Linux 或换 Python 3.10,绝对数字会变,但本文所有比较性结论(谁比谁贵、贵多少倍、失效形态)不变------这也是为什么全文都在报"倍数/占比",而不是只报单点值。
一条自查建议:如果你的平台跑出来 E1 的跳拍率是 0% ,先别怀疑脚本------多半是你的 sleep 走的是高精度可等待定时器(Windows)或 clock_nanosleep(Linux),这恰好说明"事件等待不等于低精度",精度取决于实现 ,不取决于语义名字。
11. 下篇预告
架构22:启动时间预算------从上电到第一帧的 1.2 秒都花在哪。
调度解决的是"稳态怎么跑",但车上第一次上电、第一次出图、第一次能响应方向盘,是另一套完全不同的架构问题:初始化顺序依赖、驱动加载、NVM 读取、自检、以及"谁在等谁"。下一篇会把启动过程拆成可测量的阶段账本,做三组实验:
- 顺序初始化 vs 依赖图并行初始化:在同一个依赖 DAG 上量出关键路径(而不是"代码行数");
- 懒加载的账单:把 NVM/标定读取从启动挪到首次访问,量"启动省了多少、首次访问贵了多少",并给出"该不该挪"的判据;
- 降级启动:当某个外设超时时,是等它(阻塞启动)还是先起后补(影子设备),用实测数据给出"首帧时间 vs 首帧完整度"的帕累托前沿。
一句话总结本篇想留下的东西:调度架构没有"更好",只有"你把税记在谁头上" ------记在 CPU 上(自旋)、记在精度上(睡眠/事件)、还是记在别人身上(协作式的连坐)。架构评审的工作,就是把这个选择显式地写下来,并配上能复算的数字。