Skip to content

03 实时性与任务调度 ​

位置:08-ros / 03_高级话题 / 03_实时性与任务调度.md难度:⭐(高 —— 入门门槛高,面向进阶读者) 前置知识:Linux 线程/调度基础、C++ 内存管理、02_线程模型与Executor深度解析。 交叉引用:模块二 · 核心模块、04_组件化与进程内通信。


1. 概述 ​

ROS 2 常被说成"real-time capable",但它不是实时操作系统。真实含义是:配合合适的 Linux 内核(PREEMPT_RT)、调度策略与编程纪律,ROS 2 可以做到有界的延迟(bounded latency)。本文讲清"实时"在 ROS 2 语境下的三层含义与落地手段:

  1. 定位:ROS 2 只提供"可被用于实时"的接口,不承诺实时。
  2. 手段:OS 层(PREEMPT_RT + SCHED_FIFO + 隔离核)+ 编程纪律(禁 malloc/例外/IO)+ 实时执行器。
  3. 度量:cyclictest / rdtsc 打点,以及 DDS 层对延迟的贡献分解。

2. 核心概念 ​

2.1 "Real-time capable" 的精确含义 ​

实时 = 延迟有上界(worst-case latency 可预测),不是"快"。ROS 2 的定位是:提供不破坏实时性的 API(预分配接口、无锁 waitset、可禁日志),但不保证默认配置下实时。

2.2 RT 线程纪律(核心) ​

RT 线程内禁止可能"无界阻塞或分配"的操作:

  • 禁 malloc/new(堆分配可能触发缺页、锁竞争、系统调用)。
  • 禁 C++ 异常抛出路径(展开栈可能分配)。
  • 禁阻塞 IO / printf/std::cout(stdout 可能阻塞、也可能走锁)。
  • 禁无界循环、动态容器扩容。
  • 用预分配环形缓冲传递数据,用优先级继承互斥锁(PTHREAD_PRIO_INHERIT)避免优先级反转。

2.3 OS 层机制 ​

  • PREEMPT_RT:给 Linux 内核打实时补丁,把大部分不可抢占区间改成可抢占,降低调度延迟。
  • SCHED_FIFO / SCHED_RR:实时调度策略,配合 chrt/sched_setscheduler 提升线程优先级。
  • cpu_isolate / isolcpus / cpuset:把某些 CPU 从通用调度中隔离出来,专供实时线程。

2.4 实时执行器 ​

rclcpp 主 API 不承诺实时;社区提供两类替代:

  • rclcpp::experimental::executors::EventsExecutor:事件驱动(而非每轮扫描),面向实时。
  • StaticSingleThreadedExecutor:实体集合编译期/启动时固定,省运行时分配。
  • rclc(rclc_executor):面向微控制器的 C 执行器,明确以实时为目标。

说明:这些执行器是否满足你的 worst-case 要求,须用 cyclictest + 应用打点实测,不能只看文档声称。


3. 技术原理(架构 / 源码级) ​

3.1 为什么默认 executor 不实时 ​

SingleThreadedExecutor 每轮循环涉及:

  1. rcl_wait:底层 DDS WaitSet::wait() 的唤醒延迟取决于 DDS 实现与系统调度延迟(RT 内核下可压到微秒级,普通内核下毫秒级抖动)。
  2. rcl_take:Fast DDS take_next_sample 会反序列化到 ROS message,可能触发堆分配(取决于类型是否固定大小、是否有预分配 allocation)。
  3. 回调内若用户代码 new/std::string/日志,直接破坏有界性。

EventsExecutor 的改进在于:用事件队列 + 预分配 Executable 对象,减少每轮动态行为。但它仍是 experimental 命名空间,API 可能变动。

3.2 数据流与内存分配分析(实时视角) ​

