2026 ROS 2 Lyrical C++ 入门(五):Action 实战——任务反馈、取消与超时处理

上一篇写 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 不是独立于业务实现的物理安全证明,更不能代替急停与安全控制。

还有两个实现边界:

  1. 当前使用单线程 spin,所有回调串行执行,因此这些成员变量不需要额外加锁。以后改成可重入回调组或其他并发模型,必须重新检查共享状态。
  2. 不要把耗时的硬件调用直接塞进 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,说明目标被接受;收到取消响应,说明取消请求有了处理结果;拿到最终终态,才知道任务按什么方式结束。

客户端等待超时,只能说明"我没有在规定时间里等到"。它不是服务端执行失败的充分证据,更不是机器人已经停止的证明。

把这个边界分清,再去写导航任务、机械臂抓取或多任务调度,很多看起来莫名其妙的"取消无效"和"任务重复执行",才能从设计阶段避免。

相关推荐
91刘仁德2 小时前
C++ 继承和多态 设计模式
c语言·c++·笔记
库玛西2 小时前
哈夫曼树与前缀编码:数据压缩的贪心核心
c语言·c++·笔记·考研
别动我齐刘海2 小时前
ROS2 Jazzy + C++ 实战路线——进阶学习3
c++·人工智能·vscode·python·算法·机器学习·机器人
0+1112 小时前
算法 --滑动窗口
c++·算法·leetcode
stolentime3 小时前
(有原题)CSP-S2026第一轮试题(附答案解析、markdown源码)
c++·csp
m0_734571763 小时前
深入理解C++ RAII
开发语言·c++
hetao17338373 小时前
2026-09-18 hetao1733837 的刷题记录
c++·算法
stolentime4 小时前
CSP-J2026第一轮试题(附答案解析、markdown源码)
c++·csp
Rabitebla4 小时前
【Linux系统编程】 指令(二):一条路径是怎么定位到文件的
linux·c++·笔记·学习·算法