4. 服务通信实战:同步/异步客户端、服务端多线程处理、超时与重试机制

服务通信,说白了就是ROS2里的“一问一答”模式。你发一个请求,它回一个响应。听起来简单,但实际用起来坑不少。

我记得刚带团队做第一个机器人项目时,就栽在服务通信上。客户端发了个请求,服务端处理太慢,客户端直接卡死。整个系统像死了一样。从那以后,我对服务通信的设计就格外小心。

今天咱们就把这块彻底讲透。同步客户端、异步客户端、服务端多线程、超时重试,一个一个来。

4.1 同步客户端:简单但危险

同步客户端,就是发请求后一直等,等到响应回来才继续往下走。

代码写起来很直观:

# 同步客户端示例
from example_interfaces.srv import AddTwoInts
import rclpy
from rclpy.node import Node

class SyncClient(Node):
    def __init__(self):
        super().__init__('sync_client')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        
        # 等待服务端上线
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().info('服务未就绪,继续等待...')
        
        self.req = AddTwoInts.Request()
        
    def call_service(self, a, b):
        self.req.a = a
        self.req.b = b
        
        # 同步调用,会阻塞
        self.future = self.cli.call_async(self.req)
        rclpy.spin_until_future_complete(self, self.future)
        
        if self.future.result() is not None:
            return self.future.result().sum
        else:
            self.get_logger().error('服务调用失败')
            return None

看到没?spin_until_future_complete 这行就是阻塞点。在等待期间,节点啥也干不了。

⚠️ 避坑指南
我曾经在一个导航项目里用了同步客户端。机器人走到一半,服务端处理路径规划花了3秒。这3秒里,客户端完全卡死,激光雷达数据收不到,机器人直接撞墙。同步客户端只适合在初始化阶段或非实时场景使用。

4.2 异步客户端:真正的实战选择

异步客户端就不一样了。发完请求,该干嘛干嘛。响应回来了,回调函数自动处理。

# 异步客户端示例
class AsyncClient(Node):
    def __init__(self):
        super().__init__('async_client')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().info('等待服务...')
        
        self.req = AddTwoInts.Request()
        
    def send_request(self, a, b):
        self.req.a = a
        self.req.b = b
        
        # 异步调用,不阻塞
        self.future = self.cli.call_async(self.req)
        
        # 添加回调,响应回来后自动处理
        self.future.add_done_callback(self.response_callback)
        
    def response_callback(self, future):
        try:
            response = future.result()
            self.get_logger().info(f'收到响应: {response.sum}')
        except Exception as e:
            self.get_logger().error(f'服务调用异常: {e}')

你看,call_async 调用后立即返回。节点可以继续处理其他事情。响应回来了,response_callback 自动触发。

💡 我的习惯
我个人习惯在回调函数里加一个标志位。比如 self.response_received = True。这样主循环可以检查这个标志,知道服务调用是否完成。比在回调里直接处理业务逻辑更灵活。

4.3 服务端多线程处理

服务端默认是单线程的。什么意思?就是一次只能处理一个请求。如果第一个请求处理了5秒,第二个请求就得排队等5秒。

你想想看,如果多个客户端同时发请求,单线程服务端就成了瓶颈。

解决办法?用多线程回调组。

# 多线程服务端示例
from rclpy.executors import MultiThreadedExecutor
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup

class MultiThreadServer(Node):
    def __init__(self):
        super().__init__('multi_thread_server')
        
        # 创建独立的回调组
        self.cb_group = MutuallyExclusiveCallbackGroup()
        
        # 将服务绑定到该回调组
        self.srv = self.create_service(
            AddTwoInts, 
            'add_two_ints', 
            self.handle_request,
            callback_group=self.cb_group
        )
        
    def handle_request(self, request, response):
        # 模拟耗时操作
        import time
        time.sleep(2)
        
        response.sum = request.a + request.b
        self.get_logger().info(f'处理完成: {request.a} + {request.b} = {response.sum}')
        return response

# 使用多线程执行器
def main():
    rclpy.init()
    node = MultiThreadServer()
    
    # 创建多线程执行器,指定线程数
    executor = MultiThreadedExecutor(num_threads=4)
    executor.add_node(node)
    
    try:
        executor.spin()
    finally:
        executor.shutdown()
        node.destroy_node()
        rclpy.shutdown()

这里的关键点有两个:

  • 回调组MutuallyExclusiveCallbackGroup 表示这个组里的回调是互斥的,但不同组之间可以并行
  • 多线程执行器MultiThreadedExecutor 负责分配线程
🔑 核心要点
多线程不是越多越好。我建议线程数不超过CPU核心数+1。线程太多,上下文切换的开销反而会拖慢系统。我在一个8核的工控机上试过,4个线程效果最好,8个反而变慢了。

4.4 超时与重试机制

网络通信,不可靠是常态。服务端可能挂了,可能网络延迟,可能处理超时。没有超时重试机制,系统就是纸糊的。

ROS2的服务客户端本身没有内置超时参数。需要我们自己实现。

# 带超时重试的客户端
import asyncio

class RobustClient(Node):
    def __init__(self):
        super().__init__('robust_client')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        self.req = AddTwoInts.Request()
        
        # 配置参数
        self.max_retries = 3
        self.timeout_seconds = 2.0
        self.retry_delay = 0.5
        
    async def call_with_retry(self, a, b):
        self.req.a = a
        self.req.b = b
        
        for attempt in range(1, self.max_retries + 1):
            self.get_logger().info(f'第 {attempt} 次尝试...')
            
            # 检查服务是否可用
            if not self.cli.service_is_ready():
                self.get_logger().warn('服务不可用,等待...')
                await asyncio.sleep(self.retry_delay)
                continue
            
            # 发送异步请求
            future = self.cli.call_async(self.req)
            
            try:
                # 等待指定时间
                result = await asyncio.wait_for(
                    asyncio.wrap_future(future), 
                    timeout=self.timeout_seconds
                )
                return result.sum
                
            except asyncio.TimeoutError:
                self.get_logger().warn(f'第 {attempt} 次超时')
                # 取消未完成的future
                future.cancel()
                
                if attempt < self.max_retries:
                    await asyncio.sleep(self.retry_delay)
                continue
                
            except Exception as e:
                self.get_logger().error(f'异常: {e}')
                break
        
        self.get_logger().error('所有重试均失败')
        return None

这个实现里,我用了 asyncio.wait_for 来实现超时。为什么不用 time.sleep?因为 sleep 会阻塞整个线程,而 asyncio.sleep 不会。

⚠️ 重要提醒
重试策略要谨慎设计。我曾经见过一个项目,重试间隔设了0.1秒,服务端一挂,客户端疯狂重试,直接把CPU跑满了。建议重试间隔至少0.5秒,并且加上指数退避。比如第一次等0.5秒,第二次等1秒,第三次等2秒。

4.5 实战经验总结

说了这么多,我总结几条实战经验:

场景 推荐方案 原因
初始化阶段 同步客户端 简单直接,失败就报错,不影响后续
实时控制循环 异步客户端 不阻塞主循环,保证控制频率
高并发请求 多线程服务端 避免请求排队,提高吞吐量
不可靠网络 超时+重试 保证系统鲁棒性
💡 最后一个小技巧
调试服务通信时,我习惯先开一个 ros2 service list 看看服务有没有注册成功。再用 ros2 service call 手动调一下,确认服务端能正常响应。这两个命令能帮你快速定位问题是出在客户端还是服务端。

嗯,服务通信这块就讲到这里。下一章咱们聊话题通信的进阶用法,包括QoS配置和零拷贝传输。到时候见。