网络协议 -- TCP协议,三次握手,四次握手,TCP重传,滑动窗口,拥塞学习

TCP 协议

因特网的网络层只提供无连接、不可靠的尽力服务。它可以将分组从一个主机通过因特网传送到另一台主机,可能出现比特错、丢失、重复和错序到达的情形。

传输层建立在网络层之上,为进程之间的数据传输提供服务。传输层可以通过不可靠的因特网在两个进程之间建立一条可靠的逻辑链路,提供字节流传输服务。

因特网的传输层有两个协议UDP和TCP:

  • UDP(User Datagram Protocol)只提供无连接的不可靠的服务,应用进程通过<远端IP地址,远端端口号>向远端进程发送数据,应用进程并不要求远端进程进行确认。
  • TCP(Transmission Control Protocol)为应用程序之间提供面向连接的可靠的字节流服务。TCP为全双工协议,提供流控制机制,即允许接收方控制发送方的发送速度,此外还提供拥塞控制功能。

下图即为两个端点之间TCP通信的简单示意图:

源主机的TCP进程从上层收集应用进程的数据,并在满足一定条件时发送出去,TCP发送的数据称为分段(Segment)。

TCP 报文结构

TCP头部数据格式如下:

各个字段的信息说明如下:

  • Source Port(Destination Port):分别占用16位,表示源端口号和目的端口号;用于区别主机中的不同进程,源端口号和目的端口号配合上IP首部中的源IP地址和目的IP地址就能唯一的确定一个TCP连接;
  • Sequence Number:用来标识从TCP发送端向TCP接收端发送的数据字节流,它表示在这个报文段中的的第一个数据字节在数据流中的序号,主要用来解决网络报乱序的问题;
  • Acknowledgment Number:发送确认的一端所期望收到的下一个序号,因此,确认序号应当是上次已成功收到数据字节序号加1。不过,只有当标志位中的ACK标志为1时该确认序列号的字段才有效,该字段主要用来解决丢包的问题。此外,TCP采用累计确认,即只有当确认字节之前的所有数据都到达之后才能发送确认,这样就可以用一个数字概括接收到的所有数据。
  • Data offset:用来标识TCP头部的长度,该数字为头部中字(32 bit)的数目,需要这个值是因为任选字段的长度是可变的。这个字段占4bit,因此TCP最多有60字节的头部。然而,没有任选字段,正常的长度是20字节;
  • Reserved:3个保留位,留作以后使用,全部设置为0;
  • 标志位:TCP头部中共有9个标志位,用于操控TCP的状态,主要有URG,ACK,PSH,RST,SYN,FIN,标志位的意思如下:
    • URG:此标志表示TCP包的紧急指针域有效,用来保证TCP连接不被中断,并且督促中间层设备要尽快处理这些数据;
    • ACK:此标志表示应答域有效,就是说前面所说的TCP应答号将会包含在TCP数据包中;
    • PSH:表示Push操作,数据包到达接收端以后,立即传送给应用程序,而不是在缓冲区中排队;
    • RST:表示连接复位请求,用来复位那些产生错误的连接,也被用来拒绝错误和非法的数据包;
    • SYN:表示同步序号,用来建立连接。SYN标志位和ACK标志位搭配使用,当连接请求的时候,SYN=1,ACK=0;连接被响应的时候,SYN=1,ACK=1;
    • FIN:表示发送端已经达到数据末尾,也就是说双方的数据传送完成,没有数据可以传送了。
  • Window Size:窗口大小,也就是有名的滑动窗口,用来进行流量控制。指定从被确认的字节算起可以发送多少个字节,窗口大小字段为0是合法的,说明已经接收到了 确认号-1 个字节,但是接收端没有来得及取走数据。

三次握手建立连接

TCP协议提供可靠的连接服务,无论哪一方向另一方发送数据之前,都必须先在双方之间建立一条连接。在TCP/IP协议中,连接是通过三次握手进行初始化的,三次握手的过程如下:

前两次握手,客户端进入连接状态,后两次握手,服务器进入连接状态。所以,三次握手之后,一个全双工的连接就建立起来了,之后,客户端和服务器端就可以开始传送数据。

  • 第一次握手:客户端发送连接请求报文段,将SYN位设为1,SeqNum为随机数A;
  • 第二次握手:服务器返回ACK,确认收到客户端发来的SYN,然后设置AckNum为A+1;此外,服务器发送自己的连接请求报文段,即发送SYN和随机数B作为SeqNum;
  • 第三次握手:客户端返回ACK,确认收到服务器发来的SYN,然后设置AckNum=B+1。

为什么需要三次握手建立连接?

简单来说,为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。

考虑下面一种情况:client发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达server。server收到此失效的连接请求报文段后,误认为是client发出的一个新的连接请求。于是就向client发出确认报文段,同意建立连接。

假设不采用“三次握手”,那么只要server发出确认,新的连接就建立了。由于现在client并没有发出建立连接的请求,因此不会理睬server的确认,也不会向server发送数据。但server却以为新的连接已经建立,并一直等待client发来数据。这样,server的很多资源就白白浪费掉了。

采用“三次握手”的办法可以防止上述现象发生。例如刚才那种情况,client不会向server的确认发出确认。server由于收不到确认,就知道client并没有要求建立连接。”

四次握手断开连接

客户端和服务器数据传送完毕后,需要断开TCP连接,断开连接的时候需要进行四次握手。

四次握手的过程如下:

  • 第一次握手:发起端发送FIN和SeqNum=A,进入FIN_WAIT_1状态,用来关闭发起端到接收端的数据传送,也就是告诉接收端:不会再给你发新数据了(当然,在fin包之前发送出去的数据,如果没有收到对应的ack确认报文,发起端依然会重发这些数据),但此时发起段还可以接受数据;
  • 第二次握手:接收端收到FIN包后,发送一个ACK给对方,确认序号为收到序号+1(AckNum=A+1),此时接收端仍然可以给发起段发送数据(同意关闭连接请求,但是我还有数据需要传送,稍等...);
  • 第三次握手:接收端向发起端发送FIN,用来关闭到发起端的数据传送,也就是告诉发起端:我的数据也发送完了,不会再给你发数据了。此时接收端进入CLOSE_WAIT状态;
  • 第四次握手:发起端发送ACK报文段,然后进入TIME_WAIT状态,接收端收到ACK报文段以后,就关闭连接。发起端等待2MSL后依然没有收到回复,则证明Server端已正常关闭,此时也可以关闭连接了。

如果要正确的理解四次分手的原理,还需要了解四次分手过程中的状态变化。

  • FIN_WAIT_1: FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时,它想主动关闭连接,向对方发送了FIN报文,此时该SOCKET即进入到FIN_WAIT_1状态。而当对方回应ACK报文后,则进入到FIN_WAIT_2状态。(主动方)
  • FIN_WAIT_2:FIN_WAIT_2状态下的SOCKET,表示半连接,也即主动方要求断开连接,得到了被动方的确认,但被动方还有数据要发送,因此主动方还得继续接收。(主动方)
  • TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,就等2MSL后即可回到CLOSED状态了。如果FINWAIT1状态下,收到了对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。(主动方)
  • CLOSE_WAIT:在CLOSE_WAIT状态下,被动方还有数据需要传送。(被动方)
  • LAST_ACK: 被动关闭一方在发送FIN报文后,最后等待对方的ACK报文。当收到ACK报文后,也即可以进入到CLOSED可用状态了。(被动方)
  • CLOSED: 表示连接中断。

为什么要四次握手断开连接?

TCP是全双工模式,这就意味着,当主机1发出FIN报文段时,只是表示主机1已经没有数据要发送了,主机1告诉主机2,它的数据已经全部发送完毕了;但是,这个时候主机1还是可以接受来自主机2的数据;当主机2返回ACK报文段时,表示它已经知道主机1没有数据发送了,但是主机2还是可以发送数据到主机1的;当主机2也发送了FIN报文段时,这个时候就表示主机2也没有数据要发送了,就会告诉主机1,我也没有数据要发送了,之后彼此就会中断这次TCP连接。

