跳转至

26. 双层流控与背压:额度如何限制流,闸门关上后又如何打开

一条流处于 Send 状态,状态机说它可以发数据——但它真的能往里写吗?不一定。接收方可能说"我的缓冲区只剩 4KB 了"。一个应用想创建第 101 条流,但对端说"我最多允许 100 条并发"。状态机管的是一条流内部的生死规则,这些来自外部的资源限制属于另一套系统:流控

流控还有一个容易被忽略的后半截:当额度耗尽时,发送方不能默默停下来,它必须显式地告诉对端"我被卡住了";接收方释放了缓冲区之后,也必须显式地告诉发送方"你可以继续了"。这种动态的来回协商——BLOCKED 和 MAX 帧的交互——就是背压


26.1 流控和拥塞控制为什么根本不是一回事

既然 cwnd 已经在限制发送速率了,为什么还需要另一套限制机制?因为它们保护的对象完全不同。

拥塞控制保护网络。发送方通过 ACK 反馈来探测路径承载能力——发快了丢包,发慢了浪费带宽。信号来源是网络本身:丢包率、RTT 抖动、ECN 标记。cwnd 的目标是不把路由器和交换机撑爆。

流控保护接收方。RFC 9000 §4.1 开宗明义:流控的目的是"限制发送方能够发送的数据量,以防止快速的发送方压垮慢速的接收方或消耗大量接收方资源"。信号来源不是网络,而是接收方自己——它根据缓冲区余量、应用层消费速度来决定"还能再收多少"。

一条千兆链路可能连着一个内存只有 64KB 的嵌入式设备——网络承载力充裕,接收方却吞不下去。反过来,接收方缓冲区还空着一大半,但网络已经拥塞了。发送方的每个包必须同时满足两个限制:cwnd 允许发 AND 流控额度允许发。任何一个不满足,数据就不能上路。

RFC 9000 §4.1 特别强调:"端点必须遵守对端设置的流控限制"(An endpoint MUST limit the data it sends to what its peer has allowed)。两套系统共享同一条发送路径,响应不同的信号、服务不同的目标、彼此独立运作。


26.2 一条大闸和许多小闸:连接级与流级为什么必须同时存在

QUIC 的流控不是单一的全局开关,而是被分成了两个层级。RFC 9000 §4.1 定义了两种流控帧:MAX_DATA 限制整个连接上所有流的数据总量,MAX_STREAM_DATA 限制单条流的数据量。还有第三层:MAX_STREAMS(§4.6)限制对端能创建的最大并发流数量。

三层限制各自保护不同的资源维度:

限制 保护的资源 对应帧
连接级数据额度 接收方的总缓冲区容量 MAX_DATA(§19.4)
流级数据额度 接收方为单条流分配的缓冲区 MAX_STREAM_DATA(§19.5)
并发流数量 接收方的流管理开销(状态机、缓冲区、上下文) MAX_STREAMS(§19.6)

为什么三层都必要?把两种极端情况推到尽头就能看清。

只有连接级额度,没有流级额度——一条传输大文件的流可以吞掉整个连接预算,其他流全部饿死。QUIC 做多路复用的初衷就是让不同流并行传输、互不干扰;如果一条流就能挤占所有资源,多路复用的意义荡然无存。连接级额度无法表达流之间的公平性和独立性。

只有流级额度,没有连接级额度——100 条流各自额度 1MB,加起来就是 100MB。接收方可能只有 50MB 的总缓冲区。每条流都觉得自己没超额,但总量已经远超接收方的承受能力。流级额度无法控制"所有杯子加起来的总水量"。

RFC 9000 §4.1 的措辞是:"端点使用 MAX_DATA 帧来限制对端可以发送的数据总量,使用 MAX_STREAM_DATA 帧来限制对端在单条流上可以发送的数据量。"注意协议用的是"both...and..."——两个额度必须同时生效。发送方在发送数据时,必须同时检查连接级余额和目标流的流级余额,只有两者都满足时数据才能发出。

MAX_STREAMS 则保护另一个维度:每条流不只消耗字节缓冲区,还消耗状态机、乱序缓存、流控计数器等管理开销。即使每条流的数据量很小,上万条并发流的管理成本也足以压垮接收方。§4.6 规定端点限制对端可以打开的流入流的累积数量,违反限制必须报 STREAM_LIMIT_ERROR 关闭连接。

初始值在握手时通过传输参数声明:initial_max_data 设定连接级额度,initial_max_stream_data_bidi_local/bidi_remote/uni 分别设定三类流的初始流级额度,initial_max_streams_bidi/uni 设定并发流上限。但这些初始值只是起点——接收方的资源状况随时在变,初始值不可能永远正确。额度必须能动态调整。


26.3 当额度耗尽时系统不能沉默:BLOCKED 帧如何表达背压

