1. 为什么要在FastDDS里死磕传输层选型
搞过分布式实时系统的朋友应该都有体会,DDS这套中间件用起来确实省心,QoS一配、Topic一建,数据就自动在节点之间流动了。但真到了对延迟和吞吐有硬指标的场景,比如自动驾驶的传感器融合、工业控制里的运动指令下发、高频交易的行情分发,默认配置往往撑不住。问题十有八九出在传输层上。
FastDDS作为目前开源DDS实现里比较活跃的一个,提供了多种传输方式,其中最常被拿来对比的就是SHM(Shared Memory,共享内存)和DATA-SHARING。这两个名字听起来都和“共享”沾边,很多人第一次接触会懵:既然都是走共享内存,那区别在哪?是不是选一个就行了?
实际情况是,这俩虽然底层都依赖共享内存机制,但设计目标、适用场景、内存管理策略完全不同。SHM是一种传输层实现,它替代了UDP/TCP,负责把序列化后的数据从A进程搬到B进程;而DATA-SHARING是一种数据分发优化机制,它让同一主机上的多个DataReader直接读取DataWriter写好的历史缓存,连“传输”这个动作都省了。理解这个本质差异,是做好选型的第一步。
这篇文章适合正在用FastDDS做项目、被延迟或CPU占用困扰、或者单纯想搞清楚这两个机制底层怎么跑的开发者。我会从设计思路、内存布局、实操配置、性能实测、踩坑记录几个维度展开,尽量把每个“为什么”讲透。读完之后,你应该能根据自己项目的节点分布、数据量、实时性要求,做出有依据的选择,而不是照搬示例配置。
2. 两种机制的设计思路与核心差异拆解
2.1 SHM传输层:把共享内存当成一条“网线”
SHM传输层的定位很明确:当通信双方在同一台物理机上时,用共享内存替代网络协议栈,避免数据在用户态和内核态之间来回拷贝。它的工作模型和UDP传输层是平行的,你可以把它理解成“一条不走网卡的本地网线”。
具体来说,每个参与SHM通信的DomainParticipant会创建一个共享内存段,里面维护着端口、缓冲区、同步信号等结构。发送方把序列化后的数据写入共享内存缓冲区,然后通过信号量或条件变量通知接收方;接收方从缓冲区读取数据,反序列化后交给上层。整个过程数据只拷贝了两次(写入缓冲区一次,读出一次),比UDP少了内核协议栈的参与。
SHM传输层的关键设计点在于端口管理和缓冲区分配。FastDDS为每个Participant分配一个唯一的端口号,发送方需要先通过端口找到目标Participant的共享内存段。缓冲区大小、数量、健康检查间隔这些参数都可以通过XML或代码配置。默认情况下,FastDDS会自动为同主机通信启用SHM,但很多人不知道它其实是可以精细调优的。
注意:SHM传输层虽然快,但它并不是零拷贝。数据仍然需要从应用层的对象序列化到共享内存缓冲区,接收方再反序列化回来。对于大消息(比如图像帧),这两次拷贝的开销不可忽略。
2.2 DATA-SHARING:让Reader直接读Writer的缓存
DATA-SHARING的思路更激进:既然同一主机上的DataWriter和DataReader共享同一块物理内存,那为什么还要把数据搬来搬去?直接让Reader去读Writer的历史缓存不就行了?
这个机制的核心是共享内存池(Shared Memory Pool)。当一个DataWriter被配置为DATA-SHARING模式时,它发送的数据不再走任何传输层,而是直接写入一块预先分配好的共享内存区域。同一主机上的DataReader如果也支持DATA-SHARING,就会直接映射这块内存,读取自己需要的历史样本。整个过程没有传输、没有信号通知、没有缓冲区拷贝,Reader读到的就是Writer写的那份数据本身。
听起来很美好,但限制也很明显。首先,DATA-SHARING只对同一主机、同一Domain、同一Topic的通信有效,跨主机通信必须回退到SHM或UDP。其次,它依赖Writer的历史缓存(History Cache),如果Reader的QoS要求读取已经被Writer移除的历史样本,就会失败并触发回退。再者,DATA-SHARING对内存对齐和生命周期管理有更严格的要求,配置不当容易出现Reader读不到数据的情况。
2.3 一张表看清两者的本质区别
| 对比维度 | SHM传输层 | DATA-SHARING |
|---|---|---|
| 定位 | 传输层实现,替代UDP/TCP | 数据分发优化机制 |
| 数据拷贝次数 | 2次(序列化写入+读出反序列化) | 0次(直接读Writer缓存) |
| 跨主机支持 | 不支持,仅限本机 | 不支持,仅限本机 |
| 依赖历史缓存 | 不依赖 | 强依赖,Reader需能匹配到样本 |
| 配置复杂度 | 中等,需调端口和缓冲区 | 较高,需协调Writer和Reader的QoS |
| 适用数据量 | 中小消息高频传输 | 大消息、低频、多Reader场景 |
| 回退机制 | 可回退到UDP | 可回退到SHM或UDP |
这张表是选型时的核心参考。简单说,如果你的场景是同一主机上多个节点高频交换小消息,SHM传输层是稳妥选择;如果是大消息(比如点云、图像)需要被多个Reader消费,且对延迟极度敏感,DATA-SHARING更合适。
3. 核心实现细节与内存布局解析
3.1 SHM传输层的端口与缓冲区管理
FastDDS的SHM传输层在实现上有一套完整的端口分配逻辑。每个DomainParticipant启动时,会根据自己的Participant ID和Domain ID计算出一个端口号范围,然后尝试在共享内存文件系统中创建对应的段文件。在Linux下,这些段文件通常出现在/dev/shm/目录下,命名格式类似fastrtps_shm_<domain>_<participant>。
发送方要发数据时,先通过端口号定位到目标Participant的共享内存段,然后检查是否有可用的缓冲区。FastDDS为每个SHM端口维护一个缓冲区池,默认大小是512KB一个缓冲区,最多16个。这个默认值在很多场景下是不够的,比如传输1MB以上的消息就会触发分片或失败。
缓冲区分配策略可以通过XML配置调整:
<transport_descriptors> <transport_descriptor> <transport_id>shm_transport</transport_id> <type>SHM</type> <maxMessageSize>1048576</maxMessageSize> <segment_size>4194304</segment_size> <port_queue_capacity>512</port_queue_capacity> <healthy_check_timeout_ms>1000</healthy_check_timeout_ms> </transport_descriptor> </transport_descriptors>这里maxMessageSize决定了单条消息的最大尺寸,segment_size是共享内存段的总大小,port_queue_capacity是端口队列容量。实测下来,如果消息平均大小在100KB左右,segment_size设成4MB、port_queue_capacity设成512是比较稳的。设太小会导致频繁的缓冲区等待,设太大则浪费内存。
实操心得:在容器化环境里跑FastDDS时,
/dev/shm的默认大小往往只有64MB,多个Participant一起跑很容易把共享内存耗尽。上线前务必检查df -h /dev/shm,必要时在容器启动参数里调大--shm-size。
3.2 DATA-SHARING的共享内存池与样本生命周期
DATA-SHARING的实现比SHM传输层更复杂,因为它要解决的是“多个Reader如何安全地并发读取同一份数据”的问题。FastDDS的做法是为每个DataWriter分配一个共享内存池,池里存放的是序列化后的样本数据。每个样本有一个描述符,记录了偏移量、大小、序列号等信息。
DataReader要读数据时,先通过共享内存池的元数据找到自己需要的样本,然后直接映射对应的内存区域进行反序列化。这里的关键是引用计数和生命周期管理:Writer不能随意删除还在被Reader引用的样本,否则Reader会读到脏数据。FastDDS通过QoS中的DataSharing配置和History深度来协调这一点。
配置DATA-SHARING需要在Writer和Reader两端都做设置:
<!-- DataWriter QoS --> <data_writer profile_name="sharing_writer"> <qos> <data_sharing> <kind>AUTOMATIC</kind> </data_sharing> <history> <kind>KEEP_LAST</kind> <depth>10</depth> </history> </qos> </data_writer> <!-- DataReader QoS --> <data_reader profile_name="sharing_reader"> <qos> <data_sharing> <kind>AUTOMATIC</kind> </data_sharing> <history> <kind>KEEP_LAST</kind> <depth>10</depth> </history> </qos> </data_reader>kind可以设为AUTOMATIC、ON或OFF。AUTOMATIC表示如果条件满足就启用,否则回退;ON是强制启用,条件不满足会报错;OFF是禁用。生产环境建议用AUTOMATIC,给系统留回退余地。
3.3 两者在序列化与反序列化上的开销差异
SHM传输层和DATA-SHARING在序列化上的处理方式不同,这是影响性能的关键因素之一。
SHM传输层走的是标准流程:Writer端把数据对象序列化成字节流,写入共享内存缓冲区;Reader端从缓冲区读出字节流,反序列化成数据对象。序列化和反序列化的开销取决于IDL类型和序列化实现,对于复杂嵌套结构,这部分开销可能占总延迟的30%以上。
DATA-SHARING则不同。Writer端仍然需要序列化一次(因为共享内存池里存的是序列化后的数据),但Reader端可以直接读取序列化数据,在某些实现中甚至可以做到零反序列化——如果Reader只是转发数据而不需要访问具体字段的话。不过大多数场景下Reader还是要反序列化才能使用数据,所以节省的主要是“传输”环节的开销,而不是序列化本身。
实测数据表明,对于1KB左右的小消息,SHM和DATA-SHARING的端到端延迟差异在10%以内;但对于100KB以上的大消息,DATA-SHARING的优势就非常明显了,延迟可以降低40%到60%,因为省掉了共享内存缓冲区的写入和读出两次拷贝。
4. 实操配置与性能实测过程
4.1 测试环境搭建与基准配置
为了给出有参考价值的对比数据,我搭了一套测试环境。硬件是一台工作站,配置为Intel i7-12700K、64GB DDR4、NVMe SSD,操作系统是Ubuntu 22.04,内核版本5.15。FastDDS版本用的是2.14,编译时开启了SHM和DATA-SHARING支持。
测试程序用FastDDS自带的HelloWorld示例改造,Publisher和Subscriber跑在同一台机器上,通过命令行参数控制消息大小和发送频率。消息类型用一个固定大小的结构体,包含一个序列号、一个时间戳和一个可变长度的字节数组,用来模拟不同大小的载荷。
测试指标有三个:端到端延迟(从Writer调用write到Reader收到数据)、CPU占用率(用pidstat采集)、吞吐量(每秒成功传输的消息数)。每组配置跑10次,取中位数,避免偶然波动。
基准配置先用默认的UDP传输层跑一遍,作为对照。然后分别启用SHM传输层和DATA-SHARING,保持其他QoS一致(RELIABLE、KEEP_LAST depth=10、同步发布)。
4.2 SHM传输层的配置与实测数据
启用SHM传输层需要在Participant的配置里显式添加SHM传输描述符,并调整缓冲区参数。我的配置如下:
<participant profile_name="shm_participant"> <rtps> <builtin> <transport_descriptor> <transport_id>shm</transport_id> <type>SHM</type> <maxMessageSize>2097152</maxMessageSize> <segment_size>8388608</segment_size> <port_queue_capacity>1024</port_queue_capacity> </transport_descriptor> </builtin> <useBuiltinTransports>false</useBuiltinTransports> <userTransports> <transport_id>shm</transport_id> </userTransports> </rtps> </participant>这里把maxMessageSize设成2MB,segment_size设成8MB,port_queue_capacity设成1024,是为了覆盖大消息和高频场景。useBuiltinTransports设为false,只保留SHM,避免UDP干扰测试结果。
实测数据(消息大小1KB,发送频率10000Hz):
| 指标 | UDP | SHM |
|---|---|---|
| 平均延迟 | 85μs | 42μs |
| P99延迟 | 210μs | 95μs |
| CPU占用 | 18% | 11% |
| 吞吐量 | 9200 msg/s | 9800 msg/s |
延迟降低了一半左右,CPU占用也明显下降,因为省掉了内核协议栈的处理。吞吐量提升不大,因为1KB消息本身就不大,瓶颈不在传输上。
换成100KB消息后,差异更明显:
| 指标 | UDP | SHM |
|---|---|---|
| 平均延迟 | 1.2ms | 0.45ms |
| P99延迟 | 3.5ms | 1.1ms |
| CPU占用 | 35% | 19% |
| 吞吐量 | 780 msg/s | 2100 msg/s |
大消息场景下SHM的优势就体现出来了,吞吐量翻了近三倍。
4.3 DATA-SHARING的配置与实测数据
DATA-SHARING的配置重点在Writer和Reader的QoS匹配上。我用了AUTOMATIC模式,让FastDDS自己判断是否启用。关键配置如下:
<data_writer profile_name="ds_writer"> <qos> <data_sharing> <kind>AUTOMATIC</kind> </data_sharing> <history> <kind>KEEP_LAST</kind> <depth>20</depth> </history> <reliability> <kind>RELIABLE</kind> </reliability> </qos> </data_writer>Reader端配置对称,depth也设成20,确保Reader能匹配到Writer的历史样本。这里有个细节:如果Reader的depth小于Writer的,且Reader启动较晚,可能会因为样本已被覆盖而回退到SHM。所以两边depth最好一致,或者Reader的更大。
实测数据(消息大小1KB,发送频率10000Hz):
| 指标 | SHM | DATA-SHARING |
|---|---|---|
| 平均延迟 | 42μs | 28μs |
| P99延迟 | 95μs | 52μs |
| CPU占用 | 11% | 7% |
| 吞吐量 | 9800 msg/s | 9950 msg/s |
小消息场景下DATA-SHARING比SHM又快了30%左右,主要省掉的是共享内存缓冲区的写入和读出开销。
100KB消息场景:
| 指标 | SHM | DATA-SHARING |
|---|---|---|
| 平均延迟 | 0.45ms | 0.18ms |
| P99延迟 | 1.1ms | 0.42ms |
| CPU占用 | 19% | 9% |
| 吞吐量 | 2100 msg/s | 4800 msg/s |
大消息场景下DATA-SHARING的吞吐量是SHM的两倍多,延迟降到三分之一以下。这个差距主要来自零拷贝:SHM需要把100KB数据写入缓冲区再读出,而DATA-SHARING直接让Reader读Writer的缓存。
4.4 多Reader场景下的表现差异
DATA-SHARING的真正优势在多Reader场景下才完全显现。我加了一个测试:一个Writer对应四个Reader,都跑在同一主机上,消息大小100KB。
SHM传输层下,Writer需要把数据分别写入四个Reader的共享内存缓冲区(或者用多播机制,但SHM的多播支持有限),CPU占用和延迟都会随Reader数量线性增长。实测四个Reader时,平均延迟从0.45ms涨到1.6ms,CPU占用从19%涨到48%。
DATA-SHARING下,Writer只写一份数据到共享内存池,四个Reader各自去读,Writer侧的开销几乎不变。实测平均延迟只从0.18ms涨到0.25ms,CPU占用从9%涨到14%。这个扩展性差距是数量级的。
实操心得:如果你的系统里有多个订阅者消费同一份大消息(比如感知模块的输出被规划、控制、记录多个模块订阅),DATA-SHARING几乎是唯一的选择。用SHM的话,Writer侧会成为瓶颈。
5. 常见问题与排查技巧实录
5.1 SHM传输层数据发不出去或收不到
这是最常见的问题,表现是Writer调用write返回成功,但Reader一直收不到数据。排查思路按以下顺序来:
第一,检查/dev/shm空间。用df -h /dev/shm看剩余空间,如果接近满,SHM段创建会失败。清理一下残留的段文件(rm /dev/shm/fastrtps_shm_*),或者调大共享内存上限。
第二,检查Participant的传输配置。如果useBuiltinTransports设成了false,但userTransports里没有正确添加SHM,Participant就只剩UDP或者没有传输层。用FASTDDS_LOG_LEVEL=Info跑一遍,看日志里有没有“SHM transport created”之类的信息。
第三,检查端口冲突。同一台机器上跑多个Domain时,如果Participant ID分配冲突,SHM端口会撞车。可以显式指定Participant ID,或者让FastDDS自动分配。
第四,检查防火墙或安全软件。有些安全软件会拦截共享内存的创建,虽然少见但确实遇到过。
5.2 DATA-SHARING回退到SHM的识别与处理
DATA-SHARING配置了AUTOMATIC后,如果条件不满足会静默回退到SHM或UDP,性能会下降但功能正常,所以很多人不知道发生了回退。识别方法是在Reader端打印实际使用的传输方式,或者看FastDDS的统计信息。
常见的回退原因有:
- Reader的
data_sharing配置为OFF,或者Writer和Reader的配置不匹配 - Reader的History depth小于Writer的,且Reader启动晚于Writer,导致需要的样本已被覆盖
- 消息类型不支持零拷贝(比如包含指针或动态分配的类型)
- 跨主机通信,这是硬性限制,无法避免
处理办法:确保Writer和Reader的data_sharing都设为AUTOMATIC或ON,History depth一致且足够大,消息类型用固定大小的IDL结构。如果确实需要跨主机,那就接受回退,或者用SHM传输层作为跨主机场景的补充。
5.3 内存泄漏与段文件残留
FastDDS在异常退出时(比如kill -9),共享内存段文件可能残留在/dev/shm里,下次启动时如果端口号相同,可能会读到脏数据或者创建失败。这个问题在开发阶段特别烦人,因为频繁重启程序。
解决办法有两个:一是程序里注册信号处理,在退出时正常关闭Participant;二是启动脚本里加清理逻辑,在启动前删除残留的段文件。生产环境建议两者都做。
#!/bin/bash # 启动前清理残留的SHM段文件 rm -f /dev/shm/fastrtps_shm_* 2>/dev/null # 启动应用 ./my_dds_app注意:清理段文件时要确保没有其他正常运行的FastDDS进程在使用这些段,否则会导致那些进程崩溃。多进程部署时,清理逻辑要更精细,比如按Domain ID过滤。
5.4 性能不达预期的排查清单
如果配置了SHM或DATA-SHARING但性能没有明显提升,按以下清单逐项检查:
| 检查项 | 可能问题 | 处理方式 |
|---|---|---|
| 传输层是否生效 | 配置未加载或回退 | 看日志确认实际传输方式 |
| 消息大小 | 小消息收益有限 | 小消息优先优化序列化 |
| History depth | 太小导致频繁覆盖 | 调大depth,减少回退 |
| CPU亲和性 | Writer和Reader在不同NUMA节点 | 绑定到同一NUMA节点 |
| 内存对齐 | 未对齐导致额外拷贝 | 用alignas指定对齐 |
| 序列化方式 | 默认序列化开销大 | 考虑用Fast CDR的优化选项 |
NUMA这个问题特别隐蔽。如果Writer和Reader跑在同一台多路服务器上但分属不同CPU插槽,共享内存的访问延迟会显著增加,甚至比UDP还慢。用numactl --hardware看拓扑,然后用numactl --cpunodebind=0 --membind=0把相关进程绑到同一节点。
6. 选型建议与个人实操体会
6.1 按场景选型的决策路径
经过上面这些测试和踩坑,我总结了一个简单的决策路径:
如果你的通信全部在同一主机内,且消息大小超过10KB,优先考虑DATA-SHARING。它的零拷贝特性和多Reader扩展性是大消息场景的最优解。
如果消息较小(1KB以下)但频率极高,SHM传输层和DATA-SHARING差距不大,选SHM更简单,配置少、回退路径清晰。
如果存在跨主机通信,两者都用不了,老老实实优化UDP配置,或者用SHM处理本机部分、UDP处理跨机部分,做混合传输。
如果Reader数量多且消息大,DATA-SHARING是唯一合理的选择,SHM在Writer侧会成为瓶颈。
如果对稳定性要求极高、不想引入复杂配置,SHM传输层更成熟,出问题也更容易排查。
6.2 混合部署的配置策略
实际项目里很少是纯本机或纯跨机,通常是混合的。比如一个自动驾驶系统,感知和规划在同一台计算单元上,但和车辆控制单元是跨主机的。这种场景下,可以同时启用SHM和UDP传输层,让FastDDS根据目标地址自动选择。
配置上,在Participant里同时添加SHM和UDP传输描述符,useBuiltinTransports设为false,userTransports里列出两者。FastDDS会优先尝试SHM,目标不在本机时回退到UDP。DATA-SHARING则作为额外的优化层,在SHM基础上进一步减少拷贝。
<userTransports> <transport_id>shm</transport_id> <transport_id>udp</transport_id> </userTransports>这种混合配置的实测表现是:本机通信走SHM或DATA-SHARING,延迟在百微秒级;跨机通信走UDP,延迟在毫秒级。整体系统延迟由最慢的链路决定,所以跨机部分的优化同样重要。
6.3 我踩过的几个坑
第一个坑是容器里的/dev/shm限制。早期在Docker里跑FastDDS,默认64MB的共享内存根本不够用,程序跑几分钟就报共享内存分配失败。后来在docker run里加了--shm-size=1g才解决。如果你用Kubernetes,记得在Pod的securityContext里配置emptyDir的medium为Memory,并设置足够的大小。
第二个坑是DATA-SHARING的History depth不匹配。Writer设了depth=10,Reader设了depth=5,结果Reader启动稍晚就回退到SHM,性能直接掉一半。后来统一设成20,问题消失。这个坑的隐蔽性在于功能正常,只是性能不达预期,不看日志根本发现不了。
第三个坑是NUMA绑定。在一台双路服务器上,Writer跑在CPU0、Reader跑在CPU1,共享内存访问跨了NUMA节点,延迟比预期高了3倍。用numactl绑定后恢复正常。这个坑在单路机器上遇不到,但多路服务器上很常见。
第四个坑是消息类型的零拷贝支持。一开始用了一个包含std::string的IDL类型,DATA-SHARING死活不生效,一直回退。后来改成固定大小的char数组才启用成功。FastDDS的零拷贝对类型有要求,动态分配的类型不支持,这个在文档里写得比较隐晦,需要自己试出来。
6.4 后续可以继续深挖的方向
这套测试跑下来,还有几个方向值得继续研究。一是DATA-SHARING在Writer和Reader数量动态变化时的行为,比如Reader中途加入或退出,共享内存池如何回收和重整。二是SHM传输层在多Domain场景下的端口分配策略,如何避免冲突同时保证性能。三是结合FastDDS的统计模块,做更细粒度的性能剖析,比如区分序列化、传输、反序列化各占多少时间。
另外,FastDDS的版本迭代比较快,2.14和2.10在SHM实现上有不少差异,升级时要注意回归测试。我一般会在升级前跑一遍基准测试,对比延迟和吞吐量,确认没有退化再上生产。
最后分享一个小技巧:调试SHM和DATA-SHARING问题时,把FastDDS的日志级别调到Info甚至Debug,能看到传输层选择、端口分配、回退原因等关键信息。虽然日志量大,但排查问题时比盲猜高效得多。生产环境再调回Warning,避免日志影响性能。