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

ROS 2 大消息内存路径:intra-process、loaned message 与 shared memory 不是一回事

以 4K 图像和点云管线为例,逐段核对 rclcpp、RMW 与进程边界上的所有权、分配和真实拷贝。

ROS 2 大消息内存路径:intra-process、loaned message 与 shared memory 不是一回事

1. 先画出一帧,而不是先打开“零拷贝”开关

一帧 3840×2160 的 BGR8 图像约 24 MiB,30 Hz 时有效载荷接近 720 MiB/s;百万点点云也容易达到几十 MiB。驱动、校正、推理、可视化各留一份,瓶颈很快转为内存带宽和分配抖动。把节点放进组件容器、调用 borrow_loaned_message()、给 DDS 打开 shared memory 优化的是三段不同路径,任何一个成功都不能推出端到端没有 copy。

先问每条边:字节在哪、谁拥有、何时失效、消费者拿原块还是副本、跨过何种边界。典型链路可拆成:

text
camera DMA/user buffer
  -> ROS message payload
  -> rclcpp publisher / intra-process manager
  -> RMW DataWriter history
  -> transport buffer (shared memory or network)
  -> RMW DataReader history
  -> subscription callback
  -> algorithm tensor/cloud

memcpy 可能发生在 DMA 到消息、消息到 DDS history、序列化到传输区、接收区到消息以及消息到模型输入。unique_ptr 只回答同进程交接所有权,publisher loan 只回答发布侧消息内存从哪里借,shared-memory transport 只回答进程间字节经过哪种介质。本文的主线不是给三者排优先级,而是沿这张图逐段消除未经验证的副本。

2. rclcpp intra-process:转移的是对象所有权

同一进程内,发布者和订阅者都启用 intra-process、话题与 QoS 兼容时,rclcpp 可以绕开 RMW 的序列化路径。但“端点 QoS 相互兼容”还不够:Humble、Jazzy 的 rclcpp intra-process 都硬性要求 history 为 KEEP_LAST 且 depth 大于 0,KEEP_ALL 或零 depth 会在创建端点时抛出 std::invalid_argument。最清楚的接口是发布 UniquePtr,由发布调用把所有权交给 intra-process manager,订阅回调再消费它:

cpp
#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/image.hpp>

class RectifyStage final : public rclcpp::Node {
public:
  explicit RectifyStage(const rclcpp::NodeOptions & options)
  : Node("rectify_stage", options)
  {
    auto qos = rclcpp::SensorDataQoS().keep_last(2);
    output_ = create_publisher<sensor_msgs::msg::Image>("rectified", qos);
    input_ = create_subscription<sensor_msgs::msg::Image>(
      "raw", qos,
      [this](sensor_msgs::msg::Image::UniquePtr msg) {
        RCLCPP_DEBUG(get_logger(), "consume object=%p payload=%p",
          static_cast<void *>(msg.get()),
          static_cast<void *>(msg->data.data()));
        rectify_in_place(*msg);
        output_->publish(std::move(msg));  // 此后本回调不再拥有 msg
      });
  }

private:
  void rectify_in_place(sensor_msgs::msg::Image & image);
  rclcpp::Publisher<sensor_msgs::msg::Image>::SharedPtr output_;
  rclcpp::Subscription<sensor_msgs::msg::Image>::SharedPtr input_;
};

// 组件或普通进程创建节点时都要显式设置:
auto options = rclcpp::NodeOptions().use_intra_process_comms(true);

只有一个独占订阅者时,原 unique_ptr 才能一路移动;若 个订阅者都以 UniquePtr 接收,intra-process manager 要为其中 个制造副本。多个只读消费者用 SharedPtr/ConstSharedPtr 可共享对象,但共享生命周期,绕过 const 修改会制造数据竞争;const MessageT &UniquePtrshared_ptr<const MessageT> 等受支持签名表达的是不同所有权要求。启用 intra-process 从来不是与回调签名无关的全局属性。

启动 ros2 bag record、RViz 或独立诊断节点后,publisher 同时需要 intra-process 与 inter-process 路径,rclcpp 要保留可交给 RMW 的消息。同一程序在“无人观察”和“开着工具”时可能走不同路径,单订阅实验中的地址相同不能外推到生产拓扑。

TRANSIENT_LOCAL 还涉及明确的发行版分界。Humble 的 rclcpp intra-process 只接受 VOLATILE durability;启用 intra-process 后创建 transient-local publisher 会抛 std::invalid_argument,因此不能把 late-joiner 行为写成 Humble 的通用 IPC 能力。Jazzy 起加入 transient-local IPC:publisher 侧增加 buffer 保存样本,并仍需把消息交给 RMW,供进程间 late joiner 获取。部署时应记录具体 rclcpp 补丁版本,而不能只写“ROS 2”。