TIME_WAIT 状态存在的理由:

  1. 可靠地实现TCP全双工连接的终止

    在进行关闭连接四次握手协议时,最后的ACK是由主动关闭端发出的,如果这个最终的ACK丢失,被动关闭方将重发最终的FIN,主动关闭端只有在维护状态信息的情况下才可以重新发送最终的那个ACK。如果不维护这个状态信息,主动关闭端将会响应一个RST,对端会将此响应标记为错误,所以不能进行正常的关闭。

  2. 允许老的重复分节在网络中消逝

    假设TCP协议中不存在TIME_WAIT状态的限制,再假设当前有一条TCP连接:(local_ip, local_port, remote_ip,remote_port),因某些原因,先关闭,接着很快以相同的四元组建立一条新连接。TCP协议栈是无法区分前后两条TCP连接的不同的,在它看来,这根本就是同一条连接,中间先释放再建立的过程对其来说是“感知”不到的。这样就可能发生这样的情况:前一条TCP连接由local peer发送的数据到达remote peer后,会被该remot peer的TCP传输层当做当前TCP连接的正常数据接收并向上传递至应用层(而事实上,在我们假设的场景下,这些旧数据到达remote peer前,旧连接已断开且一条由相同四元组构成的新TCP连接已建立,因此,这些旧数据是不应该被向上传递至应用层的),从而引起数据错乱进而导致各种无法预知的诡异现象。

    local peer主动调用close后,此时的TCP连接进入TIME_WAIT状态,处于该状态下的TCP连接不能立即以同样的四元组建立新连接,即发起active close的那方占用的local port在TIME_WAIT期间不能再被重新分配。由于TIME_WAIT状态持续时间为2MSL,这样保证了旧TCP连接双工链路中的旧数据包均因过期(超过MSL)而消失,此后,就可以用相同的四元组建立一条新连接而不会发生前后两次连接数据错乱的情况。

参考 再叙TIME_WAIT

TCP 状态转换图

TCP 重传机制

TCP要保证所有的数据包都可以到达,所以,必需要有重传机制。

注意,接收端给发送端的Ack确认只会确认最后一个连续的包,比如,发送端发了1,2,3,4,5一共五份数据,接收端收到了1,2,于是回ack 3,然后收到了4(注意此时3没收到),此时的TCP会怎么办?我们要知道,SeqNum和Ack是以字节数为单位,所以ack的时候,不能跳着确认,只能确认最大的连续收到的包,不然,发送端就以为之前的都收到了。

超时重传机制

每次发送数据包时,发送的数据报都有seq号,接收端收到数据后,会回复ack进行确认,表示某一seq号数据已经收到。发送方在发送了某个seq包后,等待一段时间,如果没有收到对应的ack回复,就会认为报文丢失,会重传这个数据包。

针对上面的情况,接收端不回ack,死等3,当发送方发现收不到3的ack超时后,会重传3。一旦接收方收到3后,会ack 回 4——意味着3和4都收到了。但是,这种方式会有比较严重的问题,那就是因为要死等3,所以会导致4和5即便已经收到了,而发送方也完全不知道发生了什么事,因为没有收到Ack,所以,发送方可能会悲观地认为也丢了,所以有可能也会导致4和5的重传。

快速重传机制

接收数据一方发现有数据包丢掉了。就会发送ack报文告诉发送端重传丢失的报文。如果发送端连续收到标号相同的ack包,则会触发客户端的快速重传。比较超时重传和快速重传,可以发现超时重传是发送端在傻等超时,然后触发重传;而快速重传则是接收端主动告诉发送端数据没收到,然后触发发送端重传。

比如:如果发送方发出了1,2,3,4,5份数据,第一份先到送了,于是就ack回2,结果2因为某些原因没收到,3到达了,于是还是ack回2,后面的4和5都到了,但是还是ack回2,因为2还是没有收到,于是发送端收到了三个ack=2的确认,知道了2还没有到,于是就马上重转2。然后,接收端收到了2,此时因为3,4,5都收到了,于是ack回6。

