执行器

概述

ROS 2中的执行管理通过执行器的概念进行了阐述。执行器使用底层操作系统的一个或多个线程对传入的消息和事件调用订阅者、计时器、服务服务端、动作服务端等的回调函数。显式执行器类 (在rclcpp中的 executor.hpp 、rclpy中的 executors.py 或rclc中的 executor.h ) 比ROS 1中的自旋(spin)机制提供了对执行管理的更多控制,尽管两者基本API非常相似。 [Alyssa@9804]

下面,我们重点介绍C++ 客户端库 rclcpp[Alyssa@9805]

基本用途

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

int main(int argc, char* argv[])
{
   // Some initialization.
   rclcpp::init(argc, argv);
   ...

   // Instantiate a node.
   rclcpp::Node::SharedPtr node = ...

   // Run the executor.
   rclcpp::spin(node);

   // Shutdown and exit.
   ...
   return 0;
}

spin(node) 基础的扩展为单线程执行器的初始化和调用,这是最简单的执行器: [Alyssa@9808]

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

通过调用执行器实例的 spin() ,当前线程开始向rcl和中间件层查询传入消息和其他事件,并调用相应的回调函数,直到节点关闭。为了不抵消中间件的QoS设置,传入的消息不会存储在客户端库层的队列中,而是保存在中间件中,直到被回调函数处理为止。(这是和ROS 1的一个至关重要的区别。) 等待集 用于通知执行器中间件层上的可用消息,每个队列有一个二进制标志。 [Alyssa@9809]

../_images/executors_basic_principle.png

单线程执行器也被容器进程用于 组件(components) ,即在没有显式主函数时创建和执行节点的所有情况。 [Alyssa@9810]

执行器类型

目前,rclcpp提供三种从共享父类派生的执行器类型: [Alyssa@9812]

digraph Flatland {

   Executor -> SingleThreadedExecutor [dir = back, arrowtail = empty];
   Executor -> MultiThreadedExecutor [dir = back, arrowtail = empty];
   Executor -> StaticSingleThreadedExecutor [dir = back, arrowtail = empty];
   Executor  [shape=polygon,sides=4];
   SingleThreadedExecutor  [shape=polygon,sides=4];
   MultiThreadedExecutor  [shape=polygon,sides=4];
   StaticSingleThreadedExecutor  [shape=polygon,sides=4];

   }

多线程执行器 创建可配置数量的线程,以允许并行处理多个消息或事件。静态单线程执行器 根据订阅者、计时器、服务服务端、动作服务端等优化扫描节点结构的运行时成本。当添加节点时,它仅执行一次扫描,而其他两个执行器定期扫描此类更改。因此,静态单线程执行器应仅用于在初始化期间创建所有订阅者、计时器等的节点。 [Alyssa@9813]

通过为每个节点调用 add_node(..) ,所有三个执行器都可以与多个节点一起使用。 [Alyssa@9814]

rclcpp::Node::SharedPtr node1 = ...
rclcpp::Node::SharedPtr node2 = ...
rclcpp::Node::SharedPtr node3 = ...

rclcpp::executors::StaticSingleThreadedExecutor executor;
executor.add_node(node1);
executor.add_node(node2);
executor.add_node(node2);
executor.spin();

在上面的示例中,静态单线程执行器的一个线程用于同时为三个节点提供服务。对于多线程执行器,实际并行性取决于回调组。 [Alyssa@9815]

回调组

rclcpp允许在组中组织节点的回调。这样的 回调组 可以由节点类的 create_callback_group 函数创建。然后,可以在创建订阅者、计时器等时指定此回调组 - 例如通过订阅者选项: [Alyssa@9817]

auto 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);

创建的所有订阅者、计时器等 (不指明回调组) 都会被分配 默认调用返回组。可以通过 NodeBaseInterface::get_default_callback_group() 查询默认回调组。 [Alyssa@9818]

有两种类型的回调组,必须在实例化时指定类型: [Alyssa@9819]

不同回调组的回调函数可以始终并行执行。多线程执行器使用其线程作为池,根据这些条件并行处理尽可能多的回调。 [Alyssa@9822]

自从Galactic之后,执行器(Executor)基类的接口被一个新的函数 add_callback_group(..) 改进了。这允许将回调组分配给不同的执行器。通过使用操作系统调度程序配置基础线程,特定的回调可以优先于其他回调。例如,控制循环的订阅者和计时器可以优先于节点的所有其他订阅者和标准服务。 examples_rclcpp_cbg_executor package 提供了这一机制的例程。 [Alyssa@9823]

调度语义

如果回调函数的处理时间短于消息和事件发生的时间,则执行器基本上以FIFO顺序处理它们。但是,如果某些回调的处理时间较长,则消息和事件将在堆栈的较低层排队。等待集机制只向执行器上报很少的关于这些队列的信息。具体地说,它只报告某个话题是否有任何消息。执行器使用此信息以循环方式处理消息 (包括服务和动作),而不是按FIFO顺序处理。此外,它优先考虑所有计时器事件而不是消息。以下流程图可视化了此调度语义。 [Alyssa@9825]

../_images/executors_scheduling_semantics.png

这种语义首次在 ECRTS 2019中Casini 等人的论文 中被描述。 [Alyssa@9826]

展望

虽然rclcpp的三个执行器在大多数应用程序中运行良好,但仍存在一些问题,使其不适合实时应用程序,这需要定义明确的执行时间、确定性、以及对执行顺序的自定义控制。以下是其中一些问题的摘要: [待校准@9828]

  1. 复杂和混合调度语义。理想情况下,您需要定义良好的调度语义来执行规范的时序分析。 [Alyssa@9829]

  2. 回调可能会遭受优先级反转。较高优先级的回调可能会被较低优先级的回调阻塞。 [Alyssa@9830]

  3. 对回调执行顺序没有明确的控制。 [Alyssa@9831]

  4. 对特定话题的触发没有内置控制。 [待校准@9832]

此外,执行器在CPU和内存使用方面的开销相当大。静态单线程执行器大大减少了这种开销,但对于某些应用程序来说可能还不够。 [Alyssa@9833]

以下开发版本已部分解决了这些问题: [待校准@9834]

  • rclcpp WaitSet : rclcpp的 WaitSet 类允许直接在订阅者、计时器、服务服务端、动作服务端等上等待,而不是使用执行器。它可用于实现确定性的、用户定义的处理序列,可能一起处理来自不同订阅者的多个消息。 examples_rclcpp_wait_set package 提供了几个使用该用户级等待集机制的例子。 [Alyssa@9835]

  • rclc Executor : 这个来自为微型ROS开发的C客户端库 rclc 的执行器为用户提供了对回调函数执行顺序的细粒度控制,并允许自定义触发条件来激活回调函数。此外,它实现了逻辑执行时间 (LET:Logical Execution Time) 语义的思想。 [Alyssa@9836]

更多信息