从 TCP 90μs 到 SHM 28μs:我的 RPC 框架零拷贝优化历程

从 TCP 90μs 到 SHM 28μs:我的 RPC 框架零拷贝优化历程

同一个 echo 请求,本机 TCP 走完要 90μs,换成共享内存只要 28μs。记录一下踩的坑,也聊聊两种通信方式到底差在哪。


为什么会慢------TCP 本机通信其实绕了远路

先讲一个不严谨但好理解的类比。

你去器材室拿东西。SHM 是你自己走过去拿------一次搞定。TCP 是你打电话叫你朋友去拿,你朋友可能又要叫他的朋友去拿------中间多了好几次传话,每次传话都是一次开销。

对应到代码层面:本机 TCP 通信虽然不经过物理网线(走 loopback),但数据还是要走一遍内核协议栈:

  1. send() 把数据从用户态拷进内核
  2. 内核分配 sk_buff、可能克隆一份
  3. loopback 设备把包从发送队列注回接收队列
  4. recv() 把数据从内核拷回用户态

每一步都是实在的 CPU 拷贝。loopback 设备自己在注释里都写了------"The loopback device is special. There is no DMA."------没有 DMA 意味着数据搬运全靠 CPU,看着像经过了网卡,实际上只是在内核里兜了一圈。

我截了这四个拷贝点在内核源码里的位置:

拷贝 文件 函数 干了什么
include/net/sock.h:2315 skb_do_copy_data_nocachecopy_from_iter_full 用户态数据拷进内核 skb
net/ipv4/tcp.c:1310 skb_copy_to_page_nocache 协议栈内部可能再分配 skb 并拷贝
drivers/net/loopback.c:70 loopback_xmit__netif_rx loopback 回注,无 DMA
net/ipv4/tcp.c:2525 copy_to_iter 内核 skb 拷回用户态

用 strace 验证也很直观------TCP 客户端发 100 个 echo 请求,光 write 系统调用就 237 次:

perl 复制代码
$ strace -c -e trace=write,read ./benchmark_client single echo 100 1 1

write  237 次    read  36 次    → 273 syscall / 100 请求
                                  ~2.7 次系统调用/请求

每次 send/recv 意味着至少一次内核拷贝。每个请求 3~4 次 CPU 拷贝(send 拷入 + loopback 回注 + recv 拷出,skb 克隆不一定每次都触发),100 个请求数据在内存里被搬了三四百次。


SHM 做了什么------把"传话"全干掉

共享内存说起来就一句话------Client 和 Server 映射同一块物理内存。数据写进去,对面直接读。不经过内核,没有 send/recv,协议栈完全不参与。

在我的项目数据路径里:Client 把 Proto 序列化后 memcpy 进 ring buffer,写完用 eventfd 通知 Server,Server 被 epoll 唤醒,直接从 mmap 内存读数据,body.assign 完事。

arduino 复制代码
Client 用户 Buffer
  │  ① memcpy → ring buffer(用户态,唯一一次)
  ▼
ring buffer(mmap,Server 同一物理页 → 不需要 copy_to_user)
  │  ② body.assign(用户态,直接从 mmap 读)
  ▼
Server 用户 Buffer

shm_channel.cc 里写端就这三行 memcpy------写完 frame_len、msg_type、body,store(write_idx, release) 公布。读端更直接,body.assign(data_base + offset + 8, frame_len - 8),一行从 mmap 读到 string。

同样 100 个请求 strace:

perl 复制代码
$ strace -c -e trace=write,read ./shm_proto_client

write   2 次     read   7 次    → 9 syscall
                                 0 次数据路径 syscall

TCP 273 次 → SHM 9 次。那 9 次是 eventfd 通知------和实际数据读写没关系,真正的数据搬运一次系统调用都没触发。TCP 相当于你在内核里找人帮你搬东西------你得先打通他的电话(系统调用),他再帮你搬(内核拷贝),然后告诉你搬完了(返回用户态)。SHM 是你自己走过去搬------一步到位。

ring buffer 上一帧怎么存的

每帧 8 字节帧头 + Protobuf body:

css 复制代码
┌──────────┬──────────┬────────────────┐
│frame_len │ msg_type │     body       │
│  4B      │  4B      │   变长          │
└──────────┴──────────┴────────────────┘

frame_len = 8 + body_len,不含自身那 4B。读端先读 frame_len,不够一帧就等下次 epoll,够就把整个 frame 读走,read_idx 往前挪,那块空间就算释放了。

有个特殊情况------ring buffer 尾巴上剩的 bytes 不够写一整帧。这时候 Producer 写一个 frame_len=0 的标记(跳过帧),然后把 write_idx 加上这段尾部长度,自然绕回到 buffer 头部,Consumer 读到 frame_len=0 后同样把 read_idx 加上尾部长度,再从头部继续读。双方都不是"重置为 0"------是索引正常往前推进,取模后自然回到头部。

