hello world

stay foolish, stay hungry

TCP协议详解

TCP协议

TCP 协议是 TCP/IP 体系中非常复杂的一个协议,下面介绍 TCP 的主要特点:

  1. TCP 是面向连接的传输层协议,应用程序在使用 TCP 协议之前,必须先建立 TCP 连接,传输数据结束后必须要要释放 TCP 连接。
  2. 每一条 TCP 连接只能有两个端点(endpoint),TCP 连接只能是点对点的。
  3. TCP 提供可靠交付的服务,通过 TCP 连接可以无差错、不丢失、不重复并且按顺序的传输的数据。
  4. TCP 提供全双工通信。TCP 允许通信双方进程在任何时候都可以发送数据,TCP 两端都设有发送缓存和接收缓存,用来临时存放双端通信的数据。发送时,应用程序把数据发送给 TCP 缓存后,就可以做自己的事了,TCP 在合适的时候把数据发送出去。接收时,TCP 将数据放入缓存,上层应用进程在合适的时候读取缓存中的数据。
  5. 面向字节流。TCP 中的「流」(stream)指的是流入到进程或从进程流出的字节序列,「面向字节流」的含义是,虽然应用程序进程和 TCP 交互是一次一个大小不等的数据块,但 TCP 把应用程序交下来的数据看成仅仅是一连串无结构的字节流,TCP 并不知道要传递的字节流的含义。TCP 不保证接收方收到的数据块和发送方发送的数据块具有对应的大小关系,但接收方收到的字节流必须和发送方发出的字节流完全一样,接收方必须有能力识别收到的字节流,并还原成有意义的数据。

图 1 TCP 面向流的概念

图 1 TCP 面向流的概念

TCP 不关心应用程序一次把多长的数据发送到 TCP 的缓存中,而是根据对方给出的窗口值和当前网络用塞程序来决定一个报文段应包含多少个字节。如果应用程序传送到 TCP 缓存的数据块太长,TCP 可以把它划分短一些再传送。如果应用程序一次发来很短的数据,TCP 可以等待基类有足够多的字节再构成报文段发送。

「连接」是 TCP 最基本的抽象,TCP 连接两个端点,端点是「套接字」(socket)或「插口」。端口号拼接 IP 地址构成了套接字,所以 socket = IP:port。

可靠传输

TCP 发送的报文段是交给 IP 层传输,而 IP 层只能提供尽最大努力服务,也就是说,TCP 下层的网络提供的是不可靠的传输,因此 TCP 必须采取适当的措施来保证传输的可靠性。

停等协议

「停等」就是每发完一个报文分组就停止发送,等待对方确认后再发送下一分组。

图 2 停等协议

图 2 停等协议

无差错的情况下,A 发送分组 M1 后就暂停发送,等待 B 确认之后再继续发送。如图 2 中(a)所示。

在图 2(b)中展示了出现差错的情况,M1 在传输途中丢失了,B 没收到报文,或者B 收到 M1 时监测到出现差错,就丢弃 M1,其他什么也不做(不通知 A 出现差错),而 A 在一段时间之内没收到确认,就认为刚才发送的分组 M1 丢失了,所以重传 M1 分组。为了实现超时重传,发送方每发送一个分组都需要设置一个「超时计时器」,如果在超时之前收到对方的确认,则撤销计时器,计时器的超时时间应该比分组传输的平均往返时间更长一些。而且为了清楚是哪个分组出现了差错,需要对分组进行编号。

如果 B 接收到了分组 M1,但是发出的对 M1 确认报文段丢失,那么在 A 超时计时器到期后没有收到确认报文段,则需要重传 M1,那么 B 在抽到重复的 M1 分组后应当丢弃分组,并重新发送确认报文段,如图 3(a)所示。

如果 M1 的确认报文段出现了延迟,A 最终收到了迟到的确认报文段,那么 A 应当丢弃消息,由于 M1 分组超时重传,B 仍然会收到重复的 M1,B 照样需要丢弃重复的分组。

图 3 确认报文段丢失和确认报文段迟到

图 3 确认报文段丢失和确认报文段迟到

通过确认和重传机制就可以在不可靠的网络上实现可靠的通信,接收方不需要请求发送方重传某个分组,重传是自动进行,这种传输协议常称为「自动重传请求」(ARQ automatic repeat re-quest)。

ARQ 协议可以很方便的实现可靠的传输,但是信道的利用率太低,假定 A 发送分组需要的时间是 $T_D$,B 收到分组后立刻发送确认报文段,发送确认分组的时间是 $T_A$,A 处理确认分组的时间忽略不记,分组往返时间是 RTT,那么 A 在经过时间( $T_D + RTT + T_A$)后才可以发送下一个分组。因为只有 $T_D$ 是用来传输有用的数据,所以信道利用率 U = $T_D$ / ($T_D$ + RTT + $T_A$)

图 4 停等协议信道利用率太低

图 4 停等协议信道利用率太低

比如往返时延 RTT = 20ms,发送速率 1Mb/s,发送 1200 bit 分组,忽略处理时间和 $T_A$( $T_A$ 通常远小于 $T_D$),信道利用率 U = 5.66%,如果发送速率提升到 10 Mb/s,那么 U = 5.96 * $10^{-4}$。信道在绝大多数时间内是空闲的,而在出现差错时,信道利用率会更低。

连续停等协议

