侧边栏壁纸
  • 累计撰写 53 篇文章
  • 累计收到 3 条评论

微秒级性能优化实战:自研 TCP 栈如何反超内核 52%

PacketForge
2026-08-16 / 0 评论 / 2 阅读 /

微秒级性能优化实战:自研 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 ms2.5 ms快 40%
WS 握手3.0 ms5.4 ms快 45%
WS echo RTT1.5 ms3.1 ms快 52%
跨核传递0.2 μsN/A硬件级
Fragment 分配6.6 μsN/A19× 于 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_deq0.2 μsrte_ring 无锁跨核传递
pkt_deq → rcvbuf0.4 μsTCP 11 状态机处理
rcvbuf → nrecv_seen0.1 μscond 唤醒
fragment 分配6.6 μsmempool O(1)

八、复盘:这五轮优化教给我的事

  1. 热路径上的通用设施是最大的敌人。rte_malloc 是"正确"的,但它在每包路径上就是慢。P0 的 19 倍提升来自一次"专用化":把通用分配器换成专用 mempool。
  2. 优化顺序 = 数据说话。没有 rdtsc 打点,谁也不会先想到 128μs 的 malloc;有了数据,P0 是唯一的答案。
  3. 小改动大收益往往藏在"看似无害"的地方。P2 的 read_offset 只加了一个字段,消灭的却是每次部分读取的内存平移。
  4. 删除代码也是优化。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

评论 (0)

取消