本节摘要:信号量与互斥量形似而神异——信号量发信号(中断可给不可取),互斥量占资源(带优先级继承,仅任务可用)。混用它们会引出并发世界最著名的故障:优先级反转,高优先级任务被低优先级任务持有的锁卡住,再被中间优先级无限加时。本节以一张"电机控制任务偶发超期"的工单为主线,还原反转的完整时序、演示优先级继承如何收敛它、划出互斥量的使用铁律,最后给出三类原语的选型表。这是全册分量最重的一张工单。
上一节末尾留的问题在本节开场就爆发。先给结论:多数工程师第一次听说优先级反转,都是在一次深夜排障之后;而航天史上最著名的一次(某火星探测器反复重启),病根也是它。理解这张工单,你对"锁"的认识就跨过了分水岭。
背景。某运动控制器:电机闭环任务(优先级四,周期一毫秒,截止严格)、参数管理任务(优先级二,偶发运行,操作共享参数区)、通信任务(优先级三,处理上位机指令)。共享参数区用一个"二值信号量"保护——这是团队从前后台时代延续下来的习惯写法。
现象。联调一切正常,带载测试时电机偶发"滋"的一声抖动,驱动器报瞬时过流。逻辑分析仪抓控制周期:绝大多数一毫秒内完成,偶发拉长到十几毫秒——恰好错过换相窗口。初步研判:过流是果,控制周期被拉长是因。谁拉长的?高优先级任务被拉长只有两种可能:被更高优先级抢占(没有)、被阻塞。查阻塞点:控制任务在参数区信号量上等过——等谁?等优先级二的参数任务放锁。可是参数任务明明比通信任务(优先级三)低,它拿着锁的期间被通信任务抢走了处理器——于是出现了一个荒诞的链条:优先级四的任务,实际被优先级三的任务挡住了路。病名确诊:优先级反转。
把三个任务的执行画在时间轴上,事故一目了然。低优先级任务持锁期间,高优先级任务来取锁被挂起;中优先级任务既不取锁也不放锁,但它优先级高于持锁者,理直气壮地反复抢占——锁的归还被无限期推迟,高优先级任务的等待时间不再取决于临界区长度,而取决于中优先级任务想跑多久。

修复。两步走。第一步,把保护参数区的二值信号量换成互斥量——互斥量自带优先级继承:高优先级任务等锁时,持锁的低优先级任务被临时抬升到与等待者同级,中优先级任务再也插不进队,锁的归还只隔一段临界区。第二步,审视临界区本身:参数区操作原本包含一段冗长的校验计算,把它移出锁外(先拷贝出来算,算完拿着锁只做快交换),临界区从毫秒级缩到微秒级。结果:控制周期最大值回落到一毫秒出头,带载测试过流消失,工单关闭。
解读。这次修复的两步各有寓意:换互斥量治的是"等待时间不可控"(调度层的病),缩小临界区治的是"等待时间太长"(设计层的病)。只做第一步,问题缓解但不除根——继承把阻塞压到了一段临界区,临界区本身太长照样超期。排障口诀:反转先换锁,锁外缩区间。
两者接口几乎一样(取与给),语义天差地别,一张表划清边界:
| 原语 | 语义 | 谁能用 | 优先级继承 | 典型用途 |
|---|---|---|---|---|
| 二值信号量 | 一个可重复填装的标志 | 任务给取、中断只能给 | 无 | 中断到任务的同步、任务间事件通知 |
| 计数信号量 | 一叠通行证 | 任务给取、中断只能给 | 无 | 资源池计数、事件计数 |
| 互斥量 | 归属权凭证,谁取谁必还 | 仅任务 | 有 | 保护共享资源、外设独占 |
| 递归互斥量 | 归属权凭证可叠加 | 仅任务 | 有 | 同任务多层函数都要锁同一段资源 |
**记忆锚点:信号量是"事件",互斥量是"所有权"。**事件的通知方与响应方可以素不相识(中断给、任务取),所有权则必须成对出现在同一个执行流里(谁取谁给,取了不给就是事故)。由此推出全部用法规则:中断里可以给信号量(播报事件),绝不能取任何东西(中断没有"稍后归还"的概念);互斥量进出中断双双禁止;互斥量的取与给必须严格配对,且给出时的"优先级还原"依赖配对成立。
团队工单里的原始写法错在哪?用二值信号量当锁——语义上它也能"挡住别人",但没有继承,反转无解。这是从其他系统带来的肌肉记忆:有些内核不区分两者。FreeRTOS 把选择权交给你,也把用错的责任交给你。
四条铁律,条条来自真实事故。其一,持有时间最小化:锁内只做必须原子的事,计算与输入输出全部外移——本单工单的第二步修复就是范例。其二,不成组嵌套:任务甲拿锁一再去拿锁二,任务乙拿锁二再去拿锁一,经典死锁成立。必须嵌套时全局约定加锁顺序(比如按地址升序),并写进团队规范。其三,不在持锁时阻塞:持锁等别的资源,等于把"锁的等待时间"与"那个资源的等待时间"串联,继承也救不了变长的下半段。其四,递归场景用递归互斥量:同任务重入取普通互斥量会自锁(自己等自己放的锁),递归版本记账式放行,但要保证给次数与取次数一致。
剩余风险要点名两条。继承不防死锁:两个互斥量交叉等待时,继承只会让双方互相抬升、谁也不让谁。继承不防链式反转:甲等乙的锁、乙等丙的锁,继承沿链传递的能力有限,多层等待结构要靠设计避免——原则是"锁的等待链不许超过一层"。
💡 一个实用的设计倾向:能用"队列传数据"解决的,就别用"锁护共享"。队列把竞争转化为流水线,天然无死锁无反转;锁只留给无法数据化的场合(硬件寄存器必须就地操作、共享缓冲必须原地读写)。上一节的采集通路整条无锁,正是这个倾向的体现。
变式一:中断参与同步。若事件源是中断(如编码器脉冲到达),用二值信号量或任务通知从中断里"给",任务里"取"——注意方向不可反:中断永远只给不取。变式二:资源池计数。若有三块可复用缓冲,创建初值为三的计数信号量,任务取走一块减一、用完归还加一——取不到就睡,不空转。变式三:多读一写的参数区。读多写少的场景,互斥量还能再优化:写时复制加指针原子交换(读者拿指针、写者改副本、临界区只换指针),读端零锁。三个变式对应"同步、计数、读写不对称"三类需求,原语选型表在第 8 章的坑点清单里还会复用。
锁的故事讲完,剩下两类轻量场景——多方对齐与一对一通知——各有一个专用原语,开销远低于队列与信号量。下一节看这两板斧。