☰
Fast DDS共享内存零序列化方案:SHM与Loan API深度实践
2026/10/20 23:55:49 网站建设 项目流程

去年把一套基于点云的实时系统从默认通信方式迁移到 Fast-RTPS 的共享内存路径时,我最大的感悟是:消息在进程之间跑一圈,真正的开销根本不在“发送”和“接收”这两个函数里,而在中间那段序列化和拷贝链条上。当时的场景是单机上三个进程,负责数据采集、任务管理和结果融合,每秒要传 60 帧、每帧接近 1MB 的原始结构体。用默认的 UDPv4 传输时,CPU 占用高得吓人,延迟还时不时冒尖。后来我同时开启了 Fast DDS(社区里仍习惯叫它 Fast-RTPS)的共享内存传输和 Data Sharing,再配合 Writer 侧的 loan_sample 接口,结构体不需要变成 CDR 字节流就能在两个进程之间直接搬移。这篇文章把整套零序列化方案的原理、配置代码、性能数据和踩坑过程完整记录一遍,给同样被 DDS 数据通路卡住的项目做个参考。

1. 说清楚一条DDS消息的完整旅程,才知道省在哪

1.1 从write()到take()之间的四段路

普通 DDS 一条消息的完整旅程可以粗略分成四段。第一段是应用层的序列化:你在发送线程里调用writer->write(),传入的还是一个 C++ 结构体指针,TypeSupport 会在内部按 CDR(Common Data Representation)格式把字段编码成一串连续字节。对于只含数字和定长数组的结构体,这段开销还不算明显;一旦出现 string、sequence、map 这类动态成员,就会触发动态内存分配、递归编码和长度前缀计算,开销成倍增长。

第二段是 RTPS 协议打包:序列化完的字节流被塞进 RTPS 子消息结构里,加上消息头、子消息头、序列号、QoS 相关字段,变成标准的 RTPS 数据包。第三段是真正的传输:UDPv4 路径会调用 socket、走内核协议栈,哪怕收发双方在同一台机器上,数据也要从用户态拷到内核态,再从内核态拷回用户态,期间伴随系统调用、软中断和线程唤醒。第四段是接收端的反向解码:拆 RTPS 头、反 CDR 序列化、重新构造成目标类型对象。

如果按拷贝次数粗算,普通路径至少有三次大块内存搬移:用户结构体到 CDR 缓冲是一次,CDR 缓冲到内核 socket 缓冲是一次,内核缓冲到接收端用户缓冲是一次,接收端反序列化再构造结构体是第四次。放到 1MB 级消息、60Hz 的频率上,这些搬运每秒就是几百 MB 的无效复制,纯粹消耗内存带宽和 CPU。

1.2 开销不在“传输”里,而在序列化和拷贝次数里

很多人看上报数据慢,第一反应是怀疑带宽不够,但实际上单机场景下跑环回口,UDP 每秒能吞吐几个 GB,真正的瓶颈根本不在这。我在项目里做过一次粗定位:拿性能剖析工具统计发送端和接收端两边的 CPU 分布,发现序列化和反序列化各占掉大约 30%,剩下主要是内存拷贝和协议栈处理,业务逻辑只占个位数百分比。这说明前面那段编码解码路径才是主角。

还有个容易被忽略的点:CDR 序列化不是简单 memcpy。对于嵌套结构体、带对齐规则的复杂类型,序列化函数要逐个字段判断类型、位移、长度,还要做对齐填充。这些分支逻辑跑在热点路径上,比一次性拷贝还要消耗算力。尤其当结构体里嵌套了动态容器,序列化还要负责维护长度前缀和嵌套元素的递归编码,每帧消息都会产生大量临时对象,开销非常难压。

1.3 零序列化方案省掉的三件事

所谓“零序列化”,省掉的不是某一步,而是整条链路的三个大块:第一,不再把结构体编码成 CDR 字节流再解码,结构体以原始内存布局在进程间共享;第二,不再经过内核网络协议栈,数据只在用户态的共享内存段里移动;第三,不再反复搬移内存,发送端借出共享内存缓冲区,接收端直接在同一个缓冲区上读。

这正是 Fast DDS 里“共享内存传输(SHM Transport)”和“Data Sharing + Loan API”两套机制合在一起做的事。很多人把 SHM 传输误当成零拷贝,其实 SHM 只是把网络栈换成了共享内存通道,CDR 序列化仍然存在;只有再配合 Data Sharing 和借出/租用接口,才能做到真正意义上的结构体直接传输。搞清楚这两层区别,后面的配置才不会做混。

