ROS2 service类型消息的同步调用问题浅析

本文探讨了在ROS2中从ROS1迁移到ROS2的用户遇到的问题,即服务客户端只有异步调用,没有同步调用。文章提供了使用MultiThreadedExecutor和回调组,如MutuallyExclusiveCallbackGroup和ReentrantCallbackGroup,来解决回调并发和避免死锁的方法。
摘要由CSDN通过智能技术生成

前言

许多从ROS1转战至ROS2的同学,想必遇到的一个很头疼的事情是 ROS2的service client只有异步调用,而没有同步调用。这篇文章罗列了一些解决方法,欢迎大家补充新的方法。

问题提出

构建 Node 的一种常见方法是将其实现为一个类,并使其使用阻塞spin()函数轮巡。目前,使用默认的节点执行器设置,不可能以这种方式从另一个回调同步调用服务,因为这会导致死锁。

我们代码中的一个常见模式是,我们从另一个服务回调调用多个其他服务,并且在返回主服务结果之前我们需要等待它们的结果。在这种情况下,无论您使用同步call()还是异步调用服务call_async(),服务响应都永远不会到达,因为执行器在执行回调时不会旋转。这甚至不能通过手动旋转来解决,因为回调中的spin会导致递归轮巡(recursive spinning),这是不允许的。rclpy 和 rclcpp 都存在该问题。

解决方法

解决办法一句话总结的话就是:使用MultiThreadedExecutor或MutuallyExclusiveCallbackGroup,ReentrantCallbackGroup以便在执行回调时节点仍在spin.

下面是详细的解析:觉得写的啰嗦的同学可以直接看最后的参考链接的内容,参考链接写的更精简,不过是用英文写的。下面介绍了有关在 rclpy 中处理回调组并正确使用它们以避免死锁的简短指南。对于C++编程者而言,要点是一样的,举一反三即可。

执行器和回调组(executors and callback groups)的基础知识

rclpy中的执行器(executors)

rclpy提供了rclpy提供了两种不同的executors供用户选择:

  • SingleThreadedExecutor (默认)
  • MultiThreadedExecutor

SingleThreadedExecutor 非常简单:它在单个线程中一次执行一个回调,因此前一个回调必须始终在新回调开始执行之前完成。
另一方面,MultiThreadedExecutor 能够同时执行多个回调。虽然 Python 中 GIL 的存在意味着它仍然无法利用多个 CPU 核心的真正并发执行,但它确实提供了使不同的回调执行相互重叠的通常有用的可能性,因此也允许在回调中等待,这是在SingleThreadedExecutor 根本无法实现。在这里,人们应该注意“能够”这一措辞,暗示并行执行不是给定的(更多内容将在下面的部分中介绍)。

MultiThreadedExecutors 的回调组(callback groups)

Rclpy 提供了两个不同的回调组来与 MultiThreadedExecutors 结合使用:

  • MutuallyExclusiveCallbackGroup(互斥回调组)
  • ReentrantCallbackGroup(可重入回调组)

MutuallyExclusiveCallbackGroup 允许执行器同时仅执行其回调之一,本质上使得该组中的回调好像是由 SingleThreadedExecutor 执行的。因此,将访问关键资源和潜在的非线程安全资源的任何回调放在同一个 MutuallyExclusiveCallbackGroup 中是一个不错的选择。

ReentrantCallbackGroup 允许执行程序以执行程序认为合适的任何方式安排和执行组的回调,而没有任何限制。这意味着,除了同时运行不同的回调之外,执行器还可以同时执行同一回调的不同实例。比如说:计时器回调的执行时间比计时器的触发周期长(尽管在 rclpy 中应不惜一切代价避免这种特殊情况)。

使用回调组来控制执行并避免死锁