3. loaned message:借到外壳,不等于借到整条链路

publisher loan 的目标是让应用直接填充由 middleware 管理的消息,避免先在应用堆构造,再复制进 DataWriter。rclcpp 的基本写法如下:

cpp
auto pub = node->create_publisher<my_msgs::msg::Frame>(
  "frame", rclcpp::SensorDataQoS().keep_last(4));

RCLCPP_INFO(node->get_logger(), "RMW loan support: %s",
  pub->can_loan_messages() ? "yes" : "no");

auto loan = pub->borrow_loaned_message();
auto & frame = loan.get();
frame.sequence = sequence++;
fill_pixels_directly(frame.pixels);
pub->publish(std::move(loan));  // 所有权交回 publisher,loan 不再有效

LoanedMessage 是移动型 RAII 句柄:未发布就离开作用域会归还样本,发布后也不能缓存 frame.pixels.data() 给异步 GPU 任务。loan pool 通常有限,长期持有会令后续借用失败、阻塞或退化,具体行为取决于 RMW。

borrow_loaned_message() 不保证底层支持 loan:can_loan_messages() 为 false 时会用 publisher 的本地 allocator,代码仍能运行。ROS_DISABLE_LOANED_MESSAGES=1 可主动禁用 loan 做 A/B;证据必须包含 RMW_IMPLEMENTATION、能力日志和分配计数。

loan 与 rclcpp intra-process 的组合必须按发行版准确命名。Humble 在启用 intra-process 的 publisher 上调用 publish(std::move(loan)),会抛出 storing loaned messages in intra process is not supported yet;无论该样本来自 RMW 还是本地 allocator,都不能沿这个接口发布。Jazzy、Kilted 去掉了这个异常,但并未支持把 RMW loan sample 存入 intra-process manager:只要 publisher 启用 intra-process,can_loan_messages() 就返回 false,borrow_loaned_message() 改由本地 allocator 分配,随后通过普通 rclcpp intra-process 路径发布。代码“能发布”不等于真实 RMW loan 与 IPC 已贯通。

publisher loan 也不保证订阅回调直接拿到同一个共享样本,接收端的 RMW take、executor 与回调路径必须另验。Jazzy 文档还明确把 subscription loan 默认禁用,因为现有共享生命周期语义存在安全风险;只有显式设置 ROS_DISABLE_LOANED_MESSAGES=0 才会尝试启用。这样做不是无风险的性能开关:回调保存 shared_ptr 或底层地址时,middleware 可能已经要求归还样本。除非目标 RMW、回调签名和样本归还时序都经过压力测试,不应在生产环境仅为追求 zero-copy 打开它。

许多 RMW 的 zero-copy loan 要求固定布局或有界序列。Image::dataPointCloud2::data 是动态序列;即使消息外壳借自 middleware,data.resize() 仍可能分配 payload,发布时也可能复制。可设计带 bounded_sequence 或定长数组的自定义 IDL,并把最大分辨率、stride、点数写入契约;无法固定上限时,应称为“减少某段分配/copy”,而非端到端 zero-copy。

4. shared memory:跨进程运输,不是共享 C++ 指针

两个组件装进同一容器,intra-process manager 能传进程内指针;两个独立进程即使在同一主机,也不能直接解引用对方堆上的 std::vector。shared-memory transport 由 DDS/RMW 把进程间数据放进双方都映射的区域,它保留进程隔离,却不等于 unique_ptr 穿过了进程边界。三种常被混写的路径应分开命名:

路径边界可能省掉什么仍可能发生什么
rclcpp intra-process同一进程RMW、序列化及对象副本多拥有者复制、算法输入复制
publisher loan应用到 DataWriter发布侧分配和一次移交动态 payload 分配、接收侧复制
RMW/DDS shared memory同机跨进程内核网络栈与网络传输序列化、history 间复制、反序列化

有些 DDS 的 shared-memory transport 只是把序列化字节写进共享段;更进一步的 data-sharing/zero-copy 才可能让 reader 引用共享样本。两端支持 loan、类型可 loan、QoS 满足、同机 data-sharing 生效,并且接收侧确实启用了安全可用的 loan take,才可能形成跨进程共享样本。ROS API 不保证不同 RMW、DDS 版本和 XML 配置行为一致。

