一个 10 Hz 定时器,单独运行时很准;加上图像订阅后,控制回调偶尔晚几百毫秒。很多人第一反应是换成 MultiThreadedExecutor。但 Executor 只是从等待集中取出“已经准备好的回调”,回调组、线程数、互斥锁和回调本身的执行时间共同决定谁会迟到。
这篇先把 ROS 2 的基本调度模型搭起来,再看单线程、多线程和回调组的差别。它不是实时调度教程,也不会因为换了 Executor 就自动获得优先级保证。
单线程 Executor 先把顺序看懂
SingleThreadedExecutor 只有一个线程取回调。一个图像回调里做 100 ms 的推理,期间同一个 Executor 里的定时器和状态发布都只能等。这个行为很容易复现,也适合入门时观察回调之间的阻塞关系。
1 | rclcpp::executors::SingleThreadedExecutor executor; |
命令行可以先看节点有哪些订阅、定时器和服务:
1 | ros2 node info /controller |
不要用 topic hz 的平均频率推断定时器准时。还需要在回调入口和出口记录稳态时间,并区分“消息到达后等待多久”和“回调自己执行多久”。
多线程不会自动消除锁等待
MultiThreadedExecutor 可以让多个线程同时取回调:
1 | rclcpp::executors::MultiThreadedExecutor executor( |
如果所有实体都在默认的互斥回调组里,很多回调仍然不能并行。如果把共享状态放到可重入组,却没有保护数据,问题会从“迟到”变成数据竞争。先画出数据所有权,再决定线程数,通常比直接把线程数调大更快找到根因。
回调组是并发边界,不是优先级
一个节点可以把传感器回调和控制定时器放在互斥组,把只读诊断放在可重入组。互斥组能避免同组回调同时修改状态,但它也会让一个慢回调挡住同组其他工作。可重入组允许并发进入,内部成员、缓存和消息生命周期要自己保护。
可以做一个简单的分组记录:
| 实体 | 组类型 | 共享资源 | 需要观察 |
|---|---|---|---|
| 控制定时器 | 互斥 | 当前目标、输出命令 | 执行时间和等待时间 |
| 图像订阅 | 可重入或独立组 | 图像缓存 | 队列长度和结果年龄 |
| 参数服务 | 互斥 | 配置快照 | 是否阻塞控制回调 |
| 诊断定时器 | 可重入 | 只读快照 | 是否抢占过多 CPU |
表格只是设计起点,最终要根据回调实际访问的数据来分组。
把阻塞工作移出控制回调
文件 I/O、网络请求、模型推理和长时间锁等待都不适合直接塞进周期控制回调。常见做法是让订阅回调把输入复制或转移到有界队列,后台线程完成重活,控制定时器只读取带时间戳的最新快照。队列满时要明确丢旧数据还是拒绝新数据,不能无限增长。
1 | sensor callback -> bounded queue -> worker/inference |
这种结构仍然有等待和数据年龄,优点是可以把它们测出来。Executor 只负责调度回调,不负责替你决定旧结果是否还能用于控制。
验证时把回调时间线记下来
每个回调至少记录实体名、入口时间、出口时间、线程 ID、源消息时间戳和序号。用 ros2_tracing 或结构化日志把它们合并,再看定时器周期、回调等待和队列年龄。若只打印“回调执行完成”,无法知道它是在入口前等了,还是执行中被锁挡了。
确认是 Linux 调度和迁移造成的尖峰后,再做线程绑核实验;需要验证周期漂移和 deadline miss 时,用绝对唤醒控制循环建立系统基线。三篇测的是不同层次,不应把 MultiThreadedExecutor 的效果写成实时调度保证。
参考资料
证据边界:Executor 和回调组的行为受 ROS 2 发行版、RMW 实现、线程调度和回调代码影响。本文没有给出实时优先级或固定延迟保证;具体结论需要在目标节点、QoS 和负载下测量。