2. 共享内存传输(SHM)的配置与容量规划

2.1 用代码或XML把SharedMemTransport挂到Participant上

Fast DDS 支持在同一 Participant 上挂多个传输,默认内置的是 UDPv4。要启用共享内存传输,需要自己创建一个SharedMemTransportDescriptor并加入user_transports列表。代码方式很直接,但要注意头文件路径,不同版本略有差异:

#include <fastdds/rtps/transport/shared_mem/SharedMemTransportDescriptor.hpp> #include <fastdds/dds/domain/DomainParticipantFactory.hpp> using namespace eprosima::fastdds; using namespace eprosima::fastdds::rtps; auto shm_transport = std::make_shared<SharedMemTransportDescriptor>(); shm_transport->segment_size(256 * 1024 * 1024); shm_transport->max_message_size(8 * 1024 * 1024); shm_transport->max_connections(100); dds::DomainParticipantQos pqos; pqos.transport().user_transports.push_back(shm_transport); // 是否保留内置UDP传输,取决于是否需要跨机通信 pqos.transport().use_builtin_transports = false;

如果把use_builtin_transports设成 false,Participant 就只走共享内存传输,同机器内的发现和用户数据都通过共享内存完成,速度最快,但也彻底放弃跨主机能力。如果项目以后要上多机部署,建议保留内置 UDP,让 Fast DDS 在单机时自动优先生成 SHM 通道,跨机继续走 UDP。这个“自动回退”是我比较推荐的混合配置,代价只是 Discovery 阶段会多一点 LocalLocator 的匹配,实测影响可忽略。

如果你习惯 XML profile,也是一样的逻辑:

<profiles> <transport_descriptors> <transport_descriptor> <transport_id>shm_transport</transport_id> <type>SHM</type> <segment_size>268435456</segment_size> <max_message_size>8388608</max_message_size> </transport_descriptor> </transport_descriptors> <participant profile_name="shm_participant"> <rtps> <userTransports> <transport_id>shm_transport</transport_id> </userTransports> <useBuiltinTransports>false</useBuiltinTransports> </rtps> </participant> </profiles>

两种方式本质相同。我自己的习惯是:项目还在快速迭代期时用代码配置,方便加注释和动态调整;做成发布包后改用 XML,运维改配置不用动代码。

2.2 segment_size和max_message_size怎么定量

这两个参数最容易被照抄模板然后翻车。segment_size是共享内存段的总大小,所有通过该段传输的消息都吃这一块池子;max_message_size是单条消息的最大尺寸,必须小于segment_size,并且还要容纳下序列化后的长度、RTPS 头、以及共享内存内部的元数据。

定量估算可以这样算:先统计同时活跃的 Writer 数量 W、每个 Writer 在途消息数 N、平均消息大小 S,然后乘上冗余系数。比如我有 4 个 Writer,每个最多缓存 5 帧,单帧 1MB,那基线就是 20MB。但这里有个容易踩的坑:在途消息数并不仅仅由 History 深度决定,还和接收端处理速度强相关。接收端 take 之后不马上处理、不 return_loan,缓冲就会一直占着,再大的 segment 也不够。所以我最后把 segment_size 开到了 512MB,给整机其他业务留了余量。

容器环境下还有个天然限制:Linux 的/dev/shm就是 tmpfs,大小默认可能只有 64MB。Fast DDS 的共享内存段默认落在/dev/shm上,超过这个配额会直接分配失败。我建议上线前先执行df -h /dev/shm看一眼,再决定 segment_size,否则报错的时候人都找不到方向。

2.3 SHM不是零拷贝,边界要搞清楚

这里必须把概念掰开:Fast DDS 里的 SharedMemTransport,本质上只是把 UDP socket 换成了一块共享内存环形缓冲。RTPS 发送端的 CDR 序列化、接收端的反序列化、以及把序列化数据从一段拷贝到另一段的动作,一条都不少。它省掉的是内核协议栈开销、系统调用和上下文切换,对于中小字节的进程间通信提升非常明显,但并没有省掉“编码”这回事。

真正的零序列化来自 Data Sharing 机制。它绕开了 RTPS 数据包的完整发送流程,改为直接在共享内存段里维护带签名的快照缓冲区,让 Reader 侧的take返回的指针直接指向那块数据。所以你看到的“零序列化方案”,实际是 SHM + Data Sharing + Loan API 三件套。SHM 负责打通用户态通道,Data Sharing 负责绕过协议编码,Loan API 负责让你把结构体直接放到共享缓冲区里。

