4、服务通信实战:Service与Client的同步与异步调用模式

服务通信,说白了就是ROS2里一种"请求-应答"的机制。跟话题那种"发了就不管"的发布-订阅模式不一样,服务是有来有回的——客户端发一个请求,服务端处理完再给你一个响应。

我刚开始接触ROS2时,总觉得服务跟话题差不多。后来在项目里踩了坑才明白,这两种模式适用的场景完全不同。今天咱们就把服务的两种调用方式——同步和异步,掰开揉碎了讲清楚。

核心要点:同步调用会阻塞当前线程直到收到响应;异步调用不会阻塞,你可以继续干别的事,响应回来了再处理。

服务通信同步与异步调用模式对比 服务通信:同步 vs 异步调用模式 同步调用模式 Client 节点 Service 节点 请求 (Request) 响应 (Response) 阻塞 异步调用模式 Client 节点 Service 节点 请求 (Request) 响应 (Response) 非阻塞 回调函数处理响应 主线程继续执行其他任务

4.1 同步调用:简单直接,但小心阻塞

同步调用,就是客户端发完请求后,原地等着,直到服务端把响应送回来。代码写起来很直观,跟调用普通函数差不多。

来看一个实际例子。假设我们要写一个加法计算器服务:

# 服务端代码(add_two_ints_server.py)
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

class AddTwoIntsServer(Node):
    def __init__(self):
        super().__init__('add_two_ints_server')
        self.srv = self.create_service(
            AddTwoInts, 'add_two_ints', self.add_callback)
        self.get_logger().info('服务端已启动,等待请求...')

    def add_callback(self, request, response):
        response.sum = request.a + request.b
        self.get_logger().info(
            f'收到请求: {request.a} + {request.b} = {response.sum}')
        return response

def main(args=None):
    rclpy.init(args=args)
    node = AddTwoIntsServer()
    rclpy.spin(node)
    rclpy.shutdown()

if __name__ == '__main__':
    main()

服务端代码没什么特别的。关键在于客户端怎么调用——同步方式长这样:

# 客户端代码(同步调用)
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

class AddTwoIntsClientSync(Node):
    def __init__(self):
        super().__init__('add_two_ints_client_sync')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        
        # 等待服务可用
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().warn('服务不可用,继续等待...')
        
        self.req = AddTwoInts.Request()

    def send_request(self, a, b):
        self.req.a = a
        self.req.b = b
        
        # 同步调用 —— 这里会阻塞!
        self.future = self.cli.call(self.req)
        return self.future

def main(args=None):
    rclpy.init(args=args)
    node = AddTwoIntsClientSync()
    
    response = node.send_request(3, 5)
    node.get_logger().info(f'同步调用结果: 3 + 5 = {response.sum}')
    
    node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

注意:上面的代码有个坑——cli.call()是阻塞的。如果服务端处理很慢,或者网络有延迟,你的程序就会卡在那里。我曾经在一个机器人导航项目里用了同步调用,结果服务端超时,整个控制循环都停了,机器人直接撞墙……从那以后,我对同步调用就格外小心。

4.2 异步调用:不阻塞,更灵活

异步调用就不一样了。你发完请求,不用等,该干嘛干嘛。响应回来了,通过回调函数或者Future对象来获取结果。

我个人习惯用异步方式,尤其是在需要同时处理多个任务的场景里。来看代码:

# 客户端代码(异步调用)
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

class AddTwoIntsClientAsync(Node):
    def __init__(self):
        super().__init__('add_two_ints_client_async')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().warn('服务不可用,继续等待...')
        
        self.req = AddTwoInts.Request()

    def send_request_async(self, a, b):
        self.req.a = a
        self.req.b = b
        
        # 异步调用 —— 不阻塞,立即返回一个Future
        future = self.cli.call_async(self.req)
        
        # 添加回调,响应回来时自动触发
        future.add_done_callback(self.response_callback)
        
        return future

    def response_callback(self, future):
        try:
            response = future.result()
            self.get_logger().info(
                f'异步回调结果: {self.req.a} + {self.req.b} = {response.sum}')
        except Exception as e:
            self.get_logger().error(f'服务调用失败: {e}')

