ROS2 Jazzy:高效使用回调函数(回调组)

在多线程执行器中运行节点时,ROS2 提供回调组作为控制不同回调执行的工具。本文旨在指引你如何高效使用回调组。本文假设读者对执行器的概念有基本了解。

回调组基础

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

  • 互斥回调组
  • 可重入回调组

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

  • 互斥回调组不允许回调并行执行,本质上就好像该组中的回调是由单线程执行器执行的一样。
  • 可重入回调组允许执行器以任何它认为合适的方式调度和执行该组的回调,没有限制。这意味着,除了不同的回调可以相互并行运行之外,同一回调的不同实例也可以并发执行。

属于不同回调组(任何类型)的回调始终可以相互并行执行。

还需要记住的是,不同的 ROS2 实体将其回调组传递给它们生成的所有回调。例如,如果将一个回调组分配给一个动作客户端,那么该客户端创建的所有回调都将分配到该回调组。

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

cpp 复制代码
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);

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

关于回调

在 ROS2 和执行器的上下文中,回调是指其调度和执行由执行器处理的函数。在这种上下文中,回调的示例包括:

  • 订阅回调(从主题接收和处理数据)
  • 定时器回调
  • 服务回调(在服务器中执行服务请求)
  • 动作服务器和客户端中的不同回调
  • 未来对象的完成回调

在使用回调组时,需要记住以下几个关于回调的要点:

  • 在 ROS2 中,几乎所有东西都是回调!根据定义,由执行器运行的每个函数都是回调。ROS2 系统中的非回调函数主要存在于系统的边缘(用户和传感器输入等)。
  • 有时回调是隐藏的,从用户/开发者 API 中可能不太明显。特别是在对服务或动作进行任何类型的"同步"调用时(在 rclpy 中)就是这种情况。例如,对服务的同步调用 Client.call(request) 会添加一个未来对象的完成回调,该回调需要在函数调用执行期间执行,但这个回调对用户来说不是直接可见的。

控制执行

为了使用回调组控制执行,可以考虑以下准则:

单个回调与其自身的交互

  • 如果某个回调需要与自身并行执行,则将其注册到可重入回调组。例如,一个动作/服务服务器需要能够并行处理多个动作调用。
  • 如果某个回调永远不应该与自身并行执行,则将其注册到互斥回调组。例如,一个运行发布控制命令的控制循环的定时器回调。

不同回调之间的交互

  • 如果不同的回调永远不应该并行执行,则将它们注册到同一个互斥回调组。例如,这些回调正在访问共享的关键且非线程安全的资源。
  • 如果它们应该并行执行,根据单个回调是否应该能够自身重叠,有两个选项:
    • 将它们注册到不同的互斥回调组(单个回调不会重叠)
    • 将它们注册到可重入回调组(单个回调可以重叠)

一个并行运行不同回调的例子是一个节点,它有一个同步服务客户端和一个调用该服务的定时器。请参阅下面的详细示例。

避免死锁

错误地设置节点的回调组可能会导致死锁(或其他不期望的行为),特别是如果你希望对服务或动作使用同步调用。实际上,ROS2 的 API 文档甚至提到,不应该在回调中对动作或服务进行同步调用,因为这可能会导致死锁。虽然在这方面使用异步调用确实更安全,但同步调用也可以正常工作。另一方面,同步调用也有其优点,例如使代码更简单、更易于理解。因此,本节提供了一些关于如何正确设置节点的回调组以避免死锁的准则。

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

考虑到以上情况,以下是一些有助于避免死锁的准则:

  • 如果在任何类型的回调中进行同步调用,此回调和进行调用的客户端需要属于:
    • 不同的回调组(任何类型)
    • 可重入回调组
  • 如果由于其他要求(如线程安全和/或在等待结果时阻塞其他回调)而无法实现上述配置(或者你想绝对确保永远不会出现死锁的可能性),则使用异步调用。

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

示例

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

示例代码

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

cpp 复制代码
#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "std_srvs/srv/empty.hpp"

using namespace std::placeholders;

