你可能遇到过这样的情况: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 实现与说明。
这里有三个边界:
客户端只发送一次请求,观察到结果或超时后仍保持 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 的“取消没反应”,才有机会从猜测变成可验证的排查。