为了提高信道利用率,发送方可以不使用低效率的停等协议,而采用流水线传输,发送方可以连续发送多个分组,不必每次发送完就停下来等待对方确认,这样可以使信道上一直有数据传输,从而获得更高的信道利用率。这种方式区别于 ARQ 协议,叫做「连续 ARQ 协议」

图 5 通过流水线传输提高信道利用率

图 5 通过流水线传输提高信道利用率

如图 6 所示,分组按序号从大到小排列,发送方维护了一个发送窗口,位于窗口内的 5 个分组可以连续发送出去,而不需要等待对方确认,发送方每收到一个确认,就把发送窗口向前移动一个分组的位置。接收方通常是采用累积确认的方式,接收方不必对收到的分组逐个发送确认,而是在收到按序到达的多个分组之后,对最后一个分组发送确认,表示到这个分组为止的所有分组都已经正确收到了。

图 6 连续 ARQ 协议原理

图 6 连续 ARQ 协议原理

累积确认的方式很容易实现,即使确认报文段丢失也不必重传,但不能向发送方反馈接收方已正确接收的所有分组的信息。例如发送方发送了前 5 个分组,第 3 个分组丢失,这是接收方只能对前 2 个分组确认,发送方只好重传后面 3 个分组,这种回退到 N 的重传方式叫 Go-back-N,表示需要退回重传已发送的 N 个分组。在线路质量不好的时候,连续 ARQ 协议会带来一定的负面影响。

TCP 滑动窗口

TCP 的滑动窗口是以字节为单位的,发送「窗口」表示在没有收到确认报文之前,可以连续的把窗口内的数据都发送出去,已经发送的数据,在未确认前需要暂时保留,以便超时重传。发送窗口的位置由窗口前沿和后沿共同决定,后沿之后的数据表示已经发送并且确认过,继续收到确认后后沿可以往前移动,不会向后移动。前沿通常不断向前移动,如果一直没收到确认报文,那么前沿和后沿不会移动。如果接收方通知缩小窗口,前沿也不会向前移动,前沿在这种情况下理论上可以向后移动,但是强烈不赞成前沿向后移动,因为前沿后的部分数据已经发送出去了。

图 7 所示,A 是发送方,B 是接收方,窗口是 20 字节,A 收到 B 的确认报文,确认号是 30,表明 30 号为止的数据已经接收,期望收到的下一个序号是 31。A 继续发送了 31 ~ 41 的数据,但还未收到确认报文段,发送窗口位置并未改变,如图 8 所示,31 ~ 40 这 10 个字节是已发送但未收到确认的消息,42 ~ 50 是允许发送但尚未发送的消息。

图 7

图 7

描述窗口状态需要 3 个指针,$P_1$,$P_2$ 和 $P_3$ (图 8)。其中小于 $P_1$ 的是已发送并确认的部分,大于 $P_3$ 的是不允许发送的部分,$P_3$ ~ $P_1$ 是窗口大小,$P_2$ ~ $P_1$ 是已发送但尚未确认的字节数,$P_3$ ~ $P_2$ 是允许发送但尚未发送的字节数(也叫可用窗口或有效窗口)。

图 8

图 8

图 8 中 B 的接收窗口大小是 20,窗口之外,到 30 为止的数据是已发送确认报文段的数据,并且已交付应用层,因此 B 可以不再保留这部分数据。31 ~ 50 在接收窗口内,假如 31 由于异常原因丢失,B 收到了 32 和 33,这些数据未按序到达,需要注意的是 B 只能对按序到达的数据中最高序号给出确认报文段,因此 B 给出的确认报文段依然是 31,而不是 32 或 33。

B 收到序号为 31 的数据,并把31 ~ 33 的数据交付应用层,然后 B 删除这些数据,并把接收窗口向前移动 3 个序号,同时给 A 发送确认报文段,窗口大小为 20,确认号是 34。除此之外,B 还收到序号为 37、38 和 40 的数据,这些数据没有按序到达,只能暂存在接收窗口中。A 收到 B 的确认报文段后将发送窗口向前滑动 3 个序号,但 P2 不变,现在 A 的可用窗口增大了,可以发送 42 ~ 53。如图 9 所示。

图 9

图 9

A 发送完 42 ~ 53 后,指针 P_2 向前移动和 P_3 重合,发送窗口内的序号都已用完,但没有再收到确认,由于 A 的发送窗口已满,可用窗口已减小到 0,需要停止发送。如图 10。现实中存在这种情况,A 发送的数据还没到达 B,或者 B 的确认数据滞留在网络中。为保证可靠传输,A 只能认为 B 还没收到数据,A 在超时控制器超时后需要重传这部分数据,重新设置超时计时器,直到 B 确认为止。

图 10 发送窗口已满

图 10 发送窗口已满

上文提到过应用层将数据发送到 TCP 缓存,和应用层从 TCP 缓存读取数据,窗口和 TCP 缓存的关系如图 11。

发送窗口通常只是发送缓存的一部分,发送缓存存储发送方准备发送的数据和已发送但尚未收到确认的数据,已发送的数据应当从发送缓存中删除,发送缓存和发送窗口的后沿重叠,应用层最后写入发送缓存的字节减去最后被确认的字节,就是保留在发送缓存中的字节数。

同样接收窗口也是接收缓存的一部分,接收缓存储存按序到达但尚未被应用层读取的数据和未按序到达的数据。如果收到的分组有差错,则需要丢弃掉,如果应用层来不及读取收到的数据,接收缓存最终会被占满,使的接收窗口减到 0。如果应用层可以很快的读取接收到的数据,接收窗口就可以增大,但最大不能超过接收缓存的大小。

