总体上QUIC的重传机制吸取了大量TCP重传机制的经验,本章介绍的算法和机制基本上在TCP里都有对应的版本。但是QUIC和 TCP协议上的不同也体现在这些算法/机制上,其中最重要的一个不同点是,QUIC中数据传输和数据应用交付在协议上分开的,传输是发生在packet级别的,交付则是发生在stream级别,TCP中两者都是发生在byte stream级别。QUIC packet单调递增的seq number避免了重传歧义,这在TCP里是个比较麻烦的问题。
在正式介绍QUIC的重传机制前,我觉得最好先回顾下TCP对应的重传机制,这部分内容主要来源于《TCP/IP Illustrated》这本书,Richard Stevens大牛已经将TCP协议的big picture总结的明明白白,我们这样的后来人翻翻书就能避免钻进IETF的TCP“故纸堆”中迷失自我了。
TCP在认为发生丢包时通过重传来解决,因此我们说的重传机制其实最核心的就是TCP在什么情况下认为某个segment丢失了——即loss detection,至于怎么重传在TCP里面是个相对直观的问题,即原样重传那些认为丢失的segment即可,所以有时候会把重传算法和丢包检测算法这两个概念混用。另外TCP重传往往会牵扯到CC算法控制,这里先不做展开。
总的来说TCP经典的重传机制有两个:基于时间的重传(RTO recovery)和基于ACK结构的重传(fast recovery)
TCP的超时重传机制中超时时间的设定依赖于连接的RTT,然而TCP协议面向的是字节流, cumulative ACK机制使得RTT采样这件事变得不那么容易。在没有开启Timestamp option的情况下,RTT采样只能针对整个窗口的所有segment来进行,因为发送端没法知道收到的ACK是在对端在收到窗口内哪个segment时产生的ACK。此外在有重传发生时收到ACK也不能作为RTT样本,因为有重传歧义问题。
有了Timestamp option,发送端在发送segment时将携带本端的一个时间戳,接收端在ACK是时候将segment的时间戳echo back给发送端,发送端收到ACK后用当前时间减去ACK中的时间戳就形成了一个RTT采样。Timestamp option的启用解决了cumulative ack和重传歧义问题导致的RTT预估不精确问题。当然接收端在ACK时候选择用哪个segment的时间戳是有讲究的,直接决定了对端RTT样本值。在启用Timestamp option情况下进行RTT采样的算法简单描述如下:
有了RTT样本之后,剩下的问题就是这么用这些样本来计算一个平滑的RTT,有多种方法,但基本思想都是对RTT样本进行指数移动平均来进行平滑。以standard method为例,采样下面的方法来计算RTO
srtt <- (1-g) srtt + (g)M //M是一个rtt样本,srtt是rtt的EWMA
rttvar <- (1-h) rttvar + (h) (|M - srtt|) //rttvar 是采用mean deviation的EWMA方法近似计算rtt的标准差
RTO = srtt + 4 * rttvar
srtt初始值为M, rttvar初始值为 M/2, g为1/8, h为1/4。h比g值要大使得RTT在发生变化时RTO能更快变大。
Standard method逻辑上很简单直白,但是存在一些问题,比如:
对于接收端,总的来说接收端可以在buffer里有乱序到达的segment任何时候产生SACK,接收端将最新收到的序号段作为第一个SACK block,并且也会放置一些之前的序号段提供一些SACK信息冗余。
对于发送端,在收到一个SACK后,可以将buffer里出现在SACK block里的segment标记上已被SACK,当发送端有机会重传数据时可以只重传那些未被SACK过的segment。
TCP里的SACK在协议中被认为是建议性质的,也就说说接收端在SACK了某个序号端后还能反悔,因此发送端不能在收到SACK信息后立即将buffer中对应的segment数据删掉。当然反悔机制在TCP实现中非常少见也不鼓励。
产生Spurious 重传原因可能有很多,比如乱序、ACK丢失、RTT突然增大超过了RTO等等。Spurious重传在无线网场景下发生很频繁,这个问题之所以重要是因为一旦发生将会对发送方性能产生很大影响。
TCP这么多年发展出了很多手段去解决这个问题,总的来说涉及两部分算法
下面列举几个常见的算法
DSACK option用于让发送端能够检测到Spurious重传的发生,需要双端都支持才能生效,简单来说就是接收端在产生SACK的时候将收到的重复的序号段通过SACK block告诉给发送端,即便这个序号段已经在当前cumulative ACK序号之前。
Eifel Detection算法工作依赖Timestamp option,原理非常简单,就是利用Timetamp option解决重传歧义的功能来做识别ACK是对重传segment的ACK,还是原始segment的ACK,如果是对原始的segment的ACK,那么说明重传是Spurious重传。
具体来说:当发送端决定要去重传某个segment时,记录下segment 中timestamp option的ts(TSV),当收到第一个能够包含segment的seq的ACK时检查下ACK中echo back的ts(TSER),如果TSER比TSV要小,说明ACK是对原始segment的ACK,此前的重传是Spurious重传。
Eifel Detection相比于DSACK能够在ACK丢失时候更具鲁棒性
正常情况下发送端一旦超时,TCP将表现出go-back-N行为,即发送端从最小未被ACK的segment开始重传,每收到一个ACK,向前重传直至完成恢复。F-RTO修改了这个行为,使得发送端重传开始的收到第一个ACK后直接开始发送新的segment。然后检查后面的收到的第二个ACK:
显然F-RTO只能用于检测Spurious超时重传,相比于DSACK、Eifel Detection,检测时机比较晚
简单来说就是当检测到Spurious timeout采取一些措施主要包括
QUIC在丢包检测部分很大程度上借鉴了RACK-TLP算法的设计,因此有必要介绍这个相对新的TCP丢包检测/重传算法,本章节的内容来源于RFC8985。RACK-TLP是由google的工程师提出并在2021年被IETF标准化,目前已经被集成到FreeBSD、linux、windows的TCP实现中。RACK-TLP旨在改进并替代传统的重传恢复策略。
传统的重传恢复策略前面已经介绍了:首先发送方在收到3个duplicate ACK后会触发快重传,没有SACK情况下每个RTT恢复一个segment,这一策略称为DupAck counting。快重传的同时,发送方的CC window也会减半(之所以是减半而不是减为1是因为这种情况不那么严重,至少ACK clocking还在工作)。如果快重传没有被触发、或者触发也没有恢复所有的丢包,那么发送方将最后用RTO超时重传策略兜底,一旦触发RTO超时,发送端将从第一个未被ack的segment开始重传,并且CC window重置为1,进行CC慢启动过程。RTO设置前面也介绍了,是srtt + 4 * rtt_var,并且通常会以1秒为下界,因此RTO超时重传对性能有很大的影响。
传统的重传恢复策略在以下场景下表现不好:
RACK-TLP旨在更高效应对上面提到的三个场景,设计上分为两部分:RACK 部分和TLP部分。
RACK部分最核心的问题是怎么通过最近的收到的ACK判断已经发送的segment是否loss。具体来说一个segment被认为loss需要满足两个条件:
RACK优于传统快重传的地方就在于收到ACK feeback后是通过时间阈值来判定loss,而非duplicate counting,从而能够在一些乱序程度高的链路中依旧有效(不会导致虚假重传)。
那么这个时间阈值怎么确定呢?为了能够容忍乱序,RFC8985中也给出了一个动态调整reordering window值的算法,调整的思想是:
RACK依赖于ACK feedback来判定loss,但是有些情况下可能没有足够的ACK feedback,比如tail loss。TLP通过主动发送probe包来引出对端的ACK来 解决这个问题。TLP部分的核心有两个:
第一个问题通过设置一个probe timer(PTO)来解决,定时器到期时如果没有ACK则触发发送一个probe来引出ACK/SACK feedback。PTO的默认值是2*srtt(具体也会根据一些条件来调整)。加上前面的RACK reordering timer、传统的PTO timer,现在TCP每个连接上将会有三个timer,由于这三个timer同时只会有一个生效,实现上可以通过复用一个timer来管理这三个timer。
第二个问题probe的内容是什么。协议中规定probe可以是是下一个新的segment或者重传当前flight里最后一个segment。
OK,以上就是RACK-TLP丢包检测算法的概括性介绍,具体实现可以参考RFC8985。TCP重传相关内容回顾到次为止,下面进入正题。
先定义几个概念:
前面提到QUIC 用packet number来唯一标识packet,并且PN在所处的PN space中是单调递增的,在packet丢失导致重传的时候,QUIC将重新构建新packet,选择性重传必要的数据,因此QUIC避免了TCP的重传歧义问题。和TCP一样,QUIC通过接ACK机制来保证packet的可靠性,并且QUIC对什么时候要进行ACK做了一些规定:
上面几点是QUIC强制所有实现都需要的实现的行为(MUST),除此之外QUIC还规定了一些协议认为应该要实现的立即产生ACK行为(SHOULD):
产生ACK的频率是一个性能的tradeoff,及时、高频的ACK有助于对端检测loss、及时重传、预估RTT、识别拥塞等操作,但是会增加双端的计算负载和网络带宽。QUIC建议至少收到两个ack-eliciting packet再去ACK,这一建议也来源于TCP的一个通用的建议,如果有网络条件、对端CC或者其他一些先验知识的话,可以采用其他有效的策略。
和 TCP的SACK一样,QUIC的ACK frame中包含收到的多个PN段,为了防止ACK packet丢失,PN段可能会和之前的ACK 中PN段有些重合。至于要包含多少PN段,QUIC没有强制规定,但是做了一些建议:
每个ACK frame里会有个ACK delay字段用于显式上报接收端故意引入的delay,这个delay衡量的是从收到ACK frame中最大的PN对应packet开始到ACK发生时为止。
接收端主动上报ack delay有助于发送端测量path rtt。
QUIC的丢包检测非常类似于RACK-TLP,一样都是基于ACK 来检测是否丢包,利用PTO来确保ACK被收到。一旦检测到丢包就需要进行重传来恢复。注意和TCP不同的是QUIC里没有和TCP一样的RTO超时重传packet机制。
基于ACK的丢包检测总的来说就是发送端通过ACK的顺序结构检测到了可能有丢包产生,满足下面条件就会认为是丢包:
max(kTimeThreshold * max(smoothed_rtt, latest_rtt), kGranularity)
// 其中kTimeThreshold推荐为9/8,效果就是比当前smoothed_rtt大一点。
实现上time threshold通常依赖一个timer,发送端在收到一个ack后(假设其ack了当前packet之后发送的packet),如果当前packet还没有被认为丢失,那么会设置一个丢包检测的timer,timer在当前packet sent time + time threshold 时到期,到期时将触发重传。
QUIC的Probe timeout 是为了能够在发送了ack-eliciting包后一段时间没有收到ack的话触发发送一个或者两个probe包 。PTO是为了能让QUIC连接能够从丢失tail packet、或者对端ack丢失的情况下恢复。发生了PTO超时不意味着一定有丢包,发送端也不能因此就将PTO 超时的packet标记为丢失,而是根据收到的ACK进行丢包检测。
PTO时间通过下面方式计算:
PTO = smoothed_rtt + max(4*rttvar, kGranularity) + max_ack_delay
当RTO超时时,QUIC规定要发送一个或者两个probe,去elicit对端的ack。那么问题来了,这个probe packet应该包含什么呢?总的来说QUIC并没有明确规定probe里应该包含什么,只是说应当在probe packet里发送新数据,如果连接上没有新数据的话也可以根据应用层策略去重传之前的数据。另外,如果想尽快elicit出对赌的ack的话,还可以将probe packet的PN故意跳过一个,这样对端收到后会立即ack。
TCP的预估的RTT直接用于设置RTO,因此会包含对端的主观delay。QUIC里则将客观的链路RTT和主观delay分开,预估的的smothed_rtt是纯粹的链路RTT。
QUIC packet的PN单调递增,因此根源上就不会出现重传歧义。QUIC的sender在收到一个携带ack frame的packet后也同样面临两个问题:1) ack frame里通常ack了sender 的 多个packet,因此sender需要决定用哪个packet的send_time作为rtt的起始时间。这个问题在TCP里 是由receiver决定的。2) 和TCP一样,sender也不是收到一个带ack frame的packet就产生一个rtt采样。
有了rtt采样(latest_rtt)后,和TCP 一样,需要计算平滑rtt
ack_delay = decoded ack delay from ack frame
if (hanshake confirmed)
ack_delay = min (ack_delay, max_ack_delay)
adjusted_rtt = latest_rtt
if (latest_rtt >= min_rtt + ack_delay) :
adjusted_rtt = latest_rtt - ack_delay
smoothed_rtt = 7/8 * smoothed_rtt + 1/8 * adjusted_rtt
rttvar = 3/4 * rttvar + 1/4 * abs(smoothed_rtt - adjusted_rtt)