namespace cb_group_demo
{
class ServiceNode : public rclcpp::Node
{
public:
    ServiceNode() : Node("service_node")
    {
        auto service_callback = [this](
            const std::shared_ptr<rmw_request_id_t> request_header,
            const std::shared_ptr<std_srvs::srv::Empty::Request> request,
            const std::shared_ptr<std_srvs::srv::Empty::Response> response)
        {
            (void)request_header;
            (void)request;
            (void)response;
            RCLCPP_INFO(this->get_logger(), "Received request, responding...");
        };
        service_ptr_ = this->create_service<std_srvs::srv::Empty>(
                "test_service",
                service_callback
        );
    }

private:
    rclcpp::Service<std_srvs::srv::Empty>::SharedPtr service_ptr_;

};  // class ServiceNode
}   // namespace cb_group_demo

int main(int argc, char* argv[])
{
    rclcpp::init(argc, argv);
    auto service_node = std::make_shared<cb_group_demo::ServiceNode>();

    RCLCPP_INFO(service_node->get_logger(), "Starting server node, shut down with CTRL - C");
    rclcpp::spin(service_node);
    RCLCPP_INFO(service_node->get_logger(), "Keyboard interrupt, shutting down.\n");

    rclcpp::shutdown();
    return 0;
}

另一个节点包含一个服务客户端以及一个用于进行服务调用的定时器:

cpp 复制代码
Note: The API of service client in rclcpp does not offer a synchronous call method similar to the one in rclpy, so we wait on the future object to simulate the effect of a synchronous call.

#include <chrono>
#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "std_srvs/srv/empty.hpp"

using namespace std::chrono_literals;

namespace cb_group_demo
{
class DemoNode : public rclcpp::Node
{
public:
    DemoNode() : Node("client_node")
    {
        client_cb_group_ = nullptr;
        timer_cb_group_ = nullptr;
        client_ptr_ = this->create_client<std_srvs::srv::Empty>("test_service", rmw_qos_profile_services_default,
                                                                client_cb_group_);

        auto timer_callback = [this](){
            RCLCPP_INFO(this->get_logger(), "Sending request");
            auto request = std::make_shared<std_srvs::srv::Empty::Request>();
            auto result_future = client_ptr_->async_send_request(request);
            std::future_status status = result_future.wait_for(10s);  // timeout to guarantee a graceful finish
            if (status == std::future_status::ready) {
                RCLCPP_INFO(this->get_logger(), "Received response");
            }
        };

        timer_ptr_ = this->create_wall_timer(1s, timer_callback, timer_cb_group_);
    }

private:
    rclcpp::CallbackGroup::SharedPtr client_cb_group_;
    rclcpp::CallbackGroup::SharedPtr timer_cb_group_;
    rclcpp::Client<std_srvs::srv::Empty>::SharedPtr client_ptr_;
    rclcpp::TimerBase::SharedPtr timer_ptr_;

};  // class DemoNode
}   // namespace cb_group_demo

int main(int argc, char* argv[])
{
    rclcpp::init(argc, argv);
    auto client_node = std::make_shared<cb_group_demo::DemoNode>();
    rclcpp::executors::MultiThreadedExecutor executor;
    executor.add_node(client_node);

    RCLCPP_INFO(client_node->get_logger(), "Starting client node, shut down with CTRL - C");
    executor.spin();
    RCLCPP_INFO(client_node->get_logger(), "Keyboard interrupt, shutting down.\n");

    rclcpp::shutdown();
    return 0;
}

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

问题描述

由于我们使用 1 秒的定时器进行服务调用,预期结果是服务每秒被调用一次,客户端总是能得到响应并打印"Received response"。如果我们尝试在终端中运行服务器和客户端节点,会得到以下输出:

客户端

ini 复制代码
[INFO] [1653034371.758739131] [client_node]: Starting client node, shut down with CTRL - C
[INFO] [1653034372.755865649] [client_node]: Sending request
^C[INFO] [1653034398.161674869] [client_node]: Keyboard interrupt, shutting down.

服务器

ini 复制代码
[INFO] [1653034355.308958238] [service_node]: Starting server node, shut down with CTRL-C
[INFO] [1653034372.758197320] [service_node]: Received request, responding...
^C[INFO] [1653034416.021962246] [service_node]: Keyboard interrupt, shutting down.

结果表明,服务并没有被重复调用,第一次调用的响应永远没有收到,之后客户端节点似乎卡住了,不再进行进一步的调用。也就是说,执行在死锁处停止了!

