欢迎光临
我们一直在努力

2026 ROS 2 Lyrical C++ 入门(六):为什么节点会“卡住”?——Executor、回调组与死锁排查

你可能遇到过这样的情况:Service 服务端明明已经处理请求,客户端却一直等不到结果;Action 已经开始执行,取消请求却迟迟没有反应。

第一反应往往是网络、DDS 或 QoS。于是增加超时时间,甚至把单线程换成多线程。

结果还是一样。

问题有时不在消息有没有到,而在于:处理消息的代码,是否有机会执行?

上一篇我们用定时器推进 Action 任务,避免在回调里写长循环。这一篇继续往下挖:Executor、线程与回调组分别限制了什么,为什么“用了异步接口”仍然可能把自己等住?

实测环境:2026 年 9 月 20 日,在 WSL2 / Ubuntu 26.04 LTS、ROS 2 Lyrical、rclcpp 32.0.2、GCC 15.2.0、C++17 下完成编译,并通过本文全部 8 组对照实验。运行使用 Cyclone DDS(rmw_cyclonedds_cpp)、ROS_DOMAIN_ID=187、本机发现模式。具体时序受机器负载影响,不能将本次间隔数值视为实时性保证。

示例故意制造阻塞,但等待有上限,不控制真实机器人。请勿直接放入底盘控制或机械臂安全链路。

一、先区分三种“卡住”

“节点卡住”是现象,不是诊断。

现象可能原因本文怎样验证
某个回调很久才执行 唯一线程正忙于另一个回调 耗时回调与心跳定时器
已换成多线程,仍然串行 两个回调位于同一个互斥组 保持线程数不变,只修改分组
回调等待结果,结果又无法被处理 等待者占用了被等待处理所需的资源 Service 请求与响应对照

第一种不一定是死锁:耗时工作结束,其他回调可能继续。

第三种如果无限等待,就可能形成无法自行解除的循环依赖。本文用 wait_for(3s) 给它一个退出条件,因此实际示例会超时返回,不能把它描述为“永久死锁”。

二、线程数量与回调组,管的不是同一件事

Executor 负责驱动可执行的工作,例如定时器回调、订阅回调和客户端响应处理。

SingleThreadedExecutor 一次只执行一项回调工作。MultiThreadedExecutor 提供多个工作线程,但“有空闲线程”并不等于“某个回调被允许同时执行”。

回调组还会施加约束:

  • MutuallyExclusive:同组回调不能同时执行。
  • Reentrant:允许同组并发,适用时也允许同一个回调的不同执行实例重叠。
  • 不同回调组可以并行,但还要有可用线程,且不能被业务锁等条件挡住。

没有显式指定时,节点的默认回调组是互斥组。因此,把节点直接放进多线程 Executor,并不会自动让默认组中的回调并发。

这些约束可对照 Lyrical 官方回调组指南。

下面不继续堆概念,直接观察同一份代码在不同配置下的行为。

三、准备一个独立实验包

本篇不依赖上一篇的自定义 Action,用两个定时器和标准 Trigger 服务就能说明问题。

在工作空间中建立目录:

source /opt/ros/lyrical/setup.bash
mkdir -p ~/ros2_lyrical_ws/src/lyrical_executor_demo/src
cd ~/ros2_lyrical_ws/src/lyrical_executor_demo

lyrical_executor_demo/
├── CMakeLists.txt
├── package.xml
└── src/
├── scheduling_demo.cpp
├── ping_server.cpp
└── wait_client.cpp

CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(lyrical_executor_demo LANGUAGES CXX)
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_srvs REQUIRED)
find_package(Threads REQUIRED)
foreach(target scheduling_demo ping_server wait_client)
add_executable(${target} src/${target}.cpp)
target_compile_features(${target} PRIVATE cxx_std_17)
target_link_libraries(${target} PRIVATE
rclcpp::rclcpp std_srvs::std_srvs Threads::Threads)
endforeach()
install(TARGETS scheduling_demo ping_server wait_client
DESTINATION lib/${PROJECT_NAME})
ament_package()

