ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ROS 2多线程为什么会出现优先级反转?从Callback Group到Linux锁机制

ROS 2多线程为什么会出现优先级反转?从Callback Group到Linux锁机制 在前面的文章中我们已经沿着 ROS 2 的执行链路逐步向下分析。从 Node 到 Callback从 Topic 到 DDS再到 QoS 和 ExecutorROS 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 的MultiThreadedExecutorCallback Group多线程 Callback共享状态锁实时控制任务组合起来之后会让这个问题变得非常值得关注。更重要的是优先级反转不是简单的“高优先级任务排在后面”这么简单而是高优先级任务的执行权可能因为低优先级任务持有的资源被间接阻塞。本文就从一个最简单的 Mutex 开始一步一步解释 ROS 2 多线程系统中的优先级反转到底是怎么产生的以及为什么进入机器人实时控制之后开发者必须同时关注 ROS 2 执行模型和底层实时操作系统。一、为什么ROS 2多线程需要锁从一个最简单的机器人状态开始假设我们有一个机器人控制节点RobotController里面保存着机器人当前状态RobotState ├── position ├── velocity ├── acceleration └── joint_state与此同时系统有三个 CallbackIMU 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 旧数据对于机器人控制来说这种状态组合可能并不符合预期。因此开发者经常会使用 MutexControl 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获得了一个 MutexLow │ ▼ 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。假设一个 NodeRobotController使用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 获得了 MutexLogging │ ▼ 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传感器数据产生 ↓ T1DDS收到消息 ↓ T2Callback Ready ↓ T3Executor发现Callback ↓ T4线程Ready ↓ T5线程获得CPU ↓ T6尝试获取Mutex ↓ T7Mutex获得 ↓ T8Callback执行 ↓ 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 ExecutorLinux 负责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 资源隔离。
返回列表