原因是定时器回调和客户端使用了同一个互斥回调组(节点的默认回调组)。当进行服务调用时,客户端将其回调组传递给未来对象,该未来对象的完成回调需要执行才能获得服务调用的结果。但是,由于这个完成回调和定时器回调在同一个互斥组中,并且定时器回调仍在执行(等待服务调用的结果),完成回调永远无法执行。卡住的定时器回调还会阻止自身的任何其他执行,因此定时器不会第二次触发。也就是说,服务端返回响应后,ROS2 底层触发完成回调,然后完成回调被添加到客户端关联的回调组队列中,如果回调组为互斥,执行器需要等待当前正在执行的回调(如定时器回调)完成后,再执行完成回调。

解决方案

我们可以轻松解决这个问题,例如,将定时器和客户端分配到不同的回调组。因此,让我们将客户端节点构造函数的前两行更改为如下所示(其他部分保持不变):

cpp 复制代码
client_cb_group_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
timer_cb_group_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);

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

客户端

ini 复制代码
[INFO] [1653067523.431731177] [client_node]: Starting client node, shut down with CTRL - C
[INFO] [1653067524.431912821] [client_node]: Sending request
[INFO] [1653067524.433230445] [client_node]: Received response
[INFO] [1653067525.431869330] [client_node]: Sending request
[INFO] [1653067525.432912803] [client_node]: Received response
[INFO] [1653067526.431844726] [client_node]: Sending request
[INFO] [1653067526.432893954] [client_node]: Received response
[INFO] [1653067527.431828287] [client_node]: Sending request
[INFO] [1653067527.432848369] [client_node]: Received response
^C[INFO] [1653067528.400052749] [client_node]: Keyboard interrupt, shutting down.

服务器

ini 复制代码
[INFO] [1653067522.052866001] [service_node]: Starting server node, shut down with CTRL-C
[INFO] [1653067524.432577720] [service_node]: Received request, responding...
[INFO] [1653067525.432365009] [service_node]: Received request, responding...
[INFO] [1653067526.432300261] [service_node]: Received request, responding...
[INFO] [1653067527.432272441] [service_node]: Received request, responding...
^C[INFO] [1653034416.021962246] [service_node]: KeyboardInterrupt, shutting down.

有人可能会考虑是不是仅仅避免使用节点的默认回调组就可以了。但事实并非如此:用不同的互斥回调组替换默认组没有任何改变。因此,以下配置也会导致之前发现的死锁:

cpp 复制代码
client_cb_group_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
timer_cb_group_ = client_cb_group_;

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

cpp 复制代码
client_cb_group_ = this->create_callback_group(rclcpp::CallbackGroupType::Reentrant);
timer_cb_group_ = client_cb_group_;

// 或者
client_cb_group_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
timer_cb_group_ = nullptr;

// 或者
client_cb_group_ = nullptr;
timer_cb_group_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);

// 或者
client_cb_group_ = this->create_callback_group(rclcpp::CallbackGroupType::Reentrant);
timer_cb_group_ = nullptr;

所以,我们建议在实际应用中:

  • 建立独立回调组:为耗时服务客户端分配独立的可重入回调组,避免阻塞其他回调。
  • 慎用默认组:避免在默认互斥组中混合高频定时器和长耗时服务调用。
  • 选择合适的执行器:例如使用多线程执行器(如 MultiThreadedExecutor)以支持并行回调。

关注【智践行】,发送 【机器人】 获得机器人经典学习资料

相关推荐
智践行4 小时前
ROS2 Jazzy:如何使用节点接口模板类访问节点信息(C++)
操作系统
心月狐的流火号5 小时前
计算机I/O模式演进与 Java NIO 直接内存
java·操作系统
444A4E10 小时前
深入理解Linux进程管理:从创建到替换的完整指南
linux·c语言·操作系统
JulyYu10 小时前
Android系统保存重名文件后引发的异常解决
android·操作系统·源码
有信仰3 天前
操作系统——进程和线程
操作系统
猪哥帅过吴彦祖4 天前
从源码到可执行文件:揭秘程序编译与执行的底层魔法
操作系统·编译原理·编译器
SundayBear4 天前
Autosar Os新手入门
车载系统·操作系统·autosar os
千里镜宵烛4 天前
深入理解 Linux 线程:从概念到虚拟地址空间的全面解析
开发语言·c++·操作系统·线程
OpenAnolis小助手5 天前
朗空量子与 Anolis OS 完成适配,龙蜥获得抗量子安全能力
安全·开源·操作系统·龙蜥社区·龙蜥生态