返回专辑
·Johan·3 分钟阅读

Executor 与回调组:延迟从哪来,为什么加线程救不了

用互斥回调组与阻塞 I/O 解释卡顿;多线程不是开关,预算与有界队列才是。

Executor 与回调组:延迟从哪来,为什么加线程救不了

1. 「加了 MultiThreaded」仍抖

反馈常是:已经换成 MultiThreadedExecutor,图像一来控制还抖。ROS 2 里调度对象是回调,互斥边界是回调组。默认互斥组内任意时刻只跑一个回调——80–100 ms 的推理会堵住同组 50 Hz 定时器;线程开到 8 也没用。

复现:同节点挂 10 Hz 定时器 + 订阅里 sleep(100ms)。同组时定时器间隔出现长尾;拆组并把重活移到有界队列后,长尾应收束。只开多线程、不拆组、共享状态无纪律,得到的是数据竞争,不是实时性。

底盘上视觉与轮速环曾塞进同一 composable 容器且共用默认组:过门扭摆。拆组并让检测出回调后,扭摆随长尾消失。先画实时等级,再谈 Executor 类名。

2. 执行器与回调组各自管什么

SingleThreaded 最好推理,适合轻量节点。MultiThreaded 让不同回调组并行;同互斥组仍串行。并行度由组拓扑决定,不由线程数字决定。

分诊顺序:发现 → QoS → 时间戳 → 执行器。数据没到时改 Executor 无意义。

实战三组通常够用:控制互斥组(定时器 + 短状态订阅,禁止重活);感知组(只入队);后台组(诊断/参数)。可重入组仅用于无共享可变状态的纯处理——默认互斥,证明安全后再放宽。

3. 预算、有界队列、背压

控制回调压在数毫秒;图像回调只校验 stamp 并入队;推理在工作线程。队列有界,写明丢弃策略(视觉/控制多保最新)。无界队列爆内存;「绝不丢」爆延迟。服务/action 回调同样占执行器——同步打开大文件或加载模型,等于全局停顿。

cpp
void image_cb(const sensor_msgs::msg::Image::SharedPtr msg)
{
  std::lock_guard<std::mutex> lock(mu_);
  if (queue_.size() >= kMax) {
    queue_.pop_front();
    ++drops_;
  }
  queue_.push_back(std::move(msg));
  cv_.notify_one();
}

丢弃率进诊断。Composable 容器共享 Executor 时,预算是容器级的:一个组件变重,邻居一起抖。

4. 取证:先直方图,再改线程数

分开量:触发间隔、回调耗时、出队等待。对比「同组互斥」与「拆组 + 有界队列」,负载注入前后各采一段时间。可核对:控制 P99 在注入后仍落预算内;故意把重活放回回调,长尾应立刻回来。

跨组共享用单写者队列或短临界区拷贝快照;锁内禁止推理。心跳/看门狗必须进轻量组,避免与重感知同组误触发安全停。Python 节点受 GIL 约束,更应把重活移出进程,不要照搬 C++ 线程数。

5. 与控制栈的边界

controller_manager 的控制拍对抖动极敏感。重视觉与控制不要同容器同互斥组。优化顺序:去掉回调内重活 → 拆组 → 有界队列 → 算法加速 → 线程数/亲和 → 实时内核。跳到最后两步而忽略前两步,是最常见的烧钱顺序。

6. 验收与默认策略

合并前检查:控制预算有数字;重活不在控制组;队列有界且可观测丢弃;负载注入下 P99 可接受;服务无同步重 I/O。全绿再合。

默认:控制与重感知不同组;重活出回调;先直方图再加线程。Executor 是调度器,回调组才是延迟的法律。轻量节点用 SingleThreaded 完全合理——复杂度买在刀刃上。

7. 案例时间线:过门扭摆

症状:走廊直线正常,过门扭一下。初判去拧 PID/轮距,无效。证据:控制周期 P99 从约 20 ms 飙到 80 ms+;图像回调 60–120 ms;二者同组。处理:入队 + 保最新 + 控制独立组。结果:扭摆消失,丢帧上升但进诊断。教训:扭摆可以是调度问题伪装成控制问题;先直方图后调参。

8. 锁与「最新值」一致

跨组共享用短临界区交换指针;锁内禁止推理。队列策略、QoS、控制消费三者都要承认「世界只关心现在」——FIFO 慢慢消化积压,会在 Best Effort 链路上做出批处理效果。控制组拷贝带 stamp 的最新结果,超龄则降级不用。

← 全部文章

johan's blog