静态的额度规则定义了"天花板在哪里"。但当发送方真的撞到天花板时,它不能默默停下来——因为接收方无法区分"对方额度用完了正在等我放行"和"对方暂时没数据要发"。这个歧义会导致死锁:接收方不知道需要扩窗,发送方永远等不到新额度。

RFC 9000 §4.1 的解决方案是:发送方应该(SHOULD)发送 BLOCKED 系列帧来显式表达"我被卡住了"。三种 BLOCKED 帧对应三种额度:

DATA_BLOCKED(§19.9)——连接总额度耗尽。帧携带当前的连接级限制值(maximum_data),告诉接收方:"我的连接级额度用完了,你要不要给我扩大一点?"

STREAM_DATA_BLOCKED(§19.10)——单流额度耗尽。帧携带流 ID 和当前的流级限制值,告诉接收方:"这条流的额度用完了。"

STREAMS_BLOCKED(§19.11)——并发流数量达到上限。帧携带当前的流数量限制,告诉接收方:"我想创建新流但你的 MAX_STREAMS 不允许。"分为双向和单向两种(帧类型 0x16 和 0x17)。

BLOCKED 帧不是错误,不是异常,而是正常的协商动作。它们表达的是"我还想继续推进,但你还没给我足够的额度"。RFC 9000 特别指出:如果发送方被阻塞的时间超过了空闲超时(idle timeout),连接可能会关闭。为了避免这种情况,被流控限制阻塞的发送方应该定期重发 BLOCKED 帧——这样做的目的不是催促对端,而是保持连接活性,防止因为"双方都在沉默"而被超时断开。

背压在 QUIC 里是正常交通,不是异常事故。


26.4 闸门如何重新打开:MAX 帧如何解除背压并恢复推进

BLOCKED 帧是协商的前半段——"我被卡住了"。后半段是接收方的回应:通过 MAX 系列帧重新开放额度。

MAX_DATA(§19.4)——接收方重新开放连接级总额度。通常在应用层消费了数据、释放了缓冲区之后发送。

MAX_STREAM_DATA(§19.5)——接收方重新开放单流额度。

MAX_STREAMS(§19.6)——接收方允许对端创建更多并发流。当旧的流关闭释放了管理资源后,接收方可以提高并发上限。

关键细节:MAX 帧携带的是新的绝对上限,不是增量。如果接收方之前说"你最多发 1MB",现在发送 MAX_DATA 说"你最多发 3MB"——发送方知道自己的新天花板是 3MB,不是"再加 3MB"。RFC 9000 §4.1 要求发送方忽略任何不增加限制的 MAX 帧——如果收到一个 MAX_DATA 值比当前已知的限制还小,直接丢弃,因为限制只能单调递增。

完整的协商回路是这样的:

发送方                              接收方
  |                                   |
  |--- STREAM(data) ----------------->|  正常发送
  |--- STREAM(data) ----------------->|  继续发送
  |                                   |
  |  [流控额度耗尽]                     |
  |                                   |
  |--- DATA_BLOCKED(limit=1MB) ------>|  "我被卡住了"
  |                                   |
  |                 [应用层消费数据,释放缓冲区]
  |                                   |
  |<--- MAX_DATA(limit=3MB) ----------|  "你可以发到3MB"
  |                                   |
  |--- STREAM(data) ----------------->|  恢复发送
  |                                   |

RFC 9000 §4.2 对发送 MAX 帧的时机提出了重要建议:接收方应当(SHOULD)在对端发送 BLOCKED 信号之前就发送更新。如果等到收到 BLOCKED 才回复 MAX,至少浪费一个 RTT——发送方在这段时间内完全停滞。好的实现会在接收方察觉"额度快要用完了"时就主动扩窗,不等发送方来催。

§4.2 还指出了一个工程权衡:频繁发送小的 MAX 更新会增加控制开销(每帧都要占用 Packet 空间),但更新不频繁则需要接收方预留更大的缓冲区(因为发送方可能在收到更新之前就把额度用完了)。实现通常采用自动调优:根据 RTT 和应用层消费速度来决定何时以及扩多少额度。


26.5 quicX 的流控底盘:SendFlowController 与 RecvFlowController 如何落地

协议定义了规则,quicX 的工作是把规则翻译成代码。quicX 用两个对称的控制器——SendFlowControllerRecvFlowController——分别实现发送侧和接收侧的流控逻辑。为什么拆成两个而不是合成一个?因为发送侧和接收侧的信息来源完全不同:发送侧读对端的 MAX 帧来知道"我还能发多少",接收侧读本端的消费进度来决定"我能给对端多少额度"。

SendFlowController——管理"我还能发多少"。

核心字段直接对应协议概念:

class SendFlowController {
    uint64_t sent_bytes_;           // 连接上已发送的总字节数
    uint64_t max_data_;             // 对端允许我发的上限(来自 MAX_DATA)
    uint64_t max_streams_bidi_;     // 对端允许的最大双向流数
    uint64_t max_streams_uni_;      // 对端允许的最大单向流数
    StreamIDGenerator id_generator_; // 流 ID 生成器
};

