3.1 节点与执行器


3.1 节点与执行器

本节摘要:节点是通信原语的容器,执行器是回调的调度者——这两个概念决定了你写的每个回调在何时、以何种并发方式被执行。本节先讲清二者与上下文的关系,再拆解单线程与多线程执行器、互斥与重入回调组的语义,最后用一次"定时器被慢回调拖死"的现场排错,把并发问题从概念变成可定位、可修复的工程问题。

被最多人误解的一层

写 ROS2 的人几乎都会遇到同一个困惑:明明定时器设的是 10 Hz,实际回调却按 2 Hz 在跑;或者明明订阅到了消息,日志却半天不打印。这类问题的答案几乎都不在通信层,而在执行器——它决定了回调什么时候跑、能不能并行跑。第 2 章我们看的是"消息怎么传",本节看的是"消息到了之后你的代码怎么被调",这是被最多人误解的一层。

一、节点、上下文与执行器的关系

三个概念的包含关系是:上下文包着节点,执行器调度节点里的回调。上下文(context)是进程级的全局状态,负责初始化与关闭、信号处理;一个进程通常一个上下文,里面可以放一个或多个节点。节点是命名与资源的容器:发布者、订阅者、服务、动作、参数都属于某个节点。

执行器则是"让节点活起来"的引擎。你写的 create_subscription 注册的只是回调函数,真正调起它的是 spin 这一类调用:

import rclpy from rclpy.node import Node def main(): rclpy.init() # 初始化上下文 node = Node('my_node') # ... 创建发布订阅 ... rclpy.spin(node) # 执行器开始运转,直到 Ctrl-C node.destroy_node() rclpy.shutdown()

spin 背后的逻辑是一个循环:从就绪的回调队列里取一个,执行,再取下一个。关键在"再取下一个"的方式——单线程执行器同一时刻只跑一个回调,跑不完就不取下一个;多线程执行器可以同时跑多个,但哪些能同时跑,由回调组说了算。所有"回调卡住"类问题,都发生在这个循环里。

二、单线程与多线程:并发语义的精确表述

单线程执行器(默认)的语义一句话:任何时刻最多一个回调在执行,后到的排队。它的优点是免锁——你的回调之间不会并发,成员变量不用加互斥锁,对新手极其友好。代价也直白:一个慢回调会把整个节点拖住。

多线程执行器允许并发执行回调,但并发粒度由回调组决定。互斥组(mutually exclusive)内的回调串行执行,等价于给这组回调加了一把隐式锁;重入组(reentrant)内的回调可以自由并发。同一个回调组内的两个回调永不并行;不同组之间是否并行,取决于执行器是不是多线程。

// C++:多线程执行器加回调组的典型配置 rclcpp::NodeOptions options; // 传感器回调放互斥组A,控制回调放互斥组B,两组之间可并行 auto cbg_sensor = node->create_callback_group( rclcpp::CallbackGroupType::MutuallyExclusive); auto cbg_ctrl = node->create_callback_group( rclcpp::CallbackGroupType::MutuallyExclusive); auto sub = node->create_subscription<LaserScan>("scan", 5, std::bind(&Driver::on_scan, this, _1), rclcpp::QoS(5), cbg_sensor); rclcpp::executors::MultiThreadedExecutor exec; exec.add_node(node); exec.spin();

这段配置里藏着一条工程经验:多线程不是为了"全都并发",而是为了让慢回调与要紧回调隔离开。传感器处理再慢,也不该挡住 100 Hz 的控制输出——把两者放不同互斥组,多线程执行器就能让 B 组的回调在 A 组执行期间照常运行。

图 3-1:执行器与回调组的调度效果对比

图 3-1:执行器与回调组的调度效果对比

三、现场复盘:定时器为什么只剩 2 Hz

背景是一次课堂项目的疑难杂症:学生的巡线小车控制明显迟钝,代码审查发现控制定时器周期设的是 100 毫秒,但实测输出间隔约 500 毫秒。整个节点是 Python 单线程执行器。

操作:先用时间戳取证。在定时器回调第一行与图像订阅回调第一行各打一个带时间戳的日志,跑十秒收集。结果清楚得残忍:图像回调单次执行 200 到 400 毫秒(树莓派上做了直方图均衡),期间定时器回调全部积压;五个排队回调被压缩执行,间隔就是那个诡异的 500 毫秒。

解读:这不是 bug,是单线程执行器的忠实履约——语义如此。修复走了两步:第一步最便宜,把图像缩小一半再均衡,单次处理压到 80 毫秒,症状缓解但高峰期仍抖;第二步是结构性方案,图像处理挪到独立线程池(Python 里用重入回调组加线程池执行器),控制回调留在默认互斥组,控制周期从此稳在 100 毫秒加 2 毫秒抖动。变式:同样的症状若出现在 C++ 项目且已经用了多线程执行器,第一个要查的就是"慢回调与要紧回调是否在同一个默认回调组里"——多线程执行器不会自动隔离,分组才有隔离。

⚠️ 常见坑:在回调里写阻塞等待(同步服务调用、磁盘读写、sleep)是单线程节点的自锁行为——服务响应自己也要靠执行器跑回调,你把唯一的线程堵死了,等的是永远不来的答案。跨节点同步调用要么用异步回调风格,要么给服务调用单独开重入组。

本节要点回顾

  • 上下文包节点、执行器调度回调,spin 的循环是一切回调行为的总开关;
  • 单线程执行器免锁但串行,慢回调拖垮全节点是语义而非故障;
  • 多线程执行器不自动隔离,隔离靠互斥组与重入组的划分;
  • 跨组共享数据仍需互斥锁,分组只是免掉组内并发;
  • 回调里禁止阻塞等待,同步服务调用要用异步风格或独立回调组;
  • 定位并发问题的第一步永远是时间戳取证,而不是猜测。

回调的调度问题解决后,下一节正面回答选型问题:手里这个需求,该用话题、服务还是动作。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U