首先需要明确,在rclpy和executors环境中,一个callback是一个由executor调度并执行的函数。例如:

  • 订阅回调(接收和处理来自主题的数据),
  • 定时器回调,
  • 服务回调(用于在服务器中执行服务请求),
  • action server 和 client 中的不同回调,
  • Futures 的 done-callbacks

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

  • ROS 2 中几乎所有内容都是回调!根据定义,执行器运行的每个函数都是回调。ROS 2 系统中的非回调函数主要位于系统边缘(用户和传感器输入等)。
  • 有时回调是隐藏的,并且从 rclpy 中提供的用户/开发人员 API 中它们的存在可能并不明显。尤其是对service或action的任何类型的“同步”调用(synchronous call)都是这种情况。例如,对服务的同步调用Client.call(request)添加了一个Future的done-callback,需要在函数调用执行过程中执行,但这个回调对用户来说是不直接可见的。

使用回调组控制执行

为了控制回调组的执行,可以考虑以下准则(待补充)。

  • 在同一个 MutuallyExclusiveCallbackGroup 中注册访问关键非线程安全资源的回调(或手动通过锁保护资源)。
  • 如果您有一个回调,其执行实例需要能够相互重叠,请将其注册到 ReentrantCallbackGroup。
  • 如果您有需要可能彼此并行执行的回调,请将它们注册到ReentrantCallbackGroup,或者注册到不同的 MutuallyExclusiveCallbackGroups(如果您希望回调本身不重叠,或者还需要或想要相对于某些其他回调的线程安全,优先使用该选项)。
    请注意,列表中的最后一点是允许并行执行不同回调的有效方法,甚至比简单地将所有内容注册到一个 ReentrantCallbackGroup 中更可取。

避免死锁

每个 ROS(2) 开发人员都知道,在回调中对服务或操作进行同步调用是不好的,并且可能导致死锁……但这并不完全是事实。虽然使用异步调用(并因此显式地将完成回调注册到 futures)确实更安全,并且即使使用 SingleThreadedExecutors 也可以按预期工作,但只要正确完成节点的回调组设置,同步调用也可以在回调中工作。同步调用的明显好处是它们使代码看起来更干净、更容易理解,所以让我们看看如何使它们工作而没有死锁的风险。

这里首先要注意的是,**每个节点的默认回调组都是 MutuallyExclusiveCallbackGroup。**如果用户在创建计时器、订阅、客户端等时未指定任何其他回调组,则这些实体当时或以后创建的任何回调都将使用节点的默认回调组。此外,如果节点中的所有内容都使用相同的 MutuallyExclusiveCallbackGroup,则该节点本质上就像由 SingleThreadedExecutor 处理一样,即使指定了多线程!因此,每当决定使用 MultiThreadedExecutor 时,都应始终指定一些回调组,以使executor选择有意义。

考虑到上述情况,这里有一些指南可以帮助避免死锁:

  • 使用异步调用(如果您始终只在任何地方使用这些调用,则永远不会出现死锁)。
  • 如果您在任何类型的回调中进行同步调用,则该回调和进行调用的客户端需要属于不同的回调组(任何类型),或者一个ReentrantCallbackGroup。

未能满足后一点总是会导致死锁。这种情况的一个示例是在计时器回调中进行同步服务调用(请参阅下一节)。

例子

让我们看一些关于不同回调组设置的简单但希望具有启发性的示例。以下演示代码考虑在计时器回调中同步调用服务。

您可以找到完整脚本的 Github 链接这里。请随意查看、亲自尝试并扩展它以进行进一步的实验。例如,您可以将发布者添加到服务节点(可能在调用服务时发布),在客户端节点中订阅它,也许添加一些睡眠并查看不同回调组会发生什么。出于好奇,尝试让订阅者永远不会收到任何内容,即使服务和计时器正在工作。
让我们首先浏览一下代码的主要部分。我们有一个提供简单模拟服务的节点:

class ServiceNode(Node):
    def __init__(self):
        super().__init__('mock_service_node')
        self.srv = self.create_service(Empty, 'test_service', callback=self.service_callback)

    def service_callback(self, request, result):
        self.get_logger().info('Server received request')
        return result