TCP 滑动窗口

TCP头里有一个字段叫Window,又叫Advertised-Window,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。滑动窗可以是提高TCP传输效率的一种机制。要注意滑动窗口只关注发送端和接收端自身的状况,而没有考虑整个网络的通信情况

为了说明滑动窗口,我们需要先看一下TCP缓冲区的一些数据结构:

上图中,我们可以看到:

  • 接收端LastByteRead指向了TCP缓冲区中读到的位置,NextByteExpected指向的地方是收到的连续包的最后一个位置,LastByteRcved指向的是收到的包的最后一个位置,我们可以看到中间有些数据还没有到达,所以有数据空白区。
  • 发送端的LastByteAcked指向了被接收端Ack过的位置(表示成功发送确认),LastByteSent表示发出去了,但还没有收到成功确认的Ack,LastByteWritten指向的是上层应用正在写的地方。

于是:

  • 接收端在给发送端回ACK中会汇报自己的AdvertisedWindow = MaxRcvBuffer – LastByteRcvd – 1;
  • 而发送方会根据这个窗口来控制发送数据的大小,以保证接收方可以处理。

下面我们来看一下发送方的滑动窗口示意图:

上图中分成了四个部分,分别是:(其中那个黑模型就是滑动窗口)

  1. 已收到ack确认的数据。
  2. 发还没收到ack的。
  3. 在窗口中还没有发出的(接收方还有空间)。
  4. 窗口以外的数据(接收方没空间)

下面是个滑动后的示意图(收到36的ack,并发出了46-51的字节):

下面我们来看一个接收端控制发送端的图示:

上图可以看到一个处理缓慢的Server(接收端)是怎么把Client(发送端)的TCP Sliding Window给降成0的。如果Window变成0了,发送端就不发数据了,可以想像成“Window Closed”。(有两种意外情形,第一紧急数据仍可以发送,比如用户杀掉远程机器上运行的某一个进程。第二,发送段可以发送一个用来进行窗口探测的段,下面详细介绍)

Window size 变为0之后,为了防止服务器发来的窗口更新数据包丢失后发生死锁。TCP使用了窗口探测(Zero Window Probe)技术,缩写为ZWP,也就是说发送端在窗口变成0后会发送一个1字节的段给接收方,以便强制接收端重新宣告下一个期望的字节和窗口大小。一般会尝试发送3次,如果3次过后还是0的话,有的TCP实现就会发送RST把连接断开。

此外,发送端不一定接到应用程序传递来的数据就马上把数据传送出去,同样,接收端也不一定必须尽可能快的发送确认段。特别是遇到下面这两种极端情况:

  1. 发送端每次向 TCP 连接传递一个字节;
  2. 接收端每次从 TCP 流中读取一个字节;

考虑下面的场景:

  • 远程终端连接(SSH):使用 Nagle 算法避免发送端发送多个小数据包,减轻发送端给网络的负载。(Nagle 算法:数据每次以很少量方式进入到发送端时,发送端只发送第一次到达的数据字节,然后将后面到达的缓存起来,直到发送出去的那个数据包被确认,然后将所有缓冲的字节放在一个 TCP 段中发送出去。并且继续开始缓冲,直到下一个端被确认。)不适用的场景:互动游戏,需要快速的短数据包流。
  • 低能窗口综合症:接收端的交互式应用每次仅能读取一个字节数据。使用延迟确认的优化方法可以避免接收端发送只有一个字节的窗口更新端。Clark解决方案:禁止接收端发送只有1个字节的窗口更新端,强制必须等一段时间,直到有了一定数量的可用空间之后再通知给对方。

TCP 拥塞控制

TCP通过滑动窗口来做流量控制,但是这还不够,因为滑动窗口仅依赖于连接的发送端和接收端,其并不知道网络中间发生了什么。TCP的设计者觉得,一个伟大而牛逼的协议仅仅做到流量控制并不够,因为流量控制只是网络模型4层以上的事,TCP的还应该更聪明地知道整个网络上的事。

