基于共享内存的高性能 Linux IPC 设计实践(下):工程实践与踩坑实录

2 阅读7分钟

作者:机器猫

微信:18053807088

github: github.com/code1w

本文是系列文章的下篇。上篇推演了无锁 SPSC 环形缓冲区的核心算法设计。本篇解决三个工程问题:如何跨进程共享内存、如何高效通知对端、真实开发中踩了哪些坑——并用 benchmark 数据回答"到底比 socket 快多少"。

一、memfd_create:匿名共享内存

上篇设计了环形缓冲区的数据结构,但它需要一块两个进程都能访问的共享内存。回到我们的场景——同一个 K8s pod 内的 sidecar 和游戏进程没有 fork 关系,不能通过继承 fd 表来共享内存。Linux 上创建共享内存的传统方式是 shm_open

shm_open("/my_buffer", O_CREAT | O_RDWR, 0666)
  → 在 /dev/shm/ 下创建文件
  → ftruncate 设置大小
  → mmap 映射到进程地址空间
  → 对端通过相同的路径名 shm_open 打开

这个方案有几个让人不舒服的地方:路径名可能冲突(两个不相关的程序用了同一个名字),需要手动 shm_unlink 清理(进程崩溃后残留文件),路径可被枚举(安全隐患)。

Linux 3.17 引入了 memfd_create——创建一个匿名的、没有文件系统路径的内存文件

memfd_create("ring_buffer", 0)
  → 返回一个文件描述符
  → 不关联任何路径,/dev/shm 下看不到
  → ftruncate + mmap 后使用
  → fd 关闭 + mmap 解除后,内核自动回收
特性shm_openmemfd_create
需要文件路径/dev/shm/xxx无(匿名)
命名冲突
进程崩溃后残留文件fd 关闭自动清理
安全性路径可枚举仅持有 fd 的进程可访问

但匿名意味着对端进程无法通过路径名找到这块内存。如何把 fd "交给"对端?

二、SCM_RIGHTS:传递的不是数字,是内核对象

这是一个常见误解:"通过 socket 把 fd 发给对端"。如果只是发个整数 fd=5,对端拿到 5 毫无用处——fd 编号只是进程内的文件描述符表索引,跨进程没有意义。

SCM_RIGHTS 做的事情更深:它让内核把发送方 fd 背后的 struct file 对象安装到接收方的文件描述符表中。

graph LR
    subgraph 进程 A 的 fd 表
        A3["fd=3"] --> MF["struct file (memfd)<br/>引用计数 = 2"]
        A5["fd=5"] --> EF["struct file (eventfd)"]
    end
    subgraph 进程 B 的 fd 表
        B7["fd=7"] --> MF
    end
    style MF fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style EF fill:#e3f2fd,stroke:#2196f3,stroke-width:2px

发送方的 fd=3 和接收方的 fd=7 数字不同,但它们指向内核中同一个 memfd 对象。双方各自 mmap 这个 fd,就映射到了同一片物理页——进程 A 的写入,进程 B 立即可见。

我们的通道建立需要交换 4 个 fd(两个 memfd + 两个 eventfd),通过一个四步握手协议完成:

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: 1. SendFd(memfd_c2s, tag="MEMF")
    Note right of S: RecvFd → mmap → 读环
    C->>S: 2. SendFd(efd_client, tag="EVFD")
    Note right of S: RecvFd → 获得唤醒客户端的能力
    S->>C: 3. SendFd(memfd_s2c, tag="MEMF")
    Note left of C: RecvFd → mmap → 读环
    S->>C: 4. SendFd(efd_server, tag="EVFD")
    Note left of C: RecvFd → 获得唤醒服务端的能力
    Note over C,S: 双向通道建立完成

每个 fd 传递都附带一个 4 字节的类型标签(FdTag),接收方会校验标签是否与期望一致。这看似多余,实际上是一个重要的安全检查——如果 Client 和 Server 的收发顺序不一致(比如某一方的代码被错误修改),没有标签校验的话,Server 可能把 eventfd 当 memfd 去 mmap,导致难以排查的内存损坏。

一个工程细节:握手使用阻塞式 sendmsg/recvmsg。如果恶意客户端建立连接后不发送任何 fd,recvmsg 会永久阻塞,拖垮整个事件循环。因此握手入口设置了 5 秒的 SO_RCVTIMEO 超时。

三、eventfd 通知:解耦数据路径与控制路径

环形缓冲区是纯内存操作——写方执行 memcpy + atomic store 后就完成了,但读方不知道什么时候有新数据。最朴素的方案是忙轮询(busy-poll),但这意味着 CPU 100% 占用。

我们需要一个轻量的跨进程唤醒机制。通知路径和数据路径必须解耦:数据走共享内存(零系统调用),通知走一个尽量便宜的机制。

