Zig 与 HTTP/3、QUIC:UDP 传输与协议实现

QUIC 把可靠传输、加密握手与多路复用全部搬到用户态,跑在 UDP 之上。本文用 Zig 拆解 QUIC 的包格式、变长整数、TLS 1.3 集成与头部保护,说明 HTTP/3 的 QPACK 映射,并给出 UDP GSO/GRO 批处理与连接迁移的工程实践。

1. QUIC 要解决的问题

HTTP/2 已经做了请求多路复用,但底层仍是 TCP。TCP 把整条连接视为一个有序字节流,任何一个报文段丢失,后续所有流的数据都必须在内核缓冲区里排队等待重传——这就是队头阻塞(Head-of-Line Blocking, HOL)。同时 TCP 握手与 TLS 握手串行执行,一次冷启动要花 2 个 RTT 才能发第一个请求。

QUIC 的思路是把传输层整体搬到用户态,跑在 UDP 上:

  • 流级独立重传:丢包只影响所属的那条流,其余流照常推进。
  • 握手与传输合并:TLS 1.3 内嵌在 QUIC 握手里,1-RTT 建连,会话复用下可 0-RTT。
  • 连接迁移:用连接 ID(Connection ID)而非四元组标识连接,手机从 Wi-Fi 切到蜂窝网络不中断。
  • 用户态可演进:拥塞控制、丢包检测都在用户态,升级不必等内核。

代价是复杂度全部落到应用层。如果你只是想理解 HTTP/2 与 TLS 在 Zig 里怎么用,可以先看 /zig-tls-http2-networking/;本文从 QUIC 的字节格式讲起。协议原理的完整对照见 HTTP/3 与 QUIC 协议详解 。

2. 包格式与变长整数

QUIC 有两种包:**长包头(Long Header)**用于握手阶段,**短包头(Short Header)**用于 1-RTT 阶段。长包头首字节的最高两位固定为 1,次两位是版本相关的类型:

const PacketType = enum(u2) {
    initial = 0,
    zero_rtt = 1,
    handshake = 2,
    retry = 3,
};

const LongHeader = struct {
    first: u8,
    version: u32,
    dcid_len: u8,
    dcid: []const u8,
    scid_len: u8,
    scid: []const u8,
    token_len: u64,
    token: []const u8,
    length: u64,
    packet_number: u64,
};

pub fn packetType(first: u8) ?PacketType {
    if (first & 0x80 == 0) return null; // 短包头
    return @enumFromInt((first & 0x30) >> 4);
}

2.1 变长整数编码

QUIC 用一套自描述的变长整数(Variable-Length Integer):首字节的最高两位给出长度(1/2/4/8 字节),剩余位与后续字节拼成值。

pub fn decodeVarInt(buf: []const u8) !struct { value: u64, len: usize } {
    if (buf.len == 0) return error.Truncated;
    const len: usize = @as(usize, 1) << @intCast(buf[0] >> 6);
    if (buf.len < len) return error.Truncated;

    var v: u64 = buf[0] & 0x3f;
    for (buf[1..len]) |b| {
        v = (v << 8) | b;
    }
    return .{ .value = v, .len = len };
}

pub fn encodeVarInt(w: anytype, v: u64) !void {
    if (v < (1 << 6)) {
        try w.writeByte(@intCast(v));
    } else if (v < (1 << 14)) {
        try w.writeInt(u16, @intCast(v | 0x4000), .big);
    } else if (v < (1 << 30)) {
        try w.writeInt(u32, @intCast(v | 0x80000000), .big);
    } else {
        try w.writeInt(u64, v | 0xC000000000000000, .big);
    }
}

变长整数遍布 QUIC:流 ID、偏移量、帧长度、连接 ID 长度都用它。Zig 的 std.io.Writer 泛型让编解码可以零成本地适配任意写入端(内存缓冲、socket、ArrayList)。

2.2 帧(Frame)

包体由一串帧组成,每帧以帧类型开头(也是变长整数)。1-RTT 阶段最常用的几类:

帧类型编号用途
PADDING0x00填充,使 Initial 包达到 1200 字节
PING0x01保活、探测路径
ACK0x02/0x03确认,带延迟编码
CRYPTO0x06承载 TLS 握手数据
STREAM0x08~0x0f应用数据,低 3 位是 FIN/LEN/OFF 标志
MAX_DATA0x10连接级流控
MAX_STREAM_DATA0x11流级流控
CONNECTION_CLOSE0x1c/0x1d关闭连接