图 11 TCP 缓存和窗口的关系

图 11 TCP 缓存和窗口的关系

虽然发送窗口大小是根据接收窗口大小设置的,但在同一时刻,发送窗口和接收窗口大小不一定一样,因为通过网络传递窗口值需要一定的时间,而且发送方会根据网络用塞情况会适当调整发送窗口。

超时重传时间的选择

TCP 发送方在规定的时间内没收到确认就需要重传已发送的报文段,其中重传时间选择是 TCP 最复杂的问题之一。由于网络环境的不确定性,如果重传时间设置得太短,就会引起很多报文段的不必要的重传,是网络负荷增大;如果重传时间设置大太长,会使网络空闲时间增大,降低了传输效率。

TCP 采用自适应算法,记录报文发送的时间和接收到确认的时间,这两个时间之差就是「报文段的往返时间 RTT」。TCP 使用 RTT 的加权平均返回时间 $RTT_S$(又叫「平滑的往返时间」),第一次测量到 RTT 样本时,$RTT_S$ 取值为测量到的 RTT 样本值,之后每次测量之后,就重新计算 $RTT_S$。

$$ 新的 RTT_S = (1 - \alpha) * ( 旧的 RTT_S ) + \alpha * (新的 RTT 样本) $$

$\alpha$ 取值区间为 0~1(不包含 1 ),若 $\alpha$ 接近于 0,表示新的 $RTT_S$ 和旧的 $RTT_S$ 变化不大,$RTT_S$ 受新的 RTT 样本影响不大(RTT 值更新慢);若 $\alpha$ 接近 1,则表示新的 $RTT_S$ 受 RTT 样本影响较大(RTT 值更新快)。RFC 2988 推荐的 $\alpha$ 值为 0.125 。

超时重传时间 RTO(Retransmission Time-Out)应该略大于加权平均往返时间 $RTT_S$:

$$ RTO = RTT_S + 4 * RTT_D $$

$RTT_D$ 是 RTT 的偏差的加权平均值,与 $RTT_S$ 和新的 RTT 样本之差有关。在第一次测量 RTT 时,$RTT_D$ 值为 RTT 样本值的一半,之后每次测量 RTT,就重新计算 $RTT_D$:

$$ 新的 RTT_D = ( 1 - \beta ) * (旧的 RTT_D ) + \beta * | RTT_S - 新的 RTT 样本值| $$

$\beta$ 是小于 1 的系数,推荐值是 0.25。

超时重传的情况会对计算 $RTT_S$ 和 RTO 有很大的影响,比如超时重传后收到了确认报文段,这样就无法判断确认报文段是对先前报文段的确认,还是对后来重传报文段的确认。针对这个问题 Karn 提出了一个算法:在计算加权平均 $RTT_S$ 时,只要报文重传了,就不采用其往返时间样本。

但如果网络时延突然增大,在重传时间内不会收到确认报文段,于是就重传报文段,但根据 Karn 算法,不考虑重传报文段的往返时间样本,这样重传时间无法得到更新。

所以要对 Karn 算法作出修正,报文段每重传一次,就把超时重传时间 RTO 增大一些。典型的做法是新的重传时间设置为原来的重传时间的 2 倍,当不再发生报文段重传,才根据公式计算超时重传时间。

选择确认 SACK

若收到的报文段无差错,但是未按序号到达,中间还缺少了一些序号的数据,可以通过「选择确认」(selective ACK)只传送缺少的数据而不重传已经到达接收方的数据。

TCP 接收方接收到的数据字节流序号不连续,就形成了一些不连续的字节块,如图 12,序号 1 ~ 1000收到了,但 1001 ~ 1500 没有收到,接下来 1501 ~ 3000收到了,缺少 3001 ~ 3501,再后面 4501 之后的数据没收到。如果这些字节的序号都在接收窗口内,接收方可以先接收下这些数据,并且将信息告知发送方。

图 12 不连续的字节块

图 12 不连续的字节块

要使用 SACK,那么在创建链接时需要在 TCP 首部加上「允许 SACK」选项,使用了 SACK,原来首部中的「确认字段号」用法不变,只是之后的 TCP 报文段首部都增加了 SACK 选项,以便报告收到的不连续的字节块边界。

流量控制

利用滑动窗口实现流量控制

如果发送方数据发送的过快,接收方可能 来不及接收,这就可能造成数据丢失,所以需要让发送方发送速率不要太快,以便接收方来得及处理,这就是流量控制。

利用滑动窗口机制可以很方便在 TCP 链接上实现对发送方的流量控制。如图 13 所示,建立链接时,B 告诉 A 「我的接收窗口 rwnd = 400」(rwnd 表示 receiver window),发送方的发送窗口不能超过接收方给出的接收窗口。需要注意的是,TCP 窗口单位是字节,而不是报文段。假设每个报文长度是 100 字节,报文段序号初始值是 1,箭头中大写的 ACK 表示首部中确认位 ACK,小写的 ack 表示确认字段的值。

图 13 利用可变窗口进行流量控制

图 13 利用可变窗口进行流量控制

接收方主机进行了 3 次流量控制,第一次将窗口减小到 rwnd=300,第二次又减到 rwnd=100,最后减到 rwnd=0,即不允许对方再发送数据了,暂停状态直到 B 再发送一个新的窗口值为止。

