Skip to content

关于 Executor

ROS 2 中的执行管理由 Executor 处理。 Executor 借助底层操作系统的一个或多个线程,在收到消息和事件时调用 subscription(订阅)、timer(定时器)、服务端、Action 服务端等的回调函数。 显式的 Executor 类(rclcpp 中的 executor.hpp、rclpy 中的 executors.py,或 rclc 中的 executor.h)尽管基本 API 与 ROS 1 中的 spin 机制非常相似,但提供了更精细的执行管理能力。

下文将重点介绍 C++ 客户端库 rclcpp。

在最简单的情况下,主线程通过调用 rclcpp::spin(..) 来处理 Node 的传入消息和事件,如下所示:

int main(int argc, char* argv[])
{
// 一些初始化。
rclcpp::init(argc, argv);
...
// 实例化一个 node。
rclcpp::Node::SharedPtr node = ...
// 运行 executor。
rclcpp::spin(node);
// 关闭并退出。
...
return 0;
}

调用 spin(node) 本质上等价于实例化并调用单线程 Executor(Single-Threaded Executor),这是最简单的 Executor:

rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(node);
executor.spin();

调用 Executor 实例的 spin() 后,当前线程会不断轮询 rcl 和中间件层中的传入消息及其他事件,并调用相应的回调函数,直到节点关闭。 为了避免与中间件的 QoS 设置冲突,传入的消息不会缓存在客户端库层的队列中,而是保留在中间件中,等待回调函数取出处理。 (这是与 ROS 1 的关键区别。) wait set 用于通知 Executor 中间件层上有可用消息,每个队列对应一个二进制标志。 wait set 还用于检测 timer 何时到期。

executors basic principle

单线程 Executor 也用于 组件 的容器进程,即在没有显式 main 函数而创建和执行节点的所有场景中。

目前,rclcpp 提供三种 Executor 类型,均派生自一个共享的父类:

digraph Flatland {
Executor -> SingleThreadedExecutor [dir = back, arrowtail = empty];
Executor -> MultiThreadedExecutor [dir = back, arrowtail = empty];
Executor -> EventsCBGExecutor [dir = back, arrowtail = empty];
Executor [shape=polygon,sides=4];
SingleThreadedExecutor [shape=polygon,sides=4];
MultiThreadedExecutor [shape=polygon,sides=4];
EventsCBGExecutor [shape=polygon,sides=4];
}

*多线程 Executor(Multi-Threaded Executor)*创建可配置数量的线程,以允许并行处理多个消息或事件。

所有 Executor 都可以通过对每个节点调用 add_node(..) 来使用多个节点。

rclcpp::Node::SharedPtr node1 = ...
rclcpp::Node::SharedPtr node2 = ...
rclcpp::Node::SharedPtr node3 = ...
rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(node1);
executor.add_node(node2);
executor.add_node(node3);
executor.spin();

在上面的示例中,单线程 Executor 的一个线程同时为三个节点提供服务。 在多线程 Executor 的情况下,实际的并行度取决于回调组(callback group)。

回调组事件 Executor(Callback Group Events Executor)

Section titled “回调组事件 Executor(Callback Group Events Executor)”

从历史上看,单线程和多线程 Executor 虽然实现简单、执行逻辑易于推演,但 是一个重要的性能瓶颈。

从 Lyrical Luth 版本开始提供的 EventsCBGExecutor 是多年来为降低 CPU 开销而进行的工作成果。 它不再使用 wait set,而是利用先进先出事件队列(first-in first-out events queue)来处理就绪事件。 其核心理念是”不为你不使用的东西买单”——实体不再通过轮询变化来检测,而是在变为就绪状态时才将事件加入队列等待处理。

该 Executor 支持多个 ROS 时间源(包括仿真时间),解决了 慢速运行 timer 导致 timer 事件突发的问题,并提供多线程支持。 因此,该 Executor 也可以作为 MultiThreadedExecutor 的直接替代品,因为它同样会创建可配置数量的工作线程来处理就绪事件。 在单线程模式下,EventsCBGExecutor 减少了上下文切换,并 展现出优异的性能表现。

需要注意的是:目前队列中可添加的事件数量没有上限。这意味着当进程过载、处理速度变慢时,队列中就绪实体的数量可能会无限增长。

rclcpp::Node::SharedPtr node1 = ...
rclcpp::Node::SharedPtr node2 = ...
rclcpp::Node::SharedPtr node3 = ...
rclcpp::Node::SharedPtr node4 = ...
rclcpp::executors::EventsCBGExecutor st_cbg_exec(rclcpp::ExecutorOptions(), 1);
st_cbg_exec.add_node(node1);
st_cbg_exec.add_node(node2);
st_cbg_exec.spin();
rclcpp::executors::EventsCBGExecutor mt_cbg_exec;
mt_cbg_exec.add_node(node3);
mt_cbg_exec.add_node(node4);
mt_cbg_exec.spin();