STREAM 帧的低 3 位是位标志,解码时要先剥离:

pub fn parseStreamFrame(t: u64, r: anytype) !StreamFrame {
    var f = StreamFrame{ .id = 0, .offset = 0, .len = 0, .fin = false, .data = &.{} };
    f.fin = (t & 0x01) != 0;
    const has_len = (t & 0x02) != 0;
    const has_off = (t & 0x04) != 0;

    f.id = (try decodeVarInt(r)).value;
    if (has_off) f.offset = (try decodeVarInt(r)).value;
    if (has_len) {
        f.len = (try decodeVarInt(r)).value;
    } else {
        f.len = std.math.maxInt(u64); // 延伸到包尾
    }
    return f;
}

has_len 为 0 时,帧延伸到包的末尾——这是唯一的优化,省掉一个变长整数。

3. 加密层级

QUIC 的每个包都用一个**包号空间(Packet Number Space)**和对应的密钥集,共三套:

空间密钥来源用途
Initial客户端随机数 + 固定 salt 派生的 DCID交换 ClientHello / ServerHello
HandshakeTLS 握手密钥交换证书、Finished
Application (1-RTT)TLS 应用密钥全部应用数据

Initial 密钥用固定盐值派生,任何人都能解——它的存在只是为了承载 TLS 握手,不能保护机密性。派生过程是 HKDF-Extract + HKDF-Expand-Label:

const initial_salt_v1 = [_]u8{
    0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17,
    0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a,
};

pub fn deriveInitialKeys(dcid: []const u8) !Keys {
    // initial_secret = HKDF-Extract(salt, dcid)
    var prk: [32]u8 = undefined;
    std.crypto.kdf.hkdf.HkdfSha256.extract(&prk, dcid, &initial_salt_v1);
    // client_initial_secret = HKDF-Expand-Label(prk, "client in", "", 32)
    const client_secret = try hkdfExpandLabel(prk, "client in", "", 32);
    return .{
        .key = try hkdfExpandLabel(client_secret, "quic key", "", 16),
        .iv = try hkdfExpandLabel(client_secret, "quic iv", "", 12),
        .hp = try hkdfExpandLabel(client_secret, "quic hp", "", 16),
    };
}

HKDF-Expand-Label 的结构是固定的:length 两字节大端 + label 长度字节 + "tls13 " ++ label + context 长度字节 + context。

3.1 头部保护(Header Protection)

QUIC 对包头做一层额外的混淆:用 hp 密钥对采样出的密文做 AES-ECB(或 ChaCha20),得到的掩码异或到首字节的低 4 位与包号字段上。这样即使包号明文可见,中间人也无法从包头推断出包号,避免流量分析。

pub fn removeHeaderProtection(pkt: []u8, pn_offset: usize, hp_key: [16]u8) !void {
    // 采样:从 pn_offset+4 起取 16 字节
    const sample = pkt[pn_offset + 4 ..][0..16];
    var mask: [16]u8 = undefined;
    var ctx = std.crypto.core.aes.Aes128.initEnc(hp_key);
    ctx.encrypt(&mask, sample);

    // 首字节低 4 位(长包头为低 4 位)
    const long = (pkt[0] & 0x80) != 0;
    const bits: u8 = if (long) 0x0f else 0x1f;
    pkt[0] ^= mask[0] & bits;

    // 包号长度由首字节低 2 位给出
    const pn_len: usize = @as(usize, pkt[0] & 0x03) + 1;
    for (0..pn_len) |i| {
        pkt[pn_offset + i] ^= mask[1 + i];
    }
}

采样偏移必须是 pn_offset + 4——保证无论包号是 1 字节还是 4 字节,采样窗口都不与包号字段重叠。

4. UDP 收发:批处理与 GSO/GRO

QUIC 的服务端每秒要处理海量小包,逐个 recvfrom/sendto 会被系统调用开销压垮。两个内核特性是必需的:

  • GSO(Generic Segmentation Offload):一次 sendmsg 提交一个「超包」,内核按 MTU 切分。
  • GRO(Generic Receive Offload):内核把同流的多个小包合并,一次 recvmmsg 收上来。

