diff --git a/blog/01-from-zero-userspace-tcp-stack.md b/blog/01-from-zero-userspace-tcp-stack.md new file mode 100644 index 0000000..c3bde87 --- /dev/null +++ b/blog/01-from-zero-userspace-tcp-stack.md @@ -0,0 +1,240 @@ +# 从零实现一个用户态 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`(主循环)* diff --git a/blog/02-websocket-rfc6455-in-practice.md b/blog/02-websocket-rfc6455-in-practice.md new file mode 100644 index 0000000..58d6a35 --- /dev/null +++ b/blog/02-websocket-rfc6455-in-practice.md @@ -0,0 +1,184 @@ +# 在自研 TCP 栈上跑通 WebSocket:RFC 6455 帧编解码实战 + +> 上一篇讲了自研 TCP 协议栈。这一篇往上走一层:如何在它上面实现 WebSocket 客户端, +> 包括 HTTP Upgrade 握手、帧编解码、掩码、粘包半包、控制帧——以及掉进去的坑。 + +--- + +## 一、为什么是 WebSocket + +金融行情推送(股票、期货行情源)普遍用 WebSocket 对外提供实时数据流:HTTP 完成连接升级后,就是一个双向、低开销的帧协议。相比裸 TCP,它有标准化的握手、心跳(Ping/Pong)和关闭语义;相比 HTTP 轮询,它没有每秒数百次请求的开销。 + +在自研 TCP 栈上实现 WS 客户端,整个链路是: + +``` +DPDK 收包 → 自研 TCP 栈 → stream->rcvbuf → nrecv() + → ws_recv_frame() 解帧 → 应用拿到完整 JSON 行情消息 +``` + +## 二、握手:一次普通的 HTTP 请求,加上点仪式感 + +RFC 6455 的握手本质是一个 HTTP/1.1 Upgrade 请求。`ws_connect()`(`src/ws_client.c`)的实现步骤: + +1. 生成 **16 字节随机数**,Base64 编码成 `Sec-WebSocket-Key`; +2. 发送 Upgrade 请求; +3. 收到响应后检查 `101` 状态码和 `Upgrade: websocket` 头; +4. 把响应中 `\r\n\r\n` **之后的字节**(可能是服务端抢先发的第一帧)存入 leftover 缓冲。 + +```mermaid +sequenceDiagram + participant C as DPDK WS 客户端 + participant S as WS 服务端 + + C->>S: GET /ws HTTP/1.1
Upgrade: websocket
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13 + Note over S: 校验 key,按 RFC 计算
SHA1(key+magic) 回写 Accept + S->>C: HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Sec-WebSocket-Accept: ... + Note over C: 检查 101 + Upgrade 头 + C->>C: 保存 \r\n\r\n 之后的字节
到 g_ws_leftover(防丢首帧) + C->>S: 应用消息(TEXT 帧,带掩码) +``` + +真实的握手代码(节选): + +```c +/* 生成随机 16 字节 key,base64 编码 */ +uint8_t key_bytes[16]; +ws_random_bytes(key_bytes, 16); +base64_encode(key_bytes, 16, ws_key); + +/* 构建 HTTP Upgrade 请求 */ +snprintf(handshake, sizeof(handshake), + "GET %s HTTP/1.1\r\n" + "Host: %s:%d\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\n" + "Sec-WebSocket-Version: 13\r\n", + path, host, port, ws_key); +``` + +两个值得注意的实现细节: + +- **IPv4 only**:当前用 `inet_pton(AF_INET, ...)` 解析主机,不支持域名(协议栈没有 DNS)。生产场景需要预先解析或加 DNS 模块。 +- **leftover 缓冲**(`g_ws_leftover[4096]`):服务端可以把手握响应和第一帧数据**写进同一个 TCP 段**。如果解析器只读到 `\r\n\r\n` 就返回,后面的帧字节就丢了。这里把多读的字节存起来,`ws_recv_frame()` 优先从 leftover 消费——这是 WS-over-TCP 最经典的坑之一。 + +## 三、帧格式:把 RFC 的图搬进代码注释 + +`ws_send_frame()` 的文件注释里直接画了帧结构(RFC 6455 §5.2): + +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-------+-+-------------+-------------------------------+ +|F|R|R|R| opcode|M| Payload len | Extended payload length | +|I|S|S|S| (4) |A| (7) | (16/64) | +|N|V|V|V| |S| | (if payload len==126/127) | +| |1|2|3| |K| | | ++-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - -+ +| Masking-key (4 bytes) | Payload Data | ++--------------------------------+ - - - - - - - - - - - - - - -+ +``` + +要点: + +| 字段 | 含义 | +|------|------| +| FIN (1bit) | 是否最后一帧(本项目暂未实现分片发送,但解析器兼容) | +| opcode (4bit) | 帧类型:TEXT=0x1 / BINARY=0x2 / CLOSE=0x8 / PING=0x9 / PONG=0xA | +| MASK (1bit) | 负载是否掩码。**客户端→服务端必须为 1**,服务端→客户端为 0 | +| Payload len (7bit) | 长度三档:≤125 直接存;=126 用后续 2 字节;=127 用后续 8 字节 | + +### 长度三档编码 + +```c +if (payload_len < 126) { + /* 短帧: 7bit 直接存 */ +} else if (payload_len <= 0xFFFF) { + /* 126: 后续 2 字节大端 */ +} else { + /* 127: 后续 8 字节大端 */ +} +``` + +## 四、掩码:4 字节 XOR,安全与性能兼得 + +客户端发出的每一帧都必须带 4 字节随机掩码,负载逐字节 XOR: + +```c +/* ws_send_frame: 生成掩码并逐字节 XOR */ +for (int i = 0; i < len; i++) + masked[i] = payload[i] ^ mask_key[i & 3]; +``` + +掩码的初衷是防止"缓存投毒"(恶意代理场景),实现代价极低——XOR 一条 SIMD 指令可以并行处理 16 字节。微基准实测**帧编解码吞吐 18 Gbps**(50 万次编码基准,`test/bench_tcp.c`)。 + +接收侧对应地解掩码:按规范,**服务端发来的帧不应带掩码**,但解析器做了兼容——只要 MASK 位为 1 就解。 + +## 五、接收路径:一个健壮的帧解析器长什么样 + +`ws_recv_frame()` 的完整决策流程: + +```mermaid +flowchart TD + A["ws_read 读 2 字节头
(优先消费 leftover)"] --> B{payload_len
== 126 / 127?} + B -->|126| C[再读 2 字节扩展长度] + B -->|127| D[再读 8 字节扩展长度] + B -->|≤125| E{masked?} + C --> E + D --> E + E -->|是| F[读 4 字节掩码] + E -->|否| G{opcode?} + F --> G + G -->|CLOSE 0x8| H[消费负载, 返回 0] + G -->|PING 0x9| I["读负载 → 自动回 PONG
(原样回显负载)"] + G -->|PONG 0xA| J[丢弃负载] + G -->|TEXT/BINARY| K{负载 > 应用缓冲?} + K -->|是| L["分块读掉整个负载
(保解析器同步) → 返回 -1"] + K -->|否| M[读负载 → 解掩码 → 返回] +``` + +三个容易做错的点,这里都处理了: + +1. **Ping → Pong 自动应答**:协议层自动回 PONG 并原样回显负载,应用层完全无感。配合 `main.c` 的 `-k 30` 参数(每 30 秒主动发一次 Ping),断线能被及时发现。 +2. **超大帧跳过保持同步**:如果服务端发来一个比应用缓冲区还大的帧(本项目上限 `WS_MAX_FRAME_SIZE = 64KB`),**不能只丢弃返回**——否则后续字节会被当成下一个帧头解析,整个流全部错位。正确做法是分块把负载读完再返回错误(这是 commit `9a596e6` 修复的坑)。 +3. **CLOSE 帧的负载也要消费**:否则 close 的 payload 会污染下一条连接的首帧。 + +## 六、测试体系:不需要 DPDK 也能测协议 + +协议层与 DPDK 解耦的关键收益是**可测试性**——用内核 TCP socket 就能完整验证 WS 协议逻辑: + +```bash +# 1. 协议单测(内核 TCP 跑 WS 协议,不碰 DPDK) +gcc -O2 -o test/test_ws_protocol test/test_ws_protocol.c -lpthread +python3 test/ws_test_server.py 8080 # 起 Python 服务端(依赖 websockets) +./test_ws_protocol 127.0.0.1 8080 # 跑握手/echo/订阅/推送全流程 + +# 2. C 服务端(零依赖,通常跑在远端机器) +gcc -O2 -o test/ws_echo_server test/ws_echo_server.c +./ws_echo_server 8080 + +# 3. 内核 TCP 对比基准(10 轮连接+握手+echo 延迟) +python3 test/kern_c_test.py +``` + +三层测试各司其职:协议单测快速迭代、C 服务端保证低延迟基准、内核对比脚本给出"反超内核"的证据。 + +## 七、实测数据 + +WS 协议栈在自研 TCP 上的端到端表现(`PUBLISH.md`): + +| 指标 | DPDK WS | 内核 TCP WS | 优势 | +|------|---------|-------------|------| +| WS 握手 | **3.0 ms** | 5.4 ms | 快 45% | +| WS echo RTT | **1.5 ms** | 3.1 ms | 快 52% | +| 帧编解码吞吐(CPU 微基准) | **18 Gbps** | N/A | 50 万次编码 | + +## 八、总结 + +WebSocket 协议本身很小:一个 HTTP 握手、一个 2-14 字节的帧头、一个 XOR 掩码。真正的工程量在**边界情况**——握手响应里夹带首帧、超大帧保同步、控制帧负载必须消费、粘包半包。把这些坑填平后,WS 层约 400 行代码,跑在自研 TCP 栈上,端到端延迟反超内核 52%。 + +下一篇:[微秒级性能优化实战:如何让自研 TCP 栈反超内核 52%](./03-性能优化实战-微秒级数据面.md) + +--- + +*相关源码:`src/ws_client.c`(握手 + 帧编解码)、`src/ws_client.h`(opcode 与 API)、`test/test_ws_protocol.c`(协议单测)、`test/ws_echo_server.c`(C 服务端)、`test/kern_c_test.py`(内核对比基准)* diff --git a/blog/03-performance-optimization-microsecond-dataplane.md b/blog/03-performance-optimization-microsecond-dataplane.md new file mode 100644 index 0000000..cf5acc8 --- /dev/null +++ b/blog/03-performance-optimization-microsecond-dataplane.md @@ -0,0 +1,194 @@ +# 微秒级性能优化实战:自研 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() 一次搞定 +``` + +```c +/* 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` 字段记录"已经读到哪里",读取时直接偏移,零平移: + +```c +// 优化前: // 优化后: +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()` 忙轮询:关闭是短时操作(毫秒级),自旋等待的代价远小于上下文切换: + +```c +while (state != CLOSED) rte_pause(); // PAUSE 指令,低功耗自旋 +``` + +**收益**:关闭操作无阻塞抖动,配合 P0 让连接生命周期全程低延迟。 + +## 六、P4:删除死代码——少 80 行 O(n) 扫描 + +### 问题 + +旧版 `ng_tcp_out()` 每轮迭代**扫描全部 256 个哈希桶**找待发送的 TCB。后来引入了 `active_head` 活跃连接链表,旧路径成了纯粹的死代码。 + +### 改造 + +删掉旧扫描路径(-80 行),`pkt_process` 定时器与发送统一走 `active_head` 链表遍历——只碰活跃连接。 + +**收益**:空闲连接再多,每轮迭代的开销也恒定;代码更简单,`make` 后行为不变(这是删除代码的最高境界:删除而不改变行为)。 + +## 七、五轮优化全景 + +```mermaid +flowchart LR + subgraph 基线["基线: WS echo RTT > 内核"] + A["rte_malloc 128μs
64KB 栈帧
部分读 memmove
condvar 关闭
256 桶扫描"] + end + subgraph 优化["五轮迭代"] + P0["P0 mempool
128→6.6μs (19×)"] + P1["P1 动态栈
64KB→~100B"] + P2["P2 read_offset
memmove → 0"] + P3["P3 忙轮询关闭"] + P4["P4 删死代码
-80 行"] + end + subgraph 结果["最终: 反超内核 52%"] + Z["WS echo RTT 1.5ms
跨核 0.2μs
分配 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) | + +## 八、复盘:这五轮优化教给我的事 + +1. **热路径上的通用设施是最大的敌人**。`rte_malloc` 是"正确"的,但它在每包路径上就是慢。P0 的 19 倍提升来自一次"专用化":把通用分配器换成专用 mempool。 +2. **优化顺序 = 数据说话**。没有 rdtsc 打点,谁也不会先想到 128μs 的 malloc;有了数据,P0 是唯一的答案。 +3. **小改动大收益往往藏在"看似无害"的地方**。P2 的 read_offset 只加了一个字段,消灭的却是每次部分读取的内存平移。 +4. **删除代码也是优化**。P4 没有让任何操作变快,但让每一轮迭代都变短、让系统更可理解——为后续 feature 扫清道路。 + +下一篇:[从实验代码到产品:DPDK 行情接入网关的产品化实践](./04-DPDK行情接入网关产品化.md) + +--- + +*相关源码:`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`(完整数据)* diff --git a/blog/04-dpdk-market-data-gateway-productization.md b/blog/04-dpdk-market-data-gateway-productization.md new file mode 100644 index 0000000..7f8e6e2 --- /dev/null +++ b/blog/04-dpdk-market-data-gateway-productization.md @@ -0,0 +1,171 @@ +# 从实验代码到产品:DPDK 行情接入网关的产品化实践 + +> 前三篇讲完了协议栈、WebSocket 和性能优化。这一篇把视角切到产品: +> 如何把这套技术栈包装成一个**金融行情接入网关**(`app/market_feed.c`), +> 以及它的部署方式、商用评估和演进路线。 + +--- + +## 一、产品定位:金融行业最典型的低延迟场景 + +在量化交易链路中,行情数据(股票、期货的实时报价)的到达延迟直接影响策略的成交质量。典型的接入形态: + +``` +交易所 / 行情提供商 → [WebSocket 行情流] → 行情接入网关 → 下游交易系统 + ↑ + 本项目占据的位置 +``` + +网关要做的事:**订阅多品种行情 → 以微秒级延迟接收 → 解析 → 转发/统计**。用自研 TCP 栈的收益在这里被放大——行情消息高频、小包、长连接,正是内核协议栈开销占比最大的流量形态。 + +## 二、产品功能(`app/market_feed.c`,约 420 行) + +| 功能 | 说明 | +|------|------| +| 多品种订阅 | 通过 `-s '{"action":"subscribe","symbols":[...]}'` 订阅任意品种 | +| **延迟直方图统计** | 每品种 min / avg / P50 / P95 / P99 / max,微秒级 | +| 消息速率跟踪 | 每品种 msg/s 实时计算 | +| 定时统计输出 | `-t` 秒可配置刷新间隔(默认 5s) | +| 优雅退出 | SIGINT/SIGTERM 触发有序关闭 | +| 断线检测 | 心跳 Ping + 断线统计 | + +架构与基础客户端同构(lcore0 RX/TX + lcore1 协议处理),产品层增加的是**延迟测量**和**统计展示**: + +```mermaid +flowchart LR + subgraph S["行情服务端 (demo/market_data_server.py)"] + S1["AAPL / GOOGL / MSFT / TSLA
每条消息带 timestamp"] + end + subgraph G["DPDK 行情网关 (app/market_feed.c)"] + L0["lcore0: RX burst +
ws_recv_frame"] + L0 -->|"ring→in"| L1["lcore1: TCP/WS
状态机处理"] + L1 -->|"rcvbuf + cond"| L0 + subgraph STATS["统计层 (lcore0)"] + H["延迟直方图
64 桶 × 1μs 分辨率"] + R["per-symbol
msg/s 速率"] + end + L0 --> STATS + STATS -->|"每 5s"| OUT["终端统计表输出"] + end + S1 -->|"WS 行情流 (TCP)"| G +``` + +## 三、延迟直方图:64 桶 × 1μs 分辨率的极简实现 + +延迟 = 消息携带的服务端 `timestamp` 到网关收到时刻的差值。测量与统计实现非常轻量(`app/market_feed.c`): + +```c +#define LAT_BUCKETS 64 /* 直方图桶数 (1us 分辨率) */ + +typedef struct { + uint64_t buckets[LAT_BUCKETS]; /* 延迟分布直方图 */ + uint64_t min_us, max_us, sum_us; /* 累计值 */ + uint64_t count; +} symbol_stats_t; +``` + +- 每收一条行情,算 `now - msg_timestamp`,`buckets[min(lat, 63)]++`; +- 百分位直接在累计分布上找分位点(P50/P95/P99); +- min/max/avg 用累计值 O(1) 维护; +- 输出时打印 ASCII 表格(见下文),无需外部依赖。 + +这套实现约 100 行 C,换来了产品的核心卖点:**可量化的延迟证据**。 + +## 四、端到端演示 + +```bash +# 1. 编译产品 +make app # → build/market_feed + +# 2. 启动模拟行情服务端(零依赖 Python,100 msg/s) +python3 demo/market_data_server.py 8080 100 + +# 3. 启动 DPDK 行情网关 +./build/market_feed -l 0-1 -n 2 \ + -d $DPDK_DRV/librte_mempool_ring.so \ + -d $DPDK_DRV/librte_net_ixgbe.so \ + -- -a -p 8080 -l \ + -s '{"action":"subscribe","symbols":["AAPL","GOOGL"]}' -t 5 +``` + +每 5 秒输出一次延迟统计表: + +``` +┌────────┬──────────┬────────┬────────┬────────┬────────┬──────┐ +│ Symbol │ Msgs │ Rate │ Min │ Avg │ P50 │ P99 │ +├────────┼──────────┼────────┼────────┼────────┼────────┼──────┤ +│ AAPL │ 498 │ 99 │ 598 │ 821 │ 780 │ 1245 │ +│ GOOGL │ 497 │ 99 │ 587 │ 815 │ 772 │ 1230 │ +└────────┴──────────┴────────┴────────┴────────┴────────┴──────┘ +``` + +> 延迟口径:服务端 `timestamp` → 网关 `rte_get_timer_cycles` 的端到端时间, +> 包含服务端序列化 + 内核 TCP 发送 + 网络传输 + DPDK 协议栈 + WS 解码全链路。 + +## 五、部署清单 + +生产部署需要三样东西(详见仓库 README / CLAUDE.md): + +1. **Hugepages**:`echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages` +2. **网卡绑定**:`dpdk-devbind --bind=vfio-pci `(DPDK 24.11 usertools) +3. **PMD 插件**:运行参数 `-d` 指定 mempool/NIC 驱动(Makefile 按共享库链接,PMD 运行时加载) + +云上部署:AWS `c5n.large`+(ENA PMD)或 `c5n.metal`(SR-IOV),安全组放行行情端口。 + +## 六、商用评估:什么能卖,什么不能 + +### 就绪度分级 + +| 梯队 | 场景 | 就绪度 | +|------|------|--------| +| 第一梯队(当前可商用) | **金融行情推送网关**(券商/量化/交易所) | ★★★★★ | +| | 微服务 RPC 网关(长连接复用,10ms→1-2ms) | ★★★★☆ | +| 第二梯队(3-6 月加固) | 5G 核心网 NRF、FIX 协议引擎 | ★★★★☆ | +| 第三梯队(6-12 月) | CDN 边缘推送、工业 IoT 控制面 | ★★★☆☆ | + +### 竞品对比 + +| 维度 | 本项目 | Solarflare Onload | 内核 TCP | +|------|--------|-------------------|----------| +| 部署成本 | 免费 + 2 天 | $2000+/lic | 免费 | +| WS echo 延迟 | 1.5 ms | <1 ms | 3.1 ms | +| 零拷贝 | ✅ | ✅ | ❌ | +| 可定制性 | 高(开箱即改) | 中(闭源) | 低 | +| 成熟度 | 教学/验证级 | 生产级 | 30 年生产级 | + +**推荐切入路径**:金融行情网关(1-2 月 MVP)→ 微服务 RPC(3-4 月)→ 5G 边缘(6 月+)→ CDN 推送(12 月+)。 + +### 不适合的场景(要诚实) + +| 场景 | 原因 | +|------|------| +| 公网 WebSocket 服务 | 无 SYN flood / DDoS 防护 | +| 浏览器客户端 | 需要 TLS (WSS),当前无 TLS 集成 | +| 百万并发连接 | 无 epoll 模型、无连接池管理 | +| 跨平台部署 | 依赖 DPDK + hugepages + 专用 NIC | + +## 七、演进路线图 + +```mermaid +flowchart LR + P5["P5 修复多核 RSS
active_head 并发"] --> P6["P6 WS 分片发送
header+payload 分离"] + P6 --> P7["P7 网卡 checksum offload
省 5% CPU"] + P7 --> P8["P8 事件驱动替代忙轮询
空闲降功耗"] + P8 --> P9["P9 TSO/GRO 软件实现
大消息吞吐翻倍"] + + M["商业路线: Prometheus 指标
JSON 配置 → 多核 RSS + TLS
→ HA 双活 + 热升级"] --> P5 +``` + +其中 P5 已有实验骨架:`feature/multi-core` 分支实现了 per-worker TCB 表、ring 数组与 RX 哈希分流,当前被主线程与 worker 对 `active_head` 链表的并发竞争阻塞(表现为 TCP 连接超时)。修好后吞吐可望随核数线性扩展。 + +## 八、总结 + +从 6000 行实验代码到可演示产品,中间只隔了 **100 行直方图 + 1 张统计表 + 1 个演示脚本**——但这一步把"技术能力"变成了"客户能看懂的东西"。低延迟基础设施类产品的说服力,永远来自一张带 P99 的延迟表。 + +--- + +*相关文件:`app/market_feed.c`(产品实现)、`app/README.md`(产品文档)、`demo/market_data_server.py`(模拟行情源)、`PRODUCT_ANALYSIS.md`(商用场景分析)、`PERF_REPORT.md`(性能数据)* + +--- + +**系列目录**:[① 用户态 TCP 协议栈](./01-从零实现用户态TCP协议栈.md) · [② WebSocket 实战](./02-WebSocket协议RFC6455实战.md) · [③ 性能优化实战](./03-性能优化实战-微秒级数据面.md) · [④ 产品化实践](./04-DPDK行情接入网关产品化.md) diff --git a/blog/README.md b/blog/README.md new file mode 100644 index 0000000..74300cb --- /dev/null +++ b/blog/README.md @@ -0,0 +1,29 @@ +# 项目博客系列 + +基于本仓库(DPDK 用户态 TCP/IP 协议栈 + WebSocket 客户端)的技术博客,共 4 篇。 + +## 目录 + +| # | 文章 | 主题 | 适合读者 | +|---|------|------|----------| +| 01 | [从零实现一个用户态 TCP/IP 协议栈](01-从零实现用户态TCP协议栈.md) | 架构设计、11 状态机、拥塞控制、阻塞式 Socket API | 想理解整体设计的读者 | +| 02 | [在自研 TCP 栈上跑通 WebSocket](02-WebSocket协议RFC6455实战.md) | RFC 6455 握手、帧编解码、掩码、粘包半包 | 对协议实现细节感兴趣的读者 | +| 03 | [微秒级性能优化实战](03-性能优化实战-微秒级数据面.md) | P0-P4 优化历程、测量方法、反超内核 52% 的全过程 | 性能工程师、C 开发者 | +| 04 | [从实验代码到产品:DPDK 行情接入网关](04-DPDK行情接入网关产品化.md) | 延迟直方图、产品定位、部署与商用评估 | 产品经理、架构师 | + +## 渲染说明 + +- 所有文档为 **Markdown + Mermaid 图表 + ASCII 图**,推荐在 GitHub / Gitea / Typora / VS Code(装 Markdown Preview Mermaid Support 插件)中阅读,图表会自动渲染。 +- 文中的数据均来自仓库实测:见 `PERF_REPORT.md`、`PUBLISH.md`、`README.md`。 +- 文中标注的源码位置(`src/tcp_input.c` 等)可直接跳转到仓库对应文件核对。 + +## 一句话速览 + +``` +用 6000 行 C 语言 + DPDK 24.11 从零实现用户态 TCP/IP 协议栈与 WebSocket 客户端: + · 零内存拷贝(收发各 0 次) + · 零系统调用(收发各 0 次) + · 跨核数据传递 0.2 μs + · TCP 连接延迟 1.5 ms(内核 2.5 ms,快 40%) + · WS echo RTT 1.5 ms(内核 3.1 ms,快 52%) +```