考虑一下这样的场景:某一时刻网络上的延时突然增加,那么,TCP对这个事做出的应对只有重传数据,但是,重传会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,于是,这个情况就会进入恶性循环被不断地放大。试想一下,如果一个网络内有成千上万的TCP连接都这么行事,那么马上就会形成“网络风暴”,TCP这个协议就会拖垮整个网络。

所以,TCP不能忽略网络上发生的事情,而无脑地一个劲地重发数据,对网络造成更大的伤害。对此TCP的设计理念是:TCP不是一个自私的协议,当拥塞发生的时候,要做自我牺牲。就像交通阻塞一样,每个车都应该把路让出来,而不要再去抢路了。

拥塞控制主要是四个算法(相应的论文):1)慢启动,2)拥塞避免,3)拥塞发生,4)快速恢复。 这四个算法不是一天都搞出来的,它们的发展经历了很多时间,到今天都还在优化中。

  • 1988年,TCP-Tahoe 提出了1)慢启动,2)拥塞避免,3)拥塞发生时的快速重传;
  • 1990年,TCP Reno 在Tahoe的基础上增加了 4)快速恢复

慢启动(Slow Start)

慢启动的意思是,刚刚加入网络的连接,一点一点地提速。慢启动的算法如下(cwnd全称Congestion Window):

  1. 连接建好的开始先初始化cwnd = 1,表明可以传一个MSS大小的数据。
  2. 每当收到一个ACK,cwnd++; 这样每当过了一个RTT,cwnd = cwnd*2。
  3. 还有一个ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”

所以,我们可以看到,如果网速很快的话,ACK也会返回得快,RTT(Round Trip Time,也就是一个数据包从发出去到回来的时间)也会短,那么,这个慢启动就一点也不慢。下图说明了这个过程。

拥塞避免算法(Congestion Avoidance)

ssthresh(slow start threshold)是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”。一般来说ssthresh的值是65535,单位是字节,当cwnd达到这个值时后,算法如下:

收到一个ACK时,cwnd = cwnd + 1/cwnd,这样当每过一个RTT时,cwnd = cwnd + 1

这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。很明显,是一个线性上升的算法。

拥塞发生算法

前面我们说过,当丢包的时候,会有两种情况:

1)等到RTO超时,重传数据包。TCP认为这种情况太糟糕,反应也很强烈。

  • ssthresh = cwnd /2
  • cwnd 重置为 1
  • 进入慢启动过程

2)快速重传,也就是在收到3个duplicate ACK时就开启重传,而不用等到RTO超时。

  • TCP Tahoe的实现和RTO超时一样。
  • TCP Reno(RFC5681, RFC682)的实现是:
    • cwnd = cwnd /2
    • ssthresh = cwnd
    • 进入快速恢复算法——Fast Recovery

快速恢复算法(Fast Recovery)

快速恢复算法是认为,还有3个Duplicated Acks说明网络也不那么糟糕,所以没有必要像RTO超时那么强烈。注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新:

cwnd = cwnd /2
ssthresh = cwnd

然后,真正的Fast Recovery算法如下:

  • cwnd = ssthresh + 3 * MSS (3的意思是确认有3个数据包被收到了);
  • 重传Duplicated ACKs指定的数据包;
  • 如果再收到 duplicated Acks,那么cwnd = cwnd +1;
  • 如果收到了新的Ack,那么,cwnd = sshthresh ,然后就进入了拥塞避免的算法了。

上面这个算法也有问题,那就是——它依赖于3个重复的Acks。注意,3个重复的Acks并不代表只丢了一个数据包,很有可能是丢了好多包。但这个算法只会重传一个,而剩下的那些包只能等到RTO超时,于是,进入了恶梦模式——超时一个窗口就减半一下,多个超时会超成TCP的传输速度呈级数下降,而且也不会触发Fast Recovery算法了。1995年,提出了 TCP New Reno 算法,避免这个问题。

更多阅读

图解TCP-IP协议
简析TCP的三次握手与四次分手
TCP 的那些事儿(上)
TCP 的那些事儿(下)

TCP keepalive overview
Detection of Half-Open (Dropped) Connections

你可能感兴趣的