从零实现一个用户态 TCP/IP 协议栈:用 DPDK 把内核 TCP 甩在身后
6000 行 C 代码,11 状态 TCP 状态机,RFC 5681 拥塞控制,零拷贝零系统调用。
实测:TCP 连接 1.5ms(内核 2.5ms,快 40%),WS echo RTT 1.5ms(内核 3.1ms,快 52%)。
一、为什么要绕过内核 TCP
对于绝大多数应用,Linux 内核 TCP 是完美的:30 年打磨、各种边界场景都处理过。但在低延迟数据面(金融行情、交易执行、高频网关)场景下,内核协议栈有三笔"固定开销":
| 开销 | 内核 TCP | 用户态协议栈(本项目) |
|---|---|---|
| 内存拷贝(收+发) | 2 次(skb → 用户缓冲区) | 0 次(mbuf 全程零拷贝) |
| 系统调用 | 每次 I/O 都要进入内核 | 0 次(纯用户态函数调用) |
| 协议栈可定制性 | 改内核模块,复杂且高风险 | 任意修改,改完 make 即可 |
| 可观测性 | /proc/net/tcp 有限 | 每个 TCB 可挂任意指标 |
本项目用 DPDK 24.11 接管网卡,在用户态从二层(以太网)一路实现到七层(WebSocket),数据包全程不经过内核。
测试拓扑:Intel X540 10G 网卡 → 千兆交换机 → C WebSocket 服务端,rdtsc 硬件时间戳测量。二、整体架构:双核流水线 + 无锁队列
核心思路:用两个 lcore 组成流水线,核间只通过 rte_ring SP/SC 无锁队列传递指针,数据本体零拷贝。
flowchart TD
subgraph NIC["物理网卡 (DPDK PMD)"]
RX["RX 队列 (128 desc)"]
TX["TX 队列 (512 desc)"]
end
subgraph LC0["lcore 0 — 主线程 (main.c)"]
B1["rte_eth_rx_burst 收包"]
B2["ring→out 出队 → rte_eth_tx_burst 发包"]
B3["WS 帧接收轮询 + 应用逻辑"]
end
subgraph LC1["lcore 1 — pkt_process (tcp_core.c)"]
C1["分发: ARP / ICMP / TCP"]
C2["TCP 11 状态机 + 拥塞控制"]
C3["定时器: RTO / delACK / keepalive / ZWP"]
C4["TX 输出引擎 ng_tcp_out"]
end
RX -->|"ring→in 只入队指针"| C1
C4 -->|"ring→out 只出队指针"| B2
subgraph TCB["每连接 (TCB)"]
D1["rcvbuf rte_ring (1024)"]
D2["sndbuf rte_ring (1024)"]
D3["pthread_cond_t + mutex"]
end
C2 -->|"数据入队 + cond signal"| D1
D2 --> C4
D1 --> B3
B3 -->|"nsend 分段入队"| D2为什么这样分工?
- lcore0 只做两件事:从网卡收包、向网卡发包,以及跑应用逻辑(WS 轮询)。它不碰任何协议状态,忙轮询即可。
- lcore1 是协议处理核心:TCP 状态机、拥塞控制、定时器都在这里。单线程无锁,天然避免竞态。
- 跨核传递只传
rte_mbuf *指针(8 字节入 ring),实测 0.2 μs,接近 CPU 三级缓存延迟(~15ns)的量级。
三、TCB 查找:O(1) 的两级设计
每个 TCP 连接一个 ng_tcp_stream(TCB)。数据包到达后,第一步就是找到它属于哪条连接。这里做了两级 O(1) 设计:
┌─────────────────────────────────────────────────┐
│ 256 桶哈希表 (4-tuple: sip, dip, sport, dport) │ ← 包路径: 收到段 → 查哈希 → 定位 TCB
│ ht_next 链表解决冲突 │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ fd_to_tcb[] 直接数组 (fd → TCB 指针) │ ← 应用路径: nsend(fd) 直接取 TCB
└─────────────────────────────────────────────────┘- 包路径用哈希表:
pkt_process收到一个段,用四元组哈希直接命中 TCB,平均 2.1 ns/次(微基准实测 4.81 亿次/秒)。 - 应用路径用直查数组:
nsend(fd)无需遍历,fd_to_tcb[fd]一条指令完成。 - 另有一条
active_head双向链表维护活跃连接,定时器遍历时不用扫全部 256 个桶(这是 P4 优化的成果,详见第三篇)。
四、TCP 状态机:完整的 11 状态
src/tcp_core.h 中定义了与 RFC 793 一致的 11 个状态。作为客户端,我们主要走右侧这条路径:
stateDiagram-v2
[*] --> CLOSED
CLOSED --> SYN_SENT: nconnect() 发 SYN
SYN_SENT --> ESTABLISHED: 收 SYN+ACK,回 ACK
ESTABLISHED --> FIN_WAIT_1: nclose() 发 FIN
FIN_WAIT_1 --> FIN_WAIT_2: 收 ACK
FIN_WAIT_2 --> TIME_WAIT: 收 FIN,回 ACK
TIME_WAIT --> CLOSED: 2MSL 超时
FIN_WAIT_1 --> CLOSING: 同时收到 FIN
CLOSING --> TIME_WAIT: 收 ACK
ESTABLISHED --> CLOSE_WAIT: 收到对方 FIN
CLOSE_WAIT --> LAST_ACK: 本方发 FIN
LAST_ACK --> CLOSED: 收 ACK/* src/tcp_core.h — 状态枚举 */
typedef enum _NG_TCP_STATUS {
NG_TCP_STATUS_CLOSED = 0, NG_TCP_STATUS_LISTEN,
NG_TCP_STATUS_SYN_RCVD, NG_TCP_STATUS_SYN_SENT,
NG_TCP_STATUS_ESTABLISHED, NG_TCP_STATUS_FIN_WAIT_1,
NG_TCP_STATUS_FIN_WAIT_2, NG_TCP_STATUS_CLOSING,
NG_TCP_STATUS_TIME_WAIT, NG_TCP_STATUS_CLOSE_WAIT,
NG_TCP_STATUS_LAST_ACK
} NG_TCP_STATUS;状态机入口 ng_tcp_process()(src/tcp_input.c,~690 行)按状态 + 段标志(SYN/ACK/FIN/RST)驱动迁移,每个迁移附带动作(发 ACK、唤醒等待线程、回收资源)。
五、拥塞控制:RFC 5681 全套
src/tcp_output.c 实现了完整的拥塞控制闭环,参数直接对应 RFC:
| 机制 | 实现 |
|---|---|
| 初始窗口 | cwnd = 2 × MSS(~2920 字节,RFC 6928) |
| 慢启动 | 每收一个 ACK,cwnd += MSS,指数增长 |
| 拥塞避免 | 超过 ssthresh 后按 MSS²/cwnd 线性增长 |
| 快速重传 | 收到 3 个重复 ACK 立即重传,不等 RTO |
| 快速恢复 | 快重传后 cwnd 减半,进入恢复阶段 |
| RTO | RFC 6298 RTT 估计(srtt + 4×rttvar)+ 指数退避 |
配套的定时器系统(src/tcp_timer.c)在 pkt_process 每次迭代中检查:
| 定时器 | 间隔 | 用途 |
|---|---|---|
| RTO 重传 | 动态(指数退避) | 丢包恢复 |
| 延迟 ACK | 200 ms | 合并 ACK,减少小包 |
| Keepalive | 2 小时空闲 | 探测对端存活 |
| 零窗口探测 (ZWP) | 1 s,最多 10 次 | 对端窗口恢复后继续发 |
六、乱序处理:排序链表 + 有界队列
乱序段(序号超前于接收窗口左缘)进入 ofo_head 排序链表(src/tcp_input.c),最大 64 个条目:
rcv_nxt = 1000 (期望下一个字节)
收到 seq=1460 的段 → 乱序,挂入 ofo_head(按 seq 排序)
收到 seq=2460 的段 → 乱序,插入 ofo_head 第二个位置
收到 seq=1000 的段 → 命中期望!立刻交付,然后沿 ofo_head 逐条取出:
seq=1460 ✓ 交付
seq=2460 ✓ 交付 → ofo_head 清空每个 OFO 段同样从 mempool 分配(见第三篇 P0 优化),上限 256 个,超过即丢弃,防止内存被恶意乱序攻击耗尽。
七、阻塞式 Socket API:用条件变量骗过应用层
这是最有意思的设计。用户态协议栈的难点之一:应用层习惯阻塞式调用(connect() 卡住直到连上),但协议处理在另一个 lcore 上跑。解决方案是条件变量 + 内联轮询:
sequenceDiagram
participant APP as 应用线程 (lcore0)
participant PKT as pkt_process (lcore1)
APP->>APP: nconnect(): 发 SYN, 置状态 SYN_SENT
loop 等待握手期间
APP->>APP: 内联执行 rte_eth_rx_burst + rte_eth_tx_burst
APP->>APP: pthread_cond_timedwait(stream->cond, 1ms)
end
PKT->>PKT: 收到 SYN+ACK → 状态迁移 ESTABLISHED
PKT->>APP: pthread_cond_signal(stream->cond)
APP->>APP: 醒来检查 state == ESTABLISHED → 返回成功关键点(src/tcp_socket.c):
nrecv()阻塞在stream->cond上;pkt_process收到数据入rcvbuf后signal唤醒。nconnect()在等握手的间隙顺手帮 pkt_process 干收发包的活——反正核闲着也是闲着,这段内联轮询把连接建立延迟从"跨核通知"压到了理论下限。nclose()(P3 优化后)用rte_pause()忙轮询替代条件变量定时等待,关闭路径无阻塞抖动。
应用层看到的 API 与 POSIX 几乎一致:
int fd = nsocket(); // 创建 socket
nconnect(fd, &servaddr, sizeof(servaddr)); // TCP 三次握手
ws_connect(fd, host, port, "/ws", NULL); // WS 握手
nsend(fd, buf, len, 0); // 发送(TCP 分段 + 拥塞控制)
nrecv(fd, buf, len, 0); // 阻塞接收
nclose(fd); // 四次挥手八、发送路径:从 nsend 到网线
nsend(fd, 1MB)
→ 按 MSS 1460 切成 ~700 个 ng_tcp_fragment(从 g_frag_pool 分配)
→ 逐个入 stream->sndbuf ring(不拷贝,指针传递)
└─ pkt_process 每轮迭代:
ng_tcp_out(): 从 sndbuf 出队 → 组以太网/IP/TCP 头
→ 查 ARP 表解析 MAC(未命中: 发 ARP 请求 + 段重新入队等待)
→ 受 cwnd 窗口限制,未发出的段留在 sndbuf
→ 入 ring->out → lcore0 出队 → rte_eth_tx_burst 发出ARP 请求做了 10 次/秒/目的地址 的限速(src/arp.h 链表实现),避免对端无响应时刷爆网络。
九、实测结果
同一物理链路,rdtsc 时间戳测量(完整数据见 PUBLISH.md):
| 指标 | DPDK(本项目) | Linux 内核 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 | 硬件极限 |
代码规模(纯 C,仅依赖 DPDK):
| 模块 | 行数 |
|---|---|
| TCP 核心(状态机 + 拥塞控制) | ~3500 |
| SACK + 乱序队列 | ~700 |
| 拥塞控制 | ~600 |
| 定时器 | ~200 |
| Socket API | ~500 |
| WebSocket | ~400 |
| 合计 | ~6000 |
十、总结
一个"能用"的 TCP 客户端栈其实不需要几万行代码:11 个状态、4 个定时器、2 条无锁队列、1 张哈希表,就是全部骨架。真正拉开性能差距的,是绕开内核后的零拷贝与零系统调用,以及后续一系列数据面优化(P0-P4,见第三篇)。
下一篇:在自研 TCP 栈上跑通 WebSocket:RFC 6455 帧编解码实战
相关源码:src/tcp_core.h(结构体与常量)、src/tcp_input.c(状态机)、src/tcp_output.c(拥塞控制)、src/tcp_timer.c(定时器)、src/tcp_socket.c(Socket API)、src/main.c(主循环)
评论 (0)