上一篇写 Service / Client 时,我们关心的是:请求发出后,怎样拿到响应?
机器人真正开始执行任务以后,还会遇到另一组问题:搬运到哪一步了?任务为什么一直没结束?用户点了取消,设备是不是已经停下?
尤其要小心最后一个问题。客户端不等了、服务端接受取消、任务已经结束,是三件不同的事。
这篇文章用一个"模拟搬运任务"把它们拆开:自定义 Action,编写 C++ 服务端和客户端,持续接收进度,再分别观察成功、拒绝、失败、取消以及本地超时。
目标环境:已安装 ROS 2 Lyrical 的 Ubuntu / Bash 环境,C++17。本文示例仅推进计数,不控制真实底盘或机械臂,不需要 Gazebo。
验证说明:接口和 CMake 写法对照了 ROS 2 官方 Lyrical 分支。本次写作环境无法访问 WSL 中的 ROS,因此尚未完成 Lyrical 实际编译和运行;下面的日志与结果均为预期,不是实测记录。文末提供完整复现清单。
一、Service 能异步调用,为什么还需要 Action?
不要把两者简单理解为"Service 同步,Action 异步"。上一篇使用的 Service 客户端本来就是异步发请求的。
区别在于任务语义。对于持续数秒甚至更久的工作,我们希望有目标、有中间反馈、有最终结果,还能提出取消请求。Action 将这些交互组织在一起;不必让业务代码额外拼出一套进度查询和取消协议。
本例中:
| 部分 | 内容 | 含义 |
|---|---|---|
| Goal | total_steps、fail_at | 要做多少步,是否注入故障 |
| Feedback | completed_steps、progress | 当前进度 |
| Result | completed_steps、message | 结束时完成多少步,附带说明 |
Action 自身还带有终态:SUCCEEDED、ABORTED、CANCELED。它与我们定义的 Result 字段不是一回事。收到一个结果对象,不能不看终态就认定成功。
可对照 ROS 2 Action 官方设计 理解目标生命周期。
二、先把本例的规则说清楚
为了让现象容易观察,我们采用以下规则:
- 服务端每 500 毫秒推进一步,一次只接一个任务。
- 总步数范围为 1~1000;正在忙或参数非法时拒绝新目标。
- fail_at 为 0 表示不注入故障;为 4 表示在执行第 4 步之前失败,已完成步数应为 3。
- 客户端等待最终结果超时后,默认主动请求取消,然后继续确认终态。
- 关闭自动取消后,客户端退出,服务端仍可继续推进。
这只是本文选择的业务策略,不是所有 ROS 2 Action 服务端的固定行为。
执行结构也刻意保持简单:一个单线程 Executor,加一个短小的定时器回调。没有长循环,没有 sleep,也没有 detach 后难以管理生命周期的线程。
三、建立功能包与接口
在 Bash 终端创建目录:
bash
source /opt/ros/lyrical/setup.bash
mkdir -p ~/ros2_lyrical_ws/src/lyrical_action_demo/action
mkdir -p ~/ros2_lyrical_ws/src/lyrical_action_demo/src
cd ~/ros2_lyrical_ws/src/lyrical_action_demo
将下面五个文件放到对应位置。这里把接口和节点放在同一个包中,便于第一次复现;项目变大后,可以将接口独立到专门的 interfaces 包。
text
lyrical_action_demo/
├── action/
│ └── CarryTask.action
├── src/
│ ├── carry_server.cpp
│ └── carry_client.cpp
├── CMakeLists.txt
└── package.xml
3.1 action/CarryTask.action
text
# Goal: one simulated transport task
int32 total_steps
int32 fail_at
---
# Result
int32 completed_steps
string message
---
# Feedback
int32 completed_steps
float32 progress
两个分隔符划分的是 Goal、Result、Feedback,注意 Result 在 Feedback 前面。
文件名 CarryTask.action 对应 C++ 类型 lyrical_action_demo::action::CarryTask,生成的头文件使用小写下划线形式 carry_task.hpp。它由构建过程生成,不要手工创建。
3.2 package.xml
xml
<?xml version="1.0"?>
<package format="3">
<name>lyrical_action_demo</name>
<version>0.1.0</version>
<description>Timer-based C++ action tutorial</description>
<maintainer email="maintainer@example.com">Tutorial Maintainer</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<buildtool_depend>rosidl_default_generators</buildtool_depend>
<depend>rclcpp</depend>
<depend>rclcpp_action</depend>
<depend>action_msgs</depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<export><build_type>ament_cmake</build_type></export>
</package>
3.3 CMakeLists.txt
cmake
cmake_minimum_required(VERSION 3.20)
project(lyrical_action_demo LANGUAGES C CXX)
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclcpp_action REQUIRED)
find_package(action_msgs REQUIRED)
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"action/CarryTask.action"
)
rosidl_get_typesupport_target(cpp_typesupport_target
${PROJECT_NAME} rosidl_typesupport_cpp)
foreach(target carry_server carry_client)
add_executable(${target} src/${target}.cpp)
target_compile_features(${target} PRIVATE cxx_std_17)
target_link_libraries(${target} PRIVATE
rclcpp::rclcpp
rclcpp_action::rclcpp_action
action_msgs::action_msgs
"${cpp_typesupport_target}"
)
endforeach()
install(TARGETS carry_server carry_client
DESTINATION lib/${PROJECT_NAME})
ament_export_dependencies(rosidl_default_runtime)
ament_package()
这里使用 target_link_libraries() 和导出的 CMake targets,不依赖 ament_target_dependencies()。
同包定义和使用接口时,不能只写头文件 include 就结束。rosidl_get_typesupport_target() 取得生成接口对应的 C++ 类型支持 target,再将节点链接到它;构建系统据此获得生成头文件和链接依赖。
这里同时启用 C 与 CXX,因为接口生成过程不只有手写的 C++ 文件。
写法可对照 Lyrical 官方 Action 教程源码 与 rosidl 类型支持 target 定义。
四、服务端:别让执行任务挡住取消请求
保存为 src/carry_server.cpp:
cpp
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include "rclcpp/rclcpp.hpp"
#include "rclcpp_action/rclcpp_action.hpp"
#include "lyrical_action_demo/action/carry_task.hpp"
using namespace std::chrono_literals;
using Task = lyrical_action_demo::action::CarryTask;
using ServerHandle = rclcpp_action::ServerGoalHandle<Task>;
class CarryServer : public rclcpp::Node
{
public:
CarryServer() : Node("carry_server")
{
server_ = rclcpp_action::create_server<Task>(
this, "carry_task",
[this](const rclcpp_action::GoalUUID &,
std::shared_ptr<const Task::Goal> goal)
{
if (busy_ || goal->total_steps < 1 || goal->total_steps > 1000 ||
goal->fail_at < 0 || goal->fail_at > goal->total_steps)
{
RCLCPP_WARN(get_logger(), "REJECTED: busy or invalid goal");
return rclcpp_action::GoalResponse::REJECT;
}
busy_ = true; // Reserve the one execution slot.
return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
},
[this](std::shared_ptr<ServerHandle>)
{
RCLCPP_INFO(get_logger(), "Cancel accepted; waiting for next tick");
return rclcpp_action::CancelResponse::ACCEPT;
},
[this](std::shared_ptr<ServerHandle> handle)
{
active_ = handle;
completed_ = 0;
RCLCPP_INFO(get_logger(), "START total_steps=%d",
static_cast<int>(handle->get_goal()->total_steps));
});
timer_ = create_wall_timer(500ms, [this]() { tick(); });
}
private:
void finish(const std::string & state, const std::string & message)
{
auto result = std::make_shared<Task::Result>();
result->completed_steps = completed_;
result->message = message;
if (state == "CANCELED") {
active_->canceled(result);
} else if (state == "ABORTED") {
active_->abort(result);
} else {
active_->succeed(result);
}
RCLCPP_INFO(get_logger(), "%s completed=%d", state.c_str(),
static_cast<int>(completed_));
active_.reset();
busy_ = false;
}
void tick()
{
if (!active_) {
return;
}
if (active_->is_canceling()) {
// Simulation only: no hardware needs stopping in this example.
finish("CANCELED", "Canceled before the next simulated step");
return;
}
const auto goal = active_->get_goal();
if (goal->fail_at > 0 && completed_ + 1 == goal->fail_at) {
finish("ABORTED", "Injected failure before the requested step");
return;
}
++completed_;
auto feedback = std::make_shared<Task::Feedback>();
feedback->completed_steps = completed_;
feedback->progress =
static_cast<float>(completed_) / goal->total_steps;
active_->publish_feedback(feedback);
if (completed_ == goal->total_steps) {
finish("SUCCEEDED", "All simulated steps completed");
}
}
bool busy_{false};
int32_t completed_{0};
std::shared_ptr<ServerHandle> active_;
rclcpp_action::Server<Task>::SharedPtr server_;
rclcpp::TimerBase::SharedPtr timer_;
};
int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
// Intentionally single-threaded; callbacks never block.
rclcpp::spin(std::make_shared<CarryServer>());
rclcpp::shutdown();
return 0;
}
这段代码的关键,不在计数,而在回调的分工。
目标回调负责检查参数并预留唯一执行位置;accepted 回调保存 GoalHandle;定时器逐步执行;取消回调只表达"同意取消",随后由定时器检查 is_canceling(),再调用 canceled() 返回终态。
接受取消与完成取消被明确分成了两步。
本例没有硬件,结束计数就能完成模拟任务的取消。真实机器人应当先让下层执行器停止、处理必要的清理与状态确认,再报告完成取消。Action 的 CANCELED 不是独立于业务实现的物理安全证明,更不能代替急停与安全控制。
还有两个实现边界:
- 当前使用单线程 spin,所有回调串行执行,因此这些成员变量不需要额外加锁。以后改成可重入回调组或其他并发模型,必须重新检查共享状态。
- 不要把耗时的硬件调用直接塞进 tick()。如果回调迟迟不返回,取消和其他事件仍然会被拖住。真实任务应拆分为非阻塞状态机,或使用有明确退出、同步和回收机制的工作线程。
五、客户端:超时以后,不要替服务端宣布结束
保存为 src/carry_client.cpp:
cpp
#include <chrono>
#include <exception>
#include <memory>
#include "action_msgs/srv/cancel_goal.hpp"
#include "rclcpp/rclcpp.hpp"
#include "rclcpp_action/rclcpp_action.hpp"
#include "lyrical_action_demo/action/carry_task.hpp"
using namespace std::chrono_literals;
using Task = lyrical_action_demo::action::CarryTask;
using ClientHandle = rclcpp_action::ClientGoalHandle<Task>;
using Wait = rclcpp::FutureReturnCode;
int run(const std::shared_ptr<rclcpp::Node> & node)
{
const auto steps = node->declare_parameter<int>("total_steps", 10);
const auto fail_at = node->declare_parameter<int>("fail_at", 0);
const auto wait_sec = node->declare_parameter<int>("wait_sec", 15);
const auto cancel = node->declare_parameter<bool>("cancel_on_timeout", true);
if (wait_sec < 1 || wait_sec > 3600) {
RCLCPP_ERROR(node->get_logger(), "wait_sec must be in [1, 3600]");
return 2;
}
auto client = rclcpp_action::create_client<Task>(node, "carry_task");
if (!client->wait_for_action_server(5s)) {
RCLCPP_ERROR(node->get_logger(), "Action server unavailable");
return 2;
}
Task::Goal goal;
goal.total_steps = steps;
goal.fail_at = fail_at;
rclcpp_action::Client<Task>::SendGoalOptions options;
options.feedback_callback =
[node](ClientHandle::SharedPtr,
const std::shared_ptr<const Task::Feedback> feedback)
{
RCLCPP_INFO(node->get_logger(), "FEEDBACK step=%d progress=%.0f%%",
static_cast<int>(feedback->completed_steps),
100.0 * feedback->progress);
};
auto goal_future = client->async_send_goal(goal, options);
if (rclcpp::spin_until_future_complete(node, goal_future, 3s) != Wait::SUCCESS) {
RCLCPP_ERROR(node->get_logger(),
"No goal response: acceptance UNKNOWN; do not blindly resend");
return 2;
}
auto handle = goal_future.get();
if (!handle) {
RCLCPP_WARN(node->get_logger(), "REJECTED");
return 3;
}
auto result_future = client->async_get_result(handle);
auto outcome = rclcpp::spin_until_future_complete(
node, result_future, std::chrono::seconds(wait_sec));
if (outcome == Wait::INTERRUPTED) {
RCLCPP_ERROR(node->get_logger(), "Interrupted: final state UNKNOWN");
return 2;
}
if (outcome == Wait::TIMEOUT) {
RCLCPP_WARN(node->get_logger(),
"Local result wait timed out; this does NOT stop the server");
if (!cancel) {
RCLCPP_WARN(node->get_logger(),
"Leaving without cancel; goal may continue on server");
return 4;
}
auto cancel_future = client->async_cancel_goal(handle);
if (rclcpp::spin_until_future_complete(node, cancel_future, 3s) == Wait::SUCCESS) {
const auto response = cancel_future.get();
if (response->return_code ==
action_msgs::srv::CancelGoal::Response::ERROR_NONE &&
!response->goals_canceling.empty())
{
RCLCPP_INFO(node->get_logger(),
"Cancel acknowledged; waiting for terminal result");
} else {
RCLCPP_WARN(node->get_logger(),
"Cancel not acknowledged (code=%d); still check final result",
static_cast<int>(response->return_code));
}
} else {
RCLCPP_WARN(node->get_logger(),
"No cancel response; cancellation state UNKNOWN");
}
// A cancel response is NOT the final result.
if (rclcpp::spin_until_future_complete(node, result_future, 5s) != Wait::SUCCESS) {
RCLCPP_ERROR(node->get_logger(),
"No terminal result; do NOT assume robot has stopped");
return 2;
}
}
const auto wrapped = result_future.get();
const char * state = "UNKNOWN";
int exit_code = 2;
switch (wrapped.code) {
case rclcpp_action::ResultCode::SUCCEEDED:
state = "SUCCEEDED"; exit_code = 0; break;
case rclcpp_action::ResultCode::ABORTED:
state = "ABORTED"; exit_code = 5; break;
case rclcpp_action::ResultCode::CANCELED:
state = "CANCELED"; exit_code = 6; break;
default:
break;
}
RCLCPP_INFO(node->get_logger(), "FINAL %s", state);
if (wrapped.result) {
RCLCPP_INFO(node->get_logger(), "completed=%d message=%s",
static_cast<int>(wrapped.result->completed_steps),
wrapped.result->message.c_str());
}
return exit_code;
}
int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("carry_client");
int code = 2;
try {
code = run(node);
} catch (const std::exception & error) {
RCLCPP_ERROR(node->get_logger(),
"Exception: %s; remote task state may be UNKNOWN", error.what());
}
rclcpp::shutdown();
return code;
}
客户端的等待分成几个阶段:
| 等待对象 | 本例上限 | 到期后知道什么 |
|---|---|---|
| 服务发现 | 5 秒 | 暂未发现可用服务端 |
| 目标响应 | 3 秒 | 是否被接受可能未知,不能直接重发 |
| 最终结果 | wait_sec | 只是本地等待到期 |
| 取消响应 | 3 秒 | 是否接受取消可能未知 |
| 取消后的最终结果 | 5 秒 | 未确认终态时,不能认定停止 |
wait_sec 从开始等待结果时计算,不是覆盖所有阶段的统一端到端截止时间。
注意 async_send_goal() 返回的 Future:它等待的是目标响应,不是搬运完成。拿到有效 GoalHandle 后,还要通过 async_get_result() 等待终态。
spin_until_future_complete() 则一边驱动 Executor,一边等待 Future,因此等待结果期间仍能处理反馈。本例从 main 的普通控制流调用它;不要直接照搬进一个已经被 Executor 管理的节点回调里做嵌套 spin。
取消响应中的 goals_canceling 非空,只说明有目标进入取消流程。最终怎样结束,仍以 WrappedResult 的 code 为准。如果任务恰好先完成,取消和完成之间可能发生竞争,最终得到 SUCCEEDED 并不一定是错误。
客户端 API 可对照 rclcpp_action 的 Lyrical 实现。
六、安装依赖与构建
打开用于构建的新终端,执行:
bash
source /opt/ros/lyrical/setup.bash
cd ~/ros2_lyrical_ws
rosdep install --from-paths src --ignore-src -r -y --rosdistro lyrical
colcon build --symlink-install --packages-select lyrical_action_demo
source install/setup.bash
ros2 interface show lyrical_action_demo/action/CarryTask
如果 rosdep 尚未初始化,需要先完成该工具的初始化与更新;不要把 rosdep 的 Python 弃用警告自动视为依赖安装失败,应查看最终结果与退出状态。
后续每个运行终端,都执行这两行:
bash
source /opt/ros/lyrical/setup.bash
source ~/ros2_lyrical_ws/install/setup.bash
然后在终端 A 启动服务端,并保持运行:
bash
ros2 run lyrical_action_demo carry_server
七、先跑正常任务,再故意让它出问题
以下测试请逐项执行,等待上一任务结束再启动下一项。只有"忙时拒绝"测试需要同时启动两个客户端。
7.1 正常完成:SUCCEEDED
终端 B:
bash
ros2 run lyrical_action_demo carry_client
默认 10 步,500 毫秒推进一次,通常约 5 秒结束。定时器不是在收到目标时重新启动,因此首步时刻及总时长会有偏差,不要把这个计数器当作精确定时器。
预期看到逐步递增的 FEEDBACK,以及:
text
FINAL SUCCEEDED
completed=10 message=All simulated steps completed
这不是实测输出的逐字复刻;时间戳与日志前缀省略。
7.2 参数非法:REJECTED
bash
ros2 run lyrical_action_demo carry_client --ros-args -p total_steps:=0
预期客户端报告 REJECTED。被拒绝的目标没有进入执行流程,也就不应继续等待它的任务结果。
7.3 故障注入:ABORTED
bash
ros2 run lyrical_action_demo carry_client --ros-args -p fail_at:=4
预期完成 3 步后,服务端在第 4 步开始前主动终止,客户端得到 ABORTED。这与用户请求取消不同。
7.4 等待到期后主动取消:CANCELED
bash
ros2 run lyrical_action_demo carry_client --ros-args \
-p total_steps:=40 -p wait_sec:=2
任务按正常节奏需要约 20 秒,但客户端只先等 2 秒。默认 cancel_on_timeout 为 true,所以它会主动发送取消请求,再等待最终结果。
预期顺序是本地等待超时、取消确认、FINAL CANCELED;反馈、响应日志在边界时刻可能交错。完成步数不应写死成 4,因为目标到达时间、定时器相位与调度存在差异。
这里演示的是"由客户端超时策略触发的主动取消",不是 Action 框架自动替你取消。
7.5 只超时,不取消:服务端继续做
bash
ros2 run lyrical_action_demo carry_client --ros-args \
-p total_steps:=20 -p wait_sec:=2 -p cancel_on_timeout:=false
客户端约在等待结果 2 秒后退出,但终端 A 中的任务仍会继续,随后预期出现 SUCCEEDED completed=20。
这是本文最值得亲自做的一次测试:客户端生命周期结束,并没有自动结束服务端任务。
同样,不要默认 Ctrl+C 会触发本例的业务取消。当前客户端中断时可能直接退出;如果需要"退出前先取消并确认",应额外设计关闭流程。真机还需要控制侧看门狗与安全策略,不能把客户端是否存活当成唯一保障。
7.6 忙时拒绝:没有隐式排队,也没有自动抢占
在终端 B 启动一个较长任务:
bash
ros2 run lyrical_action_demo carry_client --ros-args \
-p total_steps:=40 -p wait_sec:=30
等终端 A 打出 START 后,在终端 C 再运行默认客户端:
bash
ros2 run lyrical_action_demo carry_client
第二个目标预期被拒绝,第一个继续执行。这是 busy_ 明确实现的策略。若项目需要排队或新目标抢占旧目标,应单独设计,而不是假设 Action 自动提供。
7.7 客户端退出码
在客户端命令结束后,立即执行 echo $? 可检查进程退出码。它是本文程序定义的,不是 ROS Action 协议状态编号。
| 退出码 | 本例含义 |
|---|---|
| 0 | 成功 |
| 2 | 通信、中断、异常或无法确认终态等问题 |
| 3 | 目标被拒绝 |
| 4 | 本地超时,选择不取消 |
| 5 | 服务端执行失败 |
| 6 | 确认取消完成 |
因此 ros2 run 报进程非零退出,不一定表示崩溃;先结合 FINAL 和日志判断。这种设计也方便以后用脚本编写验收测试。
八、不写客户端,也能检查 Action
服务端运行时,可以先检查名称和类型:
bash
ros2 action list -t
ros2 action info /carry_task
ros2 interface show lyrical_action_demo/action/CarryTask
再用命令行发送目标并观察反馈:
bash
ros2 action send_goal /carry_task \
lyrical_action_demo/action/CarryTask \
"{total_steps: 6, fail_at: 0}" --feedback
不要同时让 C++ 客户端占着执行位置,否则命令行目标会被本例服务端拒绝。
九、几个特别容易误判的问题
"接口头文件找不到,是不是要手工写一个?"
不是。检查 action 路径、rosidl_generate_interfaces()、类型支持 target 链接,以及构建有没有真正完成。生成文件名是 carry_task.hpp,不是 CarryTask.hpp。
"又出现 Unknown CMake command ament_target_dependencies"
检查实际构建的是不是本文这份 CMakeLists.txt。本文没有调用这个宏,不要将旧教程中的几行混进去后再判断为同一份代码出错。也要核对当前终端加载的 ROS 发行版与工作空间路径。
"服务端在运行,但客户端找不到"
先检查所有终端是否 source 了同一个环境,ROS_DOMAIN_ID 是否一致,Action 名称、命名空间和类型是否匹配,再检查网络与中间件配置。不要一开始就修改业务回调。
"取消已经接受,机器人为什么还在动?"
接受取消只是开始取消流程。检查执行逻辑有没有处理取消状态、有没有将停止命令传到下层,以及何时才报告 CANCELED。不要在取消回调里直接假定整个物理动作已经结束。
"Future 一直没完成,是不是网络坏了?"
也可能是 Executor 没有得到执行机会。一个占住线程的长循环,就足以拖住反馈与取消处理。本文把任务拆成短定时器回调,目的正是保留处理这些事件的机会。
"等待目标响应超时了,再发一次就行?"
不一定。服务端可能已经接受了第一次目标,只是响应还没被客户端确认。重复发送可能带来重复作业;本文只报告状态未知,不自动重试。生产系统需要目标追踪、业务任务编号和去重策略。
十、从演示到机器人项目,还缺什么?
本例覆盖的是 Action 通信与基本状态处理,不是完整机器人任务执行器。用于导航或机械臂时,还需要补充:
- 真实执行器反馈、停止确认与独立安全机制;
- 业务超时和通信失联策略,而不只是客户端等待上限;
- 任务持久化、恢复、去重与重启后的状态核对;
- 明确的排队或抢占规则,以及并发状态保护;
- 控制故障、服务端掉线、取消被拒绝等异常测试。
尤其不要把本例 progress 换成一个随时间上涨的数字,就当作真实任务进度。进度应来自业务状态:完成多少工序、剩余多少距离,或者到达哪个可验证阶段。
总结:三个"结束",必须分清
写 Action,最重要的不只是会调用 async_send_goal(),而是准确理解每个响应承诺了什么。
拿到 GoalHandle,说明目标被接受;收到取消响应,说明取消请求有了处理结果;拿到最终终态,才知道任务按什么方式结束。
客户端等待超时,只能说明"我没有在规定时间里等到"。它不是服务端执行失败的充分证据,更不是机器人已经停止的证明。
把这个边界分清,再去写导航任务、机械臂抓取或多任务调度,很多看起来莫名其妙的"取消无效"和"任务重复执行",才能从设计阶段避免。