警告

您正在阅读的 ROS 2 文档版本已达到 EOL(生命周期结束),不再受官方支持。如果您想了解最新信息,请访问 Jazzy.

使用回调组

在多线程执行器(Multi-Threaded Executor)中运行节点时,ROS 2 提供了回调组(callback group)作为控制不同回调执行的工具。本页旨在指导读者如何高效地使用回调组。我们假定读者对回调组的概念有基本的了解。 执行人.

回调组的基础知识

在多线程执行器中运行节点时,ROS 2 提供两种不同类型的回调组来控制回调的执行:

  • 相互排斥的回电组

  • 重入回调组

这些回调组以不同的方式限制其回调的执行。简而言之

  • 互斥回调组可以防止其回调被并行执行--实质上,该组中的回调是由单线程执行器执行的。

  • 可重入回调组允许执行器以其认为合适的任何方式调度和执行该组的回调,不受任何限制。这意味着,除了不同的回调可以并行执行外,同一回调的不同实例也可以并发执行。

  • 属于不同回调组(任何类型)的回调总是可以并行执行。

同样重要的是要记住,不同的 ROS 2 实体会将其回调组转发给它们产生的所有回调。例如,如果给一个动作客户端分配了一个回调组,那么该客户端创建的所有回调都将分配给该回调组。

回调组可以通过节点的 创建回调组 函数,并在 rclpy 中调用组的构造函数。然后,在创建订阅、定时器等时,可将回调组作为参数/选项传入。

我的回调组 = 创建回调组(rclcpp::回调组类型::相互排斥);

rclcpp::订阅选项 选项;
选项.回调组 = 我的回调组;

我的订阅 = 创建订阅<;Int32>;("/topic";, rclcpp::传感器数据QoS(),
                                              回调, 选项);

如果用户在创建订阅、定时器等时没有指定任何回调组,该实体将被分配到节点的默认回调组。默认回调组是一个互斥回调组,可通过以下方式查询 NodeBaseInterface::get_default_callback_group() 中,并通过 Node.default_callback_group 在 rclpy 中。

关于回调

在 ROS 2 和执行器中,回调指的是由执行器负责调度和执行的函数。回调的例子有

  • 订阅回调(接收和处理来自主题的数据)、

  • 定时器回调、

  • 服务回调(用于在服务器中执行服务请求)、

  • 行动服务器和客户端中的不同回调、

  • 期货的回调。

在使用回调组时,应牢记以下几个有关回调的要点。

  • 在 ROS 2 中,几乎所有东西都是回调!根据定义,执行器运行的每个函数都是回调函数。ROS 2 系统中的非回调函数主要位于系统边缘(用户和传感器输入等)。

  • 有时,回调是隐藏的,从用户/开发人员 API 中可能看不出它们的存在。在 rclpy 中,对服务或操作的任何 "同步 "调用尤其如此。例如,同步调用 Client.call(request) 服务添加了一个 Future 的 done 回调,该回调需要在函数调用执行期间执行,但用户无法直接看到该回调。

控制执行

为了控制回调组的执行,可以考虑以下指导原则。

  • 将不应并行执行的回调注册到同一个互斥回调组。例如,回调正在访问共享的关键资源和非线程安全资源。

  • 如果回调的执行实例需要相互重叠,可将其注册到可重入回调组。举例来说,动作服务器需要并行处理多个动作调用。

  • 如果有不同的回调需要并行执行,请将它们注册到

    • 重入回调组,或

    • 不同的互斥回调组(如果希望回调不重叠,或需要其他回调的线程安全,则可选择此选项)或任何类型的不同回调组(根据其他标准选择类型)。

请注意,列表中的选项是允许不同回调并行执行的有效方法,甚至可能比简单地将所有内容注册到一个可重入回调组中更理想。

避免僵局

错误地设置节点的回调组可能会导致死锁(或其他不必要的行为),尤其是在希望使用同步调用服务或操作的情况下。事实上,即使是 ROS 2 的应用程序接口文档也提到,对操作或服务的同步调用不应在回调中进行,因为这会导致死锁。在这方面,使用异步调用确实更安全,但同步调用也能正常工作。另一方面,同步调用也有其优点,比如可以使代码更简单、更容易理解。因此,本节将就如何正确设置节点的回调组以避免死锁提供一些指导。