graph TB
    subgraph D ["数据路径 · 用户态"]
        W1["写方: memcpy + atomic store"] --> SHM["共享 mmap 区域"]
        SHM --> R1["读方: atomic load + memcpy"]
    end
    subgraph N ["通知路径 · 1 次系统调用"]
        W2["write&lpar;eventfd, 1&rpar;"] --> EFD["eventfd 内核计数器"]
        EFD --> R2["poll&lpar;eventfd&rpar; + read&lpar;&rpar;"]
    end
    style SHM fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style EFD fill:#fff3e0,stroke:#ff9800,stroke-width:2px

eventfd 是 Linux 内核中最简单的文件抽象之一——整个实现大约 300 行代码,核心是一个 uint64_t 计数器:

  • write(efd, &val, 8):原子累加 count += val,有等待者则唤醒
  • read(efd, &val, 8):原子交换 val = count; count = 0,count==0 则阻塞或返回 EAGAIN

对比其他通知方案:

方案fd 数量能否 poll通知合并延迟
eventfd1天然支持~1us
pipe2(读+写)需手动~1us
Unix socket1不支持~2us
SIGUSR 信号0不支持不确定
忙轮询0不适用不适用~0 但 CPU 100%

eventfd 的通知合并特性尤其有价值。写方可能在读方还没来得及处理时连续写入多条消息:

graph LR
    W1["write(efd, 1)"] --> K["内核计数器<br/>count = 3"]
    W2["write(efd, 1)"] --> K
    W3["write(efd, 1)"] --> K
    K --> R["read(efd) → 返回 3<br/>count 归零"]
    R --> D["一口气读完环中<br/>所有 3 条消息"]
    style K fill:#fff3e0,stroke:#ff9800,stroke-width:2px

三次通知自动合并为一次唤醒。读方只需 DrainNotify(一次 read 排空计数器),然后循环消费环中所有待读消息。

这种解耦让我们可以进一步优化——BatchWriter。普通的逐条写入每条消息需要 3 次原子操作(load write_pos、load read_pos、store write_pos)+ 1 次 eventfd 通知。BatchWriter 在构造时快照一次 read_pos,写入过程中只在本地追踪位置,Flush 时一次性 store + 一次 eventfd 通知:

逐条写入 N 条:  原子操作 3N 次 + eventfd N 次
批量写入 N 条:  原子操作 2 次  + eventfd 1 次

这在小消息高频场景下带来可观的性能提升——256B 消息 batch=200 时,吞吐从 7.3 GB/s 提升到 15.4 GB/s(2.1x)。核心原因是跨核的 acquire load(读取对方核心的 read_pos)触发缓存行迁移,代价昂贵,批量化将其从每条一次降为每批一次。

四、性能分析:数据说话

我们通过 fork() + socketpair() 搭建了 ping-pong echo 基准测试:Client 发一条消息 → Server 原样回送 → Client 收到后再发下一条,测量 RTT。

共享内存 vs Unix Socket

消息大小Socket RTTSHM RTT加速比Socket 吞吐SHM 吞吐
64B11,005 ns604 ns18.2x11 MB/s202 MB/s
256B9,646 ns570 ns16.9x51 MB/s857 MB/s
1KB9,155 ns870 ns10.5x213 MB/s2,245 MB/s
4KB9,961 ns1,716 ns5.8x784 MB/s4,552 MB/s
64KB31,699 ns17,251 ns1.8x3,943 MB/s7,246 MB/s
512KB127,627 ns322,886 ns0.4x7,835 MB/s3,097 MB/s

几个值得注意的趋势:

小消息(≤1KB):10-18 倍加速。Socket 的 RTT 底线在 ~9 微秒——这是两次系统调用 + skb 分配/释放的固有开销,与消息大小几乎无关。SHM 的 RTT 低至 570ns,纯粹是用户态 memcpy + atomic 操作。差距来自省掉的内核路径。

中等消息(4KB-64KB):优势收窄到 2-6 倍memcpy 开始成为主导因素——无论走 socket 还是 SHM,大块数据都需要拷贝。SHM 只拷贝一次(用户态→共享内存),socket 拷贝两次(用户态→内核→用户态),但绝对时间差随消息增大而被 memcpy 本身的开销稀释。

大消息(512KB):Socket 反超 2.5 倍。内核的 socket 实现对大块连续传输做了页级优化(page splicing),而环形缓冲区在接近容量上限时频繁触发哨兵回绕,加上 512KB 的 memcpy 对 CPU 缓存压力极大。这不是算法层面能优化的——大消息场景下 memcpy 本身就是瓶颈。

交叉点大约在 100KB-200KB 附近。小于此值用 SHM 有优势,大于此值 socket 可能更合适。

BatchWriter 数据

消息大小逐条基线batch=200加速比
64B13 ns/msg8 ns/msg1.55x
256B31 ns/msg16 ns/msg1.95x
1KB59 ns/msg49 ns/msg1.20x

小消息收益最大:原子操作(特别是跨核 acquire load)在总耗时中的占比最高,批量化后被均摊。1KB 时 memcpy 开始主导,批量化的收益递减但仍然可观。