def main(args=None):
    rclpy.init(args=args)
    node = AddTwoIntsClientAsync()
    
    # 发送异步请求,不阻塞
    node.send_request_async(10, 20)
    
    # 这里可以干别的事
    node.get_logger().info('请求已发送,继续执行其他任务...')
    
    # 保持节点运行,等待回调
    rclpy.spin(node)
    rclpy.shutdown()

if __name__ == '__main__':
    main()

看到区别了吗?call_async()一调用就返回,主线程可以继续往下走。响应回来了,response_callback自动被触发。

4.3 同步 vs 异步:什么时候用哪个?

这个问题,我经常被问到。其实没有的好坏,关键看场景。下面这张表是我自己总结的:

对比维度 同步调用 异步调用
代码复杂度 简单,像普通函数调用 稍复杂,需要回调或Future处理
是否阻塞 阻塞当前线程 不阻塞,立即返回
适用场景 简单查询、配置读取、一次性操作 高频调用、多任务并发、实时性要求高
错误处理 直接try-except捕获 在回调中处理,或通过Future.result()捕获
性能影响 可能拖慢主循环 几乎无影响,响应异步到达

我的建议:如果你只是偶尔调一次服务,比如启动时读取配置,用同步就够了。但如果你在控制循环里频繁调用服务,或者一个节点要同时跟多个服务通信,一定要用异步。我在做多传感器融合项目时,一个节点同时调用了5个服务,全用异步,主循环稳稳地跑在100Hz。

4.4 实战避坑:我踩过的几个坑

讲几个我实际遇到过的坑,希望能帮你少走弯路。

  • 坑一:同步调用导致死锁
    我曾经在回调函数里调用了同步服务。你想想看,回调函数本身就在spin线程里跑,你再阻塞它,整个节点就卡死了。解决方案?要么用异步,要么把同步调用放到单独的线程里。
  • 坑二:忘记等待服务可用
    服务端启动比客户端慢,客户端直接发请求,结果抛出异常。所以wait_for_service()这一步不能省。我习惯加个超时和重试机制。
  • 坑三:异步回调中修改共享数据
    回调函数在另一个线程里执行,如果你在里面修改了全局变量,记得加锁。不然数据竞争会让你调试到怀疑人生。

4.5 一个更完整的异步调用示例

最后,给一个我在项目中实际用过的模式——带超时和重试的异步调用:

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts
import time

class RobustAsyncClient(Node):
    def __init__(self):
        super().__init__('robust_async_client')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        self._pending_futures = []

    def call_with_retry(self, a, b, max_retries=3):
        """带重试的异步调用"""
        for attempt in range(max_retries):
            if not self.cli.wait_for_service(timeout_sec=2.0):
                self.get_logger().warn(f'服务不可用,重试 {attempt+1}/{max_retries}')
                continue
            
            future = self.cli.call_async(AddTwoInts.Request(a=a, b=b))
            self._pending_futures.append(future)
            
            # 添加超时检查
            future.add_done_callback(
                lambda f: self._handle_response(f, a, b))
            return future
        
        self.get_logger().error('服务不可用,放弃请求')
        return None

    def _handle_response(self, future, a, b):
        try:
            response = future.result()
            self.get_logger().info(f'{a}+{b}={response.sum}')
        except Exception as e:
            self.get_logger().error(f'请求失败: {e}')
        finally:
            self._pending_futures.remove(future)

def main(args=None):
    rclpy.init(args=args)
    node = RobustAsyncClient()
    
    # 连续发送多个请求
    for i in range(5):
        node.call_with_retry(i, i*2)
    
    rclpy.spin(node)
    rclpy.shutdown()

这个模式的好处是:请求不会阻塞主循环,失败会自动重试,而且能同时处理多个请求。我在实际项目中,这个模式帮我解决了不少棘手的问题。

总结一下:同步调用简单直接,适合低频、一次性的场景;异步调用灵活高效,适合高频、并发的场景。选哪个,取决于你的实际需求。但记住一点——在ROS2的spin循环里,尽量别用同步调用,不然很容易把自己坑进去。