首先要注意的是,每个节点的默认回调组都是互斥回调组。如果用户在创建定时器、订阅、客户端等时没有指定任何其他回调组,那么这些实体当时或之后创建的任何回调都将使用节点的默认回调组。此外,如果节点中的所有内容都使用相同的互斥回调组,那么即使指定了多线程执行器,该节点也会像由单线程执行器处理一样运行!因此,无论何时决定使用多线程执行器,都应指定一些回调组,以便执行器的选择具有意义。

有鉴于此,以下是几条有助于避免死锁的指导原则:

  • 如果在任何类型的回调中进行同步调用,该回调和进行调用的客户端都需要属于

    • 不同的回调组(任何类型),或

    • a 重入回调组。

  • 如果由于其他要求(如线程安全和/或在等待结果时阻塞其他回调)而无法进行上述配置,请使用异步调用。

违反第一点总会导致死锁。例如,在定时器回调中进行同步服务调用(请参阅下一节的示例)。

实例

让我们来看一些不同回调组设置的简单示例。下面的演示代码考虑了在定时器回调中同步调用服务的问题。

演示代码

我们有两个节点,其中一个提供简单的服务:

#include <内存>;
#include "rclcpp/rclcpp.hpp";
#include "std_srvs/srv/empty.hpp";

使用 命名空间 标准::占位符;

