4. ROS2通信机制(下):服务通信与动作通信的同步/异步模式,以及实际选型对比
上一章我们把话题通信聊透了,那种发布-订阅的模式,说白了就是“你尽管发,谁爱听谁听”。但实际工程里,很多时候我们需要的是“我问你答”或者“我让你干个活,干完了告诉我结果”。这就引出了今天的主角:服务通信和动作通信。
我个人习惯把服务通信比作“HTTP请求”,而动作通信更像是“带进度回调的异步任务”。嗯,咱们一个一个拆开看。
4.1 服务通信:同步与异步的抉择
服务通信在ROS2里用的是request / response模型。客户端发一个请求,服务端处理完返回一个响应。听起来很简单,对吧?但这里有个坑:调用方式。
4.1.1 同步调用:简单粗暴,但别滥用
同步调用就是客户端发完请求,线程卡住,等响应回来才继续往下走。代码写起来最直观:
// C++ 同步调用示例
auto client = node->create_client<example_interfaces::srv::AddTwoInts>("add_two_ints");
while (!client->wait_for_service(std::chrono::seconds(1))) {
RCLCPP_WARN(node->get_logger(), "等待服务中...");
}
auto request = std::make_shared<example_interfaces::srv::AddTwoInts::Request>();
request->a = 3;
request->b = 4;
auto result = client->async_send_request(request); // 注意:这里也是async,但可以get()
auto response = result.get(); // 阻塞在这里
RCLCPP_INFO(node->get_logger(), "结果: %ld", response->sum);
你看,虽然ROS2的C++ API里async_send_request名字带个async,但如果你调了.get(),它就变成同步了。我在项目中遇到过一个问题:某个机器人启动时,需要从地图服务加载参数。我用了同步调用,结果地图服务还没启动完,主线程就卡死了。后来加了超时机制才解决。
4.1.2 异步调用:真正的非阻塞
异步调用才是ROS2推荐的玩法。你发完请求,注册一个回调函数,该干嘛干嘛,响应回来了自动触发回调。
// C++ 异步调用示例
auto client = node->create_client<example_interfaces::srv::AddTwoInts>("add_two_ints");
while (!client->wait_for_service(std::chrono::seconds(1))) {
RCLCPP_WARN(node->get_logger(), "等待服务中...");
}
auto request = std::make_shared<example_interfaces::srv::AddTwoInts::Request>();
request->a = 10;
request->b = 20;
auto future = client->async_send_request(request,
[](rclcpp::Client<example_interfaces::srv::AddTwoInts>::SharedFuture future) {
auto response = future.get();
RCLCPP_INFO(rclcpp::get_logger("client"), "异步结果: %ld", response->sum);
});
你想想看,如果机器人要同时请求定位服务和导航服务,用同步调用就得串行等待。用异步调用,两个请求可以同时发出去,谁先回来谁先处理。说白了,这就是并发效率的提升。
4.2 动作通信:为长时间任务而生
动作通信是ROS2里我最喜欢的一个机制。它解决了服务通信的一个痛点:服务端处理时间太长怎么办?
比如让机器人去抓一个杯子。这个过程可能需要5秒钟,期间你可能想知道:走到哪了?机械臂抬起来了吗?如果中途发现杯子被拿走了,能不能取消?
动作通信提供了三个核心能力:
- Goal:下发目标(比如“去坐标(1,2)抓杯子”)
- Feedback:实时反馈进度(比如“已移动50%”)
- Result:最终结果(成功/失败 + 数据)
4.2.1 动作服务端实现
// 动作服务端核心逻辑
class PickPlaceAction : public rclcpp::Node {
public:
PickPlaceAction() : Node("pick_place_action_server") {
using namespace placeholders;
action_server_ = rclcpp_action::create_server<PickPlace>(
this,
"pick_place",
std::bind(&PickPlaceAction::handle_goal, this, _1, _2),
std::bind(&PickPlaceAction::handle_cancel, this, _1),
std::bind(&PickPlaceAction::handle_accepted, this, _1));
}
private:
rclcpp_action::Server<PickPlace>::SharedPtr action_server_;
rclcpp_action::GoalResponse handle_goal(
const rclcpp_action::GoalUUID &uuid,
std::shared_ptr<const PickPlace::Goal> goal) {
RCLCPP_INFO(this->get_logger(), "收到目标: x=%f, y=%f", goal->target_x, goal->target_y);
// 可以在这里做合法性检查
return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
}
rclcpp_action::CancelResponse handle_cancel(
const std::shared_ptr<rclcpp_action::ServerGoalHandle<PickPlace>> goal_handle) {
RCLCPP_INFO(this->get_logger(), "收到取消请求");
return rclcpp_action::CancelResponse::ACCEPT;
}
void handle_accepted(
const std::shared_ptr<rclcpp_action::ServerGoalHandle<PickPlace>> goal_handle) {
// 启动一个线程处理任务,避免阻塞
std::thread{std::bind(&PickPlaceAction::execute, this, _1), goal_handle}.detach();
}
void execute(const std::shared_ptr<rclcpp_action::ServerGoalHandle<PickPlace>> goal_handle) {
// 模拟执行过程
for (int i = 0; i < 100; i += 10) {
if (goal_handle->is_canceling()) {
auto result = std::make_shared<PickPlace::Result>();
result->success = false;
goal_handle->canceled(result);
return;
}
auto feedback = std::make_shared<PickPlace::Feedback>();
feedback->progress = i;
goal_handle->publish_feedback(feedback);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
auto result = std::make_shared<PickPlace::Result>();
result->success = true;
goal_handle->succeed(result);
}
};
4.2.2 动作客户端:异步才是王道
动作客户端天然就是异步的。你下发目标,然后通过回调接收反馈和结果:
// 动作客户端示例
auto action_client = rclcpp_action::create_client<PickPlace>(node, "pick_place");
auto goal_msg = PickPlace::Goal();
goal_msg.target_x = 1.5;
goal_msg.target_y = 2.0;
auto send_goal_options = rclcpp_action::Client<PickPlace>::SendGoalOptions();
send_goal_options.feedback_callback =
[](GoalHandlePickPlace::SharedPtr, const std::shared_ptr<const PickPlace::Feedback> feedback) {
RCLCPP_INFO(rclcpp::get_logger("client"), "进度: %d%%", feedback->progress);
};
send_goal_options.result_callback =
[](const GoalHandlePickPlace::WrappedResult &result) {
if (result.code == rclcpp_action::ResultCode::SUCCEEDED) {
RCLCPP_INFO(rclcpp::get_logger("client"), "任务成功!");
}
};
action_client->async_send_goal(goal_msg, send_goal_options);
4.3 实际选型对比:什么时候用什么?
很多初学者会问:我到底该用服务还是动作?我画了一张图帮你理清思路:
我整理了一个对比表格,方便你快速决策:
| 特性 | 服务通信 | 动作通信 |
|---|---|---|
| 通信模式 | Request-Response | Goal-Feedback-Result |
| 实时反馈 | ❌ 不支持 | ✅ 支持 |
| 取消任务 | ❌ 不支持 | ✅ 支持 |
| 典型耗时 | < 100ms | 秒级到分钟级 |
| 适用场景 | 参数查询、状态获取、简单计算 | 导航、机械臂控制、文件传输 |
| 实现复杂度 | 低 | 中高 |
我的选型口诀:
- 查个参数、算个数 → 服务同步调用(简单直接)
- 多个请求并发、不想阻塞 → 服务异步调用(高效)
- 让机器人干个活、想看进度、随时能停 → 动作通信(唯一选择)
最后说个我踩过的坑。有一次做多机器人协同,A机器人需要知道B机器人的位置。我用了服务通信,每次请求耗时2ms,看起来没问题。但后来系统扩展到10个机器人,每个机器人每秒请求10次,服务端就扛不住了。你想想看,10×10=100次/秒的请求,服务端得串行处理,延迟直接飙升到200ms。
后来怎么解决的?我把服务通信改成了话题通信。B机器人每隔100ms发布一次位置,A机器人订阅就行了。说白了,如果数据是周期性更新的,别用服务,用话题。这个教训让我明白:选型不是非黑即白,得看具体场景。