如果在 B 向 A 发送零窗口的报文段后不久,B 的接收缓存又有了一些空间,于是 B 向 A 发送了 rwnd=400 的报文段。然而报文在传输过程中丢失,A 一直在等待 B 的非零窗口通知,而 B 也一直在等待 A 发送数据,如果没有其他措施,这种互相等待的死锁局面会一直持续下去。为解决这问题,TCP 为每一个连接设有一个「持续计数器」,只要 TCP 连接的一方收到对方的零窗口通知,就启动持续计数器,如果计数器到期,就发送一个零窗口的探测报文(仅携带 1 字节数据),而对方在确认这个探测报文时给出了现在的窗口值。如果窗口值仍然是零,那么收到这个报文的一方就重新设置计数器,如果窗口不是零,那么就可以打破死锁僵局。

拥塞控制

计算机网络中,链路带宽、交换节点的缓存和处理机等都是网络的资源,在某段时间,如果对网络中某一资源的需求超过了该资源所能提供的可用部分,网络性能就要变坏,即产生了拥塞(congestion)。

网络拥塞往往是由许多因素引起的,简单的增加一些资源,往往不能解决拥塞问题,甚至有时候会使网络性能变坏。例如,当某个节点的缓存容量太小,到达的分组因空间不足而不得不丢弃,如果仅仅是扩大接收缓存,到达该节点的分组会在缓存中排队。由于链路容量和处理机性能并未提高,因此缓存中的分组等待时间大大增加,结果上层软件会因为等待超时而重传分组。由于拥塞控制引起的重传会继而加剧网络的拥塞。

拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中路由器或链路不至于过载,使网络能够承受现有的网络负荷。

拥塞控制和流量控制密切相关,但也存在一些差异,流量控制通常是指点对点通信量的控制,解决的是端到端的问题(接收段控制发送端),流量控制所要做的就是抑制发送端发送数据的速率,以便接收段来得及接收。而拥塞控制是一个全局性的过程,涉及到所有主机、路由器,以及与网络传输性能相关的所有因素。

拥塞控制流流量控制之所以会被弄混,是因为某些拥塞控制算法和流量控制算法类似,向发送端发送控制报文,来告诉发送端网络出现麻烦,必须放慢速度。

假设某个网络带宽是 1000 Gb/s,有一个服务器向一台 PC 机以 1 Gb/s 的速率传送数据,显然网络带宽足够大,因而不存在拥塞问题,但也必须要有流量控制,让服务器经常停下来,以便让 PC 机来得及接收。

而另一个网络带宽是 1 Mb/s,有 1000 台主机,其中 500 台分别向另外 500 台主机以 100k/s 的速率发送数据,那么现在的问题就不是接收段是否能来得及接收数据,而是整个网络的输入负载是否超过了网络的承受能力。

进行拥塞控制需要付出代价,首先需要获得网络内部流量分布的信息,还要在节点之间交换信息和各种命令,以便选择控制策略和实施控制。这就产生了额外的开销,有时候还需要将一些资源(缓存、带宽等)分配给个别用户(或一些类别的用户)单独使用,使得网络资源不能更好的实现共享。所以在设计拥塞控制策略时,必须全面衡量得失。

图 14 拥塞控制的作用

图 14 拥塞控制的作用

如图 14 所示,横轴是「提供的负载」,代表单位时间输入到网络的分组数目,也称作「输入负载」或「网络负载」。纵轴是「吞吐量」,代表单位时间从网络输出的分组数目。具有理想拥塞控制的网络,在吞吐量饱和之前,吞吐量应等于网络负载,当负载超过某一限度时,由于网络资源有限,吞吐量不再增长而保持为水平线,即吞吐量达到饱和,这就表明输入到网络的某些分组被某个节点丢弃了。

但实际情况下,随着网络负载的增大,网络吞吐量增长率逐渐减小,也就是说在网络还没有达到饱和时,就已经有一部分分组被丢弃了,当负载明显的小于理想的吞吐量的时候网络就进入了「轻度拥塞」的状态。当负载达到某一数值时,网络吞吐量反而随着负载的增大而下降,网络已经进入拥塞状态了。当负载继续增大到某一数值时,网络吞吐量就下降到零,网络已经无法工作。

从原理上讲,拥塞控制的方案就是让对网络资源的需求不再大于可用的网络资源,或者是增大网络中某些可用资源(增加一些链路、增加带宽等),或者减小用户对资源的需求(拒绝新的建立连接请求、让用户减轻负荷)。在采取某项措施时,还必须考虑到该措施所带来的影响。

拥塞控制是一个动态的问题,网络正朝着高速化的方向发展,很容易出现缓存不够大而造成分组的丢失,分组丢失是网络拥塞的征兆而不是原因,有些情况下,拥塞控制机制本身会引起网络性能恶化甚至发生死锁。

检测网络拥塞的方法很多,主要的检测指标有:由于缺少缓存空间而被丢弃的分组的百分比、平均队列长度、超时重传的分组数、平均分组时延、分组时延的标准差等待。

一般在检测到拥塞发生时,要将拥塞发生的信息传送到产生分组的源,通知拥塞的消息分组同样会使网络更加拥塞。另一种方法是在路由器转发的分组中保留一个比特或字段,用该比特或字段表示网络是否拥塞。也可以通过周期性的发送探测分组,询问拥塞是否发生。

