ROS 2多线程为什么会出现优先级反转?从Callback Group到Linux锁机制
在前面的文章中,我们已经沿着 ROS 2 的执行链路逐步向下分析。
从 Node 到 Callback,从 Topic 到 DDS,再到 QoS 和 Executor,ROS 2 一个看似简单的“消息到达、程序执行”的过程,实际上涉及多个层次:
ROS 2 Node
↓
Topic / Service / Action
↓
DDS / QoS
↓
Callback
↓
Callback Group
↓
Executor
↓
Thread
↓
Linux Scheduler
↓
CPU
当机器人只是运行一些普通任务时,这套机制通常不会显得特别复杂。
但当 ROS 2 开始承担机器人实时控制任务之后,一个新的问题就会逐渐暴露出来:
多个线程同时运行时,如果它们需要访问同一个资源怎么办?
例如:
IMU Callback
Joint Callback
Control Callback
都需要访问:
Robot State
为了避免数据被同时修改,开发者可能会使用:
Mutex
于是整个执行链路就变成:
Callback
↓
Thread
↓
Mutex
↓
Shared Resource
问题也随之出现。
如果一个低优先级线程拿着锁,而一个高优先级控制线程需要等待这把锁,会发生什么?
更麻烦的是,如果这时候又出现一个中优先级任务,高优先级任务可能反而被这个中优先级任务“间接阻塞”。
这就是实时操作系统中非常经典的:
优先级反转(Priority Inversion)
它并不是 ROS 2 独有的问题。
但是 ROS 2 的:
-
MultiThreadedExecutor;
-
Callback Group;
-
多线程 Callback;
-
共享状态;
-
锁;
-
实时控制任务;
组合起来之后,会让这个问题变得非常值得关注。
更重要的是:
优先级反转不是简单的“高优先级任务排在后面”这么简单,而是高优先级任务的执行权可能因为低优先级任务持有的资源被间接阻塞。
本文就从一个最简单的 Mutex 开始,一步一步解释 ROS 2 多线程系统中的优先级反转到底是怎么产生的,以及为什么进入机器人实时控制之后,开发者必须同时关注 ROS 2 执行模型和底层实时操作系统。
一、为什么ROS 2多线程需要锁?从一个最简单的机器人状态开始
假设我们有一个机器人控制节点:
RobotController
里面保存着机器人当前状态:
RobotState
├── position
├── velocity
├── acceleration
└── joint_state
与此同时,系统有三个 Callback:
IMU Callback
Joint Callback
Control Callback
可以理解成:
RobotController
│
┌────────────┼────────────┐
▼ ▼ ▼
IMU Callback Joint Callback Control Callback
│ │ │
└────────────┼────────────┘
▼
RobotState
假设:
IMU Callback
负责更新:
acceleration
而:
Joint Callback
负责更新:
joint_state
控制 Callback 则需要读取完整状态:
position
velocity
acceleration
joint_state
如果这些 Callback 同时运行,就可能出现数据竞争。
例如:
Thread 1 Thread 2
读取 position
修改 position
读取 velocity
修改 velocity
那么 Control Callback 读取到的数据可能并不是同一个时刻的完整状态。
甚至可能出现:
position = 新数据
velocity = 旧数据
acceleration = 新数据
joint_state = 旧数据
对于机器人控制来说,这种状态组合可能并不符合预期。
因此,开发者经常会使用 Mutex:
Control Callback
│
▼
mutex.lock()
│
▼
读取RobotState
│
▼
mutex.unlock()
更新数据的 Callback 同样需要:
Joint Callback
│
▼
mutex.lock()
│
▼
修改RobotState
│
▼
mutex.unlock()
这样就可以保证:
同一时刻只有一个线程进入受保护的临界区。
从程序正确性的角度看,这是非常常见的设计。
但从实时系统的角度看,问题才刚刚开始。
二、什么是优先级反转?一个简单例子就能看懂
假设现在系统中有三个线程:
High 高优先级
Medium 中优先级
Low 低优先级
它们分别负责:
High
→ 机器人实时控制
Medium
→ 图像处理
Low
→ 日志或后台数据处理
可以简单表示:
优先级:
High ★★★★★
Medium ★★★
Low ★
现在发生这样的事情。
首先:
Low Thread
获得了一个 Mutex:
Low
│
▼
Lock
│
▼
Shared Resource
此时 High Thread 到来了:
High
│
▼
尝试Lock
│
▼
等待Low释放
于是:
High
│
▼
Waiting
│
▼
Low
│
▼
持有Mutex
按正常理解:
High优先级更高,应该先运行。
但现在 High 运行不了。
因为它需要的资源被 Low 占用了。
到这里还不算最糟糕。
假设此时:
Medium Thread
开始运行。
由于 Medium 不需要这个 Mutex,它可以正常运行:
Medium
│
▼
持续运行
于是:
High
│
└── 等待Mutex
▲
│
Low
│
│ 持有Mutex
▼
被Medium挤压
结果就变成:
High
↓
等待Low
Low
↓
无法及时运行
↓
无法释放Mutex
Medium
↓
持续运行
于是出现一个非常反直觉的现象:
高优先级任务实际上被低优先级任务间接阻塞。
这就是优先级反转。
用时间线表示会更加清楚:
时间 →
────────────────────────────────────────────
Low [Lock──────────────Unlock]
↑
│
High [Wait────────────────Run]
↑
│
Medium [──────Run──────]
High 本身优先级最高。
但是由于 Low 持有它需要的资源,而 Medium 又不断占用 CPU,最终 High 反而迟迟无法执行。
这就是为什么实时系统中会特别关注:
优先级不是唯一决定任务实时性的因素。
资源依赖关系同样非常重要。
三、ROS 2里面优先级反转是怎么出现的?
现在把刚才的例子放回 ROS 2。
假设一个 Node:
RobotController
使用:
MultiThreadedExecutor
内部存在:
Control Callback
Sensor Callback
Logging Callback
对应线程可以抽象成:
Thread 1
└── Control Callback
Thread 2
└── Sensor Callback
Thread 3
└── Logging Callback
它们都需要访问:
RobotState
于是:
RobotState
▲
│
┌──────────┼──────────┐
│ │ │
│ │ │
Control Sensor Logging
Callback Callback Callback
│ │ │
▼ ▼ ▼
Thread 1 Thread 2 Thread 3
假设:
Control Thread
是高优先级实时线程。
而:
Logging Thread
是低优先级线程。
如果 Logging Callback 获得了 Mutex:
Logging
│
▼
mutex.lock()
│
▼
RobotState
此时 Control Callback 到达:
Control
│
▼
mutex.lock()
│
▼
Waiting
于是:
Control Thread
高优先级
│
▼
等待
│
▼
Logging Thread
低优先级
│
▼
持有Mutex
如果这时候系统还有:
Image Processing Thread
那么它就可能继续获得 CPU。
于是:
Control
↓
等待
Logging
↓
持锁
Image Processing
↓
运行
最终:
一个日志线程的锁,可能影响一个实时控制线程。
这就是 ROS 2 多线程系统中需要特别注意的地方。
四、Callback Group并不能自动解决优先级反转
这里很容易产生另一个误解。
有人可能会想:
ROS 2不是有 Callback Group 吗?把不同任务分到不同 Callback Group 不就可以了吗?
Callback Group 确实可以帮助开发者控制 Callback 之间的并发关系。
例如:
Group A
├── Control Callback
└── Safety Callback
Group B
├── Sensor Callback
└── Data Callback
通过:
Mutually Exclusive
Reentrant
可以定义一定的执行约束。
但是:
Callback Group解决的是 Callback 之间“能不能并发执行”等 ROS 2 层面的执行关系,并不会自动解决所有底层线程资源竞争问题。
例如:
Callback A
↓
Thread A
↓
Mutex
↓
Shared Resource
以及:
Callback B
↓
Thread B
↓
Mutex
↓
Shared Resource
只要两个线程最终访问同一个共享资源:
Shared Resource
就仍然存在同步和调度问题。
因此需要区分:
Callback Group
和:
Mutex / Scheduler / Thread Priority
它们属于不同层次。
可以理解成:
ROS 2层
────────────────────
Callback
Callback Group
Executor
────────────────────
线程层
────────────────────
Thread
Mutex
Condition Variable
────────────────────
Linux内核层
────────────────────
Scheduler
Priority
CPU Affinity
IRQ
CPU
真正的实时系统,需要把这些层次放在一起考虑。
五、为什么“加一把锁”有时候反而会让实时性变差?
从软件工程角度来说:
加锁
往往意味着:
保证数据安全。
但从实时系统角度来看:
加锁
还意味着:
引入了一段不可忽略的等待关系。
例如:
Control Thread
│
▼
Lock
│
▼
Shared Data
│
▼
Unlock
如果临界区非常短:
10μs
问题可能不大。
但如果临界区里面做了大量工作:
Lock
↓
读取数据
↓
计算
↓
日志
↓
内存分配
↓
Unlock
那么锁持有时间就可能明显增加。
这时候:
Control Thread
│
▼
Waiting
│
│
└──────> 临界区执行
实时任务的延迟就会被放大。
因此实时程序设计中经常强调:
临界区应该尽可能短。
尤其要避免:
持锁
↓
复杂计算
↓
I/O
↓
日志
↓
网络操作
↓
释放锁
这样的设计。
更加合理的思路往往是:
Lock
↓
快速读取/更新共享数据
↓
Unlock
↓
在锁外执行复杂计算
例如:
lock()
↓
copy state
↓
unlock()
calculate(state)
这样可以减少其他线程等待 Mutex 的时间。
六、优先级继承:为什么实时系统需要特殊的锁机制?
针对优先级反转问题,实时系统中有一种经典解决思路:
Priority Inheritance,优先级继承。
还是刚才的例子:
High
│
▼
等待Mutex
▲
│
Low
│
▼
持有Mutex
如果系统支持优先级继承,可以理解成:
当 High 等待 Low 持有的锁时,Low 临时继承 High 的优先级。
于是:
Low
原优先级:低
↓
继承High优先级
↓
Low获得更高调度优先级
↓
尽快完成临界区
↓
释放Mutex
↓
High继续执行
可以表示成:
High
│
▼
等待Mutex
│
▼
Low
│
└── 临时获得High优先级
│
▼
尽快执行
│
▼
Unlock
│
▼
High运行
这样就可以降低中间的中优先级任务对 Low 的干扰。
注意:
优先级继承并不是让低优先级任务永久变成高优先级任务。
它是一种针对资源竞争场景的调度机制。
当资源释放之后,任务会恢复原来的优先级状态。
实时系统中还有其他处理优先级反转的方法,例如优先级上限协议等,但核心思想都是:
不能让高优先级实时任务因为低优先级任务持有资源而出现不可控的长时间阻塞。
七、为什么实时系统更关心“最坏情况”,而不是平均情况?
这是理解优先级反转最重要的一步。
假设某机器人控制系统平时的执行时间是:
0.8ms
0.9ms
1.0ms
0.8ms
0.9ms
看起来非常稳定。
但是某一次:
15ms
那么平均值可能依然不算特别夸张。
假设平均:
≈ 1ms
开发者可能会说:
系统平均响应时间很好。
但对于一个要求:
1ms控制周期
的系统来说:
15ms
可能比:
平均1ms
更加重要。
因为实时系统关注的是:
Worst Case。
也就是:
最坏情况下,任务到底可能等待多久?
而优先级反转最危险的地方就在于:
如果资源等待时间不可控,那么最坏情况延迟也可能变得难以确定。
可以理解成:
正常情况:
Control
↓
Lock
↓
1μs
↓
Unlock
但异常情况:
Control
↓
Lock
↓
等待
↓
低优先级线程持锁
↓
中优先级任务运行
↓
其他任务运行
↓
IRQ
↓
继续等待
↓
Unlock
于是:
1μs
可能突然变成:
1ms
5ms
10ms
这就是实时控制真正害怕的:
不可预测延迟。
八、ROS 2实时控制为什么不能只看Executor?
到这里可以重新理解上一篇文章中的 Executor。
Executor 的作用是:
发现Ready Callback
↓
组织Callback执行
但它下面还有:
Thread
↓
Mutex
↓
Scheduler
↓
CPU
因此,一个 ROS 2 Callback 的实际执行延迟可以粗略拆成:
总延迟
=
通信延迟
+
Executor等待
+
线程调度等待
+
资源竞争
+
锁等待
+
CPU干扰
+
Callback自身执行时间
而优先级反转主要发生在:
资源竞争
+
锁等待
+
线程调度
这些环节。
因此,如果机器人出现:
控制周期偶发超时
不能简单地说:
“ROS 2 Executor有问题。”
也不能简单地说:
“DDS延迟了。”
真正应该做的是把整条链路拆开分析。
例如:
T0:传感器数据产生
↓
T1:DDS收到消息
↓
T2:Callback Ready
↓
T3:Executor发现Callback
↓
T4:线程Ready
↓
T5:线程获得CPU
↓
T6:尝试获取Mutex
↓
T7:Mutex获得
↓
T8:Callback执行
↓
T9:控制输出
每一个时间点都可以成为性能分析的对象。
只有这样,才能知道:
到底是通信慢,还是 Executor 慢,还是线程调度慢,还是锁竞争慢。
九、如何降低ROS 2多线程系统中的优先级反转风险?
实际工程中,可以从几个方向考虑。
第一,减少不必要的共享资源。
如果多个线程都需要:
SharedState
就容易产生锁竞争。
可以考虑:
线程A → 独立数据
线程B → 独立数据
线程C → 独立数据
通过消息传递或者其他机制降低共享内存竞争。
第二,缩短临界区。
尽量避免:
lock()
复杂计算
日志
I/O
网络操作
unlock()
更合理的是:
lock()
快速读取
unlock()
复杂计算
第三,避免实时线程承担非实时任务。
例如:
Control Thread
尽量不要同时负责:
日志
文件写入
大量动态内存操作
复杂图像处理
因为这些任务的执行时间和资源需求更加不可控。
可以考虑:
实时任务
│
├── 控制
└── 状态更新
非实时任务
│
├── 日志
├── 数据记录
└── 可视化
形成更加明确的任务边界。
第四,合理设置线程优先级。
实时控制任务通常应该与普通后台任务进行区分。
例如:
Control 高
State Est. 较高
Planning 中
Vision 中
Logging 低
但这里必须强调:
优先级设计不能脱离资源依赖关系。
如果:
High Control
依赖:
Low Logging
持有的锁,那么单纯提高 Control 优先级并不能解决问题。
第五,结合操作系统的实时同步机制。
如果系统确实存在严格实时需求,就需要进一步考虑操作系统层面的:
-
实时线程;
-
优先级调度;
-
优先级继承;
-
CPU 亲和性;
-
核心隔离;
-
IRQ 隔离;
-
内核实时性;
-
资源隔离。
这已经超出了 ROS 2 API 本身能够解决的范围。
十、从ROS 2 Callback到实时操作系统:真正的实时性是一整条链
现在重新看整个系统:
ROS 2 Application
│
┌────────────┼────────────┐
▼ ▼ ▼
Sensor Planner Control
Callback Callback Callback
│ │ │
└────────────┼────────────┘
▼
Callback Group
│
▼
Executor
│
▼
Thread
│
┌──────┴──────┐
▼ ▼
Mutex Scheduler
│ │
└──────┬──────┘
▼
CPU
这张图实际上解释了一个很重要的问题:
机器人实时性从来不是单一模块决定的。
它是多个层次共同决定的。
ROS 2 负责:
Node
Topic
DDS
QoS
Callback
Executor
Linux 负责:
Thread
Scheduler
Priority
Memory
IRQ
CPU
如果需要更高的实时确定性,就需要继续关注:
实时调度
核心隔离
IRQ隔离
资源隔离
优先级管理
对于这类对实时性、确定性和资源隔离要求较高的机器人控制系统,望获rtLinux可以作为底层实时操作系统的一种技术选择。
其核心价值并不是简单地:
“把 ROS 2 换成另一个系统。”
而是让:
ROS 2
↓
Executor
↓
实时线程
↓
实时调度
↓
核心隔离
↓
CPU
形成更加清晰的实时执行链路。
例如可以将实时控制任务与普通任务进行资源划分:
CPU Core 0
├── ROS 2 Control
└── Real-Time Tasks
CPU Core 1
├── Sensor Processing
└── State Estimation
CPU Core 2
├── Planning
└── Vision
CPU Core 3
├── Logging
├── UI
└── Background Tasks
再结合:
CPU Affinity
IRQ Affinity
Core Isolation
Thread Priority
减少不同任务之间的资源干扰。
这类设计的最终目标不是让系统“跑得更快”这么简单,而是:
让关键任务的执行时间更加可预测。
十一、优先级反转告诉我们:ROS 2实时性不能只靠“把优先级调高”
到这里,我们可以总结出几个非常重要的认识。
第一:
高优先级 ≠ 一定能够立即运行。
如果它等待某个低优先级任务持有的资源,它仍然可能被阻塞。
第二:
MultiThreadedExecutor ≠ 自动实时。
多线程可以提高并发能力,但也会带来:
锁竞争
数据竞争
资源竞争
调度竞争
第三:
Callback Group ≠ 实时调度器。
Callback Group 可以帮助组织 Callback 的并发关系,但真正的线程调度依然要落到操作系统。
第四:
Mutex解决数据一致性,但也可能引入实时延迟。
因此实时系统需要关注:
锁持有时间
锁等待时间
优先级继承
资源依赖关系
第五,也是最重要的一点:
实时系统真正关注的是最坏情况。
平均:
1ms
并不能说明系统一定满足:
1ms实时周期
如果偶尔出现:
20ms
那么这个 20ms 才可能是真正需要解决的问题。
十二、从优先级反转继续往下:CPU核心隔离为什么越来越重要?
我们已经从:
Node
↓
Callback
↓
Executor
↓
Thread
↓
Mutex
↓
Priority
一路分析到了优先级反转。
但还有一个更加底层的问题:
即使没有锁竞争,实时线程和其他任务共享同一个 CPU 核,会发生什么?
假设:
CPU Core 3
同时运行:
Control Thread
Vision Thread
Network Thread
Logging Thread
IRQ
Kernel Task
那么控制任务依然可能受到:
任务调度
中断
内核线程
Cache竞争
CPU资源竞争
等因素影响。
于是问题就继续向下延伸:
ROS 2
↓
Executor
↓
Thread
↓
Scheduler
↓
CPU
如果希望让关键控制任务拥有更加独立、可预测的执行环境,就需要开始研究:
CPU核心隔离(CPU Core Isolation)。
为什么一个机器人控制任务需要“独占”或者尽可能减少其他任务干扰的 CPU 核?
CPU Affinity 和 CPU Isolation 到底有什么区别?
IRQ 为什么也需要隔离?
以及:
为什么“给实时任务绑定一个 CPU 核”并不等于真正实现了核心隔离?
这将是下一篇非常自然的技术延伸:
《ROS 2实时控制为什么需要CPU核心隔离?从机器人控制周期看Linux实时优化》
下一篇,我们将把:
ROS 2
↓
Executor
↓
实时线程
↓
CPU Affinity
↓
IRQ Affinity
↓
Core Isolation
↓
实时Linux
完整串起来,进一步解释为什么机器人从“能运行 ROS 2”走向“稳定运行实时控制”,最终必须开始关注操作系统和 CPU 资源隔离。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)