3、ROS2基础通信机制(二):服务(Service)与动作(Action)通信——客户端-服务器模型、动作服务器的回调机制、超时与重试策略

聊完了话题(Topic),咱们来聊聊服务和动作。说实话,话题虽然好用,但它有个硬伤——没有反馈。你发个数据出去,对方收没收到?处理结果怎么样?一概不知。这在自动驾驶里可不行。

举个例子,你让底盘控制器执行一个转向指令。话题发出去,对方收到了吗?转向角度到位了吗?如果没到位,要不要重发?这些问题,话题解决不了。这时候,就需要服务和动作登场了。

3.1 服务通信:一问一答的客户端-服务器模型

服务(Service)的通信模型,说白了就是客户端-服务器(Client-Server)。客户端发一个请求(Request),服务器处理完,返回一个响应(Response)。一次通信,一问一答,干净利落。

我个人习惯把服务用在那些「需要确认结果」的场景。比如:

  • 请求地图数据
  • 触发某个校准流程
  • 查询传感器状态

服务的定义文件是 .srv 格式。它包含两部分:请求和响应,用 --- 隔开。

# 一个简单的服务示例:计算两点距离
float32 x1
float32 y1
float32 x2
float32 y2
---
float32 distance

写服务端的时候,核心是定义一个回调函数。这个函数接收请求,返回响应。

# 服务端代码片段
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

class ServiceServer(Node):
    def __init__(self):
        super().__init__('service_server')
        self.srv = self.create_service(AddTwoInts, 'add_two_ints', self.add_callback)

    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

客户端这边,需要等待服务上线。我刚开始做的时候,经常忘了等,结果一启动就报错。

# 客户端代码片段
class ServiceClient(Node):
    def __init__(self):
        super().__init__('service_client')
        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_async(self.req)
        rclpy.spin_until_future_complete(self, self.future)
        return self.future.result()
注意: 服务通信是同步的。如果服务器处理时间过长,客户端会一直阻塞。在自动驾驶中,这可能导致控制周期卡死。所以,长时间的任务别用服务,用动作。

3.2 动作通信:带反馈的长期任务

动作(Action)是ROS2里我最喜欢的通信机制。它解决了服务的痛点——你发一个目标,服务器可以给你反馈进度,最后告诉你结果。而且,你还可以中途取消任务。

动作的通信模型包含三个部分:

  • 目标(Goal):你要做什么
  • 反馈(Feedback):做到哪一步了
  • 结果(Result):最终结果

动作的定义文件是 .action 格式,三段式结构:

# 一个路径跟踪动作示例
# 目标
string map_frame
float32[] waypoints
---
# 结果
bool success
string message
---
# 反馈
float32 progress_percent
float32 remaining_distance

写动作服务器,需要实现三个回调:

  • handle_goal:决定是否接受目标
  • handle_cancel:处理取消请求
  • execute:执行任务,并发布反馈
# 动作服务器核心回调
def execute_callback(self, goal_handle):
    self.get_logger().info('开始执行路径跟踪...')
    feedback_msg = FollowPath.Feedback()

    # 模拟路径跟踪过程
    for i in range(1, 101):
        if goal_handle.is_cancel_requested:
            goal_handle.canceled()
            self.get_logger().info('任务被取消')
            return FollowPath.Result(success=False, message='用户取消')

        # 更新反馈
        feedback_msg.progress_percent = float(i)
        feedback_msg.remaining_distance = 100.0 - i
        goal_handle.publish_feedback(feedback_msg)

        # 模拟执行耗时
        time.sleep(0.1)

    goal_handle.succeed()
    result = FollowPath.Result()
    result.success = True
    result.message = '路径跟踪完成'
    return result
我的经验: 在动作的 execute 回调里,一定要频繁检查 goal_handle.is_cancel_requested。我曾经写过一个路径规划动作,没加这个检查,结果用户想取消任务,服务器还在傻跑,最后把车开沟里去了。

3.3 超时与重试策略

在自动驾驶中,通信不可能永远可靠。网络抖动、节点重启、资源竞争,都会导致请求失败。所以,超时和重试是必修课。

3.3.1 服务调用的超时处理

ROS2的服务客户端默认是异步的。你可以设置超时时间,超时了就放弃。