此外过于频繁的拥塞控制会使系统产生不稳定的振荡,过于迟缓的拥塞控制又不具有任何实用价值。因此要采取某种折中的方案,选择正确的时间常数。

RFC 2581 定义了拥塞控制的四种算法:慢开始(slow-start)、拥塞避免(congestion avoidance)、快重传(fast retransmit)和快恢复(fast recovery)。

慢开始和拥塞避免

发送方维持一个叫「拥塞窗口」cwnd(congestion window)的状态变量,拥塞窗口大小取决于网络拥塞程度,并且动态变化。发送方让自己的发送窗口等于拥塞窗口,如果再考虑接收方的接收能力,发送窗口还可能小于拥塞窗口。

发送方控制拥塞窗口的原则是:只要网络没有出现拥塞,拥塞窗口就再增大一些,以便发送更多的分组;如果网络出现拥塞,拥塞窗口就减小一些,以减小注入到网络的分组数。

网络发送拥塞时,路由器就要丢弃分组,因此只要发送方没有按时接收到应该到达的确认报文,就可以认为网络中出现了拥塞。

慢开始算法的思路是这样的,当主机开始发送数据时,如果立即将大量分组注入到网络,那么就有可能引起拥塞。较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是说从小到达逐渐增大拥塞窗口数值。刚开始发送报文段时,先把拥塞窗口 cwnd 设置为一个最大报文段 MSS 的数值,每收到一个对新的报文段确认后,把拥塞窗口增加至多一个 MSS 的数值。用这样的方式逐步增大发送方拥塞窗口 cwnd,以使分组注入到网络的速率更加合理。

在一开始发送方先设置 cwnd=1 (使用报文段个数作为窗口大小的单位,实际上 TCP 窗口的单位是字节)。发送第一个报文段 $M_1$,接收方收到后确认 $M_1$,发送方收到对 $M_1$ 的确认后,把 cwnd 增大到 2,发送方接着发送 $M_2$ 和 $M_3$ 两个报文段,接收方返回对 $M_2$ 和 $M_3$ 的确认。发送方每收到一个对新报文段的确认(重传的不算在内),就使发送方的拥塞窗口加 1,因此收到这两个确认后, cwnd 从 2 增大到 4,可以发送 $M_4$ ~ $M_7$ 共 4 个报文段。使用慢开始算法,每经过一个传输轮次(transmission round),拥塞窗口 cwnd 就加倍。

把拥塞窗口 cwnd 所允许发送的报文段都连续发送出去,并收到最后一个字节的确认,即一个发送轮次。一个传输轮次所经历的时间其实就是往返时间 RTT。例如,拥塞窗口 cwnd 的大小是 4 个报文段,那么往返时间 RTT 就是发送方连续发送 4 个报文段,并收到这 4 个报文段的确认,总共经历的时间。

图 15 发送方每收到一个确认就把窗口 cwnd 加 1

图 15 发送方每收到一个确认就把窗口 cwnd 加 1

慢开始的「慢」并不是指 cwnd 的增长速率慢,而是指在 TCP 开始发送报文段时先设置 cwnd=1,使得发送方在开始时只发送一个报文段(目的是探测网络的拥塞情况),然后逐渐增大 cwnd。为了防止拥塞窗口 cwnd 增大的过大引起的网络拥塞,还需要设置一个「慢开始门限 ssthresh」状态变量,当 cwnd < ssthresh 时,使用慢开始算法,当 cwnd > ssthresh 时该用拥塞避免算法。

拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,每经过一个往返时间 RTT 就把发送方的拥塞窗口 cwnd 加 1,而不是加倍。这样,拥塞窗口 cwnd 按线性规律缓慢增长。

无论在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(没按时收到确认),就要把慢开始门限 ssthresh 设置为出现拥塞时发送窗口值的一半(但不能小于 2 ),然后把拥塞窗口 cwnd 重新设置为 1,执行慢开始算法。迅速减少主机发送到网络中的分组数,使得发生拥塞的路由器有足够的时间把队列中积压的分组处理完。

图 16 慢开始和拥塞避免算法

图 16 慢开始和拥塞避免算法

如图 16 所示,当 TCP 连接初始化时,把拥塞窗口 cwnd 置为 1,慢开始门限 ssthresh 设置为 16。发送方每收到一个对新报文段的确认 ACK,就把拥塞窗口值加 1,然后进行下一轮次的传输,拥塞窗口 cwnd 随着传播轮次按指数规则增长( $2^N$)。当拥塞窗口 cwnd 增长到慢开始门限 ssthresh 时(即 cwnd=16),就改为执行拥塞避免算法,拥塞窗口按线性规律增长。假设拥塞窗口 cwnd 数值增长到 24 时,网络出现超时(网络可能发生拥塞),就将慢开始门限 ssthresh 更新为 12 (即变为出现拥塞时拥塞窗口 cwnd 大小的一半),拥塞窗口 cwnd 重新设置为 1,并执行慢开始算法,当拥塞窗口 cwnd 达到慢开始门限 ssthresh 时,改为拥塞避免算法,拥塞窗口线性增长。

图 16 中「乘法减小」是指不论在慢开始阶段还是拥塞避免阶段,只要出现超时(即可能出现了网络拥塞),就把慢开始门限值 ssthresh 减半,即设置为当前拥塞窗口的一半。当网络频繁出现拥塞时,ssthresh 下降的很快,大大减小了注入到网络中的分组数。「加分增大」是指拥塞避免阶段,使拥塞窗口缓慢增大,以防止网络过早出现拥塞。这两种算法合起来称为 AIMD 算法(加法增大乘法减小)。

