从 TCP 90μs 到 SHM 28μs:我的 RPC 框架零拷贝优化历程
从 TCP 90μs 到SHM28μs我的RPC框架零拷贝优化历程同一个echo请求本机 TCP 走完要 90μs换成共享内存只要 28μs。记录一下踩的坑也聊聊两种通信方式到底差在哪。为什么会慢——TCP 本机通信其实绕了远路先讲一个不严谨但好理解的类比。你去器材室拿东西。SHM 是你自己走过去拿——一次搞定。TCP 是你打电话叫你朋友去拿你朋友可能又要叫他的朋友去拿——中间多了好几次传话每次传话都是一次开销。对应到代码层面本机 TCP 通信虽然不经过物理网线走 loopback但数据还是要走一遍内核协议栈send()把数据从用户态拷进内核内核分配sk_buff、可能克隆一份loopback 设备把包从发送队列注回接收队列recv()把数据从内核拷回用户态每一步都是实在的 CPU 拷贝。loopback 设备自己在注释里都写了——“The loopback device is special. There is no DMA.”——没有 DMA 意味着数据搬运全靠 CPU看着像经过了网卡实际上只是在内核里兜了一圈。我截了这四个拷贝点在内核源码里的位置拷贝文件函数干了什么①include/net/sock.h:2315skb_do_copy_data_nocache→copy_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 tracewrite,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 通知 ServerServer 被epoll唤醒直接从 mmap 内存读数据body.assign完事。Client 用户 Buffer │ ① memcpy → ring buffer用户态唯一一次 ▼ ring buffermmapServer 同一物理页 → 不需要 ****copy_to_user**** │ ② body.assign用户态直接从 mmap 读 ▼ Server 用户 Buffershm_channel.cc 里写端就这三行 memcpy——写完 frame_len、msg_type、bodystore(write_idx, release) 公布。读端更直接body.assign(data_base offset 8, frame_len - 8)一行从 mmap 读到 string。同样 100 个请求 strace$ strace -c -e tracewrite,read ./shm_proto_client write 2 次 read 7 次 → 9 syscall 0 次数据路径 syscallTCP 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_len0 的标记跳过帧然后把write_idx加上这段尾部长度自然绕回到 buffer 头部Consumer 读到 frame_len0 后同样把read_idx加上尾部长度再从头部继续读。双方都不是重置为 0——是索引正常往前推进取模后自然回到头部。write_idx 和 read_idx 都是uint64_t一直往前加用到溢出那天其实 uint64 减法天然模 2^64——绕回了也无所谓w - r算出来的已用字节数还是对的。SPSC ring buffer 不需要额外处理回绕。eventfd 怎么通知对端写完一帧不是让对端轮询write_idx——那样 CPU 吃满没意义用 eventfdLinux内核提供的轻量事件对象。**Producer******memcpy**** 到 ring buffer → write(eventfd, 8B) ← 一次 **syscall** Consumerepoll 被唤醒 → read(eventfd, 8B) → 读 ring bufferEFD_SEMAPHORE模式write 一次计数器 1read 一次返回值 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 fd5, client fd7但指向内核里同一个 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 生成的是普通 movTSO 内存模型天然保序但编译器不能把 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 bufferreq 1MB resp 1MB每个方向只有一个 Producer完全不用锁。这就是典型的用内存换锁——100 个 Client 占 200MB 共享内存但在本机微服务的场景下完全可以接受。placement new 不能省略ShmControl 里有两个std::atomicuint64_twrite_idx / read_idx。对 atomic 来说构造函数不仅是把初值写进内存——还要初始化锁标志位用来做is_lock_free()的判断和其他内部实现状态。我第一次直接在栈上构造了一个 ShmControl然后memcpy(stack_obj, mmap_addr, sizeof)。Link 过了测试跑了偶发挂。排查了半天才反应过来——memcpy只搬了 bit patternatomic 的内部元数据还在栈上那坨已经被析构了的内存里。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 帮忙掩盖的 bugBaseMessage::_rid是每次 RPC 调用生成的唯一 IDClient 靠它把收到的响应和之前发出的请求对应起来。但这个字段在 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 的差距。跨机器的零拷贝是 RDMAInfiniBand/RoCE网卡能做到个位数微秒。但同机器不需要特殊硬件——/dev/shm就是一台 Linux 服务器上离你最近的RDMA。总结在做这次 TCP → SHM 的迁移之前我对进程间通信的理解就是socket一发一收。做完之后才意识到——即使是本机loopback走 TCP 也要穿过整条内核网络栈中间每一步拷贝和系统调用都是成本SHM 本质上就是把中间人全去了两个进程直接在同一块内存上干活。严格来说SHM 数据路径上从用户 Buffermemcpy到ring 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。

