以前做开发都是用ros1+c++开发,最近入手深度强化学习开始了ros2+Python的开发之旅,下面是记录一次死锁的解决过程。 由于话题、定时器、服务、动作、参数服务器等方式都是通过回调函数触发,所以我们通过定时器嵌套服务的方式举例。
背景介绍
死锁问题,对于熟悉C++多线程开发使用mutex的朋友肯定不陌生,死锁问题是程序中比较头秃的bug之一,往往是锁设置的不合理导致连环锁,最后锁死程序啥也干不了。ros2还是一往ros1的操作,其中一个必要重要的东西就是回调函数,话题、定时器、服务、动作、参数服务器都会产生回调函数,默认情况下程序是以单线程工作的,回调函数通过相应的事件触发过后按照时间顺序形成一个队列,然后这个线程就取出第一个回调进行处理,这个回调结束过后取下一个回调进行处理,依次反复。这个过程看上去好像并没有什么大问题,但是想象如果我要实现一个功能定时呼叫一个服务,得到服务的执行结果。好比我们每天早上的闹钟都要在固定的时间响,这个可以比作定时器的功能,然后他调用的服务就是把我们叫醒,如果我们关了闹钟继续睡这个服务给我们返回值就是叫醒操作失败,如果成功起床就表示叫醒操作成功。但是对于程序来说,特别对于单线程程序来说由于一次只能处理一个回调,这里注意服务是双向回调的,对于服务的客户端由于我们需要得到结果所以也会有回调函数,当定时器达到时间产生了一个回调过后开始处理这个回调,回调里面对呼叫一个服务,我们等待服务完成打印服务的结果,这个时候问题就来了,由于当前的时间回调函数没有执行完,所以程序会一直等待呼叫的服务完成,但是得到服务的结果需要另外一个回调来得到结果,但是当前又无法结束目前的时间回调函数这个时候死锁就来了,程序就一直卡死。
问题就是出现在服务的回调函数无法被执行,回调的状态就无法得到更新。有问题就得解决,编程语法千千万,解决方案也是千千万,首先我们想到的就是多线程,给这个服务回调开一个新的线程执行回调函数,这样就可以了,这确实是一个解决办法,C++里面的为了提高性能我们一般都是采用多线程回调,在Python里面经过实验确实也可以解决问题,但是这样做无疑不是一个很好的办法,而且和我们人的思维方式也不太一样,如果换做一个人来思考这个问题,我们遇到需要等待的问题往往是先放下当前的活转而去干别的事,对任务进行调整,例如在蒸面包的时候我们不用一直在那守着,我们一般是回去干点别的事,这就设计到一个新的概念叫做协程。很多语言都有协程的操作,Python的协程经过不断的完善现在已经比较的方便了,C++在C++20中也提出了协程。下面就是讲如何通过协程来解决这个死锁的问题。
基本概念
对于ros2在Python的回调实现有两个比较重要的概念,执行器(executors)和回调组(callback groups) ,详细的内荣可以查看官网链接,简单来说所谓的执行器可以理解为一个线程,就是真正执行这个回调的东西,而回调组就是对组的回调进行管理,对这些发生的回调的执行顺序进行管理。
关于什么是协程可以参考我的上一篇博客, 里面对什么是协程做了一个感性的讲解。
代码实现
为了避免死锁我们用async
将时间回调函数声明为一个异步的函数表示这个函数是可以中断跳出的,然后通过异步呼叫服务的方式调用服务,用await
等待返回结果。具体实现如下。
服务的服务器端我们就用一个简单的做加法的例子
from example_interfaces.srv import AddTwoInts
import rclpy
from rclpy.node import Node
class MinimalService(Node):
def __init__(self):
super().__init__('minimal_service')
self.srv = self.create_service(AddTwoInts, 'add_two_ints', self.add_two_ints_callback)
def add_two_ints_callback(self, request, response):
response.sum = request.a + request.b
self.get_logger().info('Incoming request\na: %d b: %d' % (request.a, request.b))
return response
def main(args=None):
rclpy.init(args=args)
minimal_service = MinimalService()
rclpy.spin(minimal_service)
rclpy.shutdown()
if __name__ == '__main__':
main()
接下来是在定时器回调里面调用这个服务
import rclpy
from rclpy.callback_groups import ReentrantCallbackGroup
from example_interfaces.srv import AddTwoInts
def main(args=None):
rclpy.init(args=args)
node = rclpy.create_node('minimal_client')
cb_group = ReentrantCallbackGroup() # 这个类型的回调组运行一旦产生回调就执行
cli = node.create_client(AddTwoInts, 'add_two_ints', callback_group=cb_group)
async def call_service():
nonlocal cli, node
req = AddTwoInts.Request()
req.a = 41
req.b = 1
future = cli.call_async(req)
try:
result = await future
except Exception as e:
node.get_logger().info('Service call failed %r' % (e,))
else:
node.get_logger().info(
'Result of add_two_ints: for %d + %d = %d' %
(req.a, req.b, result.sum))
while not cli.wait_for_service(timeout_sec=1.5):
node.get_logger().info('service not available, waiting again...')
timer = node.create_timer(1.0, call_service, callback_group=cb_group)
try:
rclpy.spin(node)
except KeyboardInterrupt :
print('ctrl + c exit')
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
编译运行就可以看到结果,每隔一秒请求做一次加法。
总结
死锁的关键点就在调用服务的结果状态得不到更新,可以通过多线程和协程的方式让状态得到更新,但是协程无疑在这种情况下是一个比较优的解决办法,多线程往往会带来资源竞争和状态混乱的风险,协程是由用户设计的任务的调度这种方式会比多进程安全一点。
ROS2现在已经不断完善,相比ROS1还是非常不错的,后面会不断分享一些实现并行、并发和ROS2的小技巧和心得。
最后一点虽然我们可以解决死锁的问题,但是还是应该尽量避免这种回调函数阻塞的情况,回调函数尽量能做到及时的返回。