快重传和快恢复

快重传算法要求接收方每收到一个失序的报文段后就立即发出重复确认(为了让发送方及早知道有报文段没有到达对方),而不是等待自己发送数据时才进行捎带确认。如图 17,接收方收到 $M_1$和 $M_2$ 后都分别发出了确认,假设接收方没收到 $M_3$ 但接着收到了 $M_4$,因为 $M_4$ 是失序的报文,所以接收方不对 $M_4$ 进行确认,根据可靠传输原理,接收方可以什么都不做,也可以在适当时机发送对 $M_2$ 的确认,但按快重传算法的规定,接收方应及时并且重复的发送对 $M_2$ 的确认,让发送方及早的知道报文段 $M_3$ 没有到达接收方。发送方接着传送 $M_5$ 和 $M_6$,接收方依然再次发送对 $M_2$ 的确认,这样接收方总共接到了四个对 $M_2$ 的确认,其中后面三个都是重复的。快重传算法规定,发送方只要一连收到三个重复确认就应当立即传送对方尚未收到的报文段 $M_3$,而不必等待为 $M_3$ 设置的重传计数器到期。由于发送方能够及早的重传未被确认的报文段,因此采用快重传后可以是整个网络吞吐量提高约 20%。

图 17 快重传示意图

图 17 快重传示意图

与快重传算法配合的还有快恢复算法,当发送方连续收到三个重复确认时,就执行「乘法减小」算法,把慢开始门限 ssthresh 减半,以防网络拥塞,发送方认为网络可能没有发生拥塞(如果网络发生拥塞,就不会一连有好几个报文段连续的到达接收方,也就不会导致接收方连续发送重复确认),这是不执行慢开始算法(即拥塞窗口 cwnd 不设置为 1 ),而是把 cwnd 值设置为慢开始门限 ssthresh 减半后的值,然后开始执行拥塞避免算法(加法增大),使拥塞窗口缓慢的线性增大。

图 18 连续收到三个重复确认后转入拥塞避免

图 18 连续收到三个重复确认后转入拥塞避免

如图 18 所示,「TCP Reno 版本」(快回复和快重传算法)是目前广泛使用的版本,「TCP Tahoe 版本」(慢开始算法)已废弃不用,二者的区别是 TCP Reno 版本在快重传之后采用快恢复算法,而不是慢开始算法。

也有快重传实现是将开始时的拥塞窗口 cwnd 值再增大一些(增加 3 个报文段长度),即 cwnd = ssthresh + 3 * MSS。原因是既然发送方收到三个重复的确认,就表明有三个分组已经离开网络,这三个分组停留在接收方的缓存中,不再消耗网络资源,因此可以适当的把窗口增大一些。

采用快恢复算法时,慢开始算法只是在 TCP 连接建立时和网络出现超时的时候才使用。

接收方的缓存同样是有限的,接收方根据自己的接收能力设置接收窗口 rwnd,并将窗口值写入 TCP 首部,传送给发送方,发送方的发送窗口 cwnd 一定不能超过接收方给的接收窗口的值 rwnd。

随机早期检测 RED

当路由器对分组处理时间特别长,那么就可能使这些分组经过很长时间才能到达终点,结果引起发送方对这些报文的重传。重传会引起 TCP 发送端认为网络出现拥塞,于是 TCP 发送端就采取了拥塞控制措施,但实际网络并未拥塞。网络层对 TCP 拥塞控制影响最大的就是路由器的分组丢弃策略。路由器通常按照「先进先出」的策略处理到达的分组,由于队列长度有限,因此当队列满了之后,再到达的分组都将被丢弃,这就是「尾部丢弃策略」。

路由器的尾部丢弃往往导致一连串的分组丢失,使发送方出现超时重传,使 TCP 进入拥塞控制,结果 TCP 发送方突然把数据发送速率降低到很小的数值。更严重的是,网络中通常有很多 TCP 连接,这种情况下如果发生路由器尾部丢弃,就可能会同时影响到很多条 TCP 连接,使许多 TCP 连接在同一时间突然开始拥塞控制,这叫「全局同步」,使全网通信量突然下降很多,而在网络恢复后,通信量有突然增大很多。

为了避免发生全局同步,可以在路由器采取「随机早期检测」(RED Random )措施。路由器队列维护两个参数,队列长度最小门限 $TH_{min}$ 和最大门限 $TH_{max}$。分组到达时,先计算平均队列长度 $L_{AV}$,若平均队列长度小于最小门限 $TH_{min}$,则把分组放入队列;若平均队列长度超过最大门限 $TH_{max}$,则丢弃分组,若平均队列长度介于最小门限 $TH_{min}$ 和最大门限 $TH_{max}$,则按某一概率 p 将分组丢弃。

图 19 RED 算法把路由器的到达队列划分成三个区域

图 19 RED 算法把路由器的到达队列划分成三个区域

随机早期检测 RED 不是等已发生网络拥塞才把队列尾的分组全部丢弃,而是在检测到网络拥塞的早期征兆时(即路由器的平均队列长度超过一定门限值),就先以概率 p 随机丢弃个别分组,让拥塞控制只在个别 TCP 连接上进行,从而避免全局性的拥塞控制。

