Files
dpdk-ws-client/blog/01-from-zero-userspace-tcp-stack.md

241 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 从零实现一个用户态 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 无锁队列传递指针,数据本体零拷贝。
```mermaid
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 个状态。作为客户端,我们主要走右侧这条路径:
```mermaid
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
```
```c
/* 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 上跑。解决方案是**条件变量 + 内联轮询**:
```mermaid
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 几乎一致:
```c
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 帧编解码实战](./02-WebSocket协议RFC6455实战.md)
---
*相关源码:`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`(主循环)*