ROS2 Executor与Callback Group:根治节点卡顿的并发调度指南
发布时间:2026/10/2 15:26:32来源:尧图网络
这个话题我一直想写。前阵子帮人调一个基于 ROS2 Humble 的小车底盘节点现象特别典型串口读数据偶尔卡一下整车的/cmd_vel订阅和/odom发布全部跟着掉频率服务端调个参数也能卡半天。查到最后根子全在 Executor 和 Callback Group 的搭配上。很多人把话题、服务、动作都学完了一到多线程并发就懵原因就是没搞懂这两个东西到底怎么协同。这篇文章就把关系彻底拆开既讲原理也讲怎么落地会让你知道为什么默认情况下节点那么容易“卡成 PPT”以及怎么用 Callback Group 把并发真正放到你需要的地方去。1. 从“回调串行”说起Executor 门前的世界长什么样1.1 话题、服务、动作和定时器本质都是“回调事件”先统一一下视角。很多初学者把 ROS2 的通信方式分成话题、服务、动作、参数、定时器每一套都要单独学 API很容易觉得它们是五套独立机制。但在 Executor 眼里这些全都能抽象成一类东西可唤醒的事件源。话题订阅了DDS 层收到数据后它就是一个“有数据可读”的事件服务收到请求、客户端收到响应、动作收到 goal、定时器到点、参数被修改同理。Executor 不关心这些事件具体是哪种通信方式产生的它只负责一件事等这些事件发生然后把对应的用户回调函数拿出来执行。所以你会看到文档里经常出现“callback”这个词。ROS2 的并发模型不是说“话题并发”“服务并发”而是“回调并发”。同一个节点里话题回调、定时器回调、服务回调在 Executor 眼里就是一堆待调度的函数。你关心的话题频率、服务响应时间、动作反馈延迟本质上全是“回调有没有被及时执行”的问题。1.2 Executor 是那个“循环等待 调度事件”的引擎你写节点的时候main 函数最后一行通常是rclcpp::spin(std::make_sharedMyNode());或者 Python 里rclpy.spin(node)很多人把spin当成“让节点活着”的魔法其实它的背后就是 Executor 在跑一个循环。这个循环大致是把所有实体订阅、定时器、服务等注册进一个等待集合阻塞等待直到至少一个实体“就绪”有数据、到点、有请求把就绪实体对应的回调收集起来一个一个执行回到第 1 步。这个“第 4 步一个一个执行”就是理解 ROS2 性能问题的钥匙。rclcpp::spin底层创建的是SingleThreadedExecutor也就是单线程执行器。不管你有多少个定时器、多少个订阅、多少个服务只要它们挂在同一个执行器上所有回调都是排队执行、严格串行的。前一个回调不返回后面谁也别想跑。ExecutorOptions里的门道先不谈你先记住这个结论在单线程 Executor 里Callback Group 再多也白搭因为只有一个线程在消费回调。真正的并发是“多线程 Executor 合理的 Callback Group 划分”一起作用的结果。1.3 Callback Group 是“排队规则”的边界那 Callback Group 是什么简单说它是 Node 里给回调分组的一个机制。你创建订阅、定时器、服务的时候可以指定它们属于哪个回调组。如果不指定它们会被塞进 Node 默认创建的一个 Callback Group 里。这个分组的意义在于多线程 Executor 调查“哪些回调可以被并行执行”时是以 Callback Group 为单位的。同一个 Callback Group 内部的回调即使有多个线程可用也不会被同时执行不同的 Callback Group 之间才存在并行的可能性。打个比方。Executor 是一排服务窗口Callback Group 是排队通道。单线程执行器等于只开了一个窗口所有人都走同一条通道哪怕你把通道分成十条最终也只能一个一个来。多线程执行器等于开了多个窗口但如果你让所有回调都排在一条默认通道里那多开窗口也没有意义因为窗口不会同时服务同一通道里的两个人。Callback Group 的作用就是让你把这些回调按业务意义拆进不同通道多线程窗口才能真正并行起来。2. 默认的单线程执行器为什么程序一忙就“卡成 PPT”2.1 SingleThreadedExecutor 的运转逻辑先看一个最简单的例子。假设一个节点里有两个定时器一个 100ms一个 1sauto timer_fast this-create_wall_timer( std::chrono::milliseconds(100), fast_callback); auto timer_slow this-create_wall_timer( std::chrono::seconds(1), slow_callback);如果slow_callback里做了个 500ms 的阻塞操作比如读串口等超时你会看到什么100ms 的定时器回调不会按时执行。因为两个定时器都在默认 Callback Group 里而整个节点只有单线程 Executorslow_callback一跑就是 500ms这期间fast_callback即使到期了也只能排队等着。很多人的第一反应是“我加个多线程 Executor 不就行了”结果发现还是卡。原因就是你根本没改回调组所有回调依然挤在默认互斥组里加再多线程也没有并发度。这个问题我在社区里见过不下十次。2.2 长任务拖垮整个节点一个底盘小车的典型事故说个我自己实际调过的场景。一辆用 ROS2 Humble 控制的差速小车底盘节点要同时干这几件事订阅/cmd_vel接收速度指令用定时器周期性发布/odom里程计提供一个服务用来读电池电压和串口传感器数据订阅一个 IMU 的话题做姿态融合。最初这个节点的实现就是最朴素的方式所有东西都挂在默认组上主函数rclcpp::spin(node)。跑起来之后只要上位机频繁调用读取传感器数据的服务整个节点就开始“抽风”/odom发布频率从 20Hz 掉到 3Hz/cmd_vel的订阅回调也明显变慢底盘响应迟钝。为什么因为服务回调里要等串口返回经常要阻塞几十甚至几百毫秒。这期间单线程 Executor 卡在服务回调里其他全部受害。而且这不是偶发串口读数据偶尔抖动一下整个车的控制周期就崩一次。这类问题在控制类、机器人类节点里很典型。因为这类节点天然是“高频数据采集 低频请求处理 周期性控制”混合体几种频率差异极大的任务挤在一个线程里任何一个环节出现阻塞性操作就是全链路灾难。2.3 与 ROS1 的对比为什么 ROS2 默认更“容易卡”很多从 ROS1 转过来的人会抱怨ROS1 里我用ros::AsyncSpinner多线程跑得好好的ROS2 怎么默认单线程其实这有点误解。ROS1 的ros::spin()同样是单线程所有回调也是串行执行。但 ROS1 时代很多人习惯在每个节点里只做单一任务节点拆得很碎所以单线程很少成为瓶颈。ROS2 鼓励节点做得更“大”一些一个节点里往往会塞多个话题、服务、动作于是单线程执行器的问题就暴露得更明显。另外ROS1 的AsyncSpinner确实可以开多线程去并发送回但它是“无差别并发”所有回调不加区分地被多个线程抢共享数据很容易出线程安全问题。ROS2 的设计更严格它用 Callback Group 把“哪些东西可以并行”交给开发者控制默认是全串行的你想让某些回调并行就得显式分组。这套设计更安全但代价是默认配置的并发度很低不懂分组就永远享受不到多线程的好处。3. Callback Group 的两种形态互斥组与可重入组3.1 MutuallyExclusive安全但串行ROS2 里 Callback Group 有两种类型第一种是MutuallyExclusiveCallbackGroup中文一般叫互斥组。这是 Node 默认的回调组类型。互斥组的语义很简单同一组内的回调在同一时间只能执行一个。不管 Executor 开了几个线程只要某个回调属于这个组其他属于同一个组的回调就得等它执行完。为什么要设计这种组核心目的是保护共享数据。比如你有一个成员变量current_pose_一个订阅回调更新它一个服务回调读取它如果两个回调在不同线程同时跑就需要加锁。而把它们放进同一个互斥组相当于在“回调调度层面”替你加了一把大锁组内天然串行你不必再操心这两个回调之间的数据竞争。代价就是这些回调无法并发执行吞吐量受限。但很多场景里这恰恰是你想要的“安全默认值”。3.2 Reentrant可重入意味着可以同时进多个回调第二种是ReentrantCallbackGroup可重入组。它的语义跟互斥组正好相反同一组内的不同回调可以在不同线程上并发执行。注意用词是“不同回调”并发执行。同一个回调函数本身一般不会出现同一个实例被两个线程同时执行的情况因为 Executor 把一个回调投递到线程池执行后、还没执行完之前不会再次把同一个回调实例取出。你需要的并发通常是让两个功能独立的定时器、或者一个高频率订阅和一个低频服务互相不阻塞这种情况下可重入组刚好够用。可重入组的危险也显而易见如果组内回调访问同一个成员变量你必须自己对共享数据加锁。所以说可重入组不是“我什么都想并行就可以无脑用”的选项而是“我确认这些回调互不干扰”时才能用。3.3 组与组、线程与回调的组合矩阵把 Executor 线程数和 Callback Group 类型组合起来会得到下面这张非常关键的表Executor 类型Callback Group 类型并发效果单线程 Executor默认互斥组所有回调完全串行单线程 Executor可重入组依然完全串行因为只有一个线程多线程 Executor所有回调都在默认互斥组组内串行只有不同 Node 的默认组之间可能并行多线程 Executor多个互斥组组与组之间并行组内串行多线程 Executor可重入组组内回调也可以并行并发度最高这表我建议收藏。很多“我明明用了多线程 Executor 为什么还是串行”的问题对照这张表就能看明白你缺的不是线程数而是把回调从同一个互斥组里拆出来。另外补充一个细节一个 Node 可以有任意多个 Callback Group一个 Executor 也可以管理多个 Node。真正决定并发能力的是“Executor 里线程的数量”和“每个回调属于什么组、属于哪个组”的组合跟 Node 数量没有必然关系。4. 实战用 MultiThreadedExecutor Callback Group 拆掉阻塞链路4.1 场景复现机械臂仿真里的传感器与动作服务相互踩踏我之前做 MoveIt2 Panda 机械臂仿真抓取时遇到过类似问题。节点里有这么几类任务一个高频传感器话题订阅比如力传感器或者关节电流频率 100Hz 以上一个定时器周期性发布机械臂当前状态一个动作服务端接收抓取指令内部有一段比较耗时的规划或轨迹执行偶尔有一个参数回调或者外部调试用的服务。最初我把动作服务和其他回调都放在默认组用单线程 Executor 跑。结果就是一旦动作服务开始执行整个节点的其他回调全部停摆。传感器数据在 RViz2 里看起来就像掉帧定时器发布的机械臂状态也是跳着走。这不是 MoveIt2 本身的问题而是我写节点的姿势问题。MoveIt2 的内部组件很多都有自己的线程和 executor但你自己写的控制/桥接节点如果只有一个粗放的单线程循环照样会成为全局瓶颈。4.2 代码落地C 与 Python 的分组配置先说 C 里的写法。核心思路是把高频传感器订阅放到一个互斥组动作服务放到另一个组定时器再单独一组。这样在多线程 Executor 下传感器线程不会被动作服务拖住。#include rclcpp/rclcpp.hpp #include rclcpp/callback_group.hpp #include rclcpp/executors/multi_threaded_executor.hpp #include std_msgs/msg/string.hpp #include example_interfaces/srv/trigger.hpp class MyNode : public rclcpp::Node { public: using Trigger example_interfaces::srv::Trigger; MyNode() : Node(executor_demo_node) { // 创建两个回调组传感器组和长耗时服务组 sensor_group_ this-create_callback_group( rclcpp::CallbackGroupType::MutuallyExclusive); service_group_ this-create_callback_group( rclcpp::CallbackGroupType::Reentrant); // 高频传感器订阅挂到 sensor_group_ rclcpp::SubscriptionOptions sub_opts; sub_opts.callback_group sensor_group_; sensor_sub_ this-create_subscriptionstd_msgs::msg::String( /sensor_data, rclcpp::SensorDataQoS(), [this](const std_msgs::msg::String::SharedPtr msg) { // 高频处理逻辑要求短小精悍 }, sub_opts); // 定时器单独放 sensor_group_ 或者再开一个组 timer_ this-create_wall_timer( std::chrono::milliseconds(100), [this]() { /* 状态发布 */ }, sensor_group_); // 服务端回调可能耗时挂到 service_group_ srv_ this-create_serviceTrigger( /long_task, [this](const Trigger::Request::SharedPtr req, const Trigger::Response::SharedPtr resp) { // 长时间计算或串口阻塞 }, rmw_qos_profile_services_default, service_group_); } private: rclcpp::CallbackGroup::SharedPtr sensor_group_; rclcpp::CallbackGroup::SharedPtr service_group_; rclcpp::Subscriptionstd_msgs::msg::String::SharedPtr sensor_sub_; rclcpp::TimerBase::SharedPtr timer_; rclcpp::ServiceTrigger::SharedPtr srv_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node std::make_sharedMyNode(); // 关键创建多线程 Executor并给它 4 个线程 rclcpp::executors::MultiThreadedExecutor executor( rclcpp::ExecutorOptions(), 4); executor.add_node(node); executor.spin(); rclcpp::shutdown(); return 0; }Python 对应的写法import rclpy from rclpy.executors import MultiThreadedExecutor from rclpy.callback_groups import MutuallyExclusiveCallbackGroup, ReentrantCallbackGroup from std_msgs.msg import String from example_interfaces.srv import Trigger import threading import time rclpy.init() node rclpy.create_node(executor_demo_node) sensor_group MutuallyExclusiveCallbackGroup() service_group ReentrantCallbackGroup() def sensor_cb(msg): # 高频处理 print(fsensor callback on {threading.current_thread().name}, flushTrue) def long_service_cb(req, resp): # 模拟耗时不会阻塞 sensor_cb time.sleep(1.0) resp.success True return resp sub node.create_subscription(String, /sensor_data, sensor_cb, 10, callback_groupsensor_group) srv node.create_service(Trigger, /long_task, long_service_cb, callback_groupservice_group) executor MultiThreadedExecutor(num_threads4) executor.add_node(node) executor.spin()这里你可能会注意到服务组我用了Reentrant。原因是动作/服务回调内部可能还会有其他需要并发执行的操作而且在多线程 Executor 下如果服务端出现多个请求互斥组会导致这些请求排队处理。对长耗时服务来说这种排队体验很差。但前提是你确认服务回调没有与其他回调共享可变数据否则还是要结合互斥组或者加锁。4.3 验证方法用线程 ID 和 topic hz 确认真实效果改完代码别急着收工一定要验证“并发确实发生了”。最简单的方法是在回调里打印线程 ID。C 里RCLCPP_INFO(this-get_logger(), callback on thread %zu, std::hashstd::thread::id{}(std::this_thread::get_id()));Python 里import threading def sensor_cb(msg): print(fsensor cb on {threading.current_thread().name}, flushTrue)运行之后观察日志修改前所有回调打印出来的线程 ID 都一模一样那是单线程 Executor 的唯一工作线程修改后传感器订阅回调、服务回调、定时器回调会打印出不同的线程 ID说明它们被分配到了不同的工作线程。再用ros2 topic hz /sensor_data看频率你会发现之前被拖到几 Hz 的话题稳定回到了设定频率。如果频率还是上不去先检查是不是所有回调都还在默认互斥组里再检查线程数是否足够、以及回调内部是不是有隐藏的阻塞操作。这里多说一句加线程之前先确认瓶颈在 Executor 调度而不是在回调本身。如果你的回调里写了个sleep加再多线程也只是把阻塞问题放大成更复杂的并发问题解决不了根因。5. 底层机制观察wait set、事件唤醒与锁的细节5.1 Executor 在等待什么wait set 与可唤醒实体很多教程会把 Executor 讲成一个黑盒“调度器”但排障的时候最好知道你调的东西到底在底层怎么跑。ROS2 Executor 的底层建立在 rcl 层的 wait set 机制上。所谓 wait set简单理解就是一个“等待清单”Executor 在启动后会把你节点里的订阅、定时器、服务、客户端、guard condition 等实体信息整理成一个等待集合然后调用类似rcl_wait(wait_set, timeout)的接口阻塞等待其中任何一个实体变为可读/可写/到期状态。等到事件发生后Executor 再遍历 wait set把就绪的实体收集出来对应生成一个“可执行回调”的任务然后分发到自己的线程循环里执行。这里有一个隐藏的性能问题wait set 不是一成不变的。节点里新创建订阅、服务或者 QoS 兼容性变化都可能要求 wait set 重新构建。单线程 Executor 在每个循环周期里都可能做这件事频繁重建 wait set 会造成动态内存分配和额外开销。这也是为什么 rclcpp 后来提供了StaticSingleThreadedExecutor——它会尽量复用预先构建的 wait set减少动态分配适合高频短回调、对延迟稳定性有要求的场景。5.2 多线程 Executor 的内部线程模型与锁竞争多线程 Executor 并不是简单地在“每个线程各自跑一个等待循环”。那样的话两个线程可能同时等到同一个回调就绪造成同一个回调被重复执行。所以内部一定有一个共享的“可执行回调列表”和一把保护它的锁。工作机制大致是有一个或某几个线程负责从 wait set 里捞就绪事件生成回调任务放进共享队列工作线程从队列里取任务执行。取任务的过程需要加锁这就意味着线程数并不是越多越好。线程太多大家全在抢同一把锁、频繁切换上下文反而可能拖慢整体吞吐。我实测下来的经验是在普通工控机上4 个线程的收益往往就很明显8 个以上边际收益递减甚至会因为锁竞争出现性能回退。如果你的机器人平台只有 4 核却给了 Executor 16 个线程那大部分时间都是在白费 CPU。5.3 为什么同一个组能串行CallbackGroup 的锁粒度回到文章一开始的问题为什么多线程 Executor 下同一个互斥组里的回调还是串行因为 Callback Group 内部是有锁的。当 Executor 准备把一个回调投递到线程池时它会检查这个回调属于哪个组。如果该组是互斥组且已经有回调正在执行后来的回调就必须等待。这个“正在执行”标记和等待队列就是通过组内锁实现的。所以互斥组的串行保证不是在用户代码里加锁而是在框架调度层面加的锁。这也解释了一个现象两个发布者、两个订阅者即使 QoS 不同但只要回调被塞进同一个互斥组被调度的顺序就无法并发跟你代码里写不写std::mutex没有关系。框架不让你在调度层并发是为了把“数据竞争窗口”直接堵死。可重入组之所以能并发是因为它不再维护这个“组内一次只能一个”的互斥状态。它把并发管理的责任完全交给了用户。5.4 高频率话题会“饿死”其他回调吗当前优先级与调度现状ROS2 Executor 目前没有完善的优先级调度机制。一个高频话题在同一时刻反复就绪它确实会非常频繁地进入“可执行回调”列表挤压其他低频回调的执行窗口。严格说不会完全饿死因为 Executor 一轮循环里会把所有就绪回调都执行一遍但如果高频回调持续不断地到来低频回调的延迟会显著拉大。有人问能不能让某个话题优先执行遗憾的是Callback Group 不能设置优先级Executor 也不提供类似实时线程优先级的功能。目前相对可行的做法是把要求实时性的关键回路拆到独立节点、独立进程用单独 Executor 管理减少互相干扰或者干脆在进程内用一个独立线程专门跑关键回调不经过 Executor 调度再或者降低非关键高频话题的回调频率把 CPU 留给关键路径。记住一点在 ROS2 的当前调度模型下“优先执行某个回调”是一个不存在的操作。你能做的不是让 A 优先于 B而是让 A 和 B 完全不互相影响。6. 几个容易误用的边界场景多 Executor、零拷贝与容器环境6.1 一个 Node 开两个 Executor 会怎样先说结论不建议而且多数情况下会出问题。Executor 被设计成一个 Node 的“宿主”调度环境。当你用两个 Executor 同时spin同一个 Node相当于两个调度循环都在尝试去 wait set 里捞同一个实体的就绪状态会出现重复获取回调、线程安全失控、甚至崩溃。rclcpp 社区遇到过不少这样的 issue反馈都是 undefined behavior。正确的做法是一个 Executor 可以托管多个 Node一个 Node 只能被一个 Executor 管理。如果你觉得一个 Node 里的任务实在太多太杂优先考虑把它拆成多个 Node再挂到同一个 MultiThreadedExecutor 下而不是给一个 Node 开两个 Executor。6.2 零拷贝、intra-process 与 Executor 的关系ROS2 社区里“零拷贝”是个热门话题但它和 Executor 是两个维度的事。零拷贝比如 iceoryx、DDS 共享内存、进程内通信的 intra-process解决的是数据搬运开销。消息从发布进程到订阅进程不再经历多次序列化、反序列化、复制甚至能直接传递指针。但消息到达之后回调该排队还是排队该由哪个线程执行还是由哪个线程执行。换句话说零拷贝让事件源更快地变成“就绪事件”但 Executor 的调度瓶颈并不会因为数据不拷贝而消失。有一类问题需要特别小心在用 intra-process communication 时订阅回调拿到的消息可能是发布侧内存的直接引用。如果这个回调挂在互斥组里同一个 Executor 线程中发布了新消息再触发订阅回调两者之间因为共享内存引用生命周期管理会变得微妙。建议在回调里不要长期持有UniquePtr引用尽量拷贝出你需要的数据否则线程安全边界会变得很隐蔽。6.3 容器与 MCU 环境下的 Executor 约束Docker 里跑 ROS2 Humble 或 Jazzy 已经很常见。有一个坑值得单独提容器里的 CPU 限制和MultiThreadedExecutor的线程数默认值。rclcpp 的多线程 Executor 如果构造时不指定线程数默认会参考std::thread::hardware_concurrency()。但容器环境下这个函数有时返回的是宿主机的核心数而不是容器被限制后的核心数。结果就是你在一个只有 4 核配额的小容器里Executor 却默认开了 16 个线程锁竞争和上下文切换开销反而导致性能下降。在容器里尽量显式指定线程数rclcpp::executors::MultiThreadedExecutor executor( rclcpp::ExecutorOptions(), std::min(4u, std::thread::hardware_concurrency()));MCU 端也一样micro-ROS 的 executor 资源更受限。micro-ROS Agent 负责把串口/UDP 桥接到上位机MCU 上的rclc_executor有同步和异步两种模式同步模式下所有回调都在一个主循环里串行执行异步模式本质也只是把 execution 放到另一个独立循环或中断里Callback Group 的串行/并行语义依然存在。在 MCU 上尤其要遵守“回调里不要长阻塞”这条铁律因为资源更少一颗老鼠屎坏一锅汤的问题会被放大得更厉害。7. 排障工具箱卡顿、死锁和线程问题的定位姿势7.1 先给回调打上线程 ID和并发问题坦诚相见遇到 Executor 相关问题我的第一个动作永远是打印线程 ID。这个小招数能快速告诉你两件事你的回调到底跑在几个线程上你认为应该并发的回调是不是实际上挤在同一个线程。之前有个用户说他的节点用了 MultiThreadedExecutor但传感器还是卡。我让他打印线程 ID结果发现他把所有订阅、定时器都建在默认回调组上多线程 Executor 跑起来后所有回调依然从一个组里串行取任务。虽然线程池有 4 个线程但串行组限制了并发度实际效果和单线程差不多。7.2 ros2 topic hz、gdb 和 perf 的合理分工不同症状用不同工具。症状优先排查项推荐工具话题频率掉到接近 0偶尔恢复是否回调里有阻塞操作、是否单线程 Executorros2 topic hz、打印线程 ID服务端无响应但其他话题正常服务回调是否在互斥组里排队、是否被长任务阻塞ros2 service call 回调日志进程整体 CPU 飙高、响应迟钝线程数是否过多、是否有锁竞争、死循环perf top、htop进程卡死CtrlC 都没反应是否出现死锁、或回调嵌套 spingdb attach pid后thread apply all bt怀疑 DDS 发现/网络问题容器网络、共享内存配置、发现协议ros2 doctor、ros2 topic info --verbosegdb attach是排查死锁的利器。进程卡住后gdb attach进去输入thread apply all bt能直接看到每个线程卡在哪个函数。如果发现好几个线程都在等同一个互斥锁而死锁的另一半在某个回调里等待一个永远不会到达的事件那问题就非常清楚了。7.3 回调里最常见的三种“自杀式操作”最后聊几个我踩过的坑全是在回调里“自杀”的典型第一种回调里直接sleep。哪怕只睡 50ms在单线程 Executor 里这个 50ms 就是全节点延迟的底价。传感器订阅、控制周期全部被拉长。如果必须等硬件返回请把等待放到别的线程用事件/回调通知 Executor。第二种回调里嵌套spin或spin_some。有些人在某个回调里想顺便处理一下其他事件于是调了rclcpp::spin_some(shared_from_this())。这在单线程 Executor 里会触发嵌套调度重则死锁轻则回调顺序完全不可控甚至栈溢出。Executor 已经在调度你的回调了你又在里面再开一个调度循环这是给自己挖坑。第三种在互斥组回调里等待同一个组里另一个回调产生的事件。比如服务回调里等一个标志位而这个标志位是由另一个订阅回调更新的偏偏两个回调又在同一个互斥组。结果就是订阅回调永远没有机会执行组被服务回调占着服务回调永远等不到标志位直接死锁。这个场景排查起来最隐蔽因为代码看起来逻辑完全正确。解决办法就是把这两个回调拆进不同 Callback Group或者不要用阻塞等待改为状态机/事件驱动。8. 最后再给一点实操层面的经验写 ROS2 节点这几年我养成了一套自己的习惯分享出来给你参考。第一别一上来就开多线程 Executor。先把回调函数写得短小精悍用默认单线程跑通。如果发现两个任务的频率互相影响再引入 Callback Group 和 MultiThreadedExecutor。步子迈太大并发问题会和业务问题搅在一起极难排查。第二分组按“事件流”来。我一般会这样做传感器/高频数据一组互斥组状态发布定时器和控制逻辑一组互斥组服务/动作等低频但可能耗时的任务一组可重入组。每一组的共享数据自己心里要有数跨组共享就加锁。第三线程数不要盲目拉满。先设成物理核心数的一半到实际核心数之间用ros2 topic hz观察效果不够再加。加到一定程度发现没提升就回头查锁竞争和回调阻塞而不是继续加线程。第四把线程 ID 打印留在开发期。分布式并发问题最难的是看不见。开发阶段在每个回调第一行打印线程 ID确认调度符合预期后再删掉或者放到 debug log 级别。这个习惯帮我省了很多排查时间。Executor 和 Callback Group 这层关系用一句话概括就是Executor 决定“哪里有空窗口”Callback Group 决定“哪个窗口能进哪批人”。单线程 默认组是最安全的起点多线程 合理分组才是性能的终局。先把这两件事想清楚再回头看那些“节点莫名卡顿”“服务不响应”“话题掉频率”的问题基本都能一眼定位到根因。
网站建设高端定制企业官网