随机早期检测 RED 算法正常工作的关键是要选择好三个参数:最小门限 $TH_{min}$、最大门限 $TH_{max}$ 和概率 p。最小门限 $TH_{min}$ 必须足够大,以保证路由器的输出链路有较高的利用率,最小门限 $TH_{min}$ 和最大门限 $TH_{max}$ 之间的差值也应当足够大,使得在一个 TCP 往返时间 RTT 中队列的正常增长仍在最大门限 $TH_{max}$ 内。通过最大门限 $TH_{max}$ 等于最小门限 $TH_{min}$ 的两倍是合适的。如果门限值设置的不合适,RED 也可能会引起类似于尾部丢弃那样的全局振荡。

RED 中最复杂的就是丢弃概率 p 的选择,p 不是常数,对于每个新到达的分组,都必须计算丢弃概率 p,p 取决于当前的平均队列长度 $L_{AV}$ 和所设定的两个门限值 $TH_{min}$ 和 $TH_{max}$。当平均队列长度 $L_{AV}$ 小于最小门限值 $TH_{min}$ 时,丢弃概率 p = 0;当平均队列长度 $L_{AV}$ 大于最大门限值 $TH_{max}$ 时,丢弃概率 p = 1;当平均队列长度 $L_{AV}$ 在 $TH_{min}$ 和 $TH_{max}$ 之间时,分组丢弃概率 p 在 0 到 1 之间。

图 20 分组丢弃概率 p 和两个门限值 $TH_{min}$ 和 $TH_{max}$ 的关系

图 20 分组丢弃概率 p 和两个门限值 $TH_{min}$ 和 $TH_{max}$ 的关系

由于计算机数据具有突发性的特点,路由器中队列长度经常会出现很快的起伏变化,如果仅因为瞬时队列长度超过了最小门限值 $TH_{min}$ 就丢弃分组会产生不必要的拥塞控制,因此 RED 采用加权平均的方法计算平均队列长度 $L_{AV}$。

图 21 瞬时队列长度和平均队列长度的区别

图 21 瞬时队列长度和平均队列长度的区别

$平均队列长度 L_{AV} = ( 1 - \delta ) * (旧L_{AV}) + \delta * (当前队列长度)$。公式中 $\delta$ 是在 0 到 1 之间的数,如果 $\delta$ 足够小,那么平均队列长度 $L_{AV}$ 取决于队列长度的长期变化趋势,而不受瞬时突发数据的影响。

分组丢弃概率 $p = p_{temp} / (1 - count * p_{temp})$。count 是常量,代表新到达的分组有多少个已经进入到了队列(没有被丢弃);$p_{temp}$ 是过渡的分组丢弃概率,$p_{temp} = p_{max} * (L_{AV} - TH_{min})/(TH_{max} - TH_{min})$。

假设 $p_{max} = 0.02$,count 初始值是 0,假设平均队列长度 $L_{AV}$在两个门限之间,算出过渡的分组丢弃概率 $p_{temp}$ = 0.01,由于count = 0,所以 $p=p_{temp}=0.01$,也就是说现在到达的分组进入路由器队列概率是0.99,随着分组的不断到达,count不断增大,分组丢弃概率也逐渐增大。假设一连有 50 个分组进入队列而没有被丢弃,这时分组丢弃概率增大一倍,即 $p=0.02$。再假设一连有99 个分组进入队列而没有被丢弃,那么分组丢弃概率 $p = 1$(设平均队列长度一直不变),下一个分组一定会被丢弃。分组丢弃概率 p 不仅与平均队列长度有关,还随着队列中不被丢弃的分组数目的增多而逐渐增大,就可以避免分组丢弃的过于集中。

总之,随机早期检测 RED 好处就是当平均队列长度超过门限值 $TH_{min}$时,就会有少量的分组被丢弃,使得少量的 TCP 连接会减小窗口值,使得到达路由器分组的数量减少,从而平均队列长度减少了,避免了网络拥塞。需要注意,路由器在某一时刻的瞬时队列长度完全有可能超过平均队列长度,采用随机早期检测 RED 算法丢弃概率很小,但路由器队列已经没有空间接纳新到的分组,那么 RED 的操作和「尾部丢弃」方式一样,RED 仅在可能的条件下尽量使「尾部丢弃」不要发生。

TCP 连接管理

三次握手

TCP 连接建立的过程中要解决三个问题:

  • 每一方都能确知对方的存在
  • 允许双方协商一些参数(最大窗口值、是否使用窗口扩大选项和时间戳选项以及服务质量等)
  • 能够对运输实体资源(缓存大小、连接表中的项目等)进行分配

TCP 采用客户端/服务器模式,主动发起连接建立的应用进程叫「客户」,被动等待连接建立的应用叫「服务器」。

图 22 使用三次握手建立 TCP 连接

图 22 使用三次握手建立 TCP 连接

图 22 展示了 TCP 建立连接的过程,A 是客户,B是服务器,最初两端的 TCP 进程都处于 CLOSED 状态。B 的 TCP 服务器进程先创建「传输控制块」TCB,TCB 中存储着连接的一些重要信息,比如 TCP 连接表、到发送和接收缓存的指针、到重传队列的指针、当前发送和接收的序列号等等。之后服务器就处于 LISTEN(收听) 状态,等待客户的连接请求。