相关新闻

聚焦搜索整合通义千问:开发者如何利用系统级AI提升工作流效率

聚焦搜索整合通义千问:开发者如何利用系统级AI提升工作流效率

上周,我像往常一样,在 Mac 的“聚焦搜索”里敲了几个技术关键词,想快速定位一个项目文件。搜索结果里,除了熟悉的本地文档,一个不太起眼的条目引起了我的注意——“Apple 智能”。点进去,跳转到的竟然是苹果…

2026/9/23 21:07:14 阅读更多 →
告别遥控器困境:TV Bro如何让智能电视浏览网页变得轻松愉快

告别遥控器困境:TV Bro如何让智能电视浏览网页变得轻松愉快

告别遥控器困境:TV Bro如何让智能电视浏览网页变得轻松愉快 【免费下载链接】tv-bro Simple web browser for android optimized to use with TV remote 项目地址: https://gitcode.com/gh_mirrors/tv/tv-bro 你是否曾经尝试在智能电视上浏览网页&#xff0c…

2026/9/23 21:07:14 阅读更多 →
GPT-5.6 Luna降价与用量激增:成本验证与工程化应对策略

GPT-5.6 Luna降价与用量激增:成本验证与工程化应对策略

1. 先搞清楚“降价10倍”和“用量激增”到底意味着什么最近关于GPT-5.6 Luna的消息,最核心的两个点就是“降价10倍”和“token用量激增超10倍”。这听起来很吸引人,但如果你直接冲进去用,可能会发现情况和你想象的不太一样。我建议先别急着看…

2026/9/17 13:33:30 阅读更多 →

最新新闻

校园生活服务平台全栈开发实战:SpringBoot2+Vue3+MySQL8.0

校园生活服务平台全栈开发实战:SpringBoot2+Vue3+MySQL8.0

1. 项目概述:校园生活服务平台的架构与价值校园生活服务平台是连接学生、教职工与校园服务资源的数字化桥梁。这个基于SpringBoot2Vue3MyBatis-PlusMySQL8.0的全栈解决方案,实现了从课表查询、失物招领到活动报名的全场景覆盖。我在实际开发中发现&#…

2026/9/23 21:06:50 阅读更多 →
自建GitHub镜像站实战:Nginx反向代理与缓存策略优化指南

自建GitHub镜像站实战:Nginx反向代理与缓存策略优化指南

前阵子帮团队搭了一个 GitHub 镜像站,起因很实际:持续集成流水线每次拉第三方依赖都慢得让人心慌,release 里的大文件动不动就中断,同一份制品被十几台构建机反复下载,浪费了不少时间。折腾了一周左右,把 N…

2026/9/23 21:06:50 阅读更多 →
指尖专升本的课程和服务是怎么安排的?从报名到上岸的完整流程

指尖专升本的课程和服务是怎么安排的?从报名到上岸的完整流程

一句话结论:上海专升本是一场长周期备考——大一解决报名资格,大二系统突破专业课,大三按最新考纲冲刺。指尖专升本的做法是把三年拆成清晰的阶段,每个阶段都有对应的课程、资料和负责人:线下授课为主、线上直播授权同…

2026/9/23 21:06:50 阅读更多 →
Chalice 配置文件(.chalice/config.json)完全指南:阶段化部署、Lambda 函数级配置与 IAM/网络/自定义域名实战

Chalice 配置文件(.chalice/config.json)完全指南:阶段化部署、Lambda 函数级配置与 IAM/网络/自定义域名实战

后端ServerlessCLI 【免费下载链接】chalice Python Serverless Microframework for AWS 项目地址: https://gitcode.com/gh_mirrors/ch/chalice 点击查看 免费下载 导读 本指南以 AWS 开源 Python Serverless 微框架 Chalice 的 .chalice/config.json 配置文件为…

2026/9/23 21:06:50 阅读更多 →
DRV8703D-Q1栅极驱动器调试:电荷泵、死区与双脉冲验证全流程

DRV8703D-Q1栅极驱动器调试:电荷泵、死区与双脉冲验证全流程