部署文档要记录 RMW、DDS 版本、XML、共享段容量和 fallback 日志,并做负向实验:禁用 SHM、把订阅者移到另一主机、耗尽共享段,分别观察 CPU、回退与错误。若结果不变,更可能是 payload 在共享边界前后仍被复制。

5. QoS 与 history 决定一块内存要活多久

内存优化不能脱离 QoS。KEEP_LAST(1)BEST_EFFORT 的在线感知链允许旧帧被新帧覆盖,loan 的 lease 和共享样本较快归还;RELIABLE、较大 depth 要为重传与慢订阅者保留 history,瞬时占用近似为

其中 是 payload, 是 depth, 是保留样本的 writer/reader 数量。这不是 DDS 精确公式,但能暴露把 24 MiB 图像 depth 从 2 调到 20 的代价。TRANSIENT_LOCAL 还要保留样本,并可能为进程间 late joiner 发布到 RMW;这里同样要应用前述版本边界:Humble 的 rclcpp IPC 禁止 transient-local,Jazzy 及更新实现才有 publisher 侧 IPC history。

QoS 兼容性也决定 intra-process 配对;先用 ros2 topic info -v 核对端点。但该命令显示端点兼容,不代表满足 rclcpp IPC 的构造条件,还要在配置中核对 KEEP_LAST 与非零 depth,以及 Humble 的 VOLATILE 限制。控制闭环通常要最新帧,采集可能要求可靠无损。不能为稳定 loan pool 把必需数据改成 best effort,也不能用深队列掩盖消费者跟不上。

固定消息可预分配样本,无界序列加可靠深队列却难给出上限。4K 相机若可切换编码与分辨率,应配置最大 payload、启动预热并拒绝越界模式,避免高速运行后 OOM。

6. 组合容器换来指针通路,也合并了故障域

把驱动、预处理和推理装进同一 component_container_mt 后,它们共享地址空间、allocator、线程池和进程生死。驱动越界、组件不归还 loan、图像回调占满执行器,都会连带整条链路。这是所有权优化直接带来的故障域变化。

每帧几十 MiB、版本稳定且可共同重启的相邻段适合合进程;低频元数据、实验插件和不稳定 GPU 库适合隔离。可把“驱动→校正→格式变换”作为所有权域,为域外推理、可视化或录包接受一次可测 copy。

验收还要注入组件崩溃,确认 supervisor 拉起容器、DMA 与 loan pool 释放、sequence 能识别断档。故障恢复不可预测,就不能用少一次 copy 抵账。

7. 验收:CLI 看拓扑,trace 看时序,计数器看 copy

没有单条 CLI 能宣告“零拷贝”。固定同一帧源,依次测试普通跨进程、同容器关闭/开启 intra-process、跨进程开启目标 RMW 的 SHM/loan,记录吞吐、延迟分位数、CPU、RSS、分配次数和复制字节。

bash
printenv RMW_IMPLEMENTATION
ros2 doctor --report
ros2 component list
ros2 topic info -v /camera/image
ros2 topic hz /camera/image

# 安装 ros2_tracing 后,事件名以当前发行版 `ros2 trace --list` 为准
ros2 trace --list | grep -E 'publish|take|callback|intra'
ros2 trace -s image_memory_path

echohz 和 bag 会创建额外进程间订阅者,可能改变被测路径;频率应由管线按 sequence 统计。trace 只证明调度时序,copy 还要用对象与 payload 地址、allocator 计数、memcpy 采样及已知帧大小交叉证明。地址相同仍需排除内部重分配。验收要追到算法消费端:cv_bridge 转色、PCL 重排、tensor clone 和 GPU 上传都可能复制整帧。

8. 决策顺序:先证实边,再选择机制

先冻结消息大小、帧率、拓扑和 QoS,建立 copy 基线;再把最重的同进程边改成 UniquePtr,确认单/多订阅的复制数;之后单测 publisher loan,检查能力与动态 payload;最后在需隔离的边评估 RMW SHM。每步只改一个变量。

上线门槛包括:RMW/版本锁定,fallback 可观测,消息上限与 pool/history 有预算,归还后不再访问,新增 RViz、bag、late joiner 后复测,容器崩溃可恢复,copy 字节、延迟和 RSS 满足本机业务预算。

最终应能对每一帧说清楚:这块内存是谁分配的,在哪一行转移所有权,在哪个 QoS history 中被保留,什么时候归还,跨进程时是否只是换成共享介质,以及哪几次 copy 是业务上刻意接受的。能回答这些问题,intra-process、loaned message 和 shared memory 才是工程机制;回答不了,它们只是三个容易让性能报告失真的标签。

← 全部文章

johan's blog