微秒级性能优化实战:自研 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() 一次搞定/* 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 字段记录"已经读到哪里",读取时直接偏移,零平移:
// 优化前: // 优化后:
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() 忙轮询:关闭是短时操作(毫秒级),自旋等待的代价远小于上下文切换:
while (state != CLOSED) rte_pause(); // PAUSE 指令,低功耗自旋收益:关闭操作无阻塞抖动,配合 P0 让连接生命周期全程低延迟。
六、P4:删除死代码——少 80 行 O(n) 扫描
问题
旧版 ng_tcp_out() 每轮迭代扫描全部 256 个哈希桶找待发送的 TCB。后来引入了 active_head 活跃连接链表,旧路径成了纯粹的死代码。
改造
删掉旧扫描路径(-80 行),pkt_process 定时器与发送统一走 active_head 链表遍历——只碰活跃连接。
收益:空闲连接再多,每轮迭代的开销也恒定;代码更简单,make 后行为不变(这是删除代码的最高境界:删除而不改变行为)。
七、五轮优化全景
flowchart LR
subgraph 基线["基线: WS echo RTT > 内核"]
A["rte_malloc 128μs<br/>64KB 栈帧<br/>部分读 memmove<br/>condvar 关闭<br/>256 桶扫描"]
end
subgraph 优化["五轮迭代"]
P0["P0 mempool<br/>128→6.6μs (19×)"]
P1["P1 动态栈<br/>64KB→~100B"]
P2["P2 read_offset<br/>memmove → 0"]
P3["P3 忙轮询关闭"]
P4["P4 删死代码<br/>-80 行"]
end
subgraph 结果["最终: 反超内核 52%"]
Z["WS echo RTT 1.5ms<br/>跨核 0.2μs<br/>分配 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) |
八、复盘:这五轮优化教给我的事
- 热路径上的通用设施是最大的敌人。
rte_malloc是"正确"的,但它在每包路径上就是慢。P0 的 19 倍提升来自一次"专用化":把通用分配器换成专用 mempool。 - 优化顺序 = 数据说话。没有 rdtsc 打点,谁也不会先想到 128μs 的 malloc;有了数据,P0 是唯一的答案。
- 小改动大收益往往藏在"看似无害"的地方。P2 的 read_offset 只加了一个字段,消灭的却是每次部分读取的内存平移。
- 删除代码也是优化。P4 没有让任何操作变快,但让每一轮迭代都变短、让系统更可理解——为后续 feature 扫清道路。
下一篇:从实验代码到产品:DPDK 行情接入网关的产品化实践
相关源码: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(完整数据)
评论 (0)