以"传感器回调 → 处理 → 发布"为例:

  • 理想实时路径:数据进入预分配环形缓冲(无锁 SPSC)→ RT 线程唤醒(guard condition 触发,O(1)、不分配)→ 处理(纯栈 + 预分配对象)→ 发布。
  • 破坏点定位:任何 std::vector::push_back(扩容)、std::string 赋值、rclcpp::Clock::now() 之外的日志宏、std::shared_ptr 拷贝(原子自增)都可能引入分配或锁。
  • 跨进程链路:序列化 1 次拷贝 + SHM 拷贝 + 反序列化 1 次拷贝(见 04)。RT 敏感时优先用 intra-process + 唯一所有权指针 跳过序列化。

3.3 OS 层延迟构成 ​

环节典型量级(待验证,随硬件/内核版本差异巨大)手段
调度延迟(普通内核)毫秒级抖动换 PREEMPT_RT
调度延迟(PREEMPT_RT)微秒~数十微秒SCHED_FIFO + 优先级设计
中断处理微秒级隔离核 + IRQ affinity
缺页(首次访问内存)可能数百微秒~毫秒mlockall 锁内存
DDS 序列化+传输微秒~毫秒intra-process / SHM

⚠️ 上表数字仅示意数量级,必须待验证(不同 CPU、内核、DDS 版本差异极大);切勿直接引用作为性能结论。

3.4 C++ 侧破坏 RT 的 API 与替代 ​

破坏 RT替代
new / delete预分配对象池、placement new、环形缓冲
std::string 拼接/赋值固定长度 char[N]、预分配 string + resize 不缩容
std::vector/std::map 动态增长预分配 + reserve、固定数组
std::cout/printf无锁日志环、关日志(RCUTILS_LOG_* 关闭)
普通 std::mutex优先级继承 mutex(PTHREAD_PRIO_INHERIT)
异常抛出编译 -fno-exceptions(部分 ROS 库不支持,需验证)
std::shared_ptr 高频拷贝栈对象 / unique_ptr / raw 引用

3.5 DDS 层延迟贡献分解 ​

  • 序列化:CDR 编码为固定 CPU 开销,随消息大小线性增长。
  • 拷贝:RELIABLE QoS 会多一次历史缓存写入/读取。
  • 发现/握手:首次通信有秒级发现延迟(不影响稳态,但影响首包)。
  • 网络栈:UDP 下的 socket 缓冲、分片重组;SHM 可省网络栈(见 06 的共享内存对比)。
  • 可靠性:RELIABLE 需 ACK/NACK,BEST_EFFORT 延迟更低但丢包;实时控制常用 BEST_EFFORT + 高频率刷新。

4. 实践指南 ​

4.1 内核与调度配置(bash) ​

bash
# 1) 确认是否 PREEMPT_RT
uname -r
grep PREEMPT_RT /boot/config-$(uname -r)   # 期待 CONFIG_PREEMPT_RT=y

# 2) 隔离核(启动参数 isolcpus=2,3;运行时也可用 cpuset)
sudo sh -c 'echo 2-3 > /sys/fs/cgroup/cpuset/rt/cpuset.cpus'

# 3) 用实时优先级运行节点(SCHED_FIFO,优先级 80)
sudo chrt -f 80 ros2 run my_pkg my_node

# 4) 锁住内存,避免缺页(进程内 mlockall,或外部)
sudo setcap cap_ipc_lock+ep $(which ros2)   # 授权 mlock

4.2 进程内设置 SCHED_FIFO + mlockall(C++) ​

cpp
#include <pthread.h>
#include <sched.h>
#include <sys/mman.h>

void pin_and_prioritize(int cpu, int priority) {
  cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(cpu, &cpuset);
  sched_setaffinity(0, sizeof(cpuset), &cpuset);

  struct sched_param sp{};
  sp.sched_priority = priority;   // 1~99,越大越高
  // SCHED_FIFO;带 PTHREAD_EXPLICIT_SCHED 时也需 pthread_setschedparam
  sched_setscheduler(0, SCHED_FIFO, &sp);

  mlockall(MCL_CURRENT | MCL_FUTURE);   // 锁物理内存,防缺页
}

4.3 预分配环形缓冲(无锁 SPSC 示意) ​