在 Linux 上用 UDP_SEGMENT 控制消息开启 GSO:

pub fn sendGso(fd: i32, buf: []const u8, addr: *const std.posix.sockaddr, seg_size: u16) !usize {
    var cmsg_buf: [64]u8 align(8) = undefined;
    const cmsg = std.posix.cmsghdr{
        .len = @sizeOf(std.posix.cmsghdr) + @sizeOf(u16),
        .level = std.posix.IPPROTO.UDP,
        .type = 103, // UDP_SEGMENT
    };
    @memcpy(cmsg_buf[0..@sizeOf(std.posix.cmsghdr)], std.mem.asBytes(&cmsg));
    @memcpy(cmsg_buf[@sizeOf(std.posix.cmsghdr)..][0..2], std.mem.asBytes(&seg_size));

    const iov = [1]std.posix.iovec_const{.{ .base = buf.ptr, .len = buf.len }};
    var msg = std.posix.msghdr_const{
        .name = addr,
        .namelen = @sizeOf(std.posix.sockaddr.in6),
        .iov = &iov,
        .iovlen = 1,
        .control = &cmsg_buf,
        .controllen = cmsg_buf.len,
        .flags = 0,
    };
    return std.posix.sendmsg(fd, &msg, 0);
}

服务端 UDP socket 还需要几项调优:

# 放大接收缓冲区,避免突发丢包
sysctl -w net.core.rmem_max=26214400
sysctl -w net.core.wmem_max=26214400
# 多队列分发,配合 SO_REUSEPORT
sysctl -w net.core.rps_sock_flow_entries=32768

在 Zig 里用 setsockopt 把 SO_RCVBUFFORCE 拉到 16 MiB,再用 SO_REUSEPORT 让多个 worker 线程各自持有独立 socket:

const sz: c_int = 16 * 1024 * 1024;
std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&sz)) catch {};
std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.REUSEPORT, std.mem.asBytes(&@as(c_int, 1))) catch {};

4.1 每个包都要走的处理链

一个 UDP 数据报进入 QUIC 栈要经过:

  1. 按 DCID 长度做常量时间查找(连接 ID 可能是 0~20 字节,需哈希表 + 长度前缀)。
  2. 剥离头部保护,还原包号。
  3. 用对应层级的密钥 AEAD 解密(AES-128-GCM 或 ChaCha20-Poly1305)。
  4. 重建完整包号(只传了低若干位)。
  5. 解析帧、投递到对应流。
  6. 生成 ACK 帧,聚合后随下一个包发出。

第 4 步的包号重建容易出错:包号只传低 1~4 字节,要结合已收到的最大包号推断完整值。

pub fn reconstructPn(truncated: u64, largest: u64, pn_len: usize) u64 {
    const expected = largest + 1;
    const win: u64 = @as(u64, 1) << @intCast(pn_len * 8);
    const half = win / 2;
    var candidate = (expected & ~(win - 1)) | truncated;
    if (candidate + half <= expected) candidate += win;
    if (candidate > expected + half) candidate -= win;
    return candidate;
}

5. HTTP/3 映射:QPACK 与请求流

HTTP/3 把 HTTP 语义映射到 QUIC 流上,规则很简洁:

  • 每个请求占用一条双向流,由客户端发起。
  • 首部用 QPACK 编码(QUIC 版的 HPACK),但因为有 HOL 阻塞顾虑,QPACK 把动态表更新放在单向流上,与请求流解耦。
  • 服务端推送用单向流 + PUSH_PROMISE 帧。

QPACK 的三种单向流:

流类型值作用
控制流0x00设置、流控上限
推送流0x01服务端推送
QPACK 编码流0x02动态表插入指令
QPACK 解码流0x03确认已处理的插入

流 ID 的低 2 位编码了发起方与方向:0x0 客户端双向、0x1 服务端双向、0x2 客户端单向、0x3 服务端单向。

const StreamDir = enum { client_bidi, server_bidi, client_uni, server_uni };

pub fn streamDir(id: u64) StreamDir {
    return @enumFromInt(@as(u2, @intCast(id & 0x03)));
}

QPACK 与 HPACK 的关键差别是不阻塞:首部块可以引用尚未收到的动态表条目,此时该请求被「阻塞」,等编码流上的插入到达后解阻塞。实现时必须维护 max_blocked_streams 上限,否则恶意客户端可以用阻塞请求耗尽内存。

