241 lines
11 KiB
Markdown
241 lines
11 KiB
Markdown
# 从零实现一个用户态 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`(主循环)*
|