
从 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。