6. 连接迁移与多路径

QUIC 连接由连接 ID 标识,与源/目的 IP 和端口解耦。当客户端 IP 变化(Wi-Fi 切蜂窝)时,只要它继续用同一个 DCID,服务端就能把新地址识别为同一连接。

const Conn = struct {
    dcid: [20]u8,
    dcid_len: u8,
    peer: std.posix.sockaddr.storage,
    peer_len: std.posix.socklen_t,
    // 地址变化时需要重置拥塞窗口与 RTT 估计
    congestion: Congestion,
    rtt: RttEstimator,
};

pub fn onPacketFrom(conn: *Conn, from: *const std.posix.sockaddr, len: std.posix.socklen_t) void {
    if (!addrEqual(&conn.peer, from, len)) {
        // 路径变更:验证新路径后才能迁移
        conn.peer = from.*;
        conn.peer_len = len;
        conn.congestion.reset();
        conn.rtt.reset();
    }
}

迁移前必须做路径验证(Path Validation):向新地址发送 PATH_CHALLENGE,收到回显的 PATH_RESPONSE 才确认该路径可达,防止放大攻击。

7. 观测与调试

QUIC 全加密,抓包工具看不懂。可行的观测手段:

  • qlog:QUIC 定义的结构化事件日志(JSON 流),记录每个包的收发、丢包、拥塞窗口变化。
  • 密钥日志:设置 SSLKEYLOGFILE 环境变量导出 TLS 密钥,Wireshark 可据此解密。
  • 计数器:packets_dropped、packets_lost、pto_count、cwnd 是最该暴露的指标。
pub const Metrics = struct {
    packets_sent: u64 = 0,
    packets_received: u64 = 0,
    packets_lost: u64 = 0,
    bytes_sent: u64 = 0,
    pto_count: u64 = 0,
    cwnd: u64 = 0,

    pub fn observe(self: *Metrics, m: []const u8, v: u64) void {
        _ = self;
        _ = m;
        _ = v;
    }
};

用 Zig 的 std.atomic.Value(u64) 做无锁累加,避免多线程统计打点成为瓶颈:

var packets_lost = std.atomic.Value(u64).init(0);

pub fn incrLost() void {
    _ = packets_lost.fetchAdd(1, .monotonic);
}

8. 实现路线建议

从零写一个生产级 QUIC 栈是数月工作量。务实的路线:

  • 先做客户端:只实现 Initial + Handshake + 1-RTT 的发送路径,够用来做压测与协议学习。
  • 复用 TLS 1.3 实现:QUIC 需要 TLS 1.3 的 export_keying_material 与 QUIC 传输参数扩展,用 Zig 的 std.crypto 手写 TLS 1.3 状态机是必要的,但可只覆盖必要子集。
  • 不要自己写拥塞控制:先用固定窗口跑通,再逐步引入 NewReno / CUBIC / BBR。
  • 用 qlog 做回归:把每次测试的 qlog 存档,协议改动后做 diff。

若你的服务端已经在用 WebSocket 做实时推送,迁移到 HTTP/3 之前值得先读 /zig-websocket-realtime/ 里关于长连接保活与背压的处理。HTTP/3 在性能上的真实收益与代价,参见 HTTP/3 与 QUIC 性能实测 。

小结

QUIC 把可靠传输、加密与多路复用全部收进用户态,用 UDP 承载。在 Zig 里实现的要点:

  1. 变长整数是所有帧的基础,先把它写对再写别的。
  2. extern struct + 显式大端读写,别依赖内存布局。
  3. Initial 密钥是公开派生的,不要误以为它有安全意义。
  4. 头部保护采样偏移固定为 pn_offset + 4。
  5. UDP 侧务必开 GSO/GRO,否则单核吞吐上不去。
  6. 包号重建与路径验证是两处最易写错的逻辑。

Zig 没有 GC、没有运行时,非常适合承载这类需要精确控制内存与字节布局的协议实现——每一个字节的归属都是显式的。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 终端 TUI 开发:终端控制、布局与交互
  2. Zig 打包与分发:容器镜像、系统包与 Homebrew
  3. Zig GPU 计算:Vulkan Compute 与着色器绑定