3. 零序列化组合拳:Data Sharing + Loan API

3.1 Writer侧怎么“借”一块内存来填结构体

普通 write 路径上,你先在自己的栈上或堆上构建结构体,然后 writer 内部把这块内存里的内容序列化拷贝出去。零序列化路径不这么干:你直接向 DataWriter 借一块已经待在共享内存段里的缓冲区,把结构体填进去,然后提交。

Fast DDS 2.6 之后提供了带类型的借出模板接口,代码比之前的 void* 版本安全得多:

// Writer的QoS必须允许Data Sharing dds::DataWriterQos writer_qos; writer_qos.data_sharing().on(); // 关键是这一行 writer_qos.history().kind = dds::KEEP_LAST_HISTORY_QOS; writer_qos.history().depth = 10; writer_qos.reliability().kind = dds::RELIABLE_RELIABILITY_QOS; dds::LoanableSample<Frame> loanable; dds::ReturnCode_t ret = writer->loan_sample(loanable); if (dds::ReturnCode_t::RETCODE_OK == ret) { loanable.sample->seq() = seq++; loanable.sample->timestamp() = current_time_ns(); std::memcpy(loanable.sample->points().data(), sensor_buffer, payload_bytes); ret = writer->write(loanable); // 提交并归还缓冲区 } else { // 借不到说明缓冲池或共享内存段耗尽,检查resource_limits和segment_size }

流程看起来很低调,其实发生了本质变化:loan_sample返回的loanable.sample指向的地址就在共享内存映射区里。你填充字段时,数据直接进了共享内存,之后 write 只做快照登记和通知,不发生第二次拷贝。

我提醒两点。第一,write(loanable)成功之后,这块缓冲区已经算交还给了 Writer,不能再挂到别的地方慢慢用。第二,老 Fast DDS 版本用void* loan_sample(),语义类似但需要自己强转,升级到 2.6+ 再迁移到模板版本,代码清晰很多。

3.2 Reader侧用take直接读共享内存

接收端对应的是take配合LoanableSequence。Fast DDS 的take返回的不是拷贝,而是共享缓冲区上的引用序列。你在循环里访问元素时,实际是在读发送端进程写下的同一块物理内存,读写顺序由 RTPS 协议保证。

