我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

RTOS 周期任务服务的并发三件套:RMW、RTC 与 AOP

RTOS 周期任务服务的并发三件套:RMW、RTC 与 AOP RTOS 周期任务服务的并发三件套RMW、RTC 与 AOP一个工作线程跑周期计算板载命令行和工况切换模块从另外的线程下发命令硬件写完之后再由中断回调通知它。这种“一个工作线程 多个命令入口 一份共享状态”的 MCU 服务很常见。周期服务很少栽在状态机上转移是否合法容易查难防的是读和写之间被别的线程插了一脚判断时依据的输入到发布时可能已经作废。全文只做三件小事把判据与发布收进同一个临界区RMW、挡住派发过程中的重入RTC、把锁从业务代码里挪到一层通知里AOP。做完之后并发正确性不再依赖注释提醒改坏了会编译不过或测试报错。三个机制各自怎么落地、彼此怎么配合、要付什么代价是下面几节的内容。一、典型场景与失效点服务本身没什么特别按固定节拍唤醒读硬件、算一批系数、把结果写回设备循环往复。让它变复杂的是外围命令既能从命令行来也能从工况切换模块来一个要它立刻算一次一个要它换档重新加载而硬件写完的通知又是从中断里出来的。于是并发面就出来了一个工作线程按节拍跑计算命令行、工况切换模块从另外的线程下发命令硬件写入完成后由中断回调通知工作线程命令、线程、中断三方共享同一份状态与参数。它的失效点通常集中在四处都属于平时看不出来、偶发一次才会暴露的类型问题表现关键等待无超时等硬件响应用了无限等待一旦响应丢失工作线程永久卡死周期计算静默停止停止操作谎报成功轮询若干部后直接返回成功实际工作线程还在跑状态写入点分散状态字段有 4 处直接赋值其中两处绕过了合法性校验锁代码散落每个长事务各自取放锁覆盖范围由各分支作者自行决定四条里前两条是等待与上报的写法问题后两条是结构问题也是本文要处理的部分。它们不会立刻暴露却会持续制造只在特定时序下出现的偶发缺陷。二、读改写原子性与拒绝重入RMW、RTC上面这两条结构问题分属两层一层是同一线程内部的执行顺序一层是多入口之间的互斥。RMW 和 RTC 先解决执行顺序这一层。判据与发布之间被插入的窗口常见的状态校验是逐状态用switch判断目标态是否可达。这种校验回答的是“这条转移在状态图上是否合法”却回答不了另一件事判断时读到的输入到真正写下去的时候可能已经变了。看这个服务里的一个具体例子。有个生效值决定服务是否在跑它由若干输入算出来全局禁用位、局部意图槽、工况基线。计算和执行是分开的线程 A: 读快照 → 算出候选值 开 线程 B: 改变输入下发禁用 ← 插在这里 线程 A: 把候选值 开 发布出去 ← 发布了过期结论线程 A 的快照与发布之间隔着若干毫秒涉及一次数据重载足够 B 干完活。结果是 A 用一个已经作废的输入发布了一个应该开的结论把 B 的禁用意图覆盖掉。加一个版本号把三步圈进同一临界区RMW做法是把“读旧值、判条件、发布”这三步圈进同一个临界区中途不放锁rt_mutex_take(priv-state_mutex,RT_WAITING_FOREVER);was_enabledeffective_enable_get();/* 读旧值 */if(RT_TRUEpriv-blocked){enableRT_FALSE;}/* 复核一 */if(in-gen!priv-decision_gen){retMOD_DRV_BUSY;break;}/* 复核二 */if(gate_blocks_open(enable,priv-gated,authorized)){enableRT_FALSE;}/* 复核三 */effective_enable_set(enable);/* 发布 */rt_mutex_release(priv-state_mutex);这就是 RMWRead-Modify-Write读-改-写同一临界区内完成读、判、改、写。三段复核里第二段的设计值得单独说一句它比较的是一个版本号。计算前: 记下当前版本号 gen 7 计算后: 若 priv-decision_gen 仍为 7 → 输入没变过可以发布 若已变成 8 → 中途有人改过输入本次结论作废之所以选版本号是因为比较数值时看不出 ABA 回绕输入变了一圈又回到原值比值会判成“没变过”版本号不会。布尔型输入尤其明显禁用位在快照和发布之间被置起来又清掉比数值根本察觉不到。派发被打断历史里出现假迁移RMW 解决的是并发写还有一种破坏方式来自重入。RTCRun-to-Completion运行到完成指单次事件的处理从开始到结束不被同类的第二次处理打断。它防的是这种情形某次派发正在写状态指针和迁移历史中途被中断回调或另一个线程触发的第二次派发插进来于是历史记录里出现半成品或者状态指针被写了两遍。事后翻日志时会看到一条不存在的迁移或一条重复的迁移可是没有任何记录能说明“这两条出自同一次处理”。用派发标志挡住重入RTCRTC 是 UML 状态机本来就有的语义不是可选项单次事件的守卫、退出动作、迁移动作、进入动作合在一起是一个不可分割的步骤。违反它的方式也很固定最常见的就是在进入动作里再派发一个事件让第二次处理钻到第一次的中间去。顺带说清一个常见的误解RTC 约束的只是“同一实例不并发处理两个事件”它不要求处理过程独占 CPU。被更高优先级的任务或中断抢占是允许的被抢占的处理会在调度回来后从断点继续、最终走完整个步骤这仍然是 RTC。分清这一点有实际好处如果误以为 RTC 要求独占 CPU就容易去关中断或抬优先级来“保护”处理过程把整个系统的响应性一起搭进去分清之后中断照常跑即可只要它不碰状态机的私有数据。实现只需要一个布尔字段但有两个位置必须放对if(RT_TRUEpriv-dispatching)/* 嵌套或并发派发一律拒绝 */{retMOD_INVALID_STATE;break;}priv-dispatchingRT_TRUE;/* ① 必须在读旧态之前 *//* ... 读旧态、查表、发布、写历史 ... */priv-dispatchingRT_FALSE;/* ② 必须在写历史之后 */置位早于读旧态否则读与写之间仍可被插入第二次派发RMW 的原子性被破坏。清位晚于写历史否则历史记录可能被第二次派发覆盖。这两个顺序看似显然但属于改动一行代码即静默失效的约束可以固化为测试断言置位语句的行号必须小于读旧态的行号清位语句必须大于写历史。状态字段的写入收敛到一个派发入口状态字段需要同样的纪律。把写入收敛到一个派发入口取值、查表、发布一次做完priv-dispatchingRT_TRUE;/* 进入 RTC 区间 */old_statepriv-state;/* 读 *//* 按 (源态, 事件) 双键匹配转换表 */new_statetransition-target;priv-statenew_state;/* 改 写 */record_state_history(priv,old_state,new_state);priv-dispatchingRT_FALSE;分散在 4 处的priv-state ...收敛成 1 处之后测试可以断言“状态字段的所有写入必须在这个函数体内”。这是一条能自动执行的约束不再依赖代码评审时有人记得。被拒绝的派发怎么计数被拒绝的派发会累加一个计数器通过诊断接口暴露出来用来排查“命令为什么没生效”。这里有一处取舍三类拒绝参数越界、重入检测、转换表里没有匹配边合并成一个计数看到非零值也判断不出是哪一种得配合日志定位。合并的原因是计数器必须在临界区内自增而临界区里不适合做串口输出一次printf就可能把热路径堵住。诊断分辨率因此变粗换回来的是临界区内不做输出。三、锁不该写在业务代码里AOPRMW 和 RTC 管的都是同一线程内部的顺序。并发的另一半来自多个入口同时进来命令侧可能同时在跑两次长事务而这层“加锁”逻辑不属于任何单个业务函数。锁散落在各分支的三个问题一种常见做法是让每个长事务分支自行在片段内取放状态锁。问题有三个覆盖范围由分支作者决定新增命令时容易漏或覆盖不足长事务本身可能阻塞周期服务常有一次最长 1 秒的硬件写入把它套进状态锁会连带把诊断命令一起堵住锁的取放点分散很难静态检查有没有漏放。这段逻辑叫横切关注点AOP“长事务要在执行前后加锁但这半段逻辑不属于任何业务函数”这就是横切关注点AOPAspect-Oriented Programming面向切面编程要处理的情形。这个概念 1997 年由 Xerox PARC 的 Kiczales 等人提出针对的是两个老问题同一个关注点散布在几十个函数里scattering以及它和业务逻辑缠在一起tangling改一处要动几十处。AOP 的术语体系里本文只用到两个切面aspect封装横切逻辑的模块和通知advice在主流程前后插入的动作分 before / after / around。Java 侧有整套语言和框架做这件事AspectJ 的编译期织入、Spring 的运行期代理C 里没有织入器但用函数指针加手工分发实现“在调用前后做固定动作”这种 around 通知并不困难也不需要额外工具。用一层通知把锁移出业务体做法是抽出一个通知层业务体退化成纯函数内部不出现任何锁代码staticmodule_ret_err_esvc_cmd_around(handle_tself,cmd_body_tbody,constvoid*arg,uint32_tsize){if(RT_FALSEHANDLE_VALID(self)){returnMOD_INVALID_HANDLE;}/* Before */if(RT_TRUEpriv-txn_mutex_initialized){if(RT_EOK!rt_mutex_take(priv-txn_mutex,rt_tick_from_millisecond(TXN_LOCK_WAIT_MS))){returnMOD_DRV_BUSY;/* 取锁失败未持锁故不释放 */}heldRT_TRUE;}retbody(self,arg,size);/* 连接点业务体 */if(RT_TRUEheld){rt_mutex_release(priv-txn_mutex);}/* After无条件 */returnret;}哪些命令属于长事务由命令分发表的一个字段声明不在分发器里写if{NODE_TRIGGER_CALC,RT_TRUE,svc_set_trigger_calc},{NODE_START_AUTO,RT_TRUE,svc_set_start_auto},{NODE_STOP_AUTO,RT_TRUE,svc_set_stop_auto},{NODE_HOLD,RT_FALSE,svc_set_hold},新增命令时改一个字段即可锁的施加由分发层保证。测试也随之变成可静态检查的全文件只允许一个取锁点也就是这个通知层。工作线程改用零阻塞取锁通知层用的是带超时的take拿不到锁就等一会儿超时返回忙。但工作线程不能沿用同一套工作线程在 txn_mutex 上等待 → 最长要等 1 秒 → 期间收不到停止事件 → 停止命令超时报错所以工作线程走的是零阻塞的trytake变体拿不到就放弃本轮、下一个节拍重来是否周期到点trytake 成功?进入计算态锁跨本轮持有慢写最长 1 秒线程入口单一释放点放弃本轮把待办与许可置回下一个周期重来这两种用法合起来就是一句话同一把锁要区分两种获取语义命令侧可以等因为它有超时上限工作线程不能等它等掉的是自己的响应能力。把两者塞进同一个take封装会留下隐患。阻塞在事件模型里是语义问题上面的例子看起来是“耗时过长”但在事件驱动模型里它是个语义问题每一次阻塞返回都等价于一次新事件的到达线程从阻塞中醒来的那一刻等于收到“锁到手了”这个事件。问题不在于等了一秒而在于处理周期到点事件 ├─ 阻塞在锁上 ──────────────┐ │ │ ← 解锁的瞬间等价于锁到手事件的到达 └─ 继续处理 ←──────────────┘ 与周期到点这个事件的处理过程重叠还有一个直接后果线程在阻塞期间无法响应事件队列就是收不到停止指令截止时间也一并落空。所以不要在 RTC 步骤里阻塞取锁失败就退出本轮等下一个节拍重试。同样的道理也解释了这里为什么不需要周期定时器定时器回调再转一次唤醒等于在事件队列之外另开了一条路径直接用事件等待的超时表达节拍就够了。四、三个机制怎么配合三个机制管的是三层不同粒度的事从外到内依次收窄命令侧命令行 / 其他模块AOP 通知层取事务锁工作线程零阻塞取锁业务体无锁代码本轮计算体RMW 区间状态锁内读判改发RTC 区间派发标志覆盖状态发布 历史记录一次长事务命令走完这四层通知层取事务锁超时则整体拒绝不进入业务体→ 业务体执行数据重载与决策自身不含锁代码→ 决策内部进状态锁完成一次 RMW 并发布生效值 → 期间若派发状态事件RTC 标志保证这次派发不被打断。撤掉任一层外面的层都无法单独保证正确性没有 AOP两个命令可能同时做重载没有 RMW命令与工作线程的决策互相覆盖没有 RTC派发可能被中断回调打断。五、变化、代价与适用边界两处性质上的变化三个机制用上之后值得对照的不是一串数字而是两处性质上的变化。第一处是并发写的性质变了。状态字段的写入点从 4 处收到 1 处生效值的发布点也只剩一处而且发布之前必须过版本号比对重入防护从一个口头约定变成 1 个派发标志长事务锁从各分支各自取放变成 1 取 1 放由通知层统一。原来的写法里这些保护只能写在注释上靠后来的人自觉。第二处是等待从无界变成有界。唯一等外部事件的信号量从无限等待改成 1000 ms 上限并配三态握手隔离迟到响应停止操作原来靠延时轮询、超时了还报成功现在改成等确认事件超时就如实上报。付出的代价最直接的一笔是分层带来的间接新增一条命令要同时改表项和函数读代码也得在通知层与业务体之间来回跳。这部分成本只在新功能时出现不影响运行期但确实存在。AOP 从提出起就被指出同一个毛病横切逻辑抽离之后控制流变成“非局部”的单看一个业务函数你看不出它什么时候被动过手脚业界常见的批评正是“把逻辑分散并间接化反而更难推理”。所以 C 里只放最小的一层 around 通知一个函数、一个表字段就够了没必要搬来整套切面框架用多少层取决于收益能不能覆盖这份间接成本。另一笔体现在代码规模上但别把这些数字都算到三个机制头上。函数长度、命令分支条数、定时器调用次数变小主要来自另外两件事拆长函数、把分支改成查表。反过来状态锁的无限等待计数从 26 处增到 36 处因为临界区被切得更细、取放点更多这些都是短临界区等多久由临界区长度决定不会永久卡死。看代码时别只数个数要看等的是锁还是外部事件。适用边界与未走到的路径这套做法不是普适的。如果服务只有一个线程、状态只有两三个、也没有中断回调参与那这些机制省下的麻烦抵不过分层带来的间接不如不拆。它们是给“多入口 共享状态 中断参与”的场景准备的动手之前先确认自己在不在这个场景里。还有一条路径没在真机上走到迟到响应的隔离与到期恢复要硬件真的丢一次响应才会进入。眼下没法主动注入这个条件它只有源码级测试覆盖。写在这里是想提醒各条路径的测试覆盖并不均匀测试全绿不等于每条路都验证过。小结三个机制的成本都很低RMW 是一种编码纪律不新增字段RTC 是一个布尔量加两处顺序AOP 是一个通知层加一个表字段。它们改的是同一件事把“正确性靠人自觉”换成“正确性由结构保证”。状态只能有一个写入点就可以断言“所有写入都在这个函数里”判据与发布在同一临界区就不会发布过期结论锁的施加点只有一个就可以断言“全文件只允许一处取锁”。能断言也就能放进 CI 自动检查这比在注释里写一句“注意这里要加锁”可靠得多。参考Miro SamekKey Conceptsstate-machine.com活动对象模型与 RTC 的正式定义以及RTC 与抢占是两件事的反复澄清。Miro SamekA Crash Course in UML State Machinesembedded.comRTC 步骤的原子性与响应性由最长的 RTC 步骤决定。QP 框架文档中关于为什么阻塞与 RTC 不兼容的论证每一次阻塞返回都是一次后门事件到达。Execution Semantics – How Statecharts Run and Process Eventsstatecharts.onlineRTC 步骤的定义、单步与超步语义的取舍G. Kiczales et al.,Aspect-Oriented Programming, ECOOP 1997切面、连接点、通知、织入的原始定义。关于 AOP 可读性代价的讨论如 anuragdulapalli.com 的Aspect-Oriented Programming: is it useful?横切逻辑抽离后控制流非局部化。
返回列表