libwebsockets 原生 QUIC 客户端/服务器示例全解析:乱序中继、CUBIC 拥塞控制与 Retry 握手

原创2026-10-13 13:28:471,374 阅读
文章标签:网络通信WebSocket嵌入式

libwebsockets 原生 QUIC 客户端/服务器示例全解析:乱序中继、CUBIC 拥塞控制与 Retry 握手

libwebsockets(lws)在 LWS_WITH_HTTP3 开启时自动启用 LWS_WITH_QUIC,其 QUIC 传输栈为原生实现,无需依赖外部 QUIC 协议库。本文以仓库中的 minimal-quic-client-server 示例为核心,完整讲解如何在一个进程中同时实例化 QUIC 服务端与客户端、通过 UDP 完成 128KB 双向批量传输,并深入剖析该示例内置的三项高级测试设施——重排序中继(reordering relay)、杂散包注入(stray packets)与强制 Retry——以及可插拔的拥塞控制器(NewReno / CUBIC)架构。读完本文,你将掌握 lws QUIC 的构建方式、命令行用法、数据有序性校验设计,以及从源码层面理解 QUIC 面对乱序、丢包和握手抗重放攻击时的处理原理。

示例概览:单进程内建 QUIC 服务端与客户端

minimal-quic-client-server 是一个自包含的测试型示例。默认情况下,它在进程内同时完成三件事:

  1. 在 UDP 监听端口上运行一个 QUIC 服务端;
  2. 在同一个进程里启动一个 QUIC 客户端并连接该服务端;
  3. 双方各自打开一条流(stream),向对方发送 128KB(TOTAL_DATA = 128 * 1024,见 main.c)数据。

测试通过的判定条件是:每一方都完整、按序地收到对端发来的全部数据。为了验证“按序、无丢失、无重复”,示例设计了一个巧妙的字节模式:每个发送字节的值是其在流中的偏移量(stream offset)的函数,接收方据此逐字节校验(pattern_byte()、pattern_fill()、pattern_ok(),见 main.c)。发送时把 at + i 处的偏移信息写入数据,接收时对比实际字节与预期字节,任何乱序、丢失或重复都会立刻在偏移位置附近暴露出来。

相关代码位置:

构建与运行前提

示例的 CMakeLists.txt 明确列出了三项编译期硬性要求,通过 require_lws_config() 检查:

CMake 配置项 要求值 含义
LWS_ROLE_QUIC 1 启用 QUIC 角色
LWS_WITH_CLIENT 1 启用客户端能力
LWS_WITH_SERVER 1 启用服务端能力

在满足上述配置的 libwebsockets 构建环境中,编译示例:

$ cmake . && make

需要说明的是,lws 中 QUIC 与 HTTP/3 是绑定关系:LWS_WITH_HTTP3 默认开启,且一旦开启就会自动启用 LWS_WITH_QUIC(详见 READMEs/README.quic.md)。另外,QUIC 依赖 TLS 1.3 及配套的 TLS 后端能力,示例直接使用内置在源码中的自签名测试证书(test_cert / test_key)来满足 TLS 1.3 对合法证书的要求,客户端连接本地测试时通过 LCCSCF_ALLOW_SELFSIGNED | LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK 放行自签名证书。

最简单的运行方式:

$ ./lws-minimal-quic-client-server

程序会打印双方收发进度,最终输出 Test completed successfully! Both sides received full data... 并等待 1.5 秒 ACK 后正常退出(返回码 0)。

命令行参数完整解析

示例通过 lws 内置的命令行开关机制(struct lws_switches)解析参数,参数表如下:

Option 含义
-p <port> 服务端监听的端口,同时也是客户端连接的端口(默认 7681)
--server <address> 客户端连接的服务端地址(默认 127.0.0.1)
-s 仅服务端模式:不启动客户端,且服务端不主动发送数据
-u <url> 客户端连接该 URL 指向的远端服务端,而不是运行本地服务端
--relay <port> 客户端经本进程内运行在该端口上的 UDP 中继连接服务端(详见下文)
--stray 配合 --relay 使用,中继还会向客户端发送另一条连接的杂散数据包
--write-size <n> 每次 lws_write() 发送的字节数,取值范围 1~1024(默认 1024)
--cubic 使用 CUBIC 拥塞控制器,替代默认的 NewReno

源码中对参数做了严格校验(见 main.c):

  • -p 端口必须是合法 16 位范围(0~65535),否则报错退出;
  • --write-size 超出 1~1024 直接报错 --write-size must be 1 to 1024;
  • --relay 端口同样校验 16 位范围;
  • --stray 必须与 --relay 同时使用,否则报 --stray needs --relay;
  • -u 传入的 URL 通过 lws_parse_uri() 解析出协议、地址、端口与路径。