cpp
// 示例代码,需根据实际框架调整;固定大小 + 原子索引,无分配
template <typename T, size_t N>
struct SPSC {
  std::array<T, N> buf_{};
  std::atomic<size_t> head_{0}, tail_{0};
  bool push(const T& v) {
    size_t t = tail_.load(std::memory_order_relaxed);
    size_t next = (t + 1) % N;
    if (next == head_.load(std::memory_order_acquire)) return false; // 满
    buf_[t] = v;
    tail_.store(next, std::memory_order_release);
    return true;
  }
};

4.4 cyclictest 测量调度延迟 ​

bash
# 在隔离核上测调度延迟:-p 优先级 -a 亲和核 -m 锁内存 -l 循环 -i 间隔 -h 输出直方图
sudo cyclictest -p 90 -a 2 -m -l 1000000 -i 200 -h 500 -q > hist.txt
# 关注 Max Latency 与直方图尾部分布;目标:worst-case 在可接受上界内

4.5 应用内 rdtsc 打点(测端到端延迟) ​

cpp
#include <x86intrin.h>
inline uint64_t rdtsc() { return __rdtsc(); }
uint64_t t0 = rdtsc();
do_work();
uint64_t dt = rdtsc() - t0;   // 换算:dt / 主频(GHz) ≈ 纳秒

5. 方案对比 ​

方案实时性复杂度适用
默认 SingleThreadedExecutor + 普通内核无保证低非实时
StaticSingleThreaded / EventsExecutor + PREEMPT_RT有界(需实测)中实时节点
rclc executor(C)面向微控制器中嵌入式
自定义无锁线程 + 环形缓冲(绕过 executor)可控最强高极致实时

绝对不适用场景:把同一个实时 executor 同时用于控制回路(10kHz、微秒级要求)和日志/调试/大文件 IO 回调——一次 printf 或 malloc 就能把整个 executor 的 worst-case 拖到毫秒级,实时性名存实亡。实时路径必须物理隔离(独立线程/独立核/独立执行器)。


6. 工具链 ​

  • cyclictest(rt-tests 包):测调度延迟金标准。文档:https://manpages.ubuntu.com/manpages/focal/en/man8/cyclictest.8.html
  • chrt / taskset / numactl:调度与亲和配置。
  • perf sched / ftrace / trace-cmd:观测调度与抢占事件。
  • rdtsc / chrono::steady_clock 打点:应用内延迟测量。
  • stress-ng:制造负载,验证抗干扰。
  • ros2 trace + LTTng:端到端链路级延迟分析(Tracepoints)。

7. 参考资料 ​


8. 学习路径 ​

  1. 用 cyclictest 分别在普通内核与 PREEMPT_RT 上测,直观感受调度延迟差异。
  2. 写一个 SCHED_FIFO + isolcpus + mlockall 的 demo 节点,用 chrt/htop 验证亲和与优先级。
  3. 在回调里故意加 std::cout,用打点观察 worst-case 劣化,理解"纪律"的含义。
  4. 尝试 EventsExecutor/StaticSingleThreadedExecutor,对比默认执行器。
  5. 结合 04_组件化与进程内通信 用 intra-process 消除序列化延迟。

9. 核心面试三问 ​

  1. "ROS 2 是实时的"这句话对吗?"real-time capable" 到底承诺了什么? → 不对。ROS 2 只是"可被用于实时":提供预分配接口与无锁 waitset,但不保证默认配置实时。实时 = 延迟有上界,需 PREEMPT_RT + SCHED_FIFO + 编程纪律 + 实测才成立。

  2. RT 线程里为什么禁止 malloc 和 printf?它们的"无界"体现在哪? → malloc 可能触发缺页、堆锁竞争、甚至系统调用(brk/mmap),延迟无上界;printf/std::cout 走 stdout 可能阻塞在 IO 或内部锁上。二者都让 worst-case 不可预测,故用预分配缓冲与无锁日志环替代。

  3. DDS 的 RELIABLE 和 BEST_EFFORT 对端到端延迟的影响差异是什么? → RELIABLE 有 ACK/NACK 与重传握手,首包/丢包重传会引入额外延迟与抖动;BEST_EFFORT 无确认,延迟更低更稳定但会丢数据。实时控制常用 BEST_EFFORT + 高频刷新来掩盖丢包。