A 的 TCP 客户进程也是先创建传输控制块 TCB,然后向 B 发出连接请求字段,首部的同步位 SYN = 1,同时选择一个初始序列号 seq = x。TCP 规定,SYN 报文段不能携带数据,但要消耗掉一个序号。这时 A 的客户进程进入 SYN-SENT(同步已发送)状态。

B 收到客户的连接请求后,如果同意建立连接,则向 A 发送确认,确认报文段中 SYN 和 ACK 都置为 1,确认号 ack = x + 1,同时也为自己选择一个序列号 seq = y。这个报文段也不能携带数据,同意要消耗掉一个序列号。这时 B 的服务器进程进入 SYN-RCVD(同步已收到)状态。

A 客户进程收到 B 的确认后,还要向 B 给出确认,确认报文段 ACK 置为 1,确认号 ack = y + 1,自己的序列号 seq = x + 1。TCP 规定,ACK 报文可以携带数据,如果不携带数据则不消耗序列号,下一个报文段的序列号仍然是 seq = x + 1。这时 TCP 连接已经建立,A 进入 ESTABLISH(已建立连接)状态,当 B 收到 A 的确认之后也进去 ESTABLISH(已建立连接)状态。

为什么 A 还要再发送一次确认呢?主要是为了防止「已失效的连接请求报文段」突然又传到了 B。考虑一种情况,如果 A 发出连接请求在网络节点长时间滞留了,导致 A 再重传了一次连接请求,而滞留的报文段在连接释放后的某个时间才到达 B,这本应该是一个早已失效的报文段,但 B 收到这个失效的报文段后,误以为 A 又发送了一次连接请求,于是向 A 发出确认报文,同意建立连接。假定不采用三次握手,那么 B 发出确认报文,连接就建立了。而 A 并没有发出连接请求,所以不会回应 B 的确认报文段,也不会向 B 发送数据,但是 B 以为连接已经建立,并一直等 A 的数据。而采用三次握手的办法就可以防止这个问题。

简单的理解三次握手,第一次握手可以确认客户进程发送没问题,第二次握手可以确认服务进程接收没和发送问题,第三次握手可以确认客户进程接收没问题。

四次挥手

数据传输结束后,通信的双方都可以释放连接。

图 23 使用四次挥手释放 TCP 连接

图 23 使用四次挥手释放 TCP 连接

如图 23 所示,现在 A 和 B 都处于 ESTABLISH 状态,A 先发出了连接释放报文段,并停止发送数据。A 把连接释放报文段首部的终止控制位 FIN 置为 1,序列号 seq = u,值等于前面传送过的数据最后一个字节的序列号加 1,这时 A 进入 FIN-WAIT-1(终止等待 1)状态,等待 B 的确认。TCP 规定,FIN 报文段即使不携带数据,也要消耗掉一个序列号。

B 收到连接释放报文后立即确认,确认号 ack = u + 1,这个报文段的序列号 seq = v,值等于前面已发送过数据的最后一个字节的序号加 1,之后 B 进入 CLOSE-WAIT (关闭等待)状态,从 A 到 B 这个方向的连接就释放了,TCP 处于半关闭状态,即 A 已经没有数据要发送了,但如果 B 要发送数据,A 仍要接收,也就是说 B 到 A 这个方向的连接尚未关闭。

A 收到 B 的确认后,进入 FIN-WAIT-2 (终止等待2)状态,等待 B 发出的连接释放报文段。如果 B 已经没有要向 A 发送的数据,应用进程就通知 TCP 释放连接。B 发出连接释放报文段,置 FIN 为 1,序列号为 w (半关闭状态 B 可能会发送一些数据),还需要重复上次已发送的确认号 ack = u + 1,这是 B进入 LAST-ACK(最后确认)状态,等待 A 的确认。A 在收到 B 的连接释放报文段后,必须对此发送确认,置 ACK 为 1,确认号 ack = w + 1,序列号 seq = u + 1(前面发送的 FIN 报文段需要消耗一个序号),然后进入 TIME-WAIT(时间等待)状态,这时候 TCP 连接还没有释放,必须等待时间等待计时器(time-wait timer)设置的时间 2MSL 后,A 才进入到 CLOSED 状态。时间 MSL 叫最长报文段寿命,建议设置为 2 分钟。

为什么 A 在发出最后一个 ACK 报文段后必须等待 2 MSL 时间?有两个原因。第一,为了保证 A 发送的 ACK 报文段能够到达 B,处于 LASK-ACK 状态的 B 如果收不到 A 的确认报文段,B 会超时重传 FIN + ACK 报文段。第二,防止「已失效的连接请求报文段」,A 等待 2MSL,可以使本连接持续时间内产生的所有报文段都从网络中消失,使下一个新的连接中不会出现旧的连接报文段。

除了等待计时器外,TCP 还设有一个「保活计时器」,以防止客户进程故障导致服务器进程的不必要的等待。服务器每次收到客户的数据,就重新设置保活计时器,时间设置通常是两小时,若两小时没有收到客户的数据,服务器就发送一个探测报文段,之后每隔 75 分钟发送一次,如果一连 10 个探测报文段后客户仍无响应,服务器就关闭这个连接。

TCP 的有限状态机

图 24 TCP 的有限状态机

图 24 TCP 的有限状态机

图 24 展示了 TCP 的有限状态机,方框表示 TCP 可能具有的状态,状态间的箭头表示可能发生的状态变迁。粗实现箭头表示客户进程的正常变迁,粗虚线箭头表示服务器进程的正常变迁,细线箭头表示异常变迁。