软件架构设计实战-架构21.调度架构的抖动账单:抢占、协作与事件循环各自收多少延迟税

前 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 拍。四种实现:

  1. 自旋忙等(spin) :while perf_counter() < next: pass------CPU 打满,但绝不让出。
  2. 线程睡眠(sleep) :time.sleep(next - now)------线程真挂起,由 OS 唤醒。
  3. 事件等待(event) :threading.Event().wait(next - now)------走可等待对象的内核路径。
  4. 协作式事件循环(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 本篇不测什么(边界先说清)

有三件事本文没有测,读到后面的数字时需要一起考虑:

  1. 没有测真正的线程优先级 。Python 在 Windows 上无法从标准库设置线程优先级(要用 ctypes 调 SetThreadPriority),而且 CPython 的 GIL 会让"优先级"在多线程里基本失效。所以 E1 的"抢占式"实际是"OS 定时器抢占 + 时间片轮转",反映的是定时器精度,不是优先级抢占延迟。真实的 RTOS 上这个数字通常好一个数量级(几十 µs 量级)。
  2. 没有测多核迁移。所有实验都在单进程里跑,i5-10400 的 12 个逻辑核基本是空的。核心迁移带来的缓存失效、跨 NUMA 的抖动,本文不覆盖。
  3. 没有测"优先级反转"。共享锁 + 不同优先级的经典病,需要真优先级才能构造,留到后面单独一篇。

把边界写出来的好处是:本文所有"谁比谁贵多少倍"的相对结论是稳的,"绝对微秒数"要按平台打折------第 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 ,但控制回路已经丢了一半样本。这也是现场"偶发丢帧、偶发超时"最典型的成因:不是慢,是节拍被吞了。

三条可以直接搬进设计的结论:

  1. "CPU 越忙越准时"是反直觉的真话 。自旋忙等的 p99 是 3.7 µs,是睡眠的 197 倍精度提升;代价是 98.7% 的一个核。它只该用在"错过一拍等于功能失效"的地方(如电流环 PWM 更新、硬实时采样)。
  2. 睡眠是可以用的 。Python 3.11+ 在 Windows 上走的是高精度可等待定时器,10 ms 周期的 p99 落在 0.73 ms 以内,跳拍率 0%。绝大多数 10~100 ms 的周期任务,睡眠完全够用。
  3. 事件/协作式不是"不准",而是"不可预测地准" 。两者都在半拍量级,而且跳拍率过半。这类架构适合"对时延不敏感、但对吞吐/并发敏感"的场景(通信收发、事件分发、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%

注意三个细节,它们是这段实验最有价值的部分:

  1. p99 几乎等于阻塞时长本身(49.29 ms ≈ 50 ms) 。这不是巧合:协作式的迟到上限 = 你最长的那一段不让出的时间。这条可以写成架构约束:协作式架构里,"最长不可让出段"就是你的 p99 下界。
  2. 卸载只削掉了 32.8% 的尾巴,没有归零 。因为纯 Python 忙循环在子线程里仍然持 GIL,解释器每 5 ms 才会切换一次(sys.setswitchinterval),所以主循环仍被"分期扣款"。这正好说明:"丢线程池"不是免死金牌,GIL/全局锁一类的共享资源会把延迟税再收一遍。
  3. 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(变好了) :抖动改变了采样相位,恰好更利于抑制超调。这个"侥幸"不能当成设计依据,但它说明一件事:抖动不是单调地坏,不能用一句"抖动有害"做架构决策,必须用具体对象 + 具体增益去算。

工程化建议(可直接写进架构约束):

  1. 控制回路的时间基不要建立在"谁先让出"的协作式语义上;哪怕用事件循环,也要把控制任务放到独立的、可抢占的执行体里。
  2. 判断"要不要为抖动付钱"的公式很朴素:抖动 σ 与采样周期 T 的比值,以及 T 与对象 τ 的比值。σ/T>0.3 且 T/τ<0.2 时,抖动就是稳定性风险。
  3. 反过来说,对于 τ≥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%)

这张表有三层信息:

  1. U<1 时 EDF 是理论与实测双料赢家(0 掉队)。如果你的系统保证不过载,EDF 是利用率最高的选择。
  2. U>1 时 EDF 崩溃得更快 。这是 EDF 的经典病:一旦来不及,它会优先跑"截止期最近"的那个,于是所有任务都在赶最近的死线,长作业被反复插队、永远做不完 。RMS 则因为优先级固定,低优先级任务集体饿死,但高优先级任务仍然准时 ------从系统安全角度看,这是更想要的失效模式:关键是"谁可以死"由架构决定,而不是由负载决定。
  3. FIFO 一路线性雪崩 ,从 12.99% 到 60.53%,没有拐点。它既不保高优先级,也不保截止期,只保"先来后到"。在过载下,协作式调度等于放弃了所有的失效可控性。

所以架构评审里该问的问题不是"用 RMS 还是 EDF",而是:

  • 过载时,你要保谁? 保安全(刹車/看门狗) → 固定优先级,把安全任务钉在最高优先级;
  • 能不能保证不过载? 能(有准入控制/降级策略)→ EDF 拿满利用率;
  • 过载概率极低但要求软实时 → RMS + 服务器/带宽保留(本文未覆盖,留给下一篇)。

配套的"三个护标",可以直接写成需求和测试用例:

  1. 利用率护标 :U 的实测上限(不是设计值)要留 20~30% 余量;超过就在 CI 里告警,而不是等上车。
  2. 单作业护标:任何一个作业的 C 不得超过它自己周期的一半,否则非抢占场景必然连坐(本文 32.61 ms 的例子就是 C/T=0.62)。
  3. 过载护标:人工把 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 四步选型流程

把上面八种形态压成一个可以拿着需求直接走的流程:

  1. 先定 τ,定需不需要精度。 τ≥0.2 s:表 6 显示四种架构 ×1.00,直接选"最省心"的(抢占 + 睡眠),不该为抖动花钱。τ≤0.05 s:必须进入下一步。
  2. 再定 T,定能容忍多少跳拍。 σ/T>0.3 就不可接受。此时可选解只有两个:缩小 T(提高采样率,但 U 同比例上升),或者把 σ 压到 100 µs 以内(自旋 / 硬件定时器 / 独立抢占线程)。
  3. 然后算"最长不可让出段"。 这个数字直接决定了你能不能用协作式:它约等于该架构的 p99 下界。超过 T 就不行,超过 0.5T 就要写风险条目。
  4. 最后定过载策略。 U>1 时的失效形态必须由架构指定(保安全 vs 保利用率),并用 E5 式的过载扫描做一次"压力验收"。

9. 五条可迁移的方法论

  1. 报尾不报均值。 本文所有"灾难"在均值上都看不出来:E1 的四种原语平均间隔都是 10 ms;E2 的 p50 是 11 ms 而 p99 是 49 ms;E5 的 FIFO 在 U=0.5 时"平均响应"也很好看。架构评审要求 p99 + 错过率,这是最低门槛。
  2. 先找"最长不可让出段",再谈调度。 协作式的 p99 等于它;抢占式的 p99 等于"最长不可抢占段 + 切换开销"。把这个数字写进架构文档,比写"采用实时调度"有用一万倍。
  3. 利用率是标量,可调度性是张量。 同 U=0.85,错过率从 12.68% 到 47.86%,差 35 个百分点。永远拿真实周期组合去算,不要拿"CPU 占用 60%"交差。
  4. 抖动只在它和对象同量级时才收钱。 τ≥0.2 s 的对象,四种架构 ×1.00;τ=0.02 s 的对象,半拍抖动直接发散。判断顺序:先 τ,再 T,再 σ。
  5. 过载行为必须被设计,而不是被发现。 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

口径与容差(必须一起读,否则数字对不上会以为跑错了):

  1. E1 的绝对值随机器负载浮动 。同一台机器空载跑,自旋 p99 在 2~5 µs、睡眠 p99 在 300~900 µs 之间波动;但排序关系稳定:自旋 ≪ 睡眠 ≪ 事件 ≈ 协作式,且后两者的跳拍率始终在 45%~55%。
  2. E2 依赖 GIL 的切换间隔 (默认 5 ms)。把 sys.setswitchinterval(0.001) 调小后,卸载的改善幅度会明显变大------这本身也是一个可验证的推论。
  3. E3/E5 是确定性仿真 ,只要种子和 U 不变,数字逐位可复现(不涉及真实时间)。
  4. E4 的"发散"判据是 IAE 相对理想值 >10 倍 ;τ=0.02 s 时实际放大到 10⁶~10⁷ 量级,是真实的闭环不稳定,不是数值噪声(可以用 T_end 加长再验证,IAE 会继续单调增长)。
  5. 本机基线: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 读取、自检、以及"谁在等谁"。下一篇会把启动过程拆成可测量的阶段账本,做三组实验:

  1. 顺序初始化 vs 依赖图并行初始化:在同一个依赖 DAG 上量出关键路径(而不是"代码行数");
  2. 懒加载的账单:把 NVM/标定读取从启动挪到首次访问,量"启动省了多少、首次访问贵了多少",并给出"该不该挪"的判据;
  3. 降级启动:当某个外设超时时,是等它(阻塞启动)还是先起后补(影子设备),用实测数据给出"首帧时间 vs 首帧完整度"的帕累托前沿。

一句话总结本篇想留下的东西:调度架构没有"更好",只有"你把税记在谁头上" ------记在 CPU 上(自旋)、记在精度上(睡眠/事件)、还是记在别人身上(协作式的连坐)。架构评审的工作,就是把这个选择显式地写下来,并配上能复算的数字。

相关推荐
禅与计算机程序设计艺术2 小时前
面向大语言模型智能体的 Harness 工程:工具编排、记忆与自修复闭环设计
架构
众链网络2 小时前
从一次近 5 小时的大模型中断,讲清 AI 应用的降级设计
人工智能·架构
Thneonl2 小时前
一次 rebalance,全组停摆:Kafka lag 从 0 到百万的事故复盘
后端·架构
anew___3 小时前
《从零手写操作系统 (48):网络协议栈基础——Socket API与TCP/IP分层架构》
网络协议·tcp/ip·架构
weixin_382395233 小时前
0-500 人工厂的生产 PMC 系统怎么做?基于 Tauri + SQLite 的性能架构实践与 5000 物料压测实录
数据库·架构·sqlite
软件工程师文艺4 小时前
制造业数字化转型路线图:从信息化总体规划到智能工厂的五阶段演进
架构
各类产品分享14 小时前
工业现场 AR 巡检系统的技术架构与落地难点解析
架构·ar·ar巡检
GOWIN革文品牌咨询4 小时前
长周期B2B线索培育的系统设计:状态字段、触发器与转交条件
大数据·架构
各类产品分享6 小时前
工业 AR 巡检系统的技术架构与落地实现
架构·ar·ar巡检