4. 服务通信深入:自定义服务接口、同步与异步客户端、服务端多线程处理、超时与重试机制

服务通信,说白了就是ROS2里一种“你问我答”的机制。跟话题那种“你发我收”的广播模式不一样,服务是点对点的,有请求有响应。很多刚接触ROS2的朋友容易把服务和话题搞混,其实你想想看——话题适合持续流数据,比如传感器数据;服务适合一次性任务,比如“帮我算个路径”、“给我查个参数”。

我个人习惯,在需要确认结果的场景下,一定用服务。比如控制机械臂抓取,你不能发了指令就不管了,得等它告诉你“抓到了”或者“失败了”。这就是服务的价值。

4.1 自定义服务接口(.srv)

ROS2自带了一些标准服务,比如std_srvs/srv/Empty、std_srvs/srv/SetBool。但实际项目中,这些根本不够用。我做过一个自动驾驶项目,需要查询某个障碍物的详细信息,就得自己定义服务接口。

自定义.srv文件很简单。它由三部分组成:请求、分隔符、响应。

# 查询障碍物信息
# 请求
int32 obstacle_id
string query_type
---
# 响应
bool success
string obstacle_type
float32 distance
float32 velocity
string description

这里要注意几个坑:

  • 字段类型:ROS2支持int32、float64、string、bool等基础类型,也支持数组和嵌套结构。
  • 分隔符:三个短横线---必须独占一行,前后不能有空格。我曾经因为多打了个空格,编译报错找了半小时。
  • 包名约定:我建议把自定义服务接口放在单独的包里,比如my_robot_interfaces。这样其他包引用起来方便,也符合ROS2的规范。

定义好之后,在CMakeLists.txt里加上:

rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/QueryObstacle.srv"
)

然后在package.xml里声明依赖:

<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

编译之后,你就可以在代码里直接引用这个服务了。

小技巧:我习惯在.srv文件里加注释,说明每个字段的含义和取值范围。这样团队其他人用起来一目了然,不用翻文档。

4.2 同步客户端 vs 异步客户端

ROS2的客户端有两种调用方式:同步和异步。很多初学者搞不清区别,其实很简单。

同步客户端:调用后一直等着,直到拿到响应才继续往下走。适合简单场景,比如初始化时查个参数。

auto client = node->create_client<my_interfaces::srv::QueryObstacle>("query_obstacle");
auto request = std::make_shared<my_interfaces::srv::QueryObstacle::Request>();
request->obstacle_id = 101;

while (!client->wait_for_service(std::chrono::seconds(1))) {
  RCLCPP_WARN(rclcpp::get_logger("rclcpp"), "等待服务中...");
}

auto result = client->async_send_request(request);
// 这里会阻塞,直到拿到结果
if (rclcpp::spin_until_future_complete(node, result) == rclcpp::FutureReturnCode::SUCCESS) {
  auto response = result.get();
  RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "障碍物类型: %s", response->obstacle_type.c_str());
}

异步客户端:调用后不等待,直接返回。你可以继续做其他事,等响应回来了再处理。适合高实时性场景,比如控制循环里查询障碍物。

auto client = node->create_client<my_interfaces::srv::QueryObstacle>("query_obstacle");
auto request = std::make_shared<my_interfaces::srv::QueryObstacle::Request>();
request->obstacle_id = 101;

auto future = client->async_send_request(request, 
  [](rclcpp::Client<my_interfaces::srv::QueryObstacle>::SharedFuture future) {
    auto response = future.get();
    RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "异步收到响应: %s", response->obstacle_type.c_str());
  });

// 这里可以继续做其他事情
RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "请求已发送,继续执行...");
核心区别:同步客户端会阻塞当前线程,异步客户端不会。在自动驾驶这种实时系统里,我几乎只用异步客户端。因为控制循环不能等,一等就丢帧。

4.3 服务端多线程处理

默认情况下,ROS2的服务端是单线程的。什么意思呢?就是一次只能处理一个请求。如果同时来了多个请求,后面的得排队。

这在低负载场景下没问题。但我在做多传感器融合时,多个模块同时请求障碍物信息,单线程就扛不住了。响应延迟从几毫秒飙升到几百毫秒。