# 带超时的服务调用
def call_service_with_timeout(self, a, b, timeout_sec=2.0):
    self.req.a = a
    self.req.b = b
    self.future = self.cli.call_async(self.req)

    # 等待指定时间
    start_time = self.get_clock().now()
    while rclpy.ok():
        if self.future.done():
            return self.future.result()
        
        elapsed = (self.get_clock().now() - start_time).nanoseconds / 1e9
        if elapsed > timeout_sec:
            self.future.cancel()
            self.get_logger().error(f'服务调用超时({timeout_sec}s)')
            return None
        
        rclpy.spin_once(self, timeout_sec=0.1)

3.3.2 动作的超时与重试

动作客户端也可以设置超时。我一般会设置一个「总超时」和一个「无反馈超时」。

# 动作客户端超时处理
def send_goal_with_timeout(self, goal_msg, timeout_sec=30.0):
    # 发送目标
    send_goal_future = self.action_client.send_goal_async(goal_msg)
    rclpy.spin_until_future_complete(self, send_goal_future, timeout_sec=5.0)

    if not send_goal_future.done():
        self.get_logger().error('发送目标超时')
        return None

    goal_handle = send_goal_future.result()
    if not goal_handle.accepted:
        self.get_logger().error('目标被拒绝')
        return None

    # 等待结果,带超时
    result_future = goal_handle.get_result_async()
    rclpy.spin_until_future_complete(self, result_future, timeout_sec=timeout_sec)

    if not result_future.done():
        goal_handle.cancel_goal()
        self.get_logger().error('动作执行超时,已取消')
        return None

    return result_future.result()

3.3.3 重试策略

重试不是简单的「失败了就再来一次」。那样会把系统搞崩。我常用的策略是:

策略名称 说明 适用场景
固定间隔重试 每隔固定时间重试一次 服务启动等待
指数退避 每次重试间隔翻倍(如1s, 2s, 4s, 8s...) 网络抖动、资源竞争
带抖动退避 在指数退避基础上加随机偏移 避免多个客户端同时重试
有限次数重试 重试N次后放弃 所有场景
# 指数退避重试实现
def retry_with_backoff(self, func, max_retries=5, base_delay=1.0):
    import random
    
    for attempt in range(max_retries):
        result = func()
        if result is not None:
            return result
        
        delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
        self.get_logger().warn(f'第{attempt+1}次重试,等待{delay:.1f}s')
        time.sleep(delay)
    
    self.get_logger().error(f'重试{max_retries}次均失败')
    return None
核心要点:
  • 服务适合「短平快」的请求-响应模式
  • 动作适合「长时间、带反馈、可取消」的任务
  • 超时设置要合理,太短容易误判,太长会阻塞系统
  • 重试策略一定要加退避,别把系统搞崩了

嗯,这里要注意一点。在实车调试时,我遇到过一个问题:服务调用超时后,客户端直接崩溃了。查了半天,发现是 future.result() 在超时后返回了 None,后续代码没做空值检查。所以,所有异步调用的结果,一定要判空。

说到动作,还有一个坑。动作服务器的 execute 回调默认是在单独的线程里执行的。如果你在回调里访问了共享资源,记得加锁。我曾经因为没加锁,导致两个动作同时修改同一个路径点数组,结果路径直接扭曲了。

最后,给大家一个建议。在设计通信接口时,先想清楚:这个任务需要反馈吗?需要中途取消吗?如果都不需要,用话题。如果需要确认结果,用服务。如果需要进度反馈和取消能力,用动作。选对了通信机制,后面能省很多事。

避坑指南: 我曾经在同一个节点里同时创建了10个服务客户端,结果启动时全部卡在 wait_for_service 上。后来发现,服务发现需要时间,多个客户端同时等待会导致死锁。解决方案是:用一个专门的「服务管理器」节点,统一管理所有服务调用。
ROS2 通信机制对比 话题(Topic) 发布-订阅,无反馈 服务(Service) 请求-响应,同步 动作(Action) 目标-反馈-结果,异步 服务通信流程 客户端 服务器 Request Response 动作通信流程 动作客户端 动作服务器 Goal Feedback(多次) Result 超时与重试策略 固定间隔重试 指数退避重试 带抖动退避 有限次数重试

这张图把三种通信机制放在一起对比。你可以看到,话题是单向的,服务是双向但一次性的,动作是双向且持续的。选哪个,取决于你的场景。

好了,关于服务和动作,就聊这么多。记住,通信机制选对了,系统就稳了一半。剩下的,就是写好回调,处理好超时和重试。