chore: english filenames for blog posts
This commit is contained in:
@@ -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<br/>Upgrade: websocket<br/>Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==<br/>Sec-WebSocket-Version: 13
|
||||
Note over S: 校验 key,按 RFC 计算<br/>SHA1(key+magic) 回写 Accept
|
||||
S->>C: HTTP/1.1 101 Switching Protocols<br/>Upgrade: websocket<br/>Sec-WebSocket-Accept: ...
|
||||
Note over C: 检查 101 + Upgrade 头
|
||||
C->>C: 保存 \r\n\r\n 之后的字节<br/>到 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 字节头<br/>(优先消费 leftover)"] --> B{payload_len<br/>== 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<br/>(原样回显负载)"]
|
||||
G -->|PONG 0xA| J[丢弃负载]
|
||||
G -->|TEXT/BINARY| K{负载 > 应用缓冲?}
|
||||
K -->|是| L["分块读掉整个负载<br/>(保解析器同步) → 返回 -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`(内核对比基准)*
|
||||
Reference in New Issue
Block a user