跳到主要内容

LinuxSharedTopic 设计

基础用法见 共享内存 Topic(Linux)。本文说明它与普通 Topic 的分工,以及当前实现的取舍。

1. 为什么不直接改 Topic​

LinuxSharedTopic<T> 面向 Linux / Webots 主机的进程间通信:大 payload 共享、零拷贝读取、多订阅者队列策略。Topic 面向进程内发布订阅,偏 MCU 和轻量系统,用精确类型分发、回调、同步/异步订阅者组织数据流。两者约束不同,所以共享内存语义没有塞回 Topic,而是单独实现为 LibXR::LinuxSharedTopic<T>,让 Topic 保持轻量,主机 IPC 也能沿共享内存模型独立演化。

2. 数据面和控制面分离​

LinuxSharedTopic<T> 的核心结构分两层,而不是一条简单队列:

  1. payload slot
    • 真实数据存放在共享内存槽位里
  2. descriptor queue
    • 发布时只把“哪个 slot 可读”投递给订阅者

这样:

  • payload 本体不需要在 publisher 和 subscriber 之间重复拷贝
  • 每个订阅者只需消费描述符
  • 同一个 slot 可被多个订阅者同时持有,直到最后一个释放

这是它实现零拷贝的前提。


3. 为什么热路径用 atomic + futex​

这条实现的关键约束是:

  • 热路径(发布与接收)不使用 mutex
  • publish / consume 侧尽量只做原子状态推进
  • 等待路径再用 futex 睡眠

发布和接收路径只做原子状态推进,需要等待时才通过 futex 睡眠。

4. 为什么用 refcount reclaim,而不是覆盖写​

共享内存队列的一个危险场景是:publisher 想继续写,但某个 subscriber 还没读完旧数据。

这里按引用计数回收 slot:slot 用尽时发布失败,对发布者形成反压,不覆盖仍被订阅者持有的数据。代价是高压下 publisher 可能因 slot 耗尽而失败;好处是不会在 subscriber 仍持有数据时把 payload 覆盖掉。这是安全优先的取舍。


5. 三种订阅策略的取舍​

订阅模式有三种,各自的目标和代价不同:

  • BROADCAST_FULL
  • BROADCAST_DROP_OLD
  • BALANCE_RR

BROADCAST_FULL​

已经进入订阅者队列的内容不会为了给新消息让路而被覆盖;某个慢订阅者队列满时,新 publish 可能失败。代价是慢订阅者会直接形成 backpressure。

BROADCAST_DROP_OLD​

优先保住 publisher 吞吐,让慢订阅者更偏向读到新数据。代价是订阅者会丢旧样本。

BALANCE_RR​

在多个 worker 间均衡分发。同一条消息不会广播给每个 worker,所以它是共享负载模式,不是广播模式。


6. FULL 和 DROP_OLD 的实际差异​

本节和第 9 节的数据由 LibXR 仓库 test/automatic/middleware/message/topic/ 中的基准函数 RunOverloadBenchmarks()、RunStandardBenchmarks() 和 RunLatencyBenchmarks() 测得(libxr_test 的 linux_shm_bench 用例只运行它们的缩小版本)。测试于 2026-10-04 在 Docker 容器 ghcr.io/xrobot-org/docker-image-linux:main(Ubuntu 24.04,g++ 13.3.0,Release 构建,/dev/shm 为 2 GB)中进行,宿主是 WSL2(内核 6.6.87.2,AMD Ryzen 7 8845H,16 线程),LibXR 为 dev 分支 a2f5f01。表中是单次运行的结果,数值随机器和负载变化。

bench_overload.cpp 让订阅者每取得一条消息后休眠 50 µs;发布者连续发布,失败不重试;订阅者队列长度为 8(64 KiB 载荷)或 4(1 MiB 载荷)。延迟是从发布到订阅者取得数据的时间:

载荷模式发布成功订阅者收到平均延迟p50p99
64 KiBFULL598 / 40005981080 µs1026 µs2520 µs
64 KiBDROP_OLD4000 / 4000530387 µs180 µs2381 µs
1 MiBFULL167 / 256167470 µs433 µs1523 µs
1 MiBDROP_OLD256 / 256143364 µs338 µs934 µs

慢订阅者的队列满后,FULL 让后续发布失败,已入队的消息全部送达,但它们在队列中等待,延迟较高;DROP_OLD 下发布全部成功,订阅者队列丢弃旧消息,收到的消息更新,平均延迟和中位延迟更低。所有样本都不能丢时选 FULL,要尽快拿到新数据时选 DROP_OLD,这与控制场景中只关心最新值的取舍一致。


7. BALANCE_RR 的语义​

BALANCE_RR 是独立的 balanced subscriber group,不是在广播路径上加个游标。

行为边界:

  • 一个 publish 最多只投给一个 balanced subscriber
  • 组内还有其他成员能接收时,队列已满的成员会被跳过
  • balanced group 存在但没有任何 member 能接时,整个 publish 失败

它的语义是“一组 worker 共享消费同一 topic”,不是“广播之后顺便轮流处理”。


8. 失效订阅者回收与发布者接管​

进程异常退出后,共享内存中可能遗留两类状态:

  • 死掉的 subscriber 还占着 slot
  • 死掉的 publisher 留下旧 segment

当前实现对两者都做了处理:

  • dead subscriber recycle:subscriber 侧跟踪 owner identity
  • stale publisher takeover:publisher 侧在 create-side reopen 时回收死进程遗留的 segment

9. 连续发布与逐条确认的延迟​

bench_standard.cpp 连续发布;bench_latency.cpp 每发布一条就等待订阅者确认,再发下一条。两者统计的都是从发布到订阅者取得数据的时间,但含义不同。以下是 64 字节载荷(帧长 88 字节)、只写帧头和载荷首尾字节(transport 模式)的结果,环境同第 6 节:

基准条数平均p50p95p99
bench_standard(连续发布)10000054.9 µs0.81 µs374 µs777 µs
bench_latency(逐条确认)2000047.1 µs33.6 µs104 µs293 µs

连续发布时,订阅者处理完上一条后队列中通常已有下一条,取数据不需要等待唤醒,一半样本的延迟不到 1 µs;发布快于消费时,消息在队列中积压,等待时间计入延迟,p95 达到数百微秒。平均值因此主要反映排队情况,不等于单条消息的投递延迟。逐条确认时,订阅者每次都在 futex 上等待,延迟包含唤醒和调度的时间,p50 约 34 µs,反映单条消息从发布到被取得的耗时。比较实现或配置时,吞吐看 bench_standard,单条延迟看 bench_latency 的分位数。


10. 句柄与共享布局的生命周期​

共享 payload 的裸指针只能跟着对应的数据句柄使用。通过 subscriber 取得的句柄还依赖 subscriber 自身的 slot;正常退出时先释放活动数据,再销毁 subscriber,最后关闭 topic 映射。

所有参与进程还必须对 T 的布局、ABI 和共享内存配置有相同理解。T 适合放固定布局数据,不要把进程私有指针、std::string 这类拥有进程内资源的对象直接放进去。


11. 适用与不适用场景​

适合:

  • Linux 主机多进程共享大 payload
  • 订阅者策略明确,需要 FULL / DROP_OLD / RR
  • 希望避免用户态额外 payload 拷贝

不适合:

  • MCU 侧 ISR 驱动路径
  • 进程内轻量 publish/subscribe
  • payload 不是 trivially copyable