write_idx 和 read_idx 都是 uint64_t,一直往前加,用到溢出那天?其实 uint64 减法天然模 2^64------绕回了也无所谓,w - r 算出来的已用字节数还是对的。SPSC ring buffer 不需要额外处理回绕。

eventfd 怎么通知对端

写完一帧不是让对端轮询 write_idx------那样 CPU 吃满没意义,用 eventfd,Linux 内核提供的轻量事件对象。

scss 复制代码
Producer:memcpy 到 ring buffer → write(eventfd, 8B)  ← 一次 syscall
Consumer:epoll 被唤醒 → read(eventfd, 8B) → 读 ring buffer

EFD_SEMAPHORE 模式:write 一次计数器 +1,read 一次返回值 1 且计数器 -1。Client 连续发 5 个请求(write 5 次),Server 的 epoll 醒一次、read 5 次才能把计数器清空------不会丢通知。

还有一个问题是 eventfd 怎么跨进程传?eventfd 创建出来是个匿名 fd,没有文件名,另一个进程不能 open,用 SCM_RIGHTS------Unix domain socket 的带外数据,内核直接把 sender 的 fd 复制一份到 receiver 的 fd 表,两个进程 fd 编号不一样(server fd=5, client fd=7),但指向内核里同一个 eventfd 对象。

内存序:先写数据,后公布

ring buffer 没锁,靠 std::atomic 的 release/acquire 语义保证顺序。

scss 复制代码
Producer:① memcpy 数据    ② store(write_idx, release)  ← 顺序绝不能反
Consumer:③ load(write_idx, acquire)  ④ memcpy 读数据

release 保证:① 在 ② 之前一定完成,对端 acquire 之后一定看到完整数据。如果 CPU 把这两步重排了------先公布 write_idx 再 memcpy------Consumer 就会读到半帧垃圾。帧头是旧数据、body 是新数据,解析直接出错。x86 上 release-store 生成的是普通 mov(TSO 内存模型天然保序),但编译器不能把 memcpy 挪到 store 后面------这就是 std::memory_order_release 的作用:约束编译器,不依赖 CPU 特性。


模块实现过程中的思考

两个 ring buffer 还是一个大 buffer

最初想过只用一块共享内存,双向复用------Client 写 req 和 Server 写 resp 都在同一块 buffer 上。但这样一来 req 和 resp 的 write_idx 会被两个方向竞争,要保证正确就得加锁。在本机微服务场景下锁竞争约 20ns 起,并发再高一点 pthread mutex 直接 futex 进内核------花了这么大力气省掉 TCP 协议栈,结果被一把锁拖回去。所以每个 Client 各分一对独立的 ring buffer(req 1MB + resp 1MB),每个方向只有一个 Producer,完全不用锁。

这就是典型的"用内存换锁"------100 个 Client 占 200MB 共享内存,但在本机微服务的场景下完全可以接受。

placement new 不能省略

ShmControl 里有两个 std::atomic<uint64_t>(write_idx / read_idx)。对 atomic 来说,构造函数不仅是把初值写进内存------还要初始化锁标志位(用来做 is_lock_free() 的判断)和其他内部实现状态。

我第一次直接在栈上构造了一个 ShmControl,然后 memcpy(&stack_obj, mmap_addr, sizeof)。Link 过了,测试跑了,偶发挂。排查了半天才反应过来------memcpy 只搬了 bit pattern,atomic 的内部元数据还在栈上那坨已经被析构了的内存里。mmap 上的 atomic 处于"从未构造"的状态,后续 store/load 行为未定义。不崩溃是侥幸,崩溃或丢更新才是正常。placement new 直接在 mmap 上原地调构造函数,一步到位,atomic 的所有状态都在共享内存里。

FlatBufferBuilder 为什么不能在 ring buffer 上原地构造

FlatBufferBuilder 构造时需要一块连续的 buffer。但 ring buffer 的"可用空间"是环状的------尾部一段、头部一段,不保证连续。FBB 的构造函数不关心你是不是环形,它只管 buffer + size 这块连续区域。当 ring buffer 的剩余连续空间小于 initial_size + 16 时,FBB 直接抛 std::bad_alloc

我曾经尝试过写一个自定义 Allocator 让 FBB 能跨 buffer 尾部→头部环绕分配,但 FBB 内部大量用了相对偏移指针------跨段后指针计算全乱。结论是不值得。写端老老实实在堆上构造,一次 memcpy 进 ring buffer。读端用 FlatBuffers 的 GetRoot<T>() 可以直接从 mmap 内存上原地读取字段------读端做到了真正的零拷贝,这才是 FlatBuffers 的正确用法。