--write-size 对测试意义重大:值越小,每个 UDP 数据包中承载的 STREAM 帧越多越小,从而制造出大量小 STREAM 帧需要重组的情形。默认 1024 字节对应每个数据包一个较大的 STREAM 帧。

数据有序性校验:每个字节都是偏移量的函数

QUIC 面向的是不可靠的 UDP,因此验证乱序重组能力是本示例的核心目的。发送端在 LWS_CALLBACK_CLIENT_WRITEABLE 与 LWS_CALLBACK_SERVER_WRITEABLE 回调中,按 write_size 分片填充数据并调用 lws_write(wsi, &buf<a href="https://link.gitcode.com/i/f949bcea579c7e4fc980532522e45bcc" target="_blank">LWS_PRE], to_send, LWS_WRITE_BINARY)(见 [main.c)。注意缓冲区前预留了 LWS_PRE 字节,这是 lws 协议栈写入的固定要求。

接收端分别在 LWS_CALLBACK_QT_CLIENT_RECEIVE(QUIC 传输层客户端侧收到负载)与 LWS_CALLBACK_QT_SERVER_RECEIVE(服务端侧)回调中校验(这两个回调枚举值见 lws-callbacks.h):

  • 用 pattern_ok(client_rx, in, len) 校验本次到达的数据是否与流偏移 client_rx 处的预期字节一致,不一致立即判定失败并退出事件循环;
  • 累计接收字节数并计算 djb2 风格哈希(simple_hash,初始值 5381)做整体校验;
  • 当任一侧收满 TOTAL_DATA 后置 client_done / server_done,双方都完成时输出成功日志并调度 1.5 秒后的退出(check_test_completion(),见 main.c)。

主循环还内置了两道超时防线(见 main.c):绝对超时 60 秒,以及 15 秒无任何接收活动的空闲超时,两者任一触发即判失败退出。

重排序中继(--relay):让对端必须在乱序中重组

--relay <port> 让客户端不再直连服务端,而是先连接本进程内的一个 UDP 中继。中继在每个方向把数据报按批缓存,每批最多 4 个,发送时**按逆序(last first)**送出,从而人为制造乱序:

  • 握手阶段双方交换的 CRYPTO 数据会乱序到达;
  • 流数据(STREAM 帧)同样乱序到达;
  • 对端必须把先到的数据暂存,等待其前面的空洞被填满后才能交付给上层。

中继的关键实现常量与逻辑(见 main.c):

#define RELAY_BATCH	4               /* 每方向最多缓存 4 个数据报 */
#define RELAY_HOLD_US	(2 * LWS_US_PER_MS) /* 首批到达后最多等待 2ms */
#define RELAY_DGRAM_MAX	2048            /* 单个数据报最大长度 */

行为规则如下:

  1. 每个方向(发往服务端、发往客户端)各有一个 struct relay_dir,内含长度 4 的环形批次队列与目标地址;
  2. 方向判定:若数据报来源与“发往服务端”方向的已知目的地址一致,则归入“发往客户端”方向,否则视为来自客户端、归入“发往服务端”方向(源码通过 lws_sa46_compare_ads 比较);
  3. 凑满 4 个数据报时立即触发发送;若不足 4 个,则从首个数据报到达起计时 RELAY_HOLD_US(2ms),超时也触发发送——这保证单个孤零零的数据报(例如客户端第一个 Initial)只会被延迟而不会被永远扣住;
  4. 方向队列已满时新到的数据报直接丢弃,QUIC 会像对待任何普通丢包一样通过重传恢复;
  5. 发送时从队尾到队头逆序 relay_send() 逐个发出。

使用中继时,--server 必须是数字形式的 IP 地址(如 127.0.0.1),因为中继需要把地址解析为 sockaddr 才能转发(源码用 lws_sa46_parse_numeric_address(),解析失败报 --relay needs a numeric --server address)。典型用法:

$ ./lws-minimal-quic-client-server --server 127.0.0.1 -p 7681 --relay 7682 --write-size 100

较小的 --write-size 会让每个数据包携带许多小 STREAM 帧,进一步加大乱序重组的难度,因此 ctest 的 reorder 用例固定使用 --write-size 100。

中继本身作为一个独立 vhost 实现,协议名为 quic-relay,通过 lws_create_adopt_udp(rvh, ads, relay_port, LWS_CAUDP_BIND, "quic-relay", ...) 绑定 UDP 端口,并依赖 lws_sorted_usec_list(sul)实现 2ms 定时器调度,事件驱动模型与 lws 主循环完全一致。

杂散包注入(--stray):握手期就要抗干扰

--stray 与 --relay 配合,让中继在转发给客户端的第一批以及此后每第八批(RELAY_STRAY_EVERY 8)数据之前,额外塞入一个属于另一条 QUIC 连接的长头包(long header packet)。该包的构造逻辑(relay_send_stray(),见 main.c)如下:

  • 用 lws_get_random() 生成 57 字节随机内容,作为随机的连接 ID、包号与负载;
  • 首字节按 0xf1(QUIC v2)与 0xe1(QUIC v1)交替设置,构成 Handshake 类型长头;
  • 写入随机 8 字节 DCID 与 8 字节 SCID、2 字节 varint 长度字段等头部结构;
  • 负载是没有任何一方持有密钥的密文。

从语义上讲,这类“别人的连接的数据包混进来”是网络中的常态事件。本测试的关键点在于时序:

  • 第一个杂散包先于服务端的第一个 Initial 到达,落在握手进行中;
  • 其余杂散包在握手完成后持续到达;
  • 客户端必须全部丢弃这些包,且它们绝不能改变自身连接的任何状态。

这正是 RFC 9000 7.2 的要求——服务端的连接 ID 只能取自其第一个通过认证的 Initial 包;同时也是 RFC 9368 的要求——协议版本只能由通过认证的包决定。测试最终必须仍然完成传输。运行方式:

$ ./lws-minimal-quic-client-server --server 127.0.0.1 -p 7681 --relay 7682 --stray

拥塞控制器:默认 NewReno,可选 CUBIC

lws 的 QUIC 拥塞控制采用可插拔架构:struct lws_cc_ops 定义了一组操作钩子(见 lws-quic.h):

struct lws_cc_ops {
	void (*init)(struct lws *nwsi);
	void (*on_sent)(struct lws *nwsi, size_t bytes);
	void (*on_ack)(struct lws *nwsi, size_t bytes_acked, lws_usec_t rtt);
	void (*on_loss)(struct lws *nwsi, size_t bytes_lost);
	void (*on_discard)(struct lws *nwsi, size_t bytes_discarded);
	void (*on_persistent_congestion)(struct lws *nwsi);
	int  (*can_send)(struct lws *nwsi, size_t bytes);
	lws_usec_t (*get_pacing_delay)(struct lws *nwsi, size_t bytes_to_send);
};

lws 导出两个内置实现:lws_cc_ops_newreno(默认)与 lws_cc_ops_cubic。默认使用 NewReno;如需切换,在 struct lws_context_creation_info 中设置 .quic_cc_ops。示例中 --cubic 开关的实现非常简洁(见 main.c):

if (lws_cmdline_option(argc, argv, switches[LWS_SW_CUBIC].sw))
	/* both the server and the client in this context use it */
	info.quic_cc_ops		= &lws_cc_ops_cubic;

同一上下文内的服务端与客户端都会使用所选控制器,因此在 --relay 造成乱序与丢包的前提下,双方都会用 CUBIC 进行在途字节(bytes in flight)记账与丢包恢复:

$ ./lws-minimal-quic-client-server --server 127.0.0.1 -p 7681 --relay 7682 --write-size 100 --cubic

从两个控制器的源码实现(ops-quic-cc-newreno.c 与 ops-quic-cc-cubic.c)可以观察到它们遵循 RFC 9002 的共同骨架:

  • 初始窗口:cwnd 默认取 10 个 MTU(MTU 默认 1280,可用 vhost 的 quic_mtu 覆盖),也可通过 context 的 quic_initial_cwnd 覆盖;ssthresh 初始为无穷大((size_t)-1);
  • 慢启动:cwnd < ssthresh 时每个 ACK 线性增加 bytes_acked;
  • 拥塞避免:NewReno 每 ACK 增加 mtu * bytes_acked / cwnd;CUBIC 则按 RFC 9438 用立方函数计算目标窗口,并对应用受限(app-limited)时段做了 epoch 平移处理,还实现了 TCP 友好性(Reno 近似)与 fast convergence;
  • 丢包响应:NewReno 将 ssthresh 减半(cwnd / 2,下限 2 个 MTU);CUBIC 按 cwnd * 0.7 设置 ssthresh 并更新 w_max;
  • 持续拥塞(persistent congestion):两者都把 cwnd 塌缩到 2 个 MTU 的最小值;
  • PTO 重排队:on_discard 只修正 in-flight 计数,不动 cwnd 与 ssthresh(RFC 9002 6.2.4),避免每次 PTO 永久缩小可用窗口;
  • 发送节流(pacing):两者都实现基于“信用”的 pacing——pacing_credit 按 cwnd / srtt 的速率随时间补充,并把单次突发上限封顶为 64 个包(64 * mtu),get_pacing_delay() 返回还需等待的微秒数。

如果你需要定制自己的拥塞控制(例如 BBR),只需实现 struct lws_cc_ops 并在 init() 中把私有状态挂到 nwsi->quic.qn->cc_state,再将实例地址赋给 info.quic_cc_ops 即可(完整接口说明见 lws-quic.h)。

强制 Retry:用环境变量验证 RFC 9000 握手抗重放

QUIC 的 Retry 机制(RFC 9000 8.1.2)用于服务端在握手早期验证客户端地址真实性:服务端对每个不携带 token 的 Initial 回复一个 Retry 包,客户端必须携带 Retry 中给出的 token 重发 Initial,服务端才继续握手。

本示例通过环境变量 LWS_QUIC_FORCE_RETRY 无条件触发该路径:

$ LWS_QUIC_FORCE_RETRY=1 ./lws-minimal-quic-client-server

对应的服务端实现位于 ops-quic.c(QUIC Initial 包处理路径):

  1. 检查 getenv("LWS_QUIC_FORCE_RETRY") 是否存在;
  2. 解析客户端 Initial 中是否携带 token(varint 长度字段);
  3. 无 token:构造 1200 字节 Retry 包(长头、版本回显、随机 8 字节 retry SCID),用 lws_quic_create_retry_token() 生成绑定客户端 IP 的 token,并附加 16 字节认证 tag(lws_quic_create_retry_tag()),然后回复客户端并丢弃本次 Initial;
  4. 有 token:调用 lws_quic_validate_retry_token() 校验 token 与客户端 IP,无效则直接丢弃 Initial,有效才继续 lws_quic_halfopen_admit() 进入连接建立流程。

同时,客户端侧会校验服务端 transport parameters 是否复用了 Retry 交换中的连接 ID(RFC 9000 7.3),防止降级攻击。ctest 的 retry 用例正是通过 ENVIRONMENT "LWS_QUIC_FORCE_RETRY=1" 注入该环境变量来覆盖这条路径(见 CMakeLists.txt)。

自动化测试矩阵:ctest 与 h3spec

示例的 CMakeLists.txt 把上述所有场景固化为 ctest 用例,可作为理解各参数组合用途的最权威参考:

ctest 用例名 启动命令 覆盖点
lws-minimal-quic-client-server --server <addr> -p <port> 基础双向 128KB 传输
lws-minimal-quic-client-server-reorder --relay <port> --write-size 100 乱序 CRYPTO 与众多小 STREAM 帧重组
lws-minimal-quic-client-server-cubic --relay <port> --write-size 100 --cubic CUBIC 在乱序与丢包下的 in-flight 记账
lws-minimal-quic-client-server-stray --relay <port> --stray 握手期及之后丢弃外部连接的杂散包(RFC 9000 7.2 / RFC 9368)
lws-minimal-quic-client-server-retry 环境变量 LWS_QUIC_FORCE_RETRY=1 Retry 握手与 transport parameters 校验(RFC 9000 8.1.2 / 7.3)

此外,若系统 PATH 中存在 h3spec 工具且 lws 启用了 LWS_WITH_HTTP3,ctest 还会自动以 -s 服务端模式拉起本示例作为测试服务器,运行完整的 QUIC/HTTP/3 RFC 合规测试套件(h3spec 127.0.0.1 <port> -n,见 CMakeLists.txt)。关于 QUIC 的 TLS 后端选型(GnuTLS、BoringSSL、AWS-LC、LibreSSL、wolfSSL、schannel 等)与拥塞控制接口的更多说明,可参阅 READMEs/README.quic.md。

小结

minimal-quic-client-server 以不到 800 行的单文件(main.c)演示了 lws 原生 QUIC 栈的完整能力闭环:

  • 传输正确性:用“字节=流偏移函数”的模式严格验证按序、无丢失、无重复交付;
  • 乱序与丢包韧性:通过重排序中继人为制造乱序与丢弃,验证 QUIC 的重组与重传;
  • 握手安全性:通过杂散包注入与强制 Retry,验证连接 ID、版本与地址验证的抗干扰/抗重放语义(RFC 9000 7.2、7.3、8.1.2,RFC 9368);
  • 拥塞控制可扩展性:以 lws_cc_ops 接口同时提供 NewReno 与 CUBIC 两种实现,且支持用户自定义算法。

无论你是想用 lws 搭建 QUIC/HTTP3 服务,还是希望深入理解 QUIC 协议机制在真实实现中的落地方式,这个示例及其配套 ctest 用例都是极佳的起点与参照。

登录后查看全文
libwebsockets