发送路径在构建 STREAM 帧之前,会调用 CanSendData() 检查连接级额度:

bool CanSendData(uint64_t& can_send_size, std::shared_ptr<IFrame>& blocked_frame);

如果 sent_bytes_ >= max_data_,方法生成一个 DataBlockedFrame 并返回 false——发送方被卡住了。如果还有余额但剩余不足 16KB(kDataBlockedThreshold),方法仍然返回 true 允许发送,但同时附带一个 DataBlockedFrame 作为预警——"我快用完了,你该考虑扩窗了"。这个预警机制让接收方能在发送方真正停滞之前收到信号,减少一个 RTT 的等待。

创建新流时调用 CanCreateBidiStream()CanCreateUniStream()。方法先通过 PeekNextStreamID() 预览下一个流 ID(不提交),将 ID 右移两位得到流序号,与 max_streams_* 比较。超限时生成 StreamsBlockedFrame 返回 false;允许时才调用 NextStreamID() 正式分配。剩余可创建流数不足 4 条(kStreamsBlockedThreshold)时,同样附带预警帧。

收到对端的 MAX_DATA/MAX_STREAMS 帧时,OnMaxDataReceived()OnMaxStreams*Received() 遵循 RFC 规则:只接受递增的限制值,忽略不增长的。

RecvFlowController——管理"我能给对端多少额度"。

class RecvFlowController {
    uint64_t received_bytes_;      // 连接上已接收的总字节数
    uint64_t max_data_;            // 我允许对端发送的上限
    uint64_t max_streams_bidi_;    // 我允许对端创建的最大双向流数
    uint64_t max_streams_uni_;     // 我允许对端创建的最大单向流数
};

OnDataReceived() 在每次收到数据时累加 received_bytes_,如果超过 max_data_ 说明对端违反了协议,返回 false——实现应关闭连接并报 FLOW_CONTROL_ERROR。

ShouldSendMaxData() 是自动扩窗的核心:计算剩余窗口 max_data_ - received_bytes_,当剩余不足 10% 时触发扩窗——直接翻倍 max_data_,生成 MaxDataFrame 发给对端。这种"剩余不足 10% 就翻倍"的策略是一个简洁的自动调优:窗口小时扩得少,窗口大时扩得多,天然适应不同的传输速率。

OnStreamCreated() 检查对端创建的流是否超过本端允许的并发上限。流 ID 右移两位得到序号,如果序号达到限制值,说明对端违规。剩余可创建流数不足 4 条时,主动增加 10 条额度(kStreamsIncreaseAmount),发送 MaxStreamsFrame

两个控制器的对称设计可以用一张表概括:

维度 SendFlowController RecvFlowController
视角 "我还能发多少" "对端还能给我发多少"
数据额度来源 对端的 MAX_DATA 帧 本端的消费进度
超限行为 生成 DATA_BLOCKED 通知对端 检测对端违规,关闭连接
扩窗机制 无(被动接受对端 MAX) 剩余 <10% 时翻倍,主动发 MAX_DATA
流数管理 检查 MAX_STREAMS,超限生成 STREAMS_BLOCKED 检查流 ID,超限报错;余量不足时主动发 MAX_STREAMS

quicX 的 config.h 中定义了一组阈值常量来驱动预警和扩窗策略:

常量 用途
kDataBlockedThreshold 16KB 连接级余额不足此值时发送 DATA_BLOCKED 预警
kStreamsBlockedThreshold 4 剩余可创建流数不足此值时发送 STREAMS_BLOCKED 预警
kStreamDataBlockedThreshold 4KB 流级余额不足此值时发送 STREAM_DATA_BLOCKED 预警
kStreamsIncreaseAmount 10 RecvFlowController 每次扩容的流数增量

这些常量是工程经验值,不是协议规定。协议只说"SHOULD 发 BLOCKED"和"MUST 不超限",具体什么时候预警、扩窗多少,留给实现决定。


26.6 流不只有内部命运,还有外部约束

状态机定义了流"能怎样活",流控和背压定义了流"被允许怎样活"。拥塞控制保护网络,流控保护接收方,两者是独立的控制回路。连接级额度防止总量打爆接收方,流级额度防止单流挤占其他流的空间,并发流限制防止管理开销压垮接收方——三层闸门各自守住不同的资源维度。额度耗尽时 BLOCKED 帧把沉默变成显式信号,MAX 帧把限制变成可协商的动态过程。

但协议规则只定义了"应该怎样",不定义"由谁来做"。当 MAX_STREAMS 限制了并发流数量时,新的创建请求不能简单失败,而要排队等待——这就引出了异步建流的工程问题。连接级的流创建、调度、回收、活跃列表管理——这些把规则变成行为的枢纽,是 StreamManager 的职责,也是下一章的主题。