ROS Action通讯机制详解:长任务、进度反馈与取消控制的工程实践
各位刚入坑 ROS 的朋友学完 Topic 和 Service 之后大概率都会遇到一个场景想让机器人去做一件“会持续很久、中途还想看进度、万一不想要了还能取消”的任务比如让机器人端着一摞盘子去洗或者导航到某个房间再回来。用 Topic 吧消息发出去没人告诉你最后成没成用 Service 吧请求发出去之后你必须干等不能中途喊停也不知道干到哪一步了。Action 通讯方式就是专门解决这类问题的。这篇教程我会用最直白的方式把 Action 的定义、原理、服务器端和客户端代码、编译运行、以及调试排错全过程拆开讲适合已经掌握 Topic 和 Service 基本用法、但对 Action 还停留在“听过没用过”阶段的读者。跟着把示例跑通了你的 ROS 通讯工具箱就真正补全了。1. Action 到底解决了什么问题1.1 用一个洗盘子的故事理解 Action 的完整流程先忘了那些术语想象这样一个场景你请朋友帮你把厨房里的一摞盘子全洗了。这个任务大概要持续几分钟你不可能站在原地什么都不干于是你会希望做到三件事第一任务下发之后你能时不时问一句“洗到第几个了”第二洗到一半突然来了电话要出门你希望说一句“不用洗了”对方能立刻停下来第三全部洗完以后你要得到一个明确的交代——“一共洗了12个盘子用时3分钟全部搞定了”。把这个场景翻译成 ROS 的通讯需求就是四个动作下发目标goal、汇报进度feedback、结束返回结果result、中途可以取消cancel。这四个动作恰好就是 Action 通讯方式的核心设计。这个例子不是随便选的。导航、机械臂抓取、舵机归位、语音播报等待长时间任务本质上都和“洗盘子”一样执行时间长、用户想知道进度、任务可以被中断、结束时必须有一个明确的成功或失败结论。只要你遇到这四类特征中的任意几项就应该考虑用 Action而不是 Topic 或 Service。1.2 Action 与 Topic、Service 的核心区别很多新手会把三种通讯方式混淆我先用一张表把它们的定位彻底说清楚。对比维度Topic 话题Service 服务Action 动作通讯方向单向数据流发布/订阅客户端请求服务器应答双向目标下发进度反馈结果返回是否支持长时间任务支持但无任务生命周期不适合客户端会阻塞等待专门为长任务设计是否有进度反馈无无有可以持续发布 feedback是否支持中途取消无无支持 cancel / preempt结果是否明确无“成功/失败”概念有请求-响应一次结束有明确区分 success / abort / preempted典型场景传感器数据流、里程计查询机器人状态、临时调用导航、机械臂执行、长时间作业你可以这样记Topic 像广播电台只负责一直往外播不关心有没有人听。Service 像自动售货机你投币它出货交互很快但一次只能做一件事做的时候你得等着。Action 则像一个可以随时跟踪、随时叫停的项目管理流程。这里还要多说一句Action 并不是凭空创造出的第三种通讯协议它在底层是由多个 Topic 和 Service 组合而成的。但从使用者的角度看你几乎感知不到底层的复杂管道只需要面对简单清晰的 Action 接口。这种“底层组合、上层封装”的设计思路也是 ROS 里非常经典的一种扩展方式理解这一点之后再看 Action 的代码就不会觉得神秘了。1.3 Action 的三个消息段设计Goal / Result / FeedbackAction 消息定义和普通消息最大的不同是它把通讯内容分成三段用两行连续的分隔线---隔开。这三段分别是 Goal、Result、Feedback。Goal 段是客户端发给服务器的任务描述比如“洗多少个盘子”、“导航到哪一号目标点”。Result 段是任务结束之后服务器返回给客户端的最终结果比如“共洗了12个”、“实际耗时不达标”。Feedback 段是任务执行过程中服务器主动发布给客户端的进度信息比如“已洗完第5个”、“当前电量还剩30%”。这个设计对应到生活里很好理解Goal 是你下达的命令Result 是领导的最终报告Feedback 是过程中的周报。三段消息各司其职共同支撑起完整的长任务通讯闭环。理解了这三个消息段接下来就可以动手搭建工程了。2. 动手前的准备环境搭建与 Action 文件定义2.1 创建功能包与基础依赖本文示例以 Ubuntu 系统下的 ROS 自带发行版为例无论你用的是哪一版主流发行版操作步骤几乎一致。建议在一个干净的 catkin 工作空间中操作我假设你已经把工作空间建好现在需要创建一个专门用来学习 Action 的功能包。打开终端进入工作空间src目录执行catkin_create_pkg learning_actionlib actionlib actionlib_msgs roscpp rospy std_msgs重点说一下这几个依赖的用途。actionlib是 ROS 的标准 Action 库提供 SimpleActionServer 和 SimpleActionClient 等核心类。actionlib_msgs里面定义了 Action 通讯所依赖的基础消息类型比如 GoalID、GoalStatus 等它是 actionlib 库的底层依赖。roscpp和rospy分别是 C 和 Python 的客户端库std_msgs用来提供基础消息标准。这个功能包名称我用的是learning_actionlib你在自己的项目里可以随意命名但后面对应的包名、代码文件、CMakeLists 配置都要保持一致。创建完成之后结构大概是learning_actionlib/ ├── CMakeLists.txt ├── package.xml ├── src/ ├── scripts/可选 └── action/2.2 编写 .action 文件核心消息的定义在功能包根目录下新建一个action文件夹在里面创建DoDishes.action文件。我给这个示例起名为“洗盘子任务”延续前面那个故事也方便你记忆。文件内容如下# 这是 Goal 段客户端告诉机器人要洗多少个盘子 float64 dish_number --- # 这是 Result 段机器人洗完所有盘子后汇报最终结果 float64 total_dishes_count float64 total_time --- # 这是 Feedback 段执行过程中把完成进度发给客户端 float64 percent_complete注意三个关键细节。第一三段之间的分隔线必须独立成行每行只有---三个减号不能有空格否则编译会报错。第二每一段里字段的类型和命名方式和普通.msg文件完全一致可以放 float64、 int32、 string、数组甚至自定义消息。第三三段内容各自由若干字段组成段与段之间字段名可以重复因为编译后它们会被分别生成到三个不同的消息类型中。编译这个.action文件之后ROS 会自动生成多个消息类型。其中基础的三类是DoDishesGoal、DoDishesResult、DoDishesFeedback。由这三类进一步组合又会生成一大串类DoDishesAction、DoDishesActionGoal、DoDishesActionResult、DoDishesActionFeedback等。你在写服务器和客户端代码时直接面向的是DoDishesGoal、DoDishesResult、DoDishesFeedback这三个基本类型以及一个总的DoDishesAction类型参数。2.3 CMakeLists.txt 与 package.xml 的完整配置这一步是新手最容易翻车的地方配置不对编译直接报错而且错误信息往往让人一头雾水。我按实际配置顺序逐项说明。打开CMakeLists.txt需要修改四处。第一处是find_package确保catkin的COMPONENTS里同时包含actionlib_msgs、actionlib、message_generationfind_package(catkin REQUIRED COMPONENTS actionlib actionlib_msgs message_generation roscpp rospy std_msgs )第二处是add_action_files它用来告诉 catkin 编译器action 目录下有需要编译的.action文件add_action_files( DIRECTORY action FILES DoDishes.action )第三处是generate_messages它需要声明依赖哪些消息包。这里容易漏掉actionlib_msgs因为 Action 基础消息类型依赖它generate_messages( DEPENDENCIES actionlib_msgs std_msgs )第四处是catkin_package把对外的依赖声明清楚其他功能包引用这个包时才能正确链接catkin_package( CATKIN_DEPENDS actionlib actionlib_msgs message_runtime )接着打开package.xml确保build_depend和exec_depend中有以下几项build_dependactionlib/build_depend build_dependactionlib_msgs/build_depend build_dependmessage_generation/build_depend exec_dependactionlib/exec_depend exec_dependactionlib_msgs/exec_depend exec_dependmessage_runtime/exec_depend每次修改完 CMakeLists.txt 或 package.xml 之后都需要重新编译并在新终端里重新执行 source 命令否则新生成的消息类型不会被识别。这个习惯非常重要后面调试会省很多事。到这里项目的“数据结构”层已经准备好了接下来写真正干活的服务器端。3. 服务器端实现让机器人真正“干活”3.1 服务器端的角色与核心类Action Server 是任务的执行方它要做的事包括接收客户端发来的 Goal启动执行流程在执行过程中周期性发布 Feedback在任务结束后返回 Result并且能响应客户的取消请求。在 ROS 的 actionlib 库中我们最常用的是SimpleActionServer它对底层的复杂状态处理做了大量封装让开发者只需要关心“收到目标后怎么执行”这一个核心逻辑。如果你以后接触到更复杂的场景比如一个服务器同时处理多个客户端目标、需要排队或者抢占策略可以再学习ActionServer底层原语但日常开发从 Simple 版本开始足够。构造函数里有一个细节会误导很多人最后一个参数是false表示服务器创建后不会立即启动需要我们在构造函数末尾手动调用.start()。这么设计的目的是为了确保对象初始化完成之后再开始接收消息避免在构造函数执行到一半时意外收到 Goal 请求。3.2 C 服务器端完整代码解读在src/目录下创建do_dishes_server.cpp完整代码如下#include ros/ros.h #include actionlib/server/simple_action_server.h #include learning_actionlib/DoDishesAction.h class DoDishesActionServer { protected: ros::NodeHandle nh_; actionlib::SimpleActionServerlearning_actionlib::DoDishesAction as_; std::string action_name_; learning_actionlib::DoDishesFeedback feedback_; learning_actionlib::DoDishesResult result_; public: DoDishesActionServer(std::string name) : as_(nh_, name, boost::bind(DoDishesActionServer::executeCB, this, _1), false), action_name_(name) { as_.start(); } ~DoDishesActionServer(void) {} void executeCB(const learning_actionlib::DoDishesGoalConstPtr goal) { bool success true; ros::Rate rate(1); ROS_INFO(%s: 开始洗盘子目标个数 %f, action_name_.c_str(), goal-dish_number); for (int i 0; i (int)goal-dish_number; i) { if (as_.isPreemptRequested() || !ros::ok()) { ROS_INFO(%s: 任务被取消, action_name_.c_str()); as_.setPreempted(); success false; break; } feedback_.percent_complete (float)(i * 10); as_.publishFeedback(feedback_); ROS_INFO(%s: 进度 %f%%, action_name_.c_str(), feedback_.percent_complete); rate.sleep(); } if (success) { result_.total_dishes_count goal-dish_number; result_.total_time goal-dish_number * 1.0; ROS_INFO(%s: 洗完啦, action_name_.c_str()); as_.setSucceeded(result_); } } }; int main(int argc, char** argv) { ros::init(argc, argv, do_dishes_server); DoDishesActionServer doDishes_server(do_dishes); ros::spin(); return 0; }我来逐段解释最关键的部分。构造函数中的第三行参数boost::bind(DoDishesActionServer::executeCB, this, _1)把类的成员函数绑定成回调函数。如果直接用普通函数指针会编译失败因为成员函数隐含有 this 指针。_1表示当收到 Goal 时框actionlib 会把目标对象作为第一个参数传给这个回调。executeCB是整个服务器的核心。首先取出goal-dish_number作为要洗的盘子数量。然后进入循环每洗一个盘子做三件事第一步检查是否收到取消请求调用as_.isPreemptRequested()这是最容易被忽略的一行第二步更新 feedback 并调用as_.publishFeedback(feedback_)发布进度第三步用ros::Rate(1)控制节奏让整个循环大约每秒执行一次。循环结束后用success变量决定是否调用setSucceeded。如果中途被取消success为 false则已经调用了setPreempted不会再调用setSucceeded。3.3 executeCB 中必须处理的三个关键细节实战中我发现很多新人写完服务器代码客户端却一直收不到反馈或者取消功能失灵几乎都是栽在这三个细节上。第一个细节取消检测必须在循环体内部反复执行而不能只在函数开头检查一次。因为取消请求是随时可能到达的如果你的任务循环体执行时间很长只在开头检查客户端取消后任务还会继续往下跑看起来就像“取消失效”。这个和人的行为是一样的你不可能只在出门时问一句“要不要回来”得一路随时接收最新指令。第二个细节反馈字段更新之后必须显式调用publishFeedback才能真正发布出去。只修改feedback_的成员变量而不调用发布函数客户端什么都收不到这是最简单也最常犯的错误。第三个细节setSucceeded、setAborted、setPreempted三个状态必须根据任务实际情况选择调用。正常完成用setSucceeded并把 result 填好执行中发生不可恢复的错误用setAborted任务被打断只完成了部分工作用setPreempted。不要全程只用setSucceeded否则客户端无法区分“成功完成”和“提前退出”。服务器端写完之后下面写发号施令的客户端。4. 客户端实现发任务、看进度、收结果4.1 客户端的状态机与两种调用模式SimpleActionClient是任务的发起方它负责连接服务器、发送 Goal、接收 Feedback、等待 Result。它会维护一个清晰的状态机常用的几个状态包括Pending目标等待发送、Active任务正在执行、Done任务结束有待读取结果、Rejected目标被服务器拒绝、Aborted任务中止、Preempted任务被抢占/取消。客户端有两种调用模式要按实际需求选。同步模式指sendGoal之后调用waitForResult程序会阻塞在这里直到任务结束或超时适合脚本化、一条龙式的任务流程。异步模式指sendGoal之后注册回调函数主程序继续做别的feedback 和 result 到了之后再触发回调适合需要一边执行任务一边处理其他逻辑的场景。4.2 C 客户端完整代码解读在src/目录下创建do_dishes_client.cpp完整代码如下#include ros/ros.h #include actionlib/client/simple_action_client.h #include actionlib/client/terminal_state.h #include learning_actionlib/DoDishesAction.h int main(int argc, char** argv) { ros::init(argc, argv, do_dishes_client); actionlib::SimpleActionClientlearning_actionlib::DoDishesAction ac(do_dishes, true); ROS_INFO(等待 Action Server 启动...); ac.waitForServer(); learning_actionlib::DoDishesGoal goal; goal.dish_number 5; ROS_INFO(发送洗盘子目标%f, goal.dish_number); ac.sendGoal(goal); bool finished_before_timeout ac.waitForResult(ros::Duration(30.0)); if (finished_before_timeout) { actionlib::SimpleClientGoalState state ac.getState(); ROS_INFO(任务结束%s, state.toString().c_str()); learning_actionlib::DoDishesResultConstPtr result ac.getResult(); ROS_INFO(共洗了 %f 个盘子用时 %f 秒, result-total_dishes_count, result-total_time); } else { ROS_INFO(30秒内没有完成任务请检查服务器状态); } return 0; }waitForServer()是第一步必须做的事。它会阻塞等待直到对应名称的 Action Server 在 ROS 网络中可用。这里的名字do_dishes必须和服务器端构造函数传入的字符串完全一致一个字母都不能差否则客户端会一直卡在这里。发送目标之后waitForResult(ros::Duration(30.0))的返回值很关键。它返回true表示在 30 秒内任务有最终结果无论成功还是失败返回false表示超时。所以这个返回值只能说明“有没有在时限内结束”不能说明“是否成功”。判断成功与否要调用getState()对比状态值是否为SUCCEEDED。获取结果时用ac.getResult()返回的是一个常量指针读取其中的字段要使用-运算符。如果waitForResult超时了说明服务器可能压根没干活此时getResult()返回的指针可能是空指针直接解引用会崩溃。所以先判断超时再取结果顺序不能乱。4.3 需要“取消任务”时如何配合客户端取消任务调用的是ac.cancelGoal()它会向服务器发送取消请求。但客户端发送取消请求之后任务并没有立刻消失真正的停止动作发生在服务器端。服务器收到取消请求后在executeCB循环中检测到isPreemptRequested()返回true然后调用setPreempted()此时客户端的状态才会变为PREEMPTED。所以如果你想测试取消功能应该让服务器端的循环足够长、节奏足够慢否则任务瞬间跑完你根本没机会取消。这里还有一个经验取消的联动测试是验证 Action 机制是否真正理解到位的试金石。我见过不少新人以为客户端 cancelGoal 就是魔法可以瞬间把服务器中断。实际在底层这只是一种状态传递服务器代码必须配合检查才能实现优雅的中断。4.4 Python 实现对照版本如果你更习惯 Python这里给出服务器端和客户端的精简对照实现流程和 C 版完全一致。服务器端do_dishes_server.py#!/usr/bin/env python import rospy import actionlib from learning_actionlib.msg import DoDishesAction, DoDishesFeedback, DoDishesResult class DoDishesServer: def __init__(self, name): self.server actionlib.SimpleActionServer(name, DoDishesAction, self.execute_cb, False) self.server.start() rospy.loginfo(Server %s started, name) def execute_cb(self, goal): r rospy.Rate(1) for i in range(int(goal.dish_number)): if self.server.is_preempt_requested() or rospy.is_shutdown(): self.server.set_preempted() return feedback DoDishesFeedback() feedback.percent_complete i * 10 self.server.publish_feedback(feedback) r.sleep() result DoDishesResult() result.total_dishes_count goal.dish_number result.total_time goal.dish_number self.server.set_succeeded(result) if __name__ __main__: rospy.init_node(do_dishes_server) server DoDishesServer(do_dishes) rospy.spin()客户端do_dishes_client.py#!/usr/bin/env python import rospy import actionlib from learning_actionlib.msg import DoDishesAction, DoDishesGoal def main(): rospy.init_node(do_dishes_client) client actionlib.SimpleActionClient(do_dishes, DoDishesAction) client.wait_for_server() goal DoDishesGoal() goal.dish_number 5 client.send_goal(goal) client.wait_for_result(rospy.Duration(30.0)) if client.get_state() actionlib.GoalStatus.SUCCEEDED: result client.get_result() rospy.loginfo(result: %f, result.total_dishes_count) else: rospy.loginfo(failed: %s, client.get_goal_status_text()) if __name__ __main__: main()Python 版运行时需要注意三点脚本文件必须加可执行权限chmod x消息类型要先编译生成执行前确保环境变量已经 source。如果 import 时报错说找不到learning_actionlib.msg几乎都是因为少了编译或者少了 source。5. 编译运行与调试保姆级的排错手册5.1 编译流程与运行顺序所有源码写完后回到工作空间根目录执行编译catkin_make如果编译通过会看到生成的devel目录里多出了与 action 相关的头文件和 Python 包。记住每次编译后都要在当前终端重新 sourcesource devel/setup.bash运行阶段建议开四个终端按以下顺序执行。第一个终端启动roscore第二个终端启动服务器source devel/setup.bash rosrun learning_actionlib do_dishes_server第三个终端启动客户端source devel/setup.bash rosrun learning_actionlib do_dishes_client第四个终端用来调试观察。注意一个常见的误区有人先启动客户端后启动服务器。客户端里的waitForServer()会等待理论上可以等服务器启动但这会造成一定的阻塞迷惑新手还是建议严格按照“先服务器、后客户端”的顺序来。5.2 用 rosnode / rostopic / rqt_graph 验证 Action 机制服务器和客户端都运行起来之后在第四个终端里执行rosnode list能看到do_dishes_server和do_dishes_client两个节点。再执行rostopic list你会看到一组很有规律的话题名/do_dishes/cancel /do_dishes/feedback /do_dishes/goal /do_dishes/result /do_dishes/status这串话题名拼在一起就是 Action 底层运作的真实管道。/goal用来下发任务/cancel用来取消/feedback是服务器反推进度/result是最终结果/status是状态信息。实时观察反馈可以执行rostopic echo /do_dishes/feedback客户端运行期间你能看到进度数值从 0 一步步增长整个过程非常直观。如果想更直观地看到节点与消息流的连接关系可以运行rqt_graph这个图形界面会展示两个节点之间由这些话题连接起来的网你会看到 Action 在底层其实是由多路连接组成的“复合通讯”这对深化理解很有帮助。5.3 常见问题速查表我把实际操作中最常踩的问题整理成一张速查表建议收藏起来对照排查。问题现象根本原因解决办法编译时找不到DoDishesAction.hCMakeLists 缺少add_action_files或没重新编译检查配置重新catkin_make编译时提示 actionlib_msgs 相关错误find_package或generate_messages缺少依赖补齐 actionlib_msgs重新编译客户端一直卡在waitForServer服务器没启动或名字拼写不一致检查rosnode list对比两端的 action 名称客户端 waitForResult 超时服务器循环内没有调用 setSucceeded在正常完成后调用setSucceeded(result)收不到 feedback服务器更新字段后没有 publishFeedback每次更新后调用publishFeedback任务无法取消循环内没有检查 isPreemptRequested在循环体内部添加取消检测并 breakPython import 不到 msg未生成消息包 / 未 source / 脚本无执行权限catkin_makesourcechmod xgetResult()指针崩溃超时之后直接取结果先判断 waitForResult 返回值再取结果5.4 不用写客户端也能快速测试服务器调试服务器端代码时如果不想每次写一个客户端测试可以用rostopic pub直接向 Action 的 goal 话题发送目标。Action 的 goal 话题消息类型是复合类型包含goal_id和goal两个字段直接在命令行里写完整字段比较麻烦。不过你动态观察的时候可以看到那个消息结构的嵌套形式用 CtrlC 中断后topic 消息结构会被打印出来参照它填数据即可。这个方法特别适合验证服务器是否正常工作。客户端代码的测试放在最后先用 rostopic 确认服务器端逻辑没问题再逐步加入客户端逻辑排查范围会大大缩小。5.5 两个实战习惯能帮你少走三个月弯路第一个习惯是每次修改完 CMakeLists 或 package.xml 之后不要只编译一定要重新 source 环境再运行。很多人遇到“为什么我明明加了依赖还报错”的问题其实就是环境变量还停留在旧版本进程加载的还是旧的编译产物。这个坑在长周期开发中会出现得非常频繁。第二个习惯是保持节点命名的一致性。服务器端的 action 名称、客户端的 action 名称、以及你用来观察的 rostopic 名称三方必须是同一个字符串。很多莫名其妙“连不上服务器”的问题最后查出来都是大小写、下划线或者 docker 环境里的端口隔离问题名字完全一致是排查的第一要素。再补充一个建议在学习阶段把反馈频率调低比如ros::Rate(1)一秒一次能让你非常清晰地看到 Action 的整个生命周期。等彻底理解了再把频率调到实际需求的值比如 10Hz 甚至 50Hz。调试复杂系统时看得见比跑得快重要得多。我在实际项目里用过最多次的 Action 组合就是导航任务结合反馈进度来做 UI 展示。客户端收到 feedback 就更新界面上的进度条收到 result 就把任务标记完成用户随时点击取消按钮就调一次 cancelGoal整个过程非常顺滑。这也是我强烈建议你亲手跑一遍上面这个洗盘子例子的原因——把最简单的小循环跑通等面对真实的机器人导航、机械臂抓取时你已经有足够的能力把长任务调度掌控在自己的代码里了。这次教程把 Action 的原理到实战都拆完了。接下来你可以试着改一下任务内容比如把 dish_number 改成字符串类型的菜品名、把 feedback 加入多条进度信息、加一个客户端取消按钮的交互这些练习都能帮你真正把 Action 变成顺手的基础工具。