dds::LoanableSequence<Frame> frames; dds::SampleInfoSeq infos; dds::ReturnCode_t ret = reader->take(frames, infos); if (dds::ReturnCode_t::RETCODE_OK == ret) { for (auto& frame : frames) { process_frame(frame); // 直接处理共享内存中的数据,零拷贝 } reader->return_loan(frames, infos); // 用完必须归还 }

return_loan少不得。take 拿到的引用会 pin 住共享内存段里的快照,不归还,Writer 端同一块缓冲区就永远释放不出来,历史深度再大也会被耗光。我一开始就吃过这个亏,只 take 不 return,跑了五分钟延迟从 40 微秒升到几毫秒,排查完发现是缓冲泄漏。

在启用了 Data Sharing 之后,Fast DDS 仍然会走一小部分 RTPS 控制消息用于 Discovery、心跳和数据可用性通知。这部分消息非常小,走 UDP 还是 SHM 都无所谓,但它保证了“零序列化数据通道 + 标准协议发现机制”的混合架构是兼容的。

3.3 Data Sharing怎么协商:AUTO、ON、OFF

Data Sharing 不是你想有就有的。Writer 和 Reader 两端都要配置成允许,并且 Discovery 匹配成功后才能生效。QoS 里有三档:automatic()是默认值,表示由 Fast DDS 根据类型和拓扑自动判断;on()强制开启;off()强制关闭。

匹配规则大致是这样的:两端都在 AUTO 时,Fast DDS 会尝试启用 Data Sharing;一端 ON 一端 AUTO,AUTO 端会收敛到 ON;一端 ON 一端 OFF,匹配不会失败,但 Data Sharing 协商会被禁用,通信退回普通 RTPS 数据包路径。这个降级是静默的,日志里通常只是一条 WARNING,很容易被忽略。我建议在联调阶段把两端都明确配成on(),宁可承受一次显式报错,也不要在 AUTO 下踩“不知道什么时候生效”的坑。

还要注意,Data Sharing 的生效还依赖类型支持。具体说就是下一章要讲的:类型能不能以原始内存布局直接放进共享段。

4. 结构体布局决定一切:哪些类型能真正零序列化

4.1 可以直接映射共享内存的结构体长什么样

零序列化能成立的物理基础很简单:发送进程和接收进程都把同一块物理内存映射到自己的地址空间,数据结构在内存里的字节布局是什么样,接收方看到的就是什么样。但前提是这块结构体在内存里是连续、固定大小、不含任何地址依赖的。

用官方术语说,就是 TypeSupport 报告这个类型是 plain 类型。符合这种条件的类型通常只包含基本数字类型、固定大小数组、以及这些成员的嵌套结构体,比如:

struct FrameHeader { int32_t seq; int64_t timestamp_ns; }; struct LidarPose { float x, y, z, yaw, pitch, roll; }; struct Frame { FrameHeader header; LidarPose pose; float points[200000]; uint32_t point_count; };

这段结构体里所有成员都定长、连续、可预测对齐,所以编译后的内存布局完全可以在共享缓冲区里直接解析。写进程填充,读进程拿到指针后按同一个布局解释字段,既不编码也不拷贝。

反过来,一旦出现std::string、std::vector、std::map这类成员,情况就变了。这些容器内部持有堆指针,记录的是当前进程的虚拟地址。同样的指针值映射到接收进程地址空间里,可能指向完全不相干的内存,甚至直接段错误。所以 Fast DDS 遇到这类类型会暗中退化为“共享缓冲区 + 序列化”模式,也就是数据仍然经过 CDR 编码,只是共享内存省了网络栈,谈不上零序列化。

4.2 IDL设计里的固定数组与动态序列

工业项目一般用 IDL 定义消息,再用生成器生成代码。IDL 里最容易犯的错是把定长需求写成了动态序列:

struct WrongFrame { long seq; sequence<float, 200000> points; // 最大长度200000,但仍是动态类型 }; struct RightFrame { long seq; array<float, 200000> points; // IDL 3.0的定长数组 long point_count; };

sequence<float, 200000>看着像定长,其实是“最大 200000 的动态序列”,编码时要写当前长度、要动态分配内存,类型布局不固定,将来想零拷贝都难。改成array<float, 200000>之后才是真正定长的连续内存块。

生成代码后,我强烈建议在调试阶段把类型是否 plain 的信息打印出来验证一次,别等运行时才发现静默降级。不同版本获取这个标志的 API 不太一样,以你手上生成代码的头文件为准,但逻辑都一样:类型不满足,后面再怎么调 Qos 都不会走完整零序列化。

4.3 复杂消息的降级与拆包方案

如果业务消息里实在绕不开字符串、变长数组,有两条路可以走。一条是接受降级:消息类型非 plain,但 Data Sharing 仍然可以工作,共享序列化后的快照缓冲区,节省内核拷贝和一次内存搬移。这叫有折扣的“零拷贝”,延迟能改善,但序列化占掉的 CPU 还在。

另一条是拆包:把结构体分成元数据部分和大块数据部分。元数据用一个包含 string 的小 topic 走普通序列化,频率低、没压力;大块数据用专门的定长结构体 topic,字段全部是数字和定长数组。这两个 topic 用相同的序列号或时间戳关联。拆包之后,大块直接走零序列化通道,元数据解析成本几乎可以忽略。实践中这套模式比硬塞一个超大复杂结构要顺得多,也是我最后落地的方案。

5. 实测数据与参数调优

5.1 三种配置同一场景下的对比

我在单机三进程的模拟项目里做了组对比,场景是 100Hz、每帧 800KB 的结构体,进程 A 发布,进程 B 接收并做简单求和,时长 120 秒。三种配置分别是:默认 UDPv4 + CDR 序列化;纯 SHM 传输 + CDR 序列化;SHM + Data Sharing ON + loan API(零序列化)。两端都用单 Writer 单 Reader,History 深度分别为 10 和 20,可靠性配置为 RELIABLE、Transient Local。

机器是普通的 8 核 Linux 工作站,/dev/shm挂在 tmpfs 上。测试结果取的是稳定阶段的中位数,可以看作同场景下的参考基准而非行业标尺,不同机器上的绝对数值会不一样,但相对关系是稳定的。

5.2 延迟与CPU占用数据

三种配置的结果如下表:

配置平均端到端延迟最大抖动收发两进程CPU合计
UDPv4 + CDR1.8ms4.5ms145%
SHM + CDR420us1.2ms88%
SHM + Data Sharing + loan48us180us42%

换成零序列化方案后,端到端延迟降了两个数量级,最大抖动从毫秒级收敛到百微秒级。CPU 方面,序列化和反序列化节省下来的算力非常可观,收发两个进程合计从 145% 降到 42%,省出来的 CPU 足以让同一个进程再多跑一路算法逻辑。

有个容易误读的点:48us 并不等于代码执行时间,而是“从 writer 提交到 reader 拿到可处理数据”之间的全链路延迟,其中包含 Discovery 后消息通知的开销。如果实测中看到几百微秒的数值,先检查是否真正走了零序列化路径,因为很多教训表明,配置不对时 Fast DDS 会默默降级。

5.3 QoS参数组合与缓冲池规划

零序列化方案稳定运行,关键 QoS 组合如下:Writer History 用 KEEP_LAST、depth=10,resource_limits().max_samples给到 20,allocated_samples给到 10,避免运行期反复分配;Reader 侧resource_limits().max_samples要给够,至少等于 Writer 侧 depth。

SharedMemTransport 的port_queue_capacity默认 1000,高频消息场景基本够;如果消息频率很高并且出现端口队列溢出日志,把它调到 2000 或 4000。segment_size我前面建议按在途消息数乘以平均大小再放大,高频大消息场景建议直接 512MB 起。原则是“宁可多给,不要让运行中反复动态增长”,共享内存段分配好了之后不会再重新扩展。

观察点优先级我建议这样:先看/dev/shm使用量确认 segment 建出来了,再看 Writer 侧loan_sample返回值有没有失败,最后看接收端有没有持续 take/return_loan。梳理完这三个点,性能调优基本就结束了。

6. 工程落地踩过的坑与排查顺序

6.1 /dev/shm容量与容器限制

我踩的第一个坑是容器环境。开发机上一切正常,到容器里部署就报Cannot allocate memory,查了半天才发现是 Fast DDS 在创建共享内存段时用光了 64MB 的/dev/shm。容器启动参数加上--shm-size=2g,问题立刻消失。

这里的判断方法是:进程起来后执行df -h /dev/shm和ls -la /dev/shm,看看是否有以 Fast DDS 命名规则创建的共享内存段,以及剩余空间是否少于segment_size。物理机部署也要注意系统级SHMMAX上限,太低时用sysctl kernel.shmmax调整,否则创建大 segment 照样失败。

6.2 loan的生命周期管理错误

第二个坑出在业务代码重构时。一起联调的同学把 writer 侧借出的 sample 指针塞进了一个向量,准备在后台工作线程里慢慢发送,结果第二帧就把第一帧的数据覆盖了。原因前面说过了,write()提交后缓冲区就已经归还到缓冲池,谁都能再次借走。零序列化能省拷贝的前提是“数据在共享内存里只活一个很短的时间窗”,从借出到提交,再到 Reader 读取完毕,整个生命周期必须紧凑。

如果业务上确实需要把消息保存下来异步处理,就老老实实做一次深拷贝,自己开一块普通内存存副本。省掉序列化的拷贝,总比为了省拷贝把数据搞坏强。

6.3 数据共享协商失效时的静默降级

第三个坑最隐蔽:两边的 Participant 都配置了 SHM,也配置了 Data Sharing,但实际跑起来延迟还是毫秒级。我把日志级别调到 Info 看了半天,才发现 Discovery 阶段打印了一条类似“Data Sharing is forbidden”的 WARNING,原因是 Reader 端的 DataSharing 被某个历史配置里的off()覆盖了。Data Sharing 协商一旦不成立,会立即退回普通 RTPS 网络数据包路径,但不会有 Error 级别的报错。

排查顺序我固定成一套:先确认两端版本一致,再确认两侧 Qos 都显式用的是on()而非依赖 AUTO,再看/dev/shm里有没有对应的 segment 文件,最后看运行时日志里的 DataSharing warning。每一步都过一遍,还没有效果就临时把use_builtin_transports=false打开,强制只走 SHM,这样可以快速隔离是不是 UDP 路径在兜底。这套顺序帮我解决过几次看起来像灵异事件的性能问题,本质都是协商降级。

最后再说个实际体会。整个方案跑通之后,延迟和 CPU 确实大幅下降,但系统瓶颈并没有消失,只是转移了:原来压力在序列化和内核协议栈,现在转移到共享内存的带宽、缓冲池的规划,以及业务代码对生命周期管理的纪律。能不能稳定吃掉这份收益,取决于你把“借了要还、take 完要 return_loan”这条规矩执行得有多彻底。每当我看到新接手的同学写完第一版零拷贝代码,我都会提醒他先去看一眼日志里的 DataSharing warning,再决定要不要继续优化。毕竟,最怕的不是性能差,而是以为自己已经跑在零序列化路径上,实际上早就悄悄降级了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询