简介:面向电机驱动开发与嵌入式调试人员的DRV8703D-Q1芯片调试详解文档,聚焦半桥电机驱动芯片的上手与排障。文档以实际调试为主线,从电路板设计切入,覆盖半桥电路、SPI通信与电源电路,同时结合TMS320F2812主控给出SPI…

2026/9/23 21:06:50 阅读更多 →
Springboot集成Tesseract OCR:从图片到字段的落地实践

Springboot集成Tesseract OCR:从图片到字段的落地实践

简介:一份面向Spring Boot开发者的OCR图片文字识别实现方案,聚焦如何整合Tesseract开源识别引擎完成图片文本自动提取,适合有Java基础、需要在文档扫描、证照识别等场景落地识别功能的读者参考。资源以PDF格式打包,共1个文件&…

2026/9/23 21:05:49 阅读更多 →

日新闻

3招搞定手机怎么下载微信面试难题实战项目解析

3招搞定手机怎么下载微信面试难题实战项目解析

3招搞定手机怎么下载微信面试难题实战项目解析 面试被问“手机怎么下载微信”背后的原理,90%的人答不上来。别笑,这看似弱智的问题,实则是考察你对移动应用分发机制、安全校验及网络协议理解的试金石。我带过不少校招新人,他们背了八股文,却连一个A…

2026/9/23 0:00:23 阅读更多 →
2k显示屏性能优化踩坑:版本升级后API全变了,这份源码解析救了我

2k显示屏性能优化踩坑:版本升级后API全变了,这份源码解析救了我

2k显示屏性能优化踩坑:版本升级后API全变了,这份源码解析救了我 刚把开发环境的显示器从1080P换到2K,跑老项目直接报错,版本升级后 API…

2026/9/23 0:01:25 阅读更多 →
3步搞定美眉图实战项目,告别官方文档抓不住重点

3步搞定美眉图实战项目,告别官方文档抓不住重点

3步搞定美眉图实战项目,告别官方文档抓不住重点 官方文档翻了三遍还是云里雾里?别急,美眉图在实战项目中常被用来做数据可视化,但它的原理比你想的简单。今天咱们直接上手,用一个完整的小项目把美眉图跑通,不再死磕那些冗长的理论说明。…

2026/9/23 0:01:25 阅读更多 →

周新闻

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

直接铺开项目本身吧。这几个月我一直在折腾一件事:用Flutter给OpenHarmony做一款游戏集合类的App,说白了就是把若干小游戏塞进一个壳里,用统一入口分发。这个方向本身不算新鲜,真正让我花了不少心思的,是首页那堆游戏卡…

2026/9/23 4:55:02 阅读更多 →
Word表格编号全攻略:从列表编号到题注交叉引用

Word表格编号全攻略:从列表编号到题注交叉引用

写Word文档,最让人头疼的往往是那些“看起来不起眼”的小问题。比如表格编号这事:今天在表后面多加了两个空白行,明天给客户交稿前发现整个章节的编号全部错位,光是挨个改序号就能耗掉大半个下午。我前阵子帮人整理一份上百页的技…

2026/9/23 4:49:06 阅读更多 →
从第一个站到第二个站:独立开发者的静态网站选型与落地实践

从第一个站到第二个站:独立开发者的静态网站选型与落地实践

1. 项目概述1.1 核心需求解析做独立开发者这几年,说实话,第一个网站上线的那天晚上我兴奋得没睡着。但等它跑了半年,流量惨淡、功能臃肿、代码自己都懒得看第二遍之后,我才慢慢琢磨明白一个道理:第一个网站是练手&…

2026/9/23 9:53:41 阅读更多 →

月新闻

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能分类:[AI/大模型]细分主题:AI 增强型 CI/CD 流水线自动化与 GitOps 实践:Agent 工作流、工具调用与任务拆解:从原型到生产的验收清单很多团队在尝试用大…

2026/9/23 9:53:40 阅读更多 →
容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场分类:[工程技术]细分主题:Kubernetes 生产环境运维与排障实战:可复制的项目复盘模板与决策记录大部分团队的事故复盘报告,最后都变成了躺在 Confluence 或钉…

2026/9/23 9:53:40 阅读更多 →
容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步分类:[工程技术]细分主题:Docker 容器化技术与镜像安全管理:核心链路的逐步实现与关键代码取舍面对一个积累了五六年历史包袱的单体架构应用(包含 Web 接口、后台…

2026/9/23 9:53:40 阅读更多 →