以及一个带有上述服务客户端的节点,和一个计时器(除非我们指定需要更直接的控制)

class CallbackGroupDemo(Node):
    def __init__(self, client_cb_croup, timer_cb_group, manual_calls):
        super().__init__('callback_group_demo_node')
        self.client = self.create_client(Empty, 'test_service', callback_group=client_cb_croup)
        if not manual_calls:
            self.call_timer = self.create_timer(1, self.timer_cb, callback_group=timer_cb_group)

    def call_srv(self, delay: float = 1):
        sleep(delay)
        self._call_srv()

    def _call_srv(self):
        self.get_logger().info('Client sending request')
        _ = self.client.call(Empty.Request())
        self.get_logger().info('Client received response')

    def timer_cb(self):
    	self._call_srv()

然后,我们有几个辅助函数用于旋转服务节点并从执行器领域外部进行“手动”调用:

def spin_srv(executor):
    try:
        executor.spin()
    except rclpy.executors.ExternalShutdownException:
        pass

def call_srv_manually(client_node):
    client_node.call_srv()
    client_node.get_logger().info('Test finished successfully.\n')

最后是实际的演示脚本,采用下面设置参数:客户端和计时器的回调组以及我们是否要手动发送服务调用而不是使用计时器。在这里,我们创建上述节点,将它们分配给执行器(演示节点为 MultiThreadedExecutor)并让它们spin。

def run_test(client_cb_group, timer_cb_group, manual_call):
    rclpy.init()

    node = CallbackGroupDemo(client_cb_croup=client_cb_group, timer_cb_group=timer_cb_group, manual_calls=manual_call)
    executor = MultiThreadedExecutor()
    executor.add_node(node)

    service_node = ServiceNode()
    srv_executor = SingleThreadedExecutor()
    srv_executor.add_node(service_node)
    srv_thread = Thread(target=spin_srv, args=(srv_executor, ), daemon=True)
    srv_thread.start()

    call_thread = Thread(target=call_srv_manually, args=(node, ), daemon=True)
    if manual_call:
        call_thread.start()

    try:
        print("")
        node.get_logger().info('Beginning demo, end with CTRL-C')
        executor.spin()
    except KeyboardInterrupt:
        node.get_logger().info('KeyboardInterrupt, shutting down.\n')
    node.destroy_node()
    service_node.destroy_node()
    rclpy.shutdown()
    try:
        srv_thread.join()
    except KeyboardInterrupt:
        pass    
    if manual_call: 
        call_thread.join()

请注意,将任一回调组选项设置为 None 都会导致相应的回调被分配到节点的默认 MutuallyExclusiveCallbackGroup 中。
然后让我们看看使用不同选项运行测试时会发生什么。我们首先对所有内容使用默认回调组。我们看到,如果我们手动调用服务(而不是通过回调),一切都会正常:run_test(client_cb_group=None, timer_cb_group=None, manual_call=True)输出

[INFO] [1649233901.626448480] [callback_group_demo_node]: Beginning demo, end with CTRL-C
[INFO] [1649233902.622004759] [callback_group_demo_node]: Client sending request
[INFO] [1649233902.624205200] [mock_service_node]: Server received request
[INFO] [1649233902.627408435] [callback_group_demo_node]: Client received response
[INFO] [1649233902.627800491] [callback_group_demo_node]: Test finished successfully.
^C[INFO] [1649233905.490485451] [callback_group_demo_node]: KeyboardInterrupt, shutting down.

这是因为只有一个回调在客户端运行:get_result_future 的(隐藏的)done 回调,因此它不能被任何东西阻止。但是,如果我们尝试使用计时器进行服务调用,情况就会发生变化run_test(client_cb_group=None, timer_cb_group=None, manual_call=False):

