Skip to content

ROS2 零拷贝 API 与线程模型

背景

ROS2 默认的消息传输路径中,数据从 Publisher 到 Subscriber 至少经历一次完整拷贝(中间件线程拷贝到 ROS 消息对象)。对于图像、点云等大消息,拷贝开销显著。零拷贝(zero-copy)通过共享内存避免拷贝,但引入了线程安全和生命周期管理问题。

一、零拷贝 API

Publisher 侧:borrow_loaned_message()

核心语义:借 -> 写 -> 还(转移所有权)。中间件拥有内存的分配和回收权。

cpp
// 1. 向中间件"借"一块内存(而非自己分配)
auto loaned_msg = pub->borrow_loaned_message();

// 2. 直接往这块内存里写数据
loaned_msg.get().data = 42.0;

// 3. 发布——所有权转移给中间件,loaned_msg 此后不可再访问
pub->publish(std::move(loaned_msg));

publish(std::move(...)) 之后 loaned_msg 失效,再访问是未定义行为。

如果中间件不支持 loan(如 rmw_cyclonedds),borrow_loaned_message() 会自动 fallback 到本地分配器,API 不变,只是不再零拷贝。

Subscriber 侧:默认关闭,且目前不安全

cpp
void callback(const Msg::UniquePtr msg) {
    // msg 可能指向共享内存中的数据
}

Subscriber 侧的 loaned message 默认禁用ROS_DISABLE_LOANED_MESSAGES 默认对订阅端为 1)。即使中间件支持,rclcpp 也会先拷贝一份再传给回调。强制开启:

bash
export ROS_DISABLE_LOANED_MESSAGES=0

数据流

二、线程模型对比

标准模式(非零拷贝)

DDS 收数据线程 --> 拷贝到 ROS 消息 --> Executor 调度 --> 用户回调
  • 中间件线程拷贝一份完整的 ROS 消息
  • Executor 在 ROS 侧线程中调用回调
  • 回调操作的是私有拷贝,天然线程安全

零拷贝模式

共享内存段 --> 引用计数 +1 --> Executor 调度 --> 用户回调 --> 引用计数 -1
     ^                                              |
     +---------- 引用计数归零时回收内存 ---------------+

关键区别:回调操作的是共享内存中的同一份数据,不是私有拷贝。

三、零拷贝的三个核心问题

问题 A:Publisher 会不会覆盖 Subscriber 正在读的数据?

不会。 iceoryx / FastDDS 共享内存使用引用计数机制。Publisher 写新数据时,如果旧 chunk 还有订阅者在读,中间件会分配一个新 chunk 给 Publisher。旧 chunk 在引用计数归零后才回收。

时间线:
Publisher:  写 chunk_A -------- 写 chunk_B -------- 写 chunk_C
Subscriber:    +-- 读 chunk_A --+                     (A 引用计数 > 0,不回收)
                                             chunk_A 引用计数=0,可回收

问题 B:Publisher 会不会因为 Subscriber 太慢而阻塞?

不会。 零拷贝模式下 Publisher 永远不等待 Subscriber。Subscriber 消费太慢时,根据 QoS History 策略,旧 chunk 会被安全溢出,Subscriber 丢失旧数据拿到最新数据。这和 socket 通信的背压机制完全不同。

问题 C:Subscriber 端 Loaned Message 为什么不安全?

问题出在 rclcpp 的 Executor 模型和共享内存生命周期的冲突

  1. SingleThreadedExecutor -- 回调串行执行,看似安全。但回调里如果做了异步操作(如把 UniquePtr 传给另一个线程),共享内存可能在另一个线程访问时已被回收。

  2. MultiThreadedExecutor -- 多个回调并行执行。如果两个回调引用了同一个共享内存 chunk(History depth > 1 时完全可能),且一个回调修改了数据,就会产生数据竞争。C++ 类型系统并没有阻止这种修改。

  3. 根本矛盾 -- rclcpp 的 UniquePtr 语义假设消息是独占所有的,但共享内存 chunk 的生命周期由中间件管理。Executor 释放 UniquePtr 时,中间件可能还没准备好回收;反过来,中间件已回收但 rclcpp 还持有引用。

这是 rmw_fastrtps#614rclcpp#1696 中讨论的核心问题,目前仍未完全解决。

四、线程模型对比总结

特性 标准模式 零拷贝模式
数据所有权 Subscriber 拥有私有拷贝 中间件拥有,Subscriber 只有引用
Publisher 阻塞 可能(网络背压) 不会(新分配 chunk)
回调线程安全 天然安全(私有数据) 需要额外保证
内存回收 离开作用域即回收 引用计数归零后回收
内存耗尽风险 无(堆分配) 有(RETCODE_OUT_OF_RESOURCES
适合场景 通用 大数据 + 单线程/可控的回调

五、实际建议

cpp
// 正确:回调中尽快处理完,不传出引用
void callback(const Msg::UniquePtr msg) {
    process(msg->data);  // 就地处理
    // 离开作用域,引用计数 -1
}

// 危险:把共享内存指针传给其他线程
void callback(const Msg::UniquePtr msg) {
    std::thread([msg]() {  // msg 的共享内存可能已被回收
        process(msg->data);
    }).detach();
}

// 危险:回调中长时间持有
void callback(const Msg::UniquePtr msg) {
    std::this_thread::sleep_for(100ms);  // 占用共享内存 chunk 100ms
    // Publisher 高频发送时,共享内存池会耗尽
}

回调中必须就地处理数据,不传出引用、不做异步操作、不长时间持有。否则要么触发 use-after-free,要么耗尽共享内存池。

基于 VitePress 构建