在上面的示例中,st_cbg_exec 将使用一个线程处理 node1 和 node2 的所有就绪事件,而 mt_cbg_exec 将使用系统上可用的最大线程数来处理 node3 和 node4 的就绪事件。 与多线程 Executor 一样,当 CBG Executor 使用多个线程时,实际的并行度取决于回调组。

ROS 2 允许将一个节点的回调组织为若干组。 在 rclcpp 中,可以通过 Node 类的 create_callback_group 函数创建这样的回调组。 在 rclpy 中,通过调用特定回调组类型的构造函数来完成相同的操作。 回调组必须在节点执行期间一直保持引用(例如作为类成员),否则 Executor 将无法触发回调。 然后,可以在创建 subscription、timer 等时指定此回调组——例如通过 subscription options:

my_callback_group = create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
rclcpp::SubscriptionOptions options;
options.callback_group = my_callback_group;
my_subscription = create_subscription<Int32>("/topic", rclcpp::SensorDataQoS(),
callback, options);
my_callback_group = MutuallyExclusiveCallbackGroup()
my_subscription = self.create_subscription(Int32, "/topic", self.callback, qos_profile=1,
callback_group=my_callback_group)

所有在未指定回调组的情况下创建的 subscription、timer 等,都会被分配到默认回调组(default callback group)。 默认回调组可以通过 rclcpp 中的 NodeBaseInterface::get_default_callback_group() 和 rclpy 中的 Node.default_callback_group 查询。

回调组有两种类型,类型必须在实例化时指定:

  • 互斥(Mutually exclusive): 该组的回调不能并行执行。
  • 可重入(Reentrant): 该组的回调可以并行执行。

不同回调组的回调始终可以并行执行。 多线程 Executor 将其线程作为线程池,根据上述条件尽可能并行地处理回调。 有关如何高效使用回调组的提示,请参阅 使用回调组。

rclcpp 中的 Executor 基类还有 add_callback_group(..) 函数,允许将回调组分配给不同的 Executor。 通过使用操作系统调度器配置底层线程,可以优先处理特定的回调而非其他回调。 例如,控制循环的 subscription 和 timer 可以优先于节点的所有其他 subscription 和常规服务。 examples_rclcpp_cbg_executor package 提供了此机制的演示。

如果回调的处理时间短于消息和事件发生的周期,Executor 基本上会按 FIFO(先进先出)顺序依次处理。 但如果某些回调的处理时间较长,消息和事件会在协议栈的较低层排队。 wait set 机制仅向 Executor 报告这些队列的非常有限的信息。 具体来说,它仅报告某个话题是否有消息。 Executor 使用此信息以轮转(round-robin)方式处理消息(包括服务和动作),而不是按 FIFO 顺序。 以下流程图展示了这种调度语义。

executors scheduling semantics

这种调度语义最初在 Casini 等人在 ECRTS 2019 上的论文中描述。 (注意:该论文还解释了 timer 事件优先于所有其他消息。 此优先级在 Eloquent 中被移除。)

尽管 rclcpp 的单线程和多线程 Executor 对大多数应用都能很好地工作,但仍存在一些问题,使其不适合实时应用——这类应用需要明确定义的执行时间、确定性以及对执行顺序的自定义控制。 以下是其中一些问题的总结:

  1. 复杂且混合的调度语义。理想情况下,你需要明确定义的调度语义来执行正式的时序分析。
  2. 回调可能遭受优先级反转。高优先级回调可能被低优先级回调阻塞。
  3. 没有对回调执行顺序的显式控制。
  4. 缺乏对特定 topic(话题)触发的内置控制。

此外,单线程和多线程 Executor 在 CPU 和内存使用方面的开销也不可忽视。

这些问题已通过以下发展得到部分解决:

  • rclcpp WaitSet:rclcpp 的 WaitSet 类允许直接在 subscription、timer、服务端、Action 服务端等上等待,而不是使用 Executor。它可用于实现确定性的、用户定义的处理序列,可以将来自不同 subscription 的多个消息一起处理。examples_rclcpp_wait_set package 提供了几个使用此用户级 wait set 机制的示例。
  • rclc Executor:这个来自 C 客户端库 rclc 的 Executor(为 micro-ROS 开发)赋予用户对回调执行顺序的细粒度控制,并允许自定义触发条件来激活回调。此外,它还实现了逻辑执行时间(Logical Execution Time,LET)语义的思想。
  • 回调组事件 Executor(The Callback Group Events Executor)的 CPU 开销比单线程和多线程 Executor 低 10% 到 15%,并使用 FIFO 队列来处理事件。