package.xml

<?xml version="1.0"?>
<package format="3">
<name>lyrical_executor_demo</name>
<version>0.1.0</version>
<description>Executor and callback group experiments</description>
<maintainer email="maintainer@example.com">Tutorial Maintainer</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rclcpp</depend>
<depend>std_srvs</depend>
<export><build_type>ament_cmake</build_type></export>
</package>

本文继续使用 target_link_libraries() 和导出的依赖 target,不调用 ament_target_dependencies()。Threads::Threads 用于显式链接标准线程示例需要的线程库。

四、实验一:一个耗时回调,让心跳停顿

保存为 src/scheduling_demo.cpp:

#include <chrono>
#include <memory>
#include <stdexcept>
#include <thread>
#include "rclcpp/rclcpp.hpp"

using namespace std::chrono_literals;
using Clock = std::chrono::steady_clock;

class SchedulingDemo : public rclcpp::Node
{
public:
SchedulingDemo() : Node("scheduling_demo")
{
threads = declare_parameter<int>("threads", 1);
const bool split = declare_parameter<bool>("split_groups", false);
if (threads < 1 || threads > 8) {
throw std::runtime_error("threads must be in [1, 8]");
}
if (split) {
slow_group_ = create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
fast_group_ = create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
}
last_ = Clock::now();
heartbeat_ = create_wall_timer(200ms, [this]() {
const auto now = Clock::now();
const auto gap = std::chrono::duration<double, std::milli>(now last_).count();
last_ = now;
RCLCPP_INFO(get_logger(), "heartbeat gap=%.0f ms", gap);
}, fast_group_);
slow_ = create_wall_timer(1s, [this]() {
slow_->cancel(); // Run the slow operation only once.
RCLCPP_INFO(get_logger(), "slow BEGIN");
std::this_thread::sleep_for(2s); // Deliberately bad for this experiment.
RCLCPP_INFO(get_logger(), "slow END");
}, slow_group_);
}
int threads{1};

private:
Clock::time_point last_;
rclcpp::CallbackGroup::SharedPtr slow_group_, fast_group_;
rclcpp::TimerBase::SharedPtr heartbeat_, slow_;
};

int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
auto node = std::make_shared<SchedulingDemo>();
if (node->threads == 1) {
rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(node);
executor.spin();
} else {
rclcpp::executors::MultiThreadedExecutor executor(
rclcpp::ExecutorOptions(), static_cast<size_t>(node->threads));
executor.add_node(node);
executor.spin();
}
rclcpp::shutdown();
return 0;
}

程序有两个定时器:心跳期望每 200 毫秒执行一次,慢回调在启动后约 1 秒触发,只执行一次,并故意 sleep 2 秒。

heartbeat gap 使用 steady_clock 测量相邻心跳回调的实际执行间隔,而不是使用可能随仿真暂停的 ROS 时间。重点观察 slow BEGIN 到 slow END 之间,心跳能否继续。

注意,这里 sleep 的目的就是制造坏例子,不是建议在控制回调里这样写。

后面的两个源码文件也保存完成后,再统一构建。此处先列出本实验的运行方式。

4.1 单线程、默认组

ros2 run lyrical_executor_demo scheduling_demo –ros-args \\
-p threads:=1 -p split_groups:=false

预期:开始时心跳间隔接近 200 毫秒;慢回调执行期间心跳出现明显空档;慢回调返回后恢复。

间隔不保证精确为 200 或 2000 毫秒。操作系统调度、日志输出和定时器相位都会影响结果;不要把示例当成实时性基准测试。

虽然 sleep 期间 CPU 可以运行其他线程,但唯一的 Executor 执行线程并没有从当前回调返回,不能继续处理这个节点的心跳。

4.2 已拆组,但仍是单线程

ros2 run lyrical_executor_demo scheduling_demo –ros-args \\
-p threads:=1 -p split_groups:=true

预期仍然有心跳停顿。分组只是允许并发,不会替你创建额外执行线程。

