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(),它就变成同步了。我在项目中遇到过一个问题:某个机器人启动时,需要从地图服务加载参数。我用了同步调用,结果地图服务还没启动完,主线程就卡死了。后来加了超时机制才解决。

避坑指南:同步调用千万不要在回调函数里使用!我曾经在话题回调里调了一个服务,结果死锁了。因为ROS2的Executor是单线程轮询,你阻塞了回调,整个节点就“假死”了。

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);
    }
};
个人经验:动作服务端的执行函数一定要放在独立线程里。我见过有人直接在handle_accepted里写循环,结果整个节点的回调都被堵住了。记住:不要阻塞Executor。

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 实际选型对比:什么时候用什么?

很多初学者会问:我到底该用服务还是动作?我画了一张图帮你理清思路:

ROS2 通信机制选型决策树 通信需求 需要实时进度反馈? 是 动作通信 (Action) 否 服务通信 (Service) 处理时间 < 100ms? 是 同步调用 否 异步调用 需要中途取消? 是 动作通信 否 服务通信 核心原则:有反馈/可取消 → 动作;无反馈/短耗时 → 服务;不阻塞主线程 → 异步 个人经验:80%的场景用异步服务就够了,只有长时间任务才需要动作通信

我整理了一个对比表格,方便你快速决策:

特性 服务通信 动作通信
通信模式 Request-Response Goal-Feedback-Result
实时反馈 ❌ 不支持 ✅ 支持
取消任务 ❌ 不支持 ✅ 支持
典型耗时 < 100ms 秒级到分钟级
适用场景 参数查询、状态获取、简单计算 导航、机械臂控制、文件传输
实现复杂度 低 中高

我的选型口诀:

  • 查个参数、算个数 → 服务同步调用(简单直接)
  • 多个请求并发、不想阻塞 → 服务异步调用(高效)
  • 让机器人干个活、想看进度、随时能停 → 动作通信(唯一选择)

最后说个我踩过的坑。有一次做多机器人协同,A机器人需要知道B机器人的位置。我用了服务通信,每次请求耗时2ms,看起来没问题。但后来系统扩展到10个机器人,每个机器人每秒请求10次,服务端就扛不住了。你想想看,10×10=100次/秒的请求,服务端得串行处理,延迟直接飙升到200ms。

后来怎么解决的?我把服务通信改成了话题通信。B机器人每隔100ms发布一次位置,A机器人订阅就行了。说白了,如果数据是周期性更新的,别用服务,用话题。这个教训让我明白:选型不是非黑即白,得看具体场景。