ROS2 零拷贝 API 与线程模型
背景
ROS2 默认的消息传输路径中,数据从 Publisher 到 Subscriber 至少经历一次完整拷贝(中间件线程拷贝到 ROS 消息对象)。对于图像、点云等大消息,拷贝开销显著。零拷贝(zero-copy)通过共享内存避免拷贝,但引入了线程安全和生命周期管理问题。
一、零拷贝 API
Publisher 侧:borrow_loaned_message()
核心语义:借 -> 写 -> 还(转移所有权)。中间件拥有内存的分配和回收权。
// 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 侧:默认关闭,且目前不安全
void callback(const Msg::UniquePtr msg) {
// msg 可能指向共享内存中的数据
} Subscriber 侧的 loaned message 默认禁用(ROS_DISABLE_LOANED_MESSAGES 默认对订阅端为 1)。即使中间件支持,rclcpp 也会先拷贝一份再传给回调。强制开启:
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 模型和共享内存生命周期的冲突:
SingleThreadedExecutor -- 回调串行执行,看似安全。但回调里如果做了异步操作(如把
UniquePtr传给另一个线程),共享内存可能在另一个线程访问时已被回收。MultiThreadedExecutor -- 多个回调并行执行。如果两个回调引用了同一个共享内存 chunk(History depth > 1 时完全可能),且一个回调修改了数据,就会产生数据竞争。C++ 类型系统并没有阻止这种修改。
根本矛盾 -- rclcpp 的
UniquePtr语义假设消息是独占所有的,但共享内存 chunk 的生命周期由中间件管理。Executor 释放UniquePtr时,中间件可能还没准备好回收;反过来,中间件已回收但 rclcpp 还持有引用。
这是 rmw_fastrtps#614 和 rclcpp#1696 中讨论的核心问题,目前仍未完全解决。
四、线程模型对比总结
| 特性 | 标准模式 | 零拷贝模式 |
|---|---|---|
| 数据所有权 | Subscriber 拥有私有拷贝 | 中间件拥有,Subscriber 只有引用 |
| Publisher 阻塞 | 可能(网络背压) | 不会(新分配 chunk) |
| 回调线程安全 | 天然安全(私有数据) | 需要额外保证 |
| 内存回收 | 离开作用域即回收 | 引用计数归零后回收 |
| 内存耗尽风险 | 无(堆分配) | 有(RETCODE_OUT_OF_RESOURCES) |
| 适合场景 | 通用 | 大数据 + 单线程/可控的回调 |
五、实际建议
// 正确:回调中尽快处理完,不传出引用
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,要么耗尽共享内存池。