背景
在数据通信中,发送方对数据处理速度和接收方对数据处理的速度不一定是对等的。 如果发送方的速度太快,会导致接收方处理不过来,需要把处理不了的数据放在缓冲区里(buffer)。如果接收方的缓冲区已经满了,发送方继续发送数据将会导致数据丢失。在具有不同数据处理和接受速度的网络设备中进行通信,具有流量控制机制是必不可少的。
在数据通信中,通信的两端所处于的网络状态是不可控,是动态进行变化的,尤其是移动网络。如果所处于的网络环境的时延突然增加。那么,通信的两端对这个事做出的应对只有重传,就会导致网络的负担变得更重,于是会导致更大的延迟以及更多的丢包,于是,这个情况就会进入恶性循环被不断地放大。如果一个网络内有成千上万的这样的通信,那么马上就会形成“网络风暴”,整个网络都有可能奔溃。所以在不同的网路环境下,具有拥塞控制机制是必不可少的。
TCP/QUIC等面向连接的传输层协议在实现中都需要根据通信两端的数据处理能力以及当时所处于网络环境的状态来进行数据传输速率的控制。
如何进行控制?
TCP简单回顾
TCP 引入滑动窗口的概念,在传输过程中,通信两端协商一个接收窗口 rwnd,再结合拥塞控制窗口 cwnd 计算滑动窗口 swnd。在 Linux 内核实现中,滑动窗口 cwnd 是以包为单位,所以在计算 swnd 时需要乘上 mss(最大分段大小)。
swnd = min(rwnd, cwnd * mss)
rwnd协商
接收方每次收到数据包,可以在发送 ACK 报文的时候,同时告诉发送方自己的缓存区还剩余多少是空闲的,我们也把缓存区的剩余大小称之为接收窗口(Receive Window,缩写:rwnd)。发送方收到接收窗口 rwnd 之后,便会调整自己的发送速率,也就是调整自己发送窗口的大小,当发送方收到接收窗口 rwnd 的大小为 0 时,发送方就会停止发送数据,防止出现大量丢包情况的发生。
上图,我们可以看到一个处理缓慢的服务端(接收端)是怎么把客户端(发送端)的TCP Sliding Window给降成0的。此时,你一定会问,如果Window变成0了,TCP会怎么样?是不是发送端就不发数据了?是的,发送端就不发数据了,你可以想像成“Window Closed”,那你一定还会问,如果发送端不发数据了,接收方一会儿Window size 可用了,怎么通知发送端呢?
解决这个问题,TCP使用了Zero Window Probe技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,第次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了。
RTT(Round-Trip Time)
RTT 就是数据从网络一端传送到另一端所需的时间,也就是数据包包的往返时间。
超时重传时间是用 RTO(Retransmission Timeout 超时重传时间)来表示。
假设在重传的情况下,超时时间RTO「较长或较短」时,会发生什么事情呢?
超时时间较长与较短
上图中有两种超时时间不同的情况:
- 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
精确计算 RTO的值是非常重要的,这可让我们的重传机制更高效。
根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。
RTO 应略大于 RTT,至此,可能大家觉得超时重传时间RTO的值计算,也不是很复杂。好像就是在发送端发包时记下 t0,然后接收端再把这个 ACK 回来时再记一个t1,于是 RTT = t1 - t0。但是实际上,并没有那么简单,这只是一个采样,不能代表普遍情况。
实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」 是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个动态变化的值。
我们来看看 Linux 是如何计算 RTO 的呢?
估计往返时间,通常需要采样以下两个:
- 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化。
- 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况。
RFC6289 建议使用以下的公式计算 RTO:
RFC6289 建议的 RTO 计算
其中 SRTT 是计算平滑的RTT ,DevRTR是计算平滑的RTT 与 最新 RTT 的差距。
在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。别问怎么来的,问就是大量实验中调出来的。
如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍 。 也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
拥塞控制cwnd协商
拥塞控制主要是四个算法:1)慢启动,2)拥塞避免,3)拥塞发生,4)快速恢复。
这四个算法不是一天都搞出来的,这个四算法的发展经历了很多时间,到今天都还在优化中。
备注:
- 1988年,TCP-Tahoe 提出了1)慢启动,2)拥塞避免,3)拥塞发生时的快速重传
- 1990年,TCP Reno 在Tahoe的基础上增加了4)快速恢复
我个人理解,这四个算法,其实是对应四个不同网路环境情况下进行cwnd协商的不同机制。
慢启动 – Slow Start
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这就有可能给网络添堵!
慢启动的算法其实就是一个规则:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。
- 连接建立完成后,一开始初始化 cwnd = 1,表示可以传一个 MSS(Max Segment Size) 大小的数据。
- 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
- 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
- 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个。
所以从时间维度来看算法就是:
- 每当过了一个RTT,cwnd = cwnd*2; 呈指数让升
这里,需要提一下的是一篇Google的论文《An Argument for Increasing TCP’s Initial Congestion Window》Linux 3.0后采用了这篇论文的建议——把cwnd 初始化成了 10个MSS。 而Linux 3.0以前,比如2.6,Linux采用了RFC3390,cwnd是跟MSS的值来变的,如果MSS< 1095,则cwnd = 4;如果MSS>2190,则cwnd=2;其它情况下,则是3。
可以看出慢启动算法,发送数据的数据量呈现一个指数性的增长。
这样发送数据的速度会越来越快,所以就需要有一个慢启动门限 ssthresh (slow start threshold)状态变量,来进行速率增长的限制。
- 当 cwnd < ssthresh时,使用慢启动算法。
- 当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」,也就是进入新的网路环境状态中,进行cwnd的计算。
拥塞避免算法 - Congestion Avoidance
前面说道,当拥塞窗口 cwnd「超过」慢启动门限 ssthresh 就会进入拥塞避免算法阶段。
一般来说 ssthresh 初始值(Setting ssthresh as high as possible allows the network conditions,rfc5681)
那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
接上前面的慢启动的例子,现假定 ssthresh 为 8:
- 当 8 个 ACK 应答确认到来时,每个ACK 使cwnd增加 1/8,8 个 ACK 使 cwnd 增加 1,于是这一次能够发送 9 个 MSS 大小的数据
从从时间维度来看算法就是变成了线性增长 。
- 当每过一个RTT时,cwnd = cwnd + 1
这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。很明显,是一个线性上升的算法。
虽然数据发送速率增长变慢,但是还是一直处于增长中,假定网路环境稳定(带宽恒定),一段时间厚,网络就会进入了拥塞的状况,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传,网路发生了拥塞。
当触发了重传机制,也就进入了「拥塞发生算法」。
拥塞发生
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
- 超时重传
- 快速重传
发生超时重传-拥塞发生算法
等到RTO超时,重传数据包。TCP认为这种情况太糟糕,反应也很强烈。
- sshthresh = cwnd /2
- cwnd 重置为 1
- 进入慢启动算法过程
发生快速重传-拥塞发生算法
Fast Retransmit算法触发重传,也就是在收到3个duplicate ACK时就开启重传,而不用等到RTO超时
- TCP Tahoe的实现和RTO超时一样。
- TCP Reno的实现是:
-
- cwnd = cwnd /2
- sshthresh = cwnd
- 进入快速恢复算法阶段——Fast Recovery
上面我们可以看到RTO超时后,sshthresh会变成cwnd的一半,这意味着,如果cwnd<=sshthresh时出现的丢包,那么TCP的sshthresh就会减了一半,然后等cwnd又很快地以指数级增涨爬到这个地方时,就会变成成慢慢的线性增涨。
快速恢复算法 – Fast Recovery
TCP Reno
这个算法定义在RFC5681。快速重传和快速恢复算法一般同时使用。快速恢复算法是认为,你还有3个Duplicated Acks说明网络也不那么糟糕,所以没有必要像RTO超时那么强烈。 注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新:
- cwnd = cwnd /2
- sshthresh = cwnd
然后,真正的Fast Recovery算法如下:
- cwnd = sshthresh + 3 * MSS (3的意思是确认有3个数据包被收到了)
- 重传Duplicated ACKs指定的数据包
- 如果再收到 duplicated Acks,那么cwnd = cwnd +1
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明 duplicated ACK 的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免算法状态;
以上就是拥塞控制算法大概内容,再来看下面这张图片,就是一个整体流转过程
QUIC流量拥塞控制
流量控制
QUIC 并没有引入TCP那样的滑动窗口的概念,而是采用一种“限额(limit-base)”流控方案,接收方在一个特定的流上或者整个连接上,给出一个自己能够处理的最大字节数。发送方发送的数据不能超过这两个(流和连接)“限额”。若是达到“限额”,就需要阻塞等待发送方增加限额的命令。
所以QUIC中有两个级别的数据流控制:
- 基于流的流控,它通过限制可以在单个流上可发送的数据量来防止单个流消耗整个连接的接收缓区。
- 基于连接的流控,它通过限制所有流上通过STREAM帧发送的流数据的总字节数来防止发送方超出接收方的连接缓冲区容量。
发送方不得(MUST NOT)发送超过任一限制的数据。
接收方在握手期间通过传输参数为所有流设置初始限制
- initial_max_data (0x04):10 * 250000 初始最大数据大小,指示可以在连接上发送的最大数据量(字节数)的初始值。
- initial_max_stream_data_bidi_local (0x05) 250000:指定本地发起的双向流的初始流控限额。
- initial_max_stream_data_bidi_remote (0x06) 250000:指定对端发起的双向流的初始流控限额。
- initial_max_stream_data_uni (0x07):250000 指定单向流的初始流控限额。
随后,接收方会通过MAX_STREAM_DATA(For Stream)帧或MAX_DATA(For Connection)帧告诉发送方一个更大的“限额”。
MAX_STREAM_DATA帧指定流的绝对字节偏移量上限。接收方可以基于流上当前消费的数据偏移量来确定要通告的流控偏移量上限。
MAX_DATA帧指定所有流的绝对字节偏移总和的上限。接收方维护在所有流上接收到的字节的累积总和,用于判定是否违反了通告的连接或流上数据限额。接收方可以根据所有流上消耗的字节总和来确定要通告的最大数据限额。
- 一旦接收方通告了连接或流上的限额,再通告一个更小的限额不会导致错误,但会被发送方忽略。
- 如果发送方违反了通告的连接或流上的数据限额,则接收方必须以FLOW_CONTROL_ERROR类型的错误关闭连接。
- 发送方必须忽略不增加流控限额的任何MAX_STREAM_DATA或MAX_DATA帧。
如果发送方已发送数据达到限额,则将无法发送新数据并被视为阻塞,发送方应该发送一个STREAM_DATA_BLOCKED或DATA_BLOCKED帧来向接收方表明它有数据要写入但被流控限额阻塞。如果发送方被阻塞的时间长于空闲超时定时器,即使发送方有可用于传输的数据,接收方也可能关闭连接。为了防止连接关闭,受流控阻塞的发送方应该在没有ACK触发包数据包传输时定期发送STREAM_DATA_BLOCKED或DATA_BLOCKED帧。
Stream控制
- QUIC 的Stream流基于Stream ID+Offset进行包序确认,流量控制需要保证所发送的所有包offset小于最大绝对字节偏移量 ( maximum absolute byte offset ), 该值是基于当前已经提交的字节偏移量(offset of data consumed) 而进行确定的,QUIC会把连续的已确认的offset数据向上层应用提交。QUIC支持乱序确认,但本身也是按序(offset顺序)发送数据包。
- 如果数据包N超时,发送端将超时数据包N重新设置编号M(即下一个顺序的数据包编号) 后发送给接收端。
- 在一个数据包发生超时后,其余的已经发送的数据包依旧可以得到确认,避免了TCP利用SACK才能解决的重传问题。
\
- 如图所示,当前缓冲区大小为8,QUIC按序(offset顺序)发送29-36的数据包:
- 31、32、34数据包先到达,基于offset被优先乱序确认,但30数据包没有确认,所以当前已提交的字节偏移量不变,缓存区不变。
- 30到达并确认,缓存区收缩,若接收方发送MAX_STREAM_DATA frame(协商缓存大小的特定帧)给发送方,就会增长最大绝对字节偏移量。
- 协商完毕后最大绝对字节偏移量右移,缓存区变大,同时发送方发现数据包33超时
- 发送方将超时数据包重新编号为42继续发送
以上就是最基本的数据包发送-接收过程,控制数据发送的唯一限制就是最大绝对字节偏移量,该值是接收方基于当前已经提交的偏移量(连续已确认并向上层应用提交的数据包offset)和发送方协商得出。
Connection控制
除了Stream层面的数据流控制之外,QUIC还提供了Connection层面的总体缓存大小控制,Connection具有总体的缓冲区大小限制,并且可以为其中的各个Stream动态分配缓冲区大小,在总体缓冲区大小不变的情况下优先向速度更快的Stream倾斜(并不是平均分配)。
如图所示,Connection具有传输字节上限,即Stream1、2、3的Maximum Offset之和不得超过该上限,QUIC会根据网络情况为各个Stream分配不同的偏移量,并且随着传输的进行,接收方会发送MAX_DATA frame通知发送方提高Connection总体传输字节分配上限,并在Stream连接中通过MAX_STREAM_DATA frame为各个Stream分配更多的缓存。
增加"限额"上限(Increasing Flow Control Limits)
在MAX_STREAM_DATA和MAX_DATA帧中告知多少限额以及何时发送这两种帧,是由实现自己决定。RFC9000提供了一些需要考虑的事项
- 为避免阻塞发送方,接收方可以在一个RTT中多次发送MAX_STREAM_DATA或MAX_DATA帧,或者提前足够时间发送这两种帧,以便为丢包和后续恢复留出时间。
- 控制帧会增加链路的开销,因此,频繁发送变化很小的MAX_STREAM_DATA和MAX_DATA帧是不可取的。另一方面,如果更新不频繁,就需要更大的限额增量,以避免阻塞发送方,这会要求接收方承担更大的资源消耗。在决定告知多大的限额时,需要在资源消耗和连接开销之间进行权衡。
- 接收方可以使用自动调整机制,根据RTT估计或应用消费接收到的数据的速率来调整通告限额的频率和数值,类似于常见的TCP实现。作为一种优化,只有当有其他帧要发送时,本端才可以发送与流控相关的帧,以确保流控不会导致发送额外的数据包。
- 被阻塞的发送方是可以不发送STREAM_DATA_BLOCKED或DATA_BLOCKED帧。因此,接收方禁止等待STREAM_DATA_BLOCKED或DATA_BLOCKED帧到达之后,再发送MAX_STREAM_DATA或MAX_DATA帧,因为这样做可能会导致发送方在连接的其余时间被阻塞。即使发送方发送了这些帧,接收方等待它们到达再回应也会导致发送方被阻塞至少整个RTT。
- 当发送方在被阻塞后收到新的限额时,它可能会发送大量数据作为响应,导致短期拥塞。
控制并发(Controlling Concurrency)
本端可以限制对端能够打开的输入流的累积数量,只能打开流ID小于 (max_streams(default 10) * 4 + first_stream_id_of_type)的流。初始限制在传输参数中设置,随后可以使用MAX_STREAMS帧通告后续限额。单向和双向流分别有各自的限额。
- 一旦接收方使用MAX_STREAMS帧通告流限额,再通告较小的限额就无效。必须忽略不增加流限额的MAX_STREAMS帧。
- 与在流上和连接上的流控制一样,RFC9000让实现自己来决定何时以及什么场景应该通过MAX_STREAMS向对端通告允许多少个流。
- 由于对端的限制而无法打开新流的终端应该发送一个STREAMS_BLOCKED帧。
拥塞控制
名词解释
- 触发ACK的帧(Ack-eliciting frames):除ACK、PADDING和CONNECTION_CLOSE以外的所有帧都触发ACK。
- 触发ACK的包(Ack-eliciting packets):包含ack-eliciting帧、且会触发接收者在最大ACK延迟之内生成一个ACK的包,叫做触发ACK的包。
- 在传输中的包(In-flight packets):包如果是ack-eliciting的,或者包含了一个PADDING帧,已被发送,但没有被确认、没有宣称丢失、或者没有和旧密钥一起丢弃,那么这个包正在传输中(in-flight)。
QUIC 传输机制回顾
- QUIC中所有的传输的Packet在Header中有一个packet-level字段,用来标识加密的等级和所属于的包序号空间,加密等级标识所属于的包序号空间。在一个连接的生命周期内,一个包序号空间中的包序号不会发生重复。 发送过程中一个空间内的包序号单调递增,从而来避免歧义。
- QUIC Packet 可以包含多个不同类型的帧。 所以包中的帧的类型会影响恢复与拥塞控制逻辑:
-
- 所有包都会被确认,即使这些包中包含了非触发帧,也会由于是一个触发包而被进行确认。
- 包含CRYPTO帧的长头包对于QUIC握手性能要求十分苛刻,需要使用更短的定时器来进行确认。
- 包含ACK和CONNECTION_CLOSE以外帧的包,会被计入到拥塞控制限制,并且被认为是in-flight数据。
- 包含PADDING帧的包会使得传输中的字节数增加,且不会直接触发一个ACK确认。
RTT(Round-Trip Time)
- 终端将发送到收到Packet ACK之间的时间成为RTT样本。
- 终端使用RTT样本和对端上报的主机延迟(peer-reported host delay)来生成网络链路RTT的统计描述。
- 终端计算了每条链路上的如下三个值:
-
- 链路生命周期内的最小延迟(min_rtt)
- 指数加权动态平均值(smoothed_rtt)
- 和观察到的RTT样本的平均偏差(rttva)
Generating RTT Sample
在收到一个满足了如下两个条件的ack帧之后,终端就会生成一个RTT样本:
- 拥有最大的确认包序号,且是新确认的;
- 至少有一个新确认的包是ack-eliciting的
代码逻辑如下:
List<PacketStatus> newlyAcked = ackFrame.getAckedPacketNumbers()
.filter(pn -> packetSentLog.containsKey(pn) && !packetSentLog.get(pn).acked())
.map(pn -> packetSentLog.get(pn))
.filter(packetStatus -> packetStatus != null)
.filter(packetStatus -> packetStatus.setAcked())
.collect(Collectors.toList());
Optional<PacketStatus> largestAcked = newlyAcked.stream()
.filter(s -> s.packet().getPacketNumber() == ackFrame.getLargestAcknowledged())
.findFirst();
if (largestAcked.isPresent()) {
if (newlyAcked.stream().anyMatch(s -> s.packet().isAckEliciting())) {
addSample(timeReceived, largestAcked.get().timeSent(), ackFrame.getAckDelay());
}
}
RTT采样中的lastest_rtt是当前被确认的最大的包发出后经过的时间。
latest_rtt = ack_time - send_time_of_largest_acked
只使用收到的ACK帧中的最大确认包来计算RTT采样。这是因为对端在ACK帧中只上报最大确认包的延迟。 但是RTT采样测量不会直接使用上报的ACK延迟,而是来调整后续smoothed_rtt和rttvar的计算时的RTT采样。
为了避免一个包产生多个RTT采样,一个ACK帧如果不是最新的确认最大包的ACK,那么不应该将它用来计算RTT。
Estimating min_rtt
min_rtt是发送方对一段时间内给定网络链路观察到的最小RTT的估计。
if (first_rtt_sample == 0):
min_rtt = latest_rtt
smoothed_rtt = latest_rtt
rttvar = latest_rtt / 2
first_rtt_sample = now()
return
// min_rtt ignores acknowledgment delay.
min_rtt = min(min_rtt, latest_rtt)
Estimating smoothed_rtt and rttvar
smoothed_rtt是一个终端RTT采样的指数加权动态平均数,rttvar用平均变化估计RTT采样的变化。
smoothed_rtt和rttvar初始化如下,其中kInitialRtt包含初始RTT值。
// default kInitialRtt = 500ms;
smoothed_rtt = kInitialRtt
rttvar = kInitialRtt / 2
网络路径的RTT采样记录在 latest_rtt 中。 在初始化后的第一个RTT采样上,smoothed_rtt和rttvar计算方式如下。
smoothed_rtt = latest_rtt
rttvar = latest_rtt / 2
在后续的RTT采样上,smoothed_rtt和rttvar计算方式如下:
ack_delay = decoded acknowledgment delay from ACK frame
ack_delay = min(ack_delay, max_ack_delay)
adjusted_rtt = latest_rtt
if (min_rtt + ack_delay < latest_rtt):
adjusted_rtt = latest_rtt - ack_delay
smoothed_rtt = 7/8 * smoothed_rtt + 1/8 * adjusted_rtt
rttvar_sample = abs(smoothed_rtt - adjusted_rtt)
rttvar = 3/4 * rttvar + 1/4 * rttvar_sample
PTO(Probe Timeout)
QUIC使用了一个探测超时,而不是RTO,QUIC的PTO包含了对端的期望最大确认延时,而不是一个固定的最小超时。通过这样的方式,QUIC避免了不必要的拥塞窗口缩减。
当传输了一个ack-eliciting包之后,发送端就开启了一个有如下PTO周期的定时器:
PTO = smoothed_rtt + max(4*rttvar, kGranularity) + max_ack_delay
Time Threshold
在相同包序号空间的后发的包已经被确认,且已经超过了一定时间阈值,终端应当断定当前这个包丢失。 为了防止过早断定丢包,必须把这个时间阈值设置为至少本地定时器粒度(以kGranularity常量表示)。 这个时间阈值计算方法如下:
lostSendTime = max(kTimeThreshold * max(smoothed_rtt, latest_rtt), kGranularity)
协议建议的kTimeThreshold,成为RTT乘数,是9/8。 建议的的时间粒度(kGranularity)是1ms。
Packet Threshold
根据TCP丢包检测[RFC5681] [RFC6675]的最佳实践,建议包重排序阈值(kPacketThreshold)的初始值为3。
一些网络可能表现出更高的乱序程度,导致发送者检测到伪丢包。 此外,数据包重排在QUIC中可能比TCP中更常见,究其原因,能够观察和重排TCP数据包的网络元素不能对QUIC这样做,因为QUIC的数据包号是加密的。
Loss Detection
丢包检测是独立于每个包序号空间的,这不同于RTT测量和拥塞控制。 因为RTT和拥塞控制是链路的属性,而丢包检测还依赖于密钥可用性。
如果满足如下所有的条件,就认为这个包已经丢失:
- 这个包还没有被ACK,在传输过程中,且在一个已被确认的包之前发送。
- 此包包序号加上kPacketThreshold小于确认数据包序号,或者发出去已经超过了一定时间。
lostSendTime = max(kTimeThreshold * max(smoothed_rtt, latest_rtt), kGranularity)
kPacketThreshold = 3
List<PacketStatus> lostPackets = packetSentLog.values().stream()
.filter(p -> p.inFlight())
.filter(p -> pnTooOld(p) || sentTimeTooLongAgo(p, lostSendTime))
.filter(p -> !p.packet().isAckOnly())
.collect(Collectors.toList());
private boolean pnTooOld(PacketStatus p) {
return p.packet().getPacketNumber() <= largestAcked - kPacketThreshold;
}
private boolean sentTimeTooLongAgo(PacketStatus p, Instant lostSendTime) {
return p.packet().getPacketNumber() <= largestAcked && p.timeSent().isBefore(lostSendTime);
}
Setting the Loss Detection Timer
GetLossTimeAndSpace():
time = loss_time[Initial]
space = Initial
for pn_space in [ Handshake, ApplicationData ]:
if (time == 0 || loss_time[pn_space] < time):
time = loss_time[pn_space];
space = pn_space
return time, space
GetPtoTimeAndSpace():
duration = (smoothed_rtt + max(4 * rttvar, kGranularity))
* (2 ^ pto_count)
pto_timeout = infinite
pto_space = Initial
for space in [ Initial, Handshake, ApplicationData ]:
if (space == ApplicationData):
// Include max_ack_delay and backoff for Application Data.
duration += max_ack_delay * (2 ^ pto_count)
t = time_of_last_ack_eliciting_packet[space] + duration
if (t < pto_timeout):
pto_timeout = t
pto_space = space
return pto_timeout, pto_space
SetLossDetectionTimer():
earliest_loss_time, _ = GetLossTimeAndSpace()
if (earliest_loss_time != 0):
// Time threshold loss detection.
loss_detection_timer.update(earliest_loss_time)
return
timeout, _ = GetPtoTimeAndSpace()
loss_detection_timer.update(timeout)
On Timeout
When the loss detection timer expires, the timer's mode determines
the action to be performed.
Pseudocode for OnLossDetectionTimeout follows:
OnLossDetectionTimeout():
earliest_loss_time, pn_space = GetLossTimeAndSpace()
if (earliest_loss_time != 0):
// Time threshold loss Detection
lost_packets = DetectAndRemoveLostPackets(pn_space)
assert(!lost_packets.empty())
OnPacketsLost(lost_packets)
SetLossDetectionTimer()
return
// PTO. Send new data if available, else retransmit old data.
// If neither is available, send a single PING frame.
_, pn_space = GetPtoTimeAndSpace()
SendOneOrTwoAckElicitingPackets(pn_space)
pto_count++
SetLossDetectionTimer()
QUIC的Congestion Control算法类似于TCP NewReno。
QUIC提供用于拥塞控制的信号都是通用且可以支持不同的发送端算法。各个发送端可以单边选择使用不同算法。RFC9002中拥塞控制协议就一章内容很少。
算法描述和使用也是引入了的控制器拥塞窗口概率,大小单位为字节。
#define kMaxDatagramSize 1200
#define kMinimumWindow (2 * kMaxDatagramSize)。
/*
* The RECOMMENDED value is the minimum of
* 10 * kMaxDatagramSize and max(2* kMaxDatagramSize, 14720)).
*/
#define kInitialWindow (10 * kMaxDatagramSize)
#define kLossReductionFactor (0.5f)
#define kPersistentCongestionThreshold (3)
Slow start:
- 连接建立完成后,一开始初始化 cwnd = kMinimumWindow
- 当收到一个 ACK 确认应答后,cwnd 增加 packet.size
- 当 cwnd < ssthresh时,使用慢启动算法。
- 当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」,也就是进入新的网路环境状态中,重新进行cwnd的计算。
Congestion Avoidance
- 当收到一个 ACK 确认应答后,cwnd = kMaxDatagramSize * p.size / cwnd
Recovery Period
当网络出现拥塞,也就是会发生packet loss 或者 ECN-CE增加
- 当收到一个 ACK 确认应答后
cwnd = cwnd * kLossReductionFactor
cwnd = max(cwnd, kMinimumWindow)
ssthresh = cwnd
Persistent Congestion
- 当发送方确定在足够长的持续时间内发送所有数据包都丢失时,网络将被视为持续拥塞。
cwnd = kMinimumWindow
duration = (smoothed_rtt + max(4*rttvar, kGranularity) + max_ack_delay) *
kPersistentCongestionThreshold
+========+===========================+
| Time | Action |
+========+===========================+
| t=0 | Send packet #1 (app data) |
+--------+---------------------------+
| t=1 | Send packet #2 (app data) |
+--------+---------------------------+
| t=1.2 | Recv acknowledgment of #1 |
+--------+---------------------------+
| t=2 | Send packet #3 (app data) |
+--------+---------------------------+
| t=3 | Send packet #4 (app data) |
+--------+---------------------------+
| t=4 | Send packet #5 (app data) |
+--------+---------------------------+
| t=5 | Send packet #6 (app data) |
+--------+---------------------------+
| t=6 | Send packet #7 (app data) |
+--------+---------------------------+
| t=8 | Send packet #8 (PTO 1) |
+--------+---------------------------+
| t=12 | Send packet #9 (PTO 2) |
+--------+---------------------------+
| t=12.2 | Recv acknowledgment of #9 |
+--------+---------------------------+
当在t=12.2时收到数据包9的确认时,就会宣布数据包2至8丢失。
假设:duration = 6
拥塞时间的计算方法是最早丢失的数据包与最新丢失的数据包之间的时间:8 - 1 = 7,达到了阈值6,并且在最早丢失的数据包与最新丢失的数据包之间没有一个数据包被确认,因此网络被认为处于持续的拥塞状态。
Generating Acknowledgments
Sending ACK Frames 规范
- 数据包应当被至少确认一次,并且ack-eliciting包必须在最大ACK延迟(max_ack_delay)之内被确认至少一次。
- 无论终端以什么原因发送数据包,应当尝试绑定一个最近还没发送出去的ACK帧。这样做有助于对端进行实时丢失检测。
- 终端必须立即确认所有ack-eliciting的Initial和Handshake包,加快握手。
- 所有ack-eliciting的0-RTT和1-RTT数据包,在其通告的max_ack_delay内确认
为了协助发送方的丢包检测,当终端收到下面特征ack-eliciting的数据包时,应该无延迟地发送ACK:
- 当收到的数据包的数据包号小于另一个已收到的ack-eliciting包
- 当该数据包的数据包号大于已收到的最高编号的ack-eliciting包,且该数据包与收到的最高编号数据包之间有缺失的数据包
- 在IP头中标有ECN Congestion Experienced(CE)字段的数据包
Acknowledgment Frequency
- 接收端在收到最少两个包之后,应当发送一个ACK
- 终端依赖ACK来检测丢包,基于窗口的拥塞控制器,依赖了ACK来管理它们的拥塞窗口。无论丢包检测还是拥塞控制,延迟确认都会显著影响性能。
- 减少那些只携带ACK帧Packet的频率,就能减少两端传输和处理包的成本。这也同样能够改善严重不对称连接上的吞吐量,并利用回程路径容量减少确认流量。
Managing ACK Ranges
ACK Frame {
Type (i) = 0x02..0x03,
Largest Acknowledged (i),
ACK Delay (i),
ACK Range Count (i),
First ACK Range (i),
ACK Range (..) ...,
[ECN Counts (..)],
}
ACK frame 包含以下字段:
- Largest Acknowledged:变长整数,表示对端正在ACK的最大packet number;通常是对端在生成当前ACK frame之前收到的最大packet number。与QUIC长或短报头中的数据包编号不同,ACK帧中的值不会被截断。
- ACK Delay:变长整数,以毫秒为单位编码ACK延迟。通过将字段中的值乘上ACK帧发送方发送的2^ack_delay_exponent的倍数来解码。与仅将延迟表示为整数相比,此编码允许在相同数量的字节内包含更大范围的值,但代价是分辨率较低。
- ACK Range Count:变长整数,表示当前frame中Gap和ACK Range字段的数量。
- First ACK Range:变长整数,表示在Largest Acknowledged之前正在被ACK的连续packet的数量。范围内最小的packet number = Largest Acknowledged - First ACK Range。
- ACK Ranges:包含未确认(Gap)和已确认(ACK Range)的额外数据包范围。
- ECN Counts:包含3个ECN Counts。标识ECN响应,并且上报在IP头中携带关联的ECN codepoints的QUIC packet。数据包的IP头中相关的ECN codepoint为ECT(0)、ECT(1)或CE。
ACK Range {
Gap (i),
ACK Range Length (i),
}
ACK Range字段由交替的Gap和ACK Range Length组成,Gap和ACK Range的数量由ACK Range Count字段来决定。
ACK Range 包含以下字段:
- Gap:变长整数,表示连续的还没有被ACK的packet数量。
- ACK Range Length:变长整数,表示连续的被ACK的packet数量。
//自己理解的公式
largest = previous_smallest - gap -1
smallest = largest - ack_range + 1
//RFC9000 公式
Larger ACK Range values indicate a larger range, with corresponding
lower values for the smallest packet number in the range.
Thus, given a largest packet number for the range, the smallest
value is determined by the following formula:
smallest = largest - ack_range
The value of the Gap field establishes the largest packet number value
for the subsequent ACK Range using the following formula:
largest = previous_smallest - gap - 2
代码逻辑
Range range = rangeIterator.next();
// https://www.rfc-editor.org/rfc/rfc9000.html#name-ack-frames
// "Gap: A variable-length integer indicating the number of contiguous unacknowledged packets preceding the
// packet number one lower than the smallest in the preceding ACK Range."
int gap = (int) (smallest - next.getLargest() - 2); // e.g. 9..9, 5..4 => un-acked: 8, 7, 6; gap: 2
// "ACK Range Length: A variable-length integer indicating the number of contiguous acknowledged packets
// preceding the largest packet number, as determined by the preceding Gap."
int ackRangeLength = range.size() - 1;
public int size() {
return (int) (to - from + 1);
}
Example
其它
TCP New Reno
于是,1995年,TCP New Reno(参见 RFC 6582 )算法提出来,主要就是在没有SACK的支持下改进Fast Recovery算法的——
- 当sender这边收到了3个Duplicated Acks,进入Fast Retransimit模式,开始重传重复Acks指示的那个包。如果只有这一个包丢了,那么,重传这个包后回来的Ack会把整个已经被sender传输出去的数据ack回来。如果没有的话,说明有多个包丢了。我们叫这个ACK为Partial ACK。
- 一旦Sender这边发现了Partial ACK出现,那么,sender就可以推理出来有多个包被丢了,于是乎继续重传sliding window里未被ack的第一个包。直到再也收不到了Partial Ack,才真正结束Fast Recovery这个过程
我们可以看到,这个“Fast Recovery的变更”是一个非常激进的玩法,他同时延长了Fast Retransmit和Fast Recovery的过程。