每次测试后按 Ctrl+C 退出,再运行下一项,避免多个同名节点混淆日志。

五、实验二:两条线程,为什么还是没有改善?

5.1 两线程、默认组

ros2 run lyrical_executor_demo scheduling_demo –ros-args \\
-p threads:=2 -p split_groups:=false

预期仍然出现慢回调期间的心跳空档。

线程增加了,但两个定时器仍然使用同一个默认互斥组。慢回调尚未结束时,心跳不能进入该组执行。

这解释了一种常见误区:MultiThreadedExecutor 是提供执行资源,不是解除互斥规则。

5.2 两线程、不同组

ros2 run lyrical_executor_demo scheduling_demo –ros-args \\
-p threads:=2 -p split_groups:=true

预期心跳可以在 slow BEGIN 与 slow END 之间继续出现。

slow_group_ 和 fast_group_ 都是互斥组,因此各自组内仍然串行;两个组之间则允许并行。我们没有为了并发把所有回调都改成 Reentrant。

线程与分组慢回调执行时的预期心跳
单线程,同组 明显停顿
单线程,不同组 明显停顿
两线程,同组 明显停顿
两线程,不同组 可以继续执行,但不保证精确周期

回调组保存在成员变量里,避免生命周期过早结束。定时器的 group 参数写法可核对 Lyrical Node API。

六、实验三:服务端已经处理了,客户端为什么超时?

这个实验将服务端放在另一个进程,避免把“服务端回调被本地线程卡住”和“客户端响应无法处理”混在一起。

6.1 一个快速返回的服务端

保存为 src/ping_server.cpp:

#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "std_srvs/srv/trigger.hpp"

using Trigger = std_srvs::srv::Trigger;

int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("ping_server");
auto service = node->create_service<Trigger>(
"executor_ping",
[node](const std::shared_ptr<Trigger::Request>,
std::shared_ptr<Trigger::Response> response)
{
response->success = true;
response->message = "pong";
RCLCPP_INFO(node->get_logger(),
"SERVER callback prepared response; returning");
});
rclcpp::spin(node);
rclcpp::shutdown();
return 0;
}

服务端不模拟任何耗时,只填写响应并返回。日志刻意写成“prepared response; returning”,而不是“客户端已经收到”:服务端回调准备好响应,不等于客户端已经处理响应。

6.2 一个可以切换等待方式的客户端

保存为 src/wait_client.cpp:

#include <chrono>
#include <cstdint>
#include <future>
#include <memory>
#include <stdexcept>
#include <string>
#include "rclcpp/rclcpp.hpp"
#include "std_srvs/srv/trigger.hpp"

using namespace std::chrono_literals;
using Trigger = std_srvs::srv::Trigger;
using Client = rclcpp::Client<Trigger>;
using Clock = std::chrono::steady_clock;