解决办法是多线程服务端。ROS2的rclcpp支持通过CallbackGroup来实现。

// 创建多线程执行器
auto executor = std::make_shared<rclcpp::executors::MultiThreadedExecutor>();

// 创建回调组
auto callback_group = node->create_callback_group(
  rclcpp::CallbackGroupType::MutuallyExclusive);

// 创建服务时指定回调组
auto service = node->create_service<my_interfaces::srv::QueryObstacle>(
  "query_obstacle",
  std::bind(&handle_query, this, std::placeholders::_1, std::placeholders::_2),
  rmw_qos_profile_services_default,
  callback_group);

executor->add_node(node);
executor->spin();

这里有个关键点:CallbackGroupType有两种:

类型 说明 适用场景
MutuallyExclusive 互斥,同一组内的回调不会同时执行 需要保护共享资源时
Reentrant 可重入,同一组内的回调可以同时执行 无状态服务,追求高并发
注意:多线程处理时,一定要考虑线程安全。如果服务端访问了全局变量或共享数据结构,记得加锁。我曾经因为没加锁,导致两个线程同时修改障碍物列表,数据全乱了。

4.4 服务调用超时与重试机制

服务调用不是100%可靠的。网络抖动、服务端过载、节点崩溃,都可能导致调用失败。所以,超时和重试机制是必须的。

超时设置:ROS2的同步客户端支持超时参数。我一般设成2秒,超过就认为失败。

auto result = client->async_send_request(request);
auto status = rclcpp::spin_until_future_complete(node, result, std::chrono::seconds(2));

if (status == rclcpp::FutureReturnCode::TIMEOUT) {
  RCLCPP_ERROR(rclcpp::get_logger("rclcpp"), "服务调用超时!");
  // 处理超时逻辑
} else if (status == rclcpp::FutureReturnCode::SUCCESS) {
  // 正常处理
}

重试机制:超时后不能直接放弃,得重试。我常用的策略是“指数退避”——第一次等1秒,第二次等2秒,第三次等4秒,最多重试3次。

int max_retries = 3;
int retry_delay = 1; // 初始延迟1秒

for (int i = 0; i < max_retries; ++i) {
  auto result = client->async_send_request(request);
  auto status = rclcpp::spin_until_future_complete(node, result, std::chrono::seconds(2));
  
  if (status == rclcpp::FutureReturnCode::SUCCESS) {
    // 成功,跳出循环
    break;
  }
  
  RCLCPP_WARN(rclcpp::get_logger("rclcpp"), 
    "第%d次重试,等待%d秒...", i+1, retry_delay);
  std::this_thread::sleep_for(std::chrono::seconds(retry_delay));
  retry_delay *= 2; // 指数退避
}
经验之谈:重试次数不要太多,3次足够了。如果连续3次都失败,说明系统可能出了大问题,不如直接报错,让上层决策。我在项目中就遇到过,因为网络分区导致一直重试,结果把系统资源耗尽了。

4.5 知识体系总览

下面这张图总结了本章的核心内容,你可以对照着梳理思路:

服务通信深入:知识体系 1. 自定义服务接口 (.srv) • 请求 + 分隔符 + 响应 三段式 • 支持基础类型、数组、嵌套结构 • 单独放在 interfaces 包中管理 • CMakeLists.txt + package.xml 配置 2. 客户端调用方式 • 同步客户端:阻塞等待,简单直接 • 异步客户端:非阻塞,回调处理 • 实时系统推荐异步方式 • 通过 Future 获取响应结果 3. 服务端多线程处理 • 默认单线程,请求排队处理 • MultiThreadedExecutor 实现并发 • CallbackGroup 控制并发策略 • 注意线程安全,共享资源加锁 4. 超时与重试机制 • spin_until_future_complete 超时 • 指数退避重试策略 • 最大重试次数建议3次 • 失败后及时上报,避免资源耗尽 核心:接口定义 → 客户端调用 → 服务端处理 → 异常处理

嗯,以上就是服务通信深入的全部内容。从自定义接口到多线程处理,再到超时重试,每一步都是我在实际项目中踩过坑、总结出来的经验。你把这些吃透了,ROS2的服务通信这块基本就没什么能难住你的了。

可可精品资料库