[INFO] [1649233901.626448480] [callback_group_demo_node]: Beginning demo, end with CTRL-C
[INFO] [1649233902.622004759] [callback_group_demo_node]: Client sending request
[INFO] [1649233902.624205200] [mock_service_node]: Server received request
[INFO] [1649233902.627408435] [callback_group_demo_node]: Client received response
[INFO] [1649233902.627800491] [callback_group_demo_node]: Test finished successfully.
^C[INFO] [1649233905.490485451] [callback_group_demo_node]: KeyboardInterrupt, shutting down.

死锁是由于计时器回调和上述完成回调位于同一(节点默认)MutuallyExclusiveCallbackGroup 中引起的。由于计时器回调会阻塞执行,直到收到服务调用的结果为止,因此完成回调永远不会执行,因此服务调用永远不会完成。

那么我们如何解决上述问题呢?如果我们坚持使用同步调用,我们有两种选择:将计时器和服务回调分离到不同的回调组(任何类型)或将它们放入一个 ReentrantCallbackGroup 中。对于前一种情况,我们可以用我们自己设置的组替换其中一个或两个组。因此,以下所有测试用例都会给出相同的输出:

run_test(client_cb_group=MutuallyExclusiveCallbackGroup(), timer_cb_group=None, manual_call=False) 

run_test(client_cb_group=None, timer_cb_group=MutuallyExclusiveCallbackGroup(), manual_call=False)  

group1 = MutuallyExclusiveCallbackGroup()
group2 = MutuallyExclusiveCallbackGroup()
run_test(client_cb_group=group1, timer_cb_group=group2, manual_call=False)

cb_group = ReentrantCallbackGroup()

run_test(client_cb_group=cb_group, timer_cb_group=cb_group, manual_call=False)

输出:

[INFO] [1649233909.812419539] [callback_group_demo_node]: Beginning demo, end with CTRL-C
[INFO] [1649233910.812076113] [callback_group_demo_node]: Client sending request
[INFO] [1649233910.812990099] [mock_service_node]: Server received request
[INFO] [1649233910.815389239] [callback_group_demo_node]: Client received response
[INFO] [1649233911.811726650] [callback_group_demo_node]: Client sending request
[INFO] [1649233911.812350167] [mock_service_node]: Server received request
[INFO] [1649233911.813846297] [callback_group_demo_node]: Client received response
^C[INFO] [1649233912.312435736] [callback_group_demo_node]: KeyboardInterrupt, shutting down.

正如您所看到的,计时器现在也不断重复触发(如预期),而之前的第一次执行被卡住并阻止了计时器回调的任何进一步执行。

最后一个演示案例是看看如果我们将两个回调组替换为相同的 MutuallyExclusiveCallbackGroup(与节点的默认回调组不同)会发生什么。结果与使用默认组时的结果相同:

cb_group = MutuallyExclusiveCallbackGroup() 
run_test(client_cb_group=cb_group,timer_cb_group=cb_group,manual_call=False)

输出

[INFO] [1649233905.498086234] [callback_group_demo_node]: Beginning demo, end with CTRL-C
[INFO] [1649233906.497072443] [callback_group_demo_node]: Client sending request
[INFO] [1649233906.497673382] [mock_service_node]: Server received request ← DEADLOCK
^C[INFO] [1649233909.797191396] [callback_group_demo_node]: KeyboardInterrupt, shutting down.

这不起作用的原因是 future 的完成回调(这是被计时器回调阻止的关键回调)由服务客户端分配给 future。因此,该回调将使用与客户端相同的回调组。如果它使用节点的默认值,那么事情就会起作用,至少在这种特殊情况下是这样。

当我们使用两个不同的 MutuallyExclusiveCallbackGroups 时,它起作用了,因为计时器回调(被服务调用阻止的回调)与客户端(将其回调组中继到 Future 的完成回调)位于不同的组中。因此,完成回调能够与计时器回调并行执行,客户端返回服务调用的结果,并且计时器回调能够完成(并在下次计时器触发时再次执行)!

参考连接

https://karelics.fi/ros-2-common-issues-and-mistakes/
https://karelics.fi/deadlocks-in-rclpy/
https://github.com/ros2/rclcpp/issues/773

  • 19
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值