class WaitClient : public rclcpp::Node
{
public:
WaitClient() : Node("wait_client")
{
threads = declare_parameter<int>("threads", 2);
mode_ = declare_parameter<std::string>("mode", "wait");
const bool split = declare_parameter<bool>("split_groups", false);
if (threads < 1 || threads > 8 || (mode_ != "wait" && mode_ != "async")) {
throw std::runtime_error("Invalid threads or mode");
}
// Keep async state serialized in the default mutually-exclusive group.
if (mode_ == "async" && split) {
throw std::runtime_error("Use split_groups:=false for the async experiment");
}
if (split) {
client_group_ = create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
}
client_ = create_client<Trigger>(
"executor_ping", rclcpp::ServicesQoS(), client_group_);

trigger_ = create_wall_timer(500ms, [this]() { send_once(); });
watchdog_ = create_wall_timer(100ms, [this]() {
if (pending_ && Clock::now() >= deadline_) {
client_->remove_pending_request(request_id_);
pending_ = false;
RCLCPP_WARN(get_logger(),
"ASYNC TIMEOUT: local request tracking removed, server NOT canceled");
}
});
}

bool server_ready() { return client_->wait_for_service(5s); }
int threads{2};

private:
void send_once()
{
trigger_->cancel();
auto request = std::make_shared<Trigger::Request>();
RCLCPP_INFO(get_logger(), "CLIENT sending, mode=%s", mode_.c_str());

if (mode_ == "async") {
const auto handle = client_->async_send_request(
request, [this](Client::SharedFuture response) {
pending_ = false;
const auto result = response.get(); // Already ready inside this callback.
RCLCPP_INFO(get_logger(), "ASYNC RESPONSE success=%d message=%s",
static_cast<int>(result->success), result->message.c_str());
});
request_id_ = handle.request_id;
deadline_ = Clock::now() + 3s;
pending_ = true;
RCLCPP_INFO(get_logger(), "CLIENT callback returning without waiting");
return;
}

auto future = client_->async_send_request(request);
RCLCPP_INFO(get_logger(), "CLIENT waiting inside timer callback");
if (future.wait_for(3s) == std::future_status::ready) {
const auto response = future.get();
RCLCPP_INFO(get_logger(), "WAIT RESPONSE success=%d message=%s",
static_cast<int>(response->success), response->message.c_str());
} else {
client_->remove_pending_request(future);
RCLCPP_WARN(get_logger(),
"WAIT TIMEOUT: timer is only now allowed to return");
}
}

std::string mode_;
rclcpp::CallbackGroup::SharedPtr client_group_;
Client::SharedPtr client_;
rclcpp::TimerBase::SharedPtr trigger_, watchdog_;
bool pending_{false};
int64_t request_id_{0};
Clock::time_point deadline_;
};

int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
auto node = std::make_shared<WaitClient>();
// No executor is spinning yet; this does not occupy an executor callback.
if (!node->server_ready()) {
RCLCPP_ERROR(node->get_logger(), "Server unavailable; start ping_server first");
rclcpp::shutdown();
return 2;
}
if (node->threads == 1) {
rclcpp::executors::SingleThreadedExecutor executor;
executor.add_node(node);
executor.spin();
} else {
rclcpp::executors::MultiThreadedExecutor executor(
rclcpp::ExecutorOptions(), static_cast<size_t>(node->threads));
executor.add_node(node);
executor.spin();
}
rclcpp::shutdown();
return 0;
}

现在三个源码文件齐全,可以构建:

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_executor_demo
source install/setup.bash

每个运行终端都加载:

source /opt/ros/lyrical/setup.bash
source ~/ros2_lyrical_ws/install/setup.bash

为复现本文实测配置,请在每个运行终端继续设置:

export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export ROS_DOMAIN_ID=187
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST

如果未安装 Cyclone DDS,可先执行 sudo apt install ros-lyrical-rmw-cyclonedds-cpp。所有实验节点应使用一致的配置。本机发现模式仅用于本文的同机实验,不适用于跨机器部署。

本次首次使用默认 Fast DDS 与本机发现配置时,客户端未发现服务端,尚未进入请求实验;切换为上述配置后全部通过。这里记录的是当前环境的观察,不代表 Fast DDS 普遍不可用,也不能把服务发现失败当作回调组死锁。

终端 A 保持服务端运行:

ros2 run lyrical_executor_demo ping_server

终端 B 运行客户端:

ros2 run lyrical_executor_demo wait_client –ros-args \\
-p threads:=2 -p split_groups:=false -p mode:=wait

预期服务端很快打印准备响应的日志,客户端却在约 3 秒后报告 WAIT TIMEOUT。

为什么 async_send_request() 没有救它?

因为代码紧接着调用 future.wait_for(3s),又在当前回调里把执行流堵住了。请求发送方式是异步的,等待方式却重新变成了阻塞。

本例的依赖关系是:

定时器回调占用默认互斥组
↓ 等待
Future 就绪
↓ 需要
Executor 执行客户端响应处理
↓ 需要
进入同一个默认互斥组
↓ 但该组正被等待中的定时器回调占用

服务端快不快、客户端线程多不多,都不能直接解除这个组内循环依赖。

