当前位置: 首页 > news >正文

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

从 TCP 90μs 到SHM28μ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:2315skb_do_copy_data_nocachecopy_from_iter_full用户态数据拷进内核 skb
net/ipv4/tcp.c:1310skb_copy_to_page_nocache协议栈内部可能再分配 skb 并拷贝
drivers/net/loopback.c:70loopback_xmit__netif_rxloopback 回注,无 DMA
net/ipv4/tcp.c:2525copy_to_iter内核 skb 拷回用户态






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

$ 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完事。

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:

$ 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:

┌──────────┬──────────┬────────────────┐ │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内核提供的轻量事件对象。

**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 语义保证顺序。

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()可以直接从 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 两端都修了。


性能数据和理解

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

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

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

项目地址:github.com/lczllx/lyqtRpc。

http://www.jsqmd.com/news/1365801/

相关文章:

  • 2026年Q3大米行业供应厂家实力格局与选型逻辑分析 - 优企名品
  • Kimi转 word 工具推荐:首选「AI 导出鸭」平板版,专为 iPad/安卓平板打造,深度适配 Kimi 等主流 AI,一键无损导出 Word,完整保留公式图表与代码高亮。
  • 广州团建公司哪家口碑好?制造业HR与商贸企业的真实评价怎么看 - 陀螺团建
  • 宿迁汽车音响老店,亲测汽车音响首推宿迁车之友 - GrowthUME
  • ComfyUI-WanVideoWrapper实战指南:解锁AI视频生成的全新可能
  • 题解:AtCoder AT_abc470_a Fizz
  • 2026上新:武冈除甲醛公司上半年度总结:本地品牌深度盘点 - 专注室内空气检测治理
  • 简易冲击试验机哪家品牌好?优质实力生产厂家口碑推荐与定制指南 - 品牌推荐大师
  • UDP协议栈内核数据结构与守护进程创建流程分析
  • 13 — 暂存区深入:你挑出来准备交的作业
  • 2026杭州AI搜索优化服务商深度评测与选型指南 - 品牌报告
  • SleeperX:Mac智能睡眠管理完全解决方案 - 告别电量焦虑的终极指南
  • OpenCV+Python人脸识别实战:工业级优化方案
  • FineReport分页功能详解与实战应用
  • Unity雨滴窗户效果:RenderTexture与Shader实现动态雨痕渲染
  • 破解在职学历提升痛点:学历提升学校推荐中OFFER四支柱方法论如何实现高效拿证? - 全域品牌推荐
  • 2026年激光生产线定制行业应用现状及选型参考指南 - 互联网科技品牌测评
  • 2026年泰州抖音推广公司推荐:如何选择最适合您的合作伙伴 - GrowthUME
  • 解决ROS安装中APT内存锁冲突的实用指南
  • 南京中考复读学校哪家好?南京天元中考复读学校怎么样 - GrowthUME
  • VMware虚拟机免费安装与配置指南:从官方渠道获取合法版本
  • Python新闻数据可视化分析系统设计与实战
  • 微电网双层优化:光伏储能容量配置新方法
  • B站字幕下载终极指南:3分钟轻松获取B站CC字幕资源
  • 音乐解锁神器:3分钟解决加密音乐播放难题,实现跨平台自由聆听
  • 5分钟彻底清理Windows臃肿:Win11Debloat让你的电脑重获新生
  • 德州大数据分析师考试难不难?中山优才教育报考全攻略 - 人工智能报名机构推荐
  • 2026年激光打标机行业态势与主流品牌选型参考 - 互联网科技品牌测评
  • Vibe Coding理念下的现代生物信息学工作流实践:从脚本到声明式分析
  • 苏州中考落榜学生,可以跨市到南京天元中考复读学校就读吗 - GrowthUME