OpenSSL QUIC 流控(Flow Control)设计解析:双层信用模型、水位线状态机与窗口自动调优
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
导读
QUIC 的流控(Flow Control)负责在连接级(connection-level)与流级(stream-level)两个层面约束对端可发送的数据量,是保证接收端缓存不溢出、端到端吞吐得以收敛的关键机制。本文以 OpenSSL 仓库中的 QUIC 流控设计文档 doc/designs/quic-design/quic-fc.md 为主体,结合其核心实现 ssl/quic/quic_fc.c、include/internal/quic_fc.h 及帧处理与测试代码,完整讲解信用(credit)模型、TX/RX 两侧状态机事件、水位线(watermark)术语体系以及 RX 窗口的动态自动调优(auto-tuning)算法。读完本文,你将掌握 OpenSSL QUIC 实现中流控的完整数据流与判定规则,并能将设计与源码一一对应,为深入阅读或二次开发 OpenSSL QUIC 栈打下基础。
QUIC 流控概览:双层级信用模型
QUIC 的流控同时在连接级和流级两个层面生效。任何时刻,流数据的发送都可能被连接级流控、流级流控,或两者同时限制。流控采用信用(credit)模型:相关的流控上限被表达为"自流或连接开始以来,允许在单个流上、或跨所有流发送的最大字节数",并且该上限可以被周期性地抬高(bump)。
关于 "credit" 一词:在 OpenSSL 源码中,"credit" 即"当前还可发送的受控字节数",等价于 CWM 与 SWM 之差(见 include/internal/quic_fc.h 中
ossl_quic_rxfc_get_credit的实现:cwm - swm)。
受控字节(Controlled Bytes)与重传计数
连接级与流级流控都只与QUIC 流数据(stream data)的传输有关。流级流控统计的是某个流上发送的逻辑字节总数:
- 它不统计重传。一个字节如果被发送、丢失、再发送,对流控而言仍只计作一个字节;
- 因此"某条流上已发送的逻辑字节总数"等价于该流的当前"长度(length)"。
本质上,相关的量是:对于该流上我们曾发送过的所有 STREAM 帧(offset, len),取max(offset + len)。
正确确定这一数值至关重要——如果发送端误以为自己的流控信用已耗尽,而对端认为并未耗尽,那么对端可能会无限期等待发送端继续发送数据,之后再授予更多流控信用,从而造成死锁(deadlock)。
帧类型与层级对应
- 连接级流控由
MAX_DATA帧控制; - 流级流控由
MAX_STREAM_DATA帧控制; - RFC 9000 定义的
DATA_BLOCKED与STREAM_DATA_BLOCKED帧实际作用比表面看起来小:对端不允许依赖它们(例如,对端不能等我们发出DATA_BLOCKED才提高连接级信用;一个合规的 QUIC 实现甚至可以完全不生成这两种帧)。它们的两个实际用途是:提升流控性能、作为调试辅助手段。因此其实现并非关键。
CRYPTO 流不受流控、流控与拥塞控制相互独立
由上述定义自然可以推出:CRYPTO 帧的流(CRYPTO stream)不受流控约束。此外,流控与拥塞控制(congestion control)是完全独立的机制——在特定场景下,两者之一或两者同时都可能限制我们发送应用数据的能力。
在 OpenSSL 源码中也可以看到这种独立性:流控实现在 ssl/quic/quic_fc.c,而拥塞控制是另一套独立的模块(如 ssl/quic/cc_newreno.c),二者互不调用。
核心术语:RWM / SWM / CWM 水位线体系
设计文档用一张示意图概括了 RX 侧各水位线与窗口的关系:
RWM SWM SWM' CWM CWM' | | | | | | |<-- credit| -->| | | <-|- threshold -|----->| | -----------------> window size文档引入的术语体系如下:
| 术语 | 含义 |
|---|---|
| Controlled bytes(受控字节) | 任何对流控计数的字节:STREAM 帧负载中的应用数据字节,首次发送时计数(重传不计)。 |
| Retirement(退役,仅 RX 侧) | 从 QUIC 流中取出一个或多个受控字节并交给应用的过程;退役后我们不再对这些字节负责。RX 流控设计非常依赖退役节奏——我们希望对端不仅以 QUIC 实现处理入站数据的速度发送,还要以应用能处理的速度发送。 |
| Retired Watermark(RWM,退役水位线,仅 RX 侧) | 自连接或流开始以来退役的受控字节总数。 |
| Spent Watermark(SWM,已消费水位线) | 我们已发送(TX 侧)或已接收(RX 侧)的受控字节数,代表已消耗的流控预算;它是单调的、永不回退。RX 侧的这些字节不一定已被退役。 |
| Credit Watermark(CWM,信用水位线) | 目前已被授权可发送的字节数;这是自连接或流开始以来的累计值,因此同样单调。 |
| Credit(信用) | 始终等于 SWM 与 CWM 之差(CWM - SWM)。 |
| Threshold(阈值,仅 RX 侧) | 允许 RWM 逼近 CWM 的程度;当 RWM 到达阈值时才选择抬高 CWM 以授予对端更多信用。阈值是相对 CWM 的(从 CWM 中减去)。 |
| Window size(窗口大小,仅 RX 侧) | 每次到达或超过阈值时,我们(或对端)抬高 CWM 的量。新的 CWM 等于SWM 加窗口大小(注意是加到 SWM 上,而非旧 CWM 上)。 |
两个必须牢记的不变量:
- 若可用信用为 0,则 TX 侧因缺乏信用而被阻塞;
- 若发生任何会导致SWM 超过 CWM的情况,则属于流控协议违规(flow control protocol violation),应终止连接。
在源码中,这些水位线被直接实现为结构体字段,见 include/internal/quic_fc.h 中的QUIC_RXFC:
uint64_t cwm, swm, rwm, esrwm, hwm, cur_window_size, max_window_size; OSSL_TIME epoch_start;其中esrwm是当前自动调优 epoch 开始时的 RWM 值,hwm是迄今从 STREAM 帧中见过的最高流长度(offset + payload length),cur_window_size与max_window_size分别是当前窗口大小与窗口大小上限。
TX 侧流控:简单的状态机
TX 侧流控"异常简单",可建模为以下状态机:
---> event: On TX (numBytes) ---> event: On TX Window Updated (numBytes) <--- event: On TX Blocked Get TX Window() -> numBytes连接级 TX 状态机事件
- On TX:每当我们发送一个数据包时传入。
numBytes是包中发送的受控字节总数(即非重传的 STREAM 帧负载字节数),累加到 TX 侧 SWM。该值可以为 0(此时无需传事件)。 - On TX Window Updated:每当我们的 CWM 被提高时传入,即收到
MAX_DATA帧时(或收到initial_max_data传输参数时),参数为该帧中的整数值。该事件表达的是 CWM(自连接开始以来允许发送的受控字节累计数),因此是单调的、永不回退;如果传入的值低于之前任何一次事件的值,说明对端协议错误或本地编程错误。 - Get TX Window:返回我们的信用值(允许发送的受控字节数)。它被 On TX 事件减少、被 On TX Window Updated 事件增加。实际上它就是"最后一次 On TX Window Updated 的值"与"迄今所有 On TX 事件
numBytes之和"的差,仅此而已。 - On TX Blocked:在 Get TX Window 返回值从非零变为零的任何边沿时刻发出(总是发生在处理某个 On TX 事件期间)。该事件用于辅助决定何时生成
DATA_BLOCKED帧。
我们绝不能超出流控上限,否则对端可能以错误终止连接。初始的连接级信用由对端通过initial_max_data传输参数告知,其余信用全部来自MAX_DATA帧。
流级 TX:与连接级完全相同的机制
流级 TX 流控与连接级 TX 完全一致,仅事件来源不同:
- On TX Window Updated由
MAX_STREAM_DATA帧触发,或基于相应传输参数(initial_max_stream_data_bidi_local、initial_max_stream_data_bidi_remote、initial_max_stream_data_uni); - On TX Blocked事件可用于决定何时生成
STREAM_DATA_BLOCKED帧。
关键约束:一条流上可发送的受控字节数同时受连接级与流级流控限制,因此实际可发送的受控字节数为连接级与流级两个状态机Get TX Window返回值中的较小者。
源码中的 TX 流控实现
ssl/quic/quic_fc.c 将上述状态机实现为一组直接的函数:
ossl_quic_txfc_init:初始化 TXFC;流级 TXFC 通过parent指针挂接连接级 TXFC(conn_txfc != NULL),连接级 TXFC 的 parent 为 NULL;ossl_quic_txfc_bump_cwm:即On TX Window Updated操作;若传入的 CWM 不大于当前值则返回 0(no-op),否则更新并返回 1;ossl_quic_txfc_get_credit:即Get TX Window;对流级 TXFC,还会递归调用连接级 TXFC 并返回两者中较小的信用值——这正是文档所述"取较小者"约束的落地:r = ossl_quic_txfc_get_credit_local(txfc, 0); if (txfc->parent != NULL) { conn_r = ossl_quic_txfc_get_credit_local(txfc->parent, consumed); if (conn_r < r) r = conn_r; } return r;ossl_quic_txfc_consume_credit:即On TX操作;若传入字节数超过当前信用,则消耗剩余信用并返回 0,提示调用方存在严重编程错误;流级调用时同步在连接级 TXFC 上消费;ossl_quic_txfc_has_become_blocked:即On TX Blocked标志,供调用方决定是否需要发送DATA_BLOCKED或STREAM_DATA_BLOCKED帧(帧中应携带ossl_quic_txfc_get_cwm()的返回值)。
在通道层面,ssl/quic/quic_channel.c 解析传输参数时将initial_max_data直接映射为ossl_quic_txfc_bump_cwm(&ch->conn_txfc, v)(见 quic_channel.c 中QUIC_TPARAM_INITIAL_MAX_DATA分支),而每个流的 TXFC 则通过ossl_quic_txfc_init(&qs->txfc, &ch->conn_txfc)与连接级 TXFC 建立父子关系。
RX 侧流控:以退役节奏驱动信用发放
RX 侧连接级流控的状态机如下:
---> event: On RX Controlled Bytes (numBytes) [internal event] ---> event: On Retire Controlled Bytes (numBytes) <--- event: Increase Window (numBytes) <--- event: Flow Control ErrorRX 侧连接级流控负责指示何时生成MAX_DATA帧以提高对端的连接级发送信用,比 TX 侧复杂一些。
连接级 RX 状态机事件
- On RX Controlled Bytes(内部事件):由流级流控控制器在收到任何受控字节时自动生成,调用方不直接传入。
numBytes为收到的受控字节数。之所以由流级生成,是因为重传的流数据只能计数一次,流级流控最擅长判断收到了多少"新的、非重传的"流负载字节。 - Flow Control Error:若收到的受控字节数超过我们授权的量,状态机发出该事件,此时应以协议错误终止连接。
- Increase Window:当状态机认为应授予对端更多流控信用(即应抬高 CWM)时发出。
numBytes为新的 CWM 值,且相对之前所有 Increase Window 事件是单调的。 - On Retire Controlled Bytes:当一个或多个受控字节从任意流出队并交给应用时传入。状态机依据 On Retire 事件的节奏决定何时增大流控窗口,因此该事件必须在受控字节处理完成(即已交给应用)之后才传入。
这里体现了设计文档强调的关键点:RX 流控希望对端的发送速率既不超过 QUIC 实现的处理能力,也不超过应用消费数据的能力——退役节奏正是把应用消费速率纳入流控决策的桥梁。
流级 RX:On RX Stream Frame 事件
流级 RX 流控与连接级 RX 类似,但有几点差异:
- 没有 On RX Controlled Bytes 事件;
- On Retire Controlled Bytes 事件可以(由实现决定)同时传递给连接级流控控制器——因为这两个事件总是同时发生;
- 新增一个替代事件:
---> event: On RX Stream Frame (offsetPlusLength, isFin)该事件在收到 STREAM 帧时传入:offsetPlusLength是 STREAM 帧 offset 字段与帧负载长度的和;isFin指示该帧是否设置了 FIN 标志。该事件既用于向连接级流控控制器生成内部 On RX Controlled Bytes 事件,也用于流级流控判断对端是否违反流控上限。
状态机对offsetPlusLength做单调处理:如果之前已有相同或更大的值,则忽略该事件。之所以用这种 API 而非On RX (numBytes)风格,是因为该 API 是单调的、更易使用——调用方无需记忆某个受控字节是否已在之前的 STREAM 帧中计过数(毕竟之前的帧可能重复包含了其中部分字节)。
源码中的 RX 流控实现与调用链
在 ssl/quic/quic_fc.c 中:
ossl_quic_rxfc_on_rx_stream_frame(rxfc, end, is_fin)实现On RX Stream Frame:计算delta = end - hwm(仅在end > hwm时),对自身与 parent(连接级 RXFC)分别调用内部on_rx_controlled_bytes;同时处理 FIN 相关的FINAL_SIZE_ERROR——流结束后大小不可改变(end与已记录hwm不符时置错);- 内部
on_rx_controlled_bytes检查num_bytes > cwm - swm,若超出则置error_code = OSSL_QUIC_ERR_FLOW_CONTROL_ERROR并截断计数——即Flow Control Error事件; ossl_quic_rxfc_on_retire(rxfc, num_bytes, rtt)实现On Retire Controlled Bytes:累加 RWM,并调用rxfc_update_cwm决定是否抬高 CWM(抬高后置has_cwm_changed标志,供上层生成MAX_DATA/MAX_STREAM_DATA帧)。
调用链可以在帧解包路径 ssl/quic/quic_rx_depack.c 中观察到:每收到一个 STREAM 帧或 RESET_STREAM 帧,都会调用ossl_quic_rxfc_on_rx_stream_frame,随后用ossl_quic_rxfc_get_error检查是否发生流控违规,若违规则通过ossl_quic_channel_raise_protocol_error以FLOW_CONTROL_ERROR终止连接(见 quic_rx_depack.c 中 RESET_STREAM 处理段);CRYPTO 帧则使用独立的 standalone RXFC(crypto_rxfc),违规时报CRYPTO_BUFFER_EXCEEDED。
值得一提的复用:ossl_quic_rxfc_init_standalone创建的standalone RXFC还被用于流数量(stream count)强制与CRYPTO 缓冲区强制(见 quic_channel.c 中max_streams_bidi_rxfc、max_streams_uni_rxfc的初始化),这也是文档之外的一个实现细节。
RX 窗口自动调优(Auto-Tuning)
对于 RX 流控,我们必须确定窗口大小——即每次 RWM 到达阈值时,加到对端 SWM 上以得出新 CWM 的值。窗口大小应根据网络条件动态调整。
许多实现采用"只增不减"的窗口增大机制(可以增加窗口大小但不能减少),OpenSSL 采纳了这一简单方案。
算法原理
常见算法是一种"自动调优"(auto-tuning)方法:
- 测量窗口的消耗速率(即 CWM 被抬高后 RWM 向 CWM 逼近的速率);
- 与测得的连接RTT比较;
- 如果消耗完一个窗口大小所需的时间超过 RTT 的固定倍数,则将窗口大小加倍,直到达到实现选择的最大窗口大小。
自动调优按epoch(周期)进行:每个 epoch 结束时决定是否加倍窗口大小,并开启一个新的 epoch。
OpenSSL 的实现细节
在 ssl/quic/quic_fc.c 中:
- 阈值被定义为窗口大小的 3/4(
WINDOW_THRESHOLD_NUM 3、WINDOW_THRESHOLD_DEN 4),rxfc_cwm_bump_desired在cwm - rwm <= 3/4 * cur_window_size且流未终结(!is_fin)时判定需要抬高 CWM;对极端大的窗口兜底使用 1/2 作为阈值; - epoch 的起点由
rxfc_start_epoch记录:epoch_start = now(),esrwm = rwm; - 是否加倍由
rxfc_should_bump_window_size决定,其推导过程即文档所述:
b = rwm - esrwm (本 epoch 内消耗的窗口字节数) dw = b / window_size T_window = dt / dw = (dt * window_size) / b当T_window < 4 * RTT(ossl_time_compare(t_window, ossl_time_multiply(rtt, 4)) < 0)时判定应加倍窗口大小。源码注释特别说明:把除法b保留在等号左侧,可降低 64 位纳秒表示溢出的风险,且除法后仍留有充足精度;
rxfc_adjust_window_size执行加倍(new_window_size *= 2),并夹在min_window_size与max_window_size之间(max 优先于 min),随后开启新 epoch;- 新 CWM 按文档公式计算:
new_cwm = rwm + cur_window_size(加到 SWM/RWM 而非旧 CWM 上),只有确实大于当前 CWM 时才更新并置has_cwm_changed(见rxfc_update_cwm)。
窗口大小参数通过ossl_quic_rxfc_init的initial_window_size与max_window_size传入,也可用ossl_quic_rxfc_set_max_window_size动态调整。
帧生成与测试验证
- 帧生成:TX 打包器 ssl/quic/quic_txp.c 负责生成
MAX_DATA/MAX_STREAM_DATA帧(并遵循 RFC 9000 s. 13.3 的建议:仅在有意义时生成更多MAX_STREAM_DATA帧);连接级与流级的 CWM 变更标志(ossl_quic_rxfc_has_cwm_changed)驱动帧的按需发送。 - 测试:test/quic_fc_test.c 系统验证了 TXFC/RXFC 的行为——例如初始化后 SWM 为 0、bump CWM 到 2000 后信用为 2000、消费 500 后本地信用降为 1500、流级 TXFC 与连接级 TXFC 联合取小、
has_become_blocked边沿标志等;测试入口由 test/recipes/70-test_quic_fc.t 注册。
小结
OpenSSL 的 QUIC 流控设计可以浓缩为几个要点:
- 双层信用模型:连接级由
MAX_DATA与initial_max_data驱动,流级由MAX_STREAM_DATA与三个initial_max_stream_data_*传输参数驱动;实际可发送字节数取两层信用的较小者。 - 三个水位线:SWM(已消费)、CWM(已授权)、RWM(已退役,RX 侧)——信用恒为
CWM - SWM,任何导致 SWM 越过 CWM 的情况都是流控违规。 - TX 极简、RX 以退役驱动:TX 侧只是加减法状态机;RX 侧由"收到受控字节"与"退役受控字节"两个节奏共同决定何时抬高 CWM,从而让对端发送速率同时匹配 QUIC 实现处理能力与应用消费能力。
- 窗口自动调优:以 3/4 窗口为阈值,以
T_window < 4 * RTT为判据按 epoch 加倍窗口大小,且只增不减、受最大窗口上限约束。 - 边界清晰:CRYPTO 流不受流控;
DATA_BLOCKED/STREAM_DATA_BLOCKED仅作性能增强与调试用途;流控与拥塞控制相互独立。
这套设计与实现可以从设计文档 doc/designs/quic-design/quic-fc.md 出发,对照 ssl/quic/quic_fc.c、include/internal/quic_fc.h 与 test/quic_fc_test.c 逐行印证,是理解 OpenSSL QUIC 栈(以及 RFC 9000 流控语义)的一条清晰路径。
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考