3 秒到期后,回调终于返回,组才重新可用。本例也会移除本地 pending request,避免放弃等待后仍无限积累请求记录;晚到的响应不会再交给这一条已经移除的请求。

如果把有限等待改成无限 wait() 或对尚未就绪的 Future 直接 get(),在这些条件下就可能永久等住。本文不要求读者实际制造无限等待。

七、修复方案一:给响应处理让出组和线程

保留 wait 模式,只改分组:

ros2 run lyrical_executor_demo wait_client –ros-args \\
-p threads:=2 -p split_groups:=true -p mode:=wait

预期客户端可以收到 WAIT RESPONSE,而不必等到 3 秒上限。

原因是定时器仍在默认组,客户端响应处理放进另一个互斥组;第二条线程能够处理响应,使 Future 就绪,让第一条线程结束等待。

再做一个反向实验:

ros2 run lyrical_executor_demo wait_client –ros-args \\
-p threads:=1 -p split_groups:=true -p mode:=wait

预期又会超时。虽然组分开了,唯一线程仍然在等待,没有其他线程处理响应。

这是一种可用修复,但不是无限扩展方案。如果很多回调都同步等待,有限的线程池仍可能被占满;跨组回调如果拿着同一把业务锁,也可能互相卡住。

因此,不能把“分成两个组”理解成任何阻塞程序的通用免死金牌。

八、修复方案二:发出请求后返回,让响应来继续工作

更适合这个例子的方式,是 async 模式:

ros2 run lyrical_executor_demo wait_client –ros-args \\
-p threads:=1 -p split_groups:=false -p mode:=async

预期即使单线程、同一个默认互斥组,也能收到 ASYNC RESPONSE。

区别只有一处,却非常重要:定时器发出请求后立即返回,不再占着回调执行位置等结果。

程序随后在响应回调里读取已经就绪的 Future,继续处理业务。不是所有 get() 都会阻塞:关键是调用时 Future 是否已经就绪。

这个模式也有 3 秒等待上限,只不过由另一个短定时器检查,而不是让发请求的回调睡在那里。超过期限时调用 remove_pending_request() 清理本地状态。

相关 API 与清理要求可对照 Lyrical rclcpp Client 实现与说明。

