4、服务通信实战:Service与Client的同步与异步调用模式
服务通信,说白了就是ROS2里一种"请求-应答"的机制。跟话题那种"发了就不管"的发布-订阅模式不一样,服务是有来有回的——客户端发一个请求,服务端处理完再给你一个响应。
我刚开始接触ROS2时,总觉得服务跟话题差不多。后来在项目里踩了坑才明白,这两种模式适用的场景完全不同。今天咱们就把服务的两种调用方式——同步和异步,掰开揉碎了讲清楚。
核心要点:同步调用会阻塞当前线程直到收到响应;异步调用不会阻塞,你可以继续干别的事,响应回来了再处理。
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循环里,尽量别用同步调用,不然很容易把自己坑进去。