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 回调默认是在单独的线程里执行的。如果你在回调里访问了共享资源,记得加锁。我曾经因为没加锁,导致两个动作同时修改同一个路径点数组,结果路径直接扭曲了。
最后,给大家一个建议。在设计通信接口时,先想清楚:这个任务需要反馈吗?需要中途取消吗?如果都不需要,用话题。如果需要确认结果,用服务。如果需要进度反馈和取消能力,用动作。选对了通信机制,后面能省很多事。
wait_for_service 上。后来发现,服务发现需要时间,多个客户端同时等待会导致死锁。解决方案是:用一个专门的「服务管理器」节点,统一管理所有服务调用。
这张图把三种通信机制放在一起对比。你可以看到,话题是单向的,服务是双向但一次性的,动作是双向且持续的。选哪个,取决于你的场景。
好了,关于服务和动作,就聊这么多。记住,通信机制选对了,系统就稳了一半。剩下的,就是写好回调,处理好超时和重试。