这里有三个边界:

  • 本例 async 模式强制使用默认互斥组,使响应回调、发送回调和超时检查串行,保护 pending_ 等状态。故意组合 async 与 split_groups:=true 会报参数错误,不能直接放开后宣称线程安全。
  • 100 毫秒检查一次超时不意味着精确截止;如果 Executor 被别的耗时工作占用,超时检查也会延迟。它不是独立安全看门狗。
  • remove_pending_request() 只是移除客户端等待记录,不是取消服务端正在执行的工作。Service 没有因此获得 Action 的取消语义。
  • 客户端只发送一次请求,观察到结果或超时后仍保持 spin,请按 Ctrl+C 退出。这样方便查看日志,不需要抢读一闪而过的窗口。

    九、把这些结论接回上一篇 Action

    上一篇的服务端使用定时器分步推进,所以处理一次计数后就返回,Executor 有机会处理取消请求。

    如果你将整个搬运过程改成同组回调里的长循环,取消请求即使已经在等待处理,也可能无法及时进入业务逻辑。

    客户端也有类似问题:在某个回调里发出目标后,同步等待最终结果,若响应处理需要同组执行机会,就可能再次形成等待关系。

    但不要把 Service 的示例机械当成所有 Action 问题的唯一原因。Action 还要检查目标是否接受、服务端是否产生终态、取消是否被执行,以及系统是否失联。

    建议先区分:

    • 请求是否进入服务端;
    • 服务端有没有准备好响应或结果;
    • 客户端的处理代码有没有机会执行;
    • 收到的是阶段响应,还是最终结果。

    十、几个“修好了,又埋坑”的操作

    10.1 把所有回调组都改成 Reentrant

    这可能允许并发,也可能把共享状态暴露给多个执行线程。互斥组保护的是该组内回调的执行关系;一旦跨组或允许重入,任务变量、容器和设备句柄就需要重新审查。

    本例的 last_ 只有心跳回调访问,两个独立互斥组不会让两个心跳实例同时修改它。因此并不是“有多线程就所有成员都必须加锁”,而是要看谁实际共享了什么。

    10.2 在回调里再次 spin_until_future_complete()

    上一篇客户端从 main 的普通流程中使用 spin_until_future_complete(),与在已经由 Executor 驱动的回调里嵌套 spin 不同。

    不要为了等待一个响应,把同一个节点再次交给另一个 Executor。重复关联或递归 spin 可能触发运行时错误,也不是解决互斥组依赖的正确方式。优先采用响应回调延续业务。

    10.3 把超时从 3 秒改成 30 秒

    如果问题是循环等待,改大超时只会让空等更久。要先确认缺的是时间,还是执行机会。

    10.4 以为 sleep 会释放回调组

    sleep 可以让出 CPU,但不会让当前回调返回。线程和该回调占用的组仍然没有完成这次执行。

    10.5 看见服务端日志,就宣布 DDS 没问题

    日志只是证据的一部分。服务端回调准备响应,不等于传输和客户端处理都成功。本文依靠对照实验定位组约束;真实项目仍需结合发现、类型、域、通信和日志进一步判断。

    十一、8 组实测结果与复现清单

    实验参数组合本次实测结果(全部通过)
    心跳 A threads=1,split=false 慢回调期间停顿
    心跳 B threads=1,split=true 仍停顿
    心跳 C threads=2,split=false 仍停顿
    心跳 D threads=2,split=true 慢回调期间继续心跳
    请求 A threads=2,split=false,mode=wait WAIT TIMEOUT
    请求 B threads=2,split=true,mode=wait WAIT RESPONSE
    请求 C threads=1,split=true,mode=wait WAIT TIMEOUT
    请求 D threads=1,split=false,mode=async ASYNC RESPONSE

    这里的 split 是表格缩写,实际参数名为 split_groups。运行前确认 ping_server 可用,每项只运行一个客户端,测试后退出,再开始下一项。

    本次心跳 A、B、C 的最大间隔分别为 2200、2000、2200 毫秒,慢回调执行期间均没有心跳日志;心跳 D 在慢回调期间记录到 10 次心跳,最大间隔为 200 毫秒。

    请求 A 的实际日志节选如下,时间戳保留,约 3 秒后退出等待:

    [INFO] [1789900555.174585276] [wait_client]: CLIENT sending, mode=wait
    [INFO] [1789900555.174725979] [wait_client]: CLIENT waiting inside timer callback
    [WARN] [1789900558.174891246] [wait_client]: WAIT TIMEOUT: timer is only now allowed to return

    其余请求实验分别记录到 WAIT RESPONSE、WAIT TIMEOUT、ASYNC RESPONSE。所有测试进程采集完成后由测试脚本发送 SIGINT 正常结束,退出码均为 0。构建成功及这 8 组结果说明本文实验在上述配置中得到复现,不代表覆盖所有中间件、负载和异常场景。

    如果你的输出不同,应先保留完整日志、参数和版本,而不是为了让现象“符合教程”继续增加 sleep。

    总结:先画清谁在等谁,再决定加多少线程

    节点没有响应,不一定是通信失败;多线程没有改善,也不一定是线程数太少。

    排查这类问题,先看三个条件:

    谁占着执行线程?谁占着互斥组?它等待的结果,又需要谁来处理?

    如果等待者占用了完成等待所必需的资源,就应该修改执行结构,而不是只延长超时。

    对于本文的请求场景,最清晰的改法是:发请求后返回,让 Executor 继续工作,在响应回调中处理结果。确实需要阻塞等待时,则必须同时确认线程、回调组、锁和退出条件。

    把这个关系想清楚,Service 的“收不到响应”和 Action 的“取消没反应”,才有机会从猜测变成可验证的排查。

    赞(0)
    未经允许不得转载:171主机测评 » 2026 ROS 2 Lyrical C++ 入门(六):为什么节点会“卡住”?——Executor、回调组与死锁排查
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址