
去年把一套基于点云的实时系统从默认通信方式迁移到 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 会在内部按 CDRCommon 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_sharedSharedMemTransportDescriptor(); 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设成 falseParticipant 就只走共享内存传输同机器内的发现和用户数据都通过共享内存完成速度最快但也彻底放弃跨主机能力。如果项目以后要上多机部署建议保留内置 UDP让 Fast DDS 在单机时自动优先生成 SHM 通道跨机继续走 UDP。这个“自动回退”是我比较推荐的混合配置代价只是 Discovery 阶段会多一点 LocalLocator 的匹配实测影响可忽略。如果你习惯 XML profile也是一样的逻辑profiles transport_descriptors transport_descriptor transport_idshm_transport/transport_id typeSHM/type segment_size268435456/segment_size max_message_size8388608/max_message_size /transport_descriptor /transport_descriptors participant profile_nameshm_participant rtps userTransports transport_idshm_transport/transport_id /userTransports useBuiltinTransportsfalse/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 API3.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::LoanableSampleFrame 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::LoanableSequenceFrame 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、OFFData Sharing 不是你想有就有的。Writer 和 Reader 两端都要配置成允许并且 Discovery 匹配成功后才能生效。QoS 里有三档automatic()是默认值表示由 Fast DDS 根据类型和拓扑自动判断on()强制开启off()强制关闭。匹配规则大致是这样的两端都在 AUTO 时Fast DDS 会尝试启用 Data Sharing一端 ON 一端 AUTOAUTO 端会收敛到 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; sequencefloat, 200000 points; // 最大长度200000但仍是动态类型 }; struct RightFrame { long seq; arrayfloat, 200000 points; // IDL 3.0的定长数组 long point_count; };sequencefloat, 200000看着像定长其实是“最大 200000 的动态序列”编码时要写当前长度、要动态分配内存类型布局不固定将来想零拷贝都难。改成arrayfloat, 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 单 ReaderHistory 深度分别为 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、depth10resource_limits().max_samples给到 20allocated_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-size2g问题立刻消失。这里的判断方法是进程起来后执行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_transportsfalse打开强制只走 SHM这样可以快速隔离是不是 UDP 路径在兜底。这套顺序帮我解决过几次看起来像灵异事件的性能问题本质都是协商降级。最后再说个实际体会。整个方案跑通之后延迟和 CPU 确实大幅下降但系统瓶颈并没有消失只是转移了原来压力在序列化和内核协议栈现在转移到共享内存的带宽、缓冲池的规划以及业务代码对生命周期管理的纪律。能不能稳定吃掉这份收益取决于你把“借了要还、take 完要 return_loan”这条规矩执行得有多彻底。每当我看到新接手的同学写完第一版零拷贝代码我都会提醒他先去看一眼日志里的 DataSharing warning再决定要不要继续优化。毕竟最怕的不是性能差而是以为自己已经跑在零序列化路径上实际上早就悄悄降级了。