# 微秒级性能优化实战:自研 TCP 栈如何反超内核 52% > 五轮迭代(P0-P4),把 WS echo RTT 从"落后内核"打到 **1.5ms vs 3.1ms,快 52%**。 > 本文记录每一轮优化的测量方法、改动内容和量化收益——包括一次 19 倍的分配性能提升。 --- ## 一、方法论:先测量,再动手 优化最忌讳"我觉得这里慢"。本项目建立了一套三层测量体系: | 手段 | 测什么 | 工具 | |------|--------|------| | `rdtsc` 打点 | 数据路径各环节的 CPU 周期级延迟 | 代码内埋点,`rte_get_timer_cycles()` | | tcpdump 抓包 | 协议行为正确性(重传、乱序、窗口) | `tcpdump -i eno1 -nn -e 'arp or tcp port 8080'` | | 对照基准 | 同一链路内核 TCP 的表现 | `test/kern_c_test.py`(10 轮连接+握手+echo) | 基线数据先摸清楚。首版跑通后,跨核数据路径上发现了第一个大问题: ``` rx_enq → pkt_deq (跨核 ring) 0.2 μs ✓ 达标 pkt_deq → rcvbuf (协议处理) 0.4 μs ✓ 达标 rcvbuf → nrecv_seen (通知) 0.1 μs ✓ 达标 pkt_deq → fragment alloc 128 μs ✗✗✗ 超标 20 倍! ``` **热路径上的 `rte_malloc` 一次 128 μs**——这是全部延迟的地板,也是 P0 的靶子。 ## 二、P0:Fragment Mempool——消除热路径 malloc(19× 提升) ### 问题 每个 TCP 段到达时,都要 `rte_malloc` 一个 `ng_tcp_fragment` 结构来装数据。通用分配器的开销(锁、元数据、碎片管理)在万兆流量下被放大成主要瓶颈。 ### 改造 把结构体里的 `*data` 指针改成 **flexible array `data[]`**,让"结构体 + 负载"成为一块连续内存,然后用 `rte_mempool` 预分配池管理: ``` 改造前: 改造后: struct ng_tcp_fragment { struct ng_tcp_fragment { ... ... unsigned char *data; ← 移除 int read_offset; ← 新增 → rte_malloc(sizeof+len) unsigned char data[]; ← 内嵌 → rte_mempool_get() 一次搞定 ``` ```c /* src/tcp_core.h — mempool 配置 */ #define FRAG_POOL_SIZE 4096 /* 在途 fragment 上限 */ #define OFO_POOL_SIZE 256 /* 乱序段上限 */ #define FRAG_ELT_SIZE (sizeof(struct ng_tcp_fragment) + TCP_MSS_DEFAULT + 1) /* O(1) 无锁分配 */ static inline struct ng_tcp_fragment *frag_alloc(int data_len) { struct ng_tcp_fragment *f = NULL; if (rte_mempool_get(g_frag_pool, (void **)&f) < 0) return NULL; ... } ``` `rte_mempool_get` 走 per-lcore 缓存,**无锁、O(1)、缓存友好**。 ### 收益 ``` fragment 分配: 128 μs → 6.6 μs (19 倍) ``` 一次改动把数据路径地板从 128μs 打到 6.6μs,端到端延迟立刻反超内核。 ## 三、P1:WS 帧动态栈分配——64KB 栈占用降到 ~100 字节 ### 问题 首版 `ws_recv_frame` 在栈上固定开了一个 64KB 的接收缓冲。应用线程栈占用暴涨,也增加了 cache 污染。 ### 改造 改为按需 `alloca()` 动态分配——小帧只占几十字节,大帧才多占: ``` 改造前: uint8_t buf[WS_MAX_FRAME_SIZE]; // 恒 64KB 改造后: 按帧头解析出的 payload_len alloca() // 20B ~ 1500B(典型行情消息) ``` **收益**:典型消息的栈占用 **64KB → 20~1500 字节**。 ## 四、P2:read_offset——消灭 memmove ### 问题 `nrecv(buf, n)` 要求读 n 字节,但一个 fragment 里可能有 1500 字节。应用只取 200 字节时,首版把剩余 1300 字节 `memmove` 到头部,等待下次读取——**每次部分读取都平移一次数据**。 ### 改造 加一个 `read_offset` 字段记录"已经读到哪里",读取时直接偏移,零平移: ```c // 优化前: // 优化后: rte_memcpy(buf, f->data, n); rte_memcpy(buf, f->data + f->read_offset, n); memmove(f->data, f->data + n, f->length - n); f->read_offset += n; f->length -= n; f->length -= n; ``` **收益**:部分读取场景下数据平移 **1 次 → 0 次**。WS 行情消息常常是"先读 2 字节帧头、再读 4 字节掩码、再读负载"的多次小读模式,这一改动直接命中了高频路径。 ## 五、P3:nclose 忙轮询——关闭路径零阻塞 ### 问题 首版 `nclose()` 用 `pthread_cond_timedwait` 等待 FIN 确认,关闭路径引入了不可控的唤醒延迟。 ### 改造 改为 `rte_pause()` 忙轮询:关闭是短时操作(毫秒级),自旋等待的代价远小于上下文切换: ```c while (state != CLOSED) rte_pause(); // PAUSE 指令,低功耗自旋 ``` **收益**:关闭操作无阻塞抖动,配合 P0 让连接生命周期全程低延迟。 ## 六、P4:删除死代码——少 80 行 O(n) 扫描 ### 问题 旧版 `ng_tcp_out()` 每轮迭代**扫描全部 256 个哈希桶**找待发送的 TCB。后来引入了 `active_head` 活跃连接链表,旧路径成了纯粹的死代码。 ### 改造 删掉旧扫描路径(-80 行),`pkt_process` 定时器与发送统一走 `active_head` 链表遍历——只碰活跃连接。 **收益**:空闲连接再多,每轮迭代的开销也恒定;代码更简单,`make` 后行为不变(这是删除代码的最高境界:删除而不改变行为)。 ## 七、五轮优化全景 ```mermaid flowchart LR subgraph 基线["基线: WS echo RTT > 内核"] A["rte_malloc 128μs
64KB 栈帧
部分读 memmove
condvar 关闭
256 桶扫描"] end subgraph 优化["五轮迭代"] P0["P0 mempool
128→6.6μs (19×)"] P1["P1 动态栈
64KB→~100B"] P2["P2 read_offset
memmove → 0"] P3["P3 忙轮询关闭"] P4["P4 删死代码
-80 行"] end subgraph 结果["最终: 反超内核 52%"] Z["WS echo RTT 1.5ms
跨核 0.2μs
分配 6.6μs"] end A --> P0 --> P1 --> P2 --> P3 --> P4 --> Z ``` 最终成绩单(同一物理链路实测): | 指标 | DPDK(优化后) | 内核 TCP | 优势 | |------|---------------|----------|------| | TCP 连接 | **1.5 ms** | 2.5 ms | 快 40% | | WS 握手 | **3.0 ms** | 5.4 ms | 快 45% | | WS echo RTT | **1.5 ms** | 3.1 ms | **快 52%** | | 跨核传递 | **0.2 μs** | N/A | 硬件级 | | Fragment 分配 | **6.6 μs** | N/A | 19× 于 malloc | CPU 数据面微基准(无网卡依赖,i7-12700 / DDR5 / GCC -O2): | 测试项 | 性能 | |--------|------| | 哈希表查找(1000 条目) | **2.1 ns/次** → 4.81 亿次/秒 | | Ring Buffer SP/SC(1M fragments) | **26.87 Gbps** → 230 万 pps | | WS 帧编解码(50 万次) | **18.00 Gbps** | rdtsc 延迟追踪(最新实测): | 路径 | 延迟 | 说明 | |------|------|------| | rx_enq → pkt_deq | **0.2 μs** | rte_ring 无锁跨核传递 | | pkt_deq → rcvbuf | **0.4 μs** | TCP 11 状态机处理 | | rcvbuf → nrecv_seen | **0.1 μs** | cond 唤醒 | | fragment 分配 | **6.6 μs** | mempool O(1) | ## 八、复盘:这五轮优化教给我的事 1. **热路径上的通用设施是最大的敌人**。`rte_malloc` 是"正确"的,但它在每包路径上就是慢。P0 的 19 倍提升来自一次"专用化":把通用分配器换成专用 mempool。 2. **优化顺序 = 数据说话**。没有 rdtsc 打点,谁也不会先想到 128μs 的 malloc;有了数据,P0 是唯一的答案。 3. **小改动大收益往往藏在"看似无害"的地方**。P2 的 read_offset 只加了一个字段,消灭的却是每次部分读取的内存平移。 4. **删除代码也是优化**。P4 没有让任何操作变快,但让每一轮迭代都变短、让系统更可理解——为后续 feature 扫清道路。 下一篇:[从实验代码到产品:DPDK 行情接入网关的产品化实践](./04-DPDK行情接入网关产品化.md) --- *相关源码:`src/tcp_core.h`(mempool 与 fragment)、`src/tcp_socket.c`(P2/P3)、`src/ws_client.c`(P1)、`src/tcp_core.c`(P4)、`test/bench_tcp.c`(微基准)、`PERF_REPORT.md`(完整数据)*