命名空间 分组演示
{
 服务节点 :  rclcpp::节点
{
:
    服务节点() : 节点("service_node";)
    {
        服务ptr_ = ->;创建服务<;std_srvs::服务::>;(
                "test_service";,
                标准::约束(及样品;服务节点::服务回调, , _1, _2, _3)
        );
    }

私人:
    rclcpp::服务<;std_srvs::服务::>::SharedPtr 服务ptr_;

    空白 服务回调(
             标准::共享_ptr<;rmw_request_id_t>; 请求标题,
             标准::共享_ptr<;std_srvs::服务::::要求>; 要求,
             标准::共享_ptr<;std_srvs::服务::::回应>; 回应)
    {
        (空白)请求标题;
        (空白)要求;
        (空白)回应;
        RCLCPP_INFO(->;get_logger(), 收到请求,正在回复......";);
    }
};  // 类 ServiceNode
}   // 命名空间 cb_group_demo

int 主要(int 参数, 烧焦* 参数[])
{
    rclcpp::启动(参数, 参数);
    汽车 服务节点 = 标准::共享<;分组演示::服务节点>;();

    RCLCPP_INFO(服务节点->;get_logger(), "启动服务器节点,使用 CTRL-C 关闭";);
    rclcpp::后旋(服务节点);
    RCLCPP_INFO(服务节点->;get_logger(), 键盘中断,正在关闭。\n";);

    rclcpp::关闭();
    返回 0;
}

和另一个包含服务客户端和服务调用计时器的服务:

请注意: rclcpp 中服务客户端的 API 并不提供类似 rclpy 中的同步调用方法,因此我们需要等待未来对象来模拟同步调用的效果。

#include 时间顺序<chrono>;
#include <内存>;
#include "rclcpp/rclcpp.hpp";
#include "std_srvs/srv/empty.hpp";

使用 命名空间 标准::计时器;

命名空间 分组演示
{
 演示节点 :  rclcpp::节点
{
:
    演示节点() : 节点("client_node";)
    {
        用户组 = nullptr;
        timer_cb_group_ = nullptr;
        客户端ptr_ = ->;创建客户端<;std_srvs::服务::>;("test_service";, rmw_qos_profile_services_default,
                                                                用户组);
        timer_ptr_ = ->;创建隔离墙计时器(1s, 标准::约束(及样品;演示节点::定时器回调, ),
                                            timer_cb_group_);
    }

私人:
    rclcpp::回调组::SharedPtr 用户组;
    rclcpp::回调组::SharedPtr timer_cb_group_;
    rclcpp::客户<;std_srvs::服务::>::SharedPtr 客户端ptr_;
    rclcpp::定时器基数::SharedPtr timer_ptr_;

    空白 定时器回调()
    {
        RCLCPP_INFO(->;get_logger(), 发送请求";);
        汽车 要求 = 标准::共享<;std_srvs::服务::::要求>;();
        汽车 result_future = 客户端ptr_->;异步发送请求(要求);
        标准::未来状态 地位 = result_future.等待(10s);  // 超时以保证优雅结束
        如果 (地位 == 标准::未来状态::就绪) {
            RCLCPP_INFO(->;get_logger(), 收到回复";);
        }
    }
};  // 类 DemoNode
}   // 命名空间 cb_group_demo

int 主要(int 参数, 烧焦* 参数[])
{
    rclcpp::启动(参数, 参数);
    汽车 客户节点 = 标准::共享<;分组演示::演示节点>;();
    rclcpp::执行人::多线程执行器 执行人;
    执行人.添加节点(客户节点);

    RCLCPP_INFO(客户节点->;get_logger(), "启动客户端节点,使用 CTRL-C 关闭";);
    执行人.后旋();
    RCLCPP_INFO(客户节点->;get_logger(), 键盘中断,正在关闭。\n";);

    rclcpp::关闭();
    返回 0;
}

客户端节点的构造函数包含用于设置服务客户端和计时器回调组的选项。在上述默认设置下(两者均为 nullptr / ) 时,定时器和客户端都将使用节点的默认互斥回调组。

问题

由于我们使用 1 秒计时器调用服务,因此预期结果是服务每秒被调用一次,客户端始终会收到响应并打印 已收到 回应.如果我们尝试在终端运行服务器和客户端节点,会得到以下输出结果。

[INFO] [1653034371.758739131] [client_node]:启动客户端节点,用 CTRL-C 关闭
[INFO] [1653034372.755865649] [client_node]:发送请求
^C[INFO][1653034398.161674869][client_node]:键盘中断,正在关闭。

因此,事实证明,不是服务被重复调用,而是第一次调用的响应从未收到,此后客户端节点似乎被卡住,无法继续调用。也就是说,执行过程陷入了死锁!

原因是定时器回调和客户端使用的是同一个互斥回调组(节点的默认值)。在进行服务调用时,客户端会将其回调组传递给 Future 对象(在 Python 版本中隐藏在调用方法中),该对象的 done-callback 必须执行才能获得服务调用的结果。但是,由于这个 done-callback 和定时器回调在同一个互斥组中,而且定时器回调仍在执行(等待服务调用的结果),因此 done-callback 永远无法执行。被卡住的定时器回调也会阻止自身的任何其他执行,因此定时器不会再次启动。

解决方案

我们可以很容易地解决这个问题,例如,将计时器和客户端分配给不同的回调组。因此,让我们将客户端节点构造函数的前两行修改如下(其他内容保持不变):

用户组 = ->;创建回调组(rclcpp::回调组类型::相互排斥);
timer_cb_group_ = ->;创建回调组(rclcpp::回调组类型::相互排斥);

现在我们得到了预期的结果,即定时器重复触发,每次服务调用都能得到应有的结果:

[INFO] [1653067523.431731177] [client_node]:启动客户端节点,用 CTRL-C 关闭
[INFO] [1653067524.431912821] [client_node]:发送请求
[INFO] [1653067524.433230445] [client_node]:收到响应
[INFO] [1653067525.431869330] [client_node]:发送请求
[INFO] [1653067525.432912803] [client_node]:收到响应
[INFO] [1653067526.431844726] [client_node]:发送请求
[INFO] [1653067526.432893954] [client_node]:收到响应
[INFO] [1653067527.431828287] [client_node]:发送请求
[INFO] [1653067527.432848369] [client_node]:收到响应
^C[INFO][1653067528.400052749][client_node]:键盘中断,正在关闭。

有人可能会想,仅仅避免节点的默认回调组是否就足够了?事实并非如此:用一个不同的互斥组代替默认组并不会改变什么。因此,以下配置也会导致之前发现的死锁。

用户组 = ->;创建回调组(rclcpp::回调组类型::相互排斥);
timer_cb_group_ = 用户组;

事实上,在这种情况下,一切正常运行的确切条件是,定时器和客户端必须不属于同一个互斥组。因此,以下所有配置(以及其他一些配置)都能产生所需的结果,即定时器重复触发并完成服务调用。

用户组 = ->;创建回调组(rclcpp::回调组类型::重入式);
timer_cb_group_ = 用户组;

用户组 = ->;创建回调组(rclcpp::回调组类型::相互排斥);
timer_cb_group_ = nullptr;

用户组 = nullptr;
timer_cb_group_ = ->;创建回调组(rclcpp::回调组类型::相互排斥);

用户组 = ->;创建回调组(rclcpp::回调组类型::重入式);
timer_cb_group_ = nullptr;