_rid 没被序列化------TCP 帮忙掩盖的 bug

BaseMessage::_rid 是每次 RPC 调用生成的唯一 ID,Client 靠它把收到的响应和之前发出的请求对应起来。但这个字段在 JSON 和 Proto envelope 里从来没被写进去。TCP 路径上单连接按序收发,第一个响应一定先于第二个回来,隐式保证了顺序一致性,匹配从来没出错。

切换到 SHM 之后,ring buffer 是环形无锁的------Client 连着发 3 个请求,Server 处理完的响应写入顺序可能和请求顺序不完全一致。Client 收到一个 resp,取 resp->rid()expected_rid 对比------永远不等,请求挂起。查了半天才发现 _rid 根本没出现在序列化数据里。后来在 JsonMessage::serialize()rpc_envelope.proto 里加上了 id 字段,TCP 和 SHM 两端都修了。


性能数据和理解

bash 复制代码
单线程 echo 16B:

         QPS       P50
TCP      10,706    90μs
SHM      25,216    28μs

提升      2.4×     ↓69%

注意:同步 RPC 模式下,Client 发完一个请求就阻塞等响应,下一个请求必须等这次往返结束才发出,任何时候最多只有一个小包在飞。Nagle 算法在这种场景下根本没有积攒合并的机会,不会触发延迟发送,所以设不设 TCP_NODELAY 对 P50 数据基本没影响------这不是关了 Nagle,是同步调用模式天然绕开了它。

小 payload(≤4KB)的瓶颈不是 memcpy 带宽------拷贝 16 字节一瞬的事。真正耗时的是系统调用(~20μs)+ 协议栈逻辑(拥塞控制/Nagle ~30μs)+ 内核调度。SHM 把系统调用从每请求 2 次压到 1 次(只剩 eventfd 通知),协议栈全砍了------去掉这 ~50μs,就是 28μs 和 90μs 的差距。

跨机器的零拷贝是 RDMA(InfiniBand/RoCE 网卡,能做到个位数微秒)。但同机器不需要特殊硬件------/dev/shm 就是一台 Linux 服务器上离你最近的"RDMA"。


总结

在做这次 TCP → SHM 的迁移之前,我对进程间通信的理解就是"socket 一发一收"。做完之后才意识到------即使是本机 loopback,走 TCP 也要穿过整条内核网络栈,中间每一步拷贝和系统调用都是成本,SHM 本质上就是把"中间人"全去了,两个进程直接在同一块内存上干活。

严格来说,SHM 数据路径上从用户 Buffer memcpy 到 ring buffer 这一步仍然是 CPU 拷贝,不算"零拷贝"。我这里说的零拷贝,是指相比 TCP 的 3~4 次内核拷贝压到了 1 次用户态拷贝------而且该路径上已经没有 copy_from_user / copy_to_user 这类内核态拷贝了。真正全程零拷贝的场景是读端直接 GetRoot<T>() 原地读取 FlatBuffers,但这就要求 payload 必须按 FlatBuffers 格式构造,有一定适用条件。

三种通信方式的适用场景:

复制代码
同机器进程之间 → SHM(零额外硬件,28μs)
同机房跨机器   → RDMA(需要 InfiniBand/RoCE 网卡,~5μs)
跨机房 / 广域网 → TCP(普适性最强,90μs+)

项目地址:github.com/lczllx/lyqt...

相关推荐
tangwangbi2 小时前
Linux 系统配置文件:/etc/profile、~/.bashrc 和 ~/.bash_profile 三者之间的区别与作用
linux·运维·bash
步行cgn2 小时前
YAML 的语法规则
spring boot·后端
蜀道山老天师2 小时前
Shell Bash变量与运算符(含条件测试与流程控制)
linux·运维·bash
Mr.朱鹏2 小时前
Linux 服务器 LVM 根分区在线动态扩容
linux·服务器·数据库
骇客野人2 小时前
SpringBoot电商系统用户注册、登录、留存及数据埋点设计与落地实施方案
java·spring boot·后端
mengge.cloud2 小时前
存储技术基础小白教程
linux·运维·服务器·wpf·存储
tang777893 小时前
反爬虫场景下代理IP池的动态扩容与失效IP自动清理方案实现
linux·服务器·网络·爬虫代理·住宅代理ip
新时代牛马4 小时前
Linux 系统调用与IPC 完整篇:从syscall 入口、VDSO 到pipe/shm/futex 选型
linux·运维·服务器
IT_陈寒5 小时前
React的状态更新坑得我差点加班到天亮
前端·人工智能·后端
fpcc5 小时前
计算机原理—Linux是如何加载可执行文件到内存
linux