TCP协议作为传输层协议,它在网络层IP协议不可靠的尽力而为服务至上提供了一个可靠数据传输服务。TCP协议的数据传输确保了其上层协议读出的数据是无损坏、无间隔、按序、非冗余的二进制流。
TCP是面向连接的,在两个进程通过TCP协议发送数据时,必须先要经过互相“握手”,来建立确保数据传输的参数。TCP连接不是在电路交换网络中的端到端TDM或者FDM电路,也不是构建在网络层上的虚电路,所以中间的网络元素并不会维持TCP的连接状态,对于路由器而言,它们所看到的只是一个IP数据报文,而不是连接。
TCP连接提供全双工服务,也就是说进程A与进程B存在一条TCP连接时,在同一时间内,它们都是可以互相发送数据的。并且,TCP是一个点对点数据传输的协议,即不支持多播。
TCP报文由首部字段和数据字段组成:
TCP报文长度并不是无限的,它受限于所谓的最大报文段长度(Maximum Segment Size, MSS),简称MSS,MSS和什么有关呢?
一般来说,MSS根据最初确定的由本地发送主机的最大链路层帧长度(Maximum Transmission Unit, MTU),简称MTU来设置,设置MSS需要保证一个TCP报文加上网络层IP协议的首部长度(40字节)将适合底层的单个链路层帧。在链路层协议中,以太网和PPP协议都具有1500字节的MTU,所以用MTU减去IP协议首部长度字段就可以得到MSS的典型值为1460字节。
从上面这张图我们可以看到TCP首部字段至少有20个字节。在开头,和UDP协议类似,都有2个字节的源端口号字段和2字节的目的端口号字段,也包含用于校验数据正确性的检验和字段。除此之外,它还包含以下几个字段:
序号字段和确认号是TCP协议实现可靠传输的关键字段。TCP把数据看成是一个无结构、有序的字节流(而不是像UDP那样把把数据看成是一整个包),序号字段也就是建立在传输的字节流上,即序号应当对应于该报文段首字节的字节流编号。确认号比序号稍复杂些,由于TCP是全双工协议,因此进程A在向进程B发送数据时也需要接收进程B传来的数据,进程A填充进报文段的确认号是进程A期望从进程B收到的下一个字节的序号。通过上述这个机制,TCP就可以建立起一个可靠的连接,我们举几个例子:
我们待会会仔细介绍上述例子的具体实现原理。
步骤如下:
SYN
比特标记为1,序号取一个任意值x
。此时,客户端进入SYN_SENT
状态。SYN
报文后,向客户端发送一个ACK
TCP报文:SYN
比特标记为1,序号再取一个任意值y
,并将确认号设为x + 1
,并将其发送给客户端。服务器进入SYN_RCVD
状态。y + 1
。然后客户端进入ESTABLISHED
状态。ESTABLISHED
状态。为什么TCP的连接建立过程需要三次握手?
因为服务器和客户端都需要知道对方的接收消息功能和发送消息功能是正常的。
如果连接建立只需要一次握手,那么客户端无法获知服务端是否能够接收消息或者发送分组。在这种情况下,客户端向服务器发送了一个SYN
包,然后建立连接。如果仅仅是这样,客户端根本无法知道服务器是否收到了这个请求。
如果连接建立需要两次握手,那么客户端可以获知服务端的接收和发送分组的功能是正常的,但是服务端只能够知道客户端发送分组的功能是正常的,无法获知接收报文的功能是否正常。
当连接需要三次握手时,服务端就可以获知客户端发送和接收报文的功能是正常的。所以通过三次握手就可以建立连接了。
至于为什么不是四次握手,因为没有必要,在三次握手的情况下就已经可以让服务器和客户端知道对方的接收消息功能和发送消息功能是正常的。
TCP连接的解除需要经历四次挥手过程:
我们设定:主动断开方为客户端,被动断开方为服务器
步骤如下:
FIN
比特设为1,序号设为x
,确认号设为y
(注意,这里的x
和y
和连接建立时不同)。客户端进入FIN_WAIT_1
状态。ACK
TCP报文:确认号设置为x + 1
。服务器进入CLOSE_WAIT
状态,客户端收到报文后进入FIN_WAIT_2
状态。FIN
比特设为1,序号设为y
。服务器进入LAST_ACK
状态,客户端收到报文后进入TIME_WAIT
状态。y + 2
。其实你仔细看就会发现,可以将四次挥手拆分为两个部分:客户端的挥手和服务端的挥手,因为它们都有个共同特征就是先发送了FIN
报文然后再接收ACK
报文。之所以这么设计是因为TCP是全双工的协议,因为FIN
报文就代表自己的数据已经发送完毕了,但这个时候还是可以接收来自对方的数据的。当对方也没有数据要发送时,也需要主动发送一个FIN
报文,然后再接收对方的ACK
,最后愉快地断开连接。同时这也是为什么TCP挥手需要四次。
主动关闭TCP连接的端系统在完成四次挥手后需要进入TIME_WAIT
状态,时长2MSL
。TIME_WAIT
主要作用为:
在Linux系统中,解决大量TIME_WAIT
的方法有:
net.ipv4.tcp_tw_reuse = 1
表示允许将TIME_WAIT
套接字用于新的连接。net.ipv4.tcp_tw_recycle = 1
表示开启TCP中TIME_WAIT
的快速回收。TCP若需要保证所有的数据包都能够正确地抵达,必须要有一个重传的机制。那么什么时候会发生重传呢,有以下几种情况:
在上图中,进程A在发送SEQ=21
的数据包(数据长度为11字节)后,由于网络故障发生了掉包。此时对于进程A来说最直观的感受就是迟迟没有收到来自进程B的ACK
包。当超过一定的时间后,进程A就会重新尝试发送SEQ=21
的数据包,直到收到进程B的ACK
包。
丢包重传的效率在于超时时间的设置是否合理,如果超时时间太长,那么显而易见的效果就是会严重影响传输性能。如果超时时间太短,可能会导致并没有发生丢包就进行了重传,严重的可能会产生网络拥塞,增加数据包的时延。
首先这个超时时间肯定不能设置为一个恒定的值,因为受限于物理、网络设备等影响,往返时间(RTT) 肯定是有所差异的。例如从上海到北京的RTT肯定要比从上海到美国的RTT短。
那么这个超时时间是如何设置的呢,这涉及到了TCP的RTT算法:
RTT算法实现有很多种,例如RFC793定义的经典算法,Karn / Partridge算法、Jacobson / Karels算法等。这里我们只简单提下RFC793的经典算法,其它算法有兴趣的可以自行Google。
算法的实现步骤如下:
Sample RTT
。当然,任何一个采样获取的Sample RTT
是非典型的,所以自然要采取一个对Sample RTT
取平均值的一个算法,我们称这个平均值为Estimated RTT
,一旦获取到一个新的Sample RTT
,该算法就会通过以下计算方法来更新Estimated RTT
的值:Dev RTT
,用于估算Sample RTT
和Estimated RTT
的偏离程度:Estimated RTT
和Dev RTT
的值后,首先需要肯定的是超时时间肯定需要大于Estimated RTT
,否则将会造成不必要重传,然后肯定也不能大太多,否则当报文发生丢失时,TCP将不能及时重传报文段。因此需要将Estimated RTT
加上一点余量,所以实际设置的超时时间TimeoutInterval
为:TimeoutInterval
为1秒。并且只要Estimated RTT
更新了,上述的TimeoutInterval
也会立刻得到更新。还有一种情况可能就是进程A发送的SEQ=21
的数据包(数据长度为10字节)已经成功到达进程B的TCP接收窗口,进程B在发送ACK
包后,由于网络故障导致ACK
包发生了丢包。此时进程A的直观感受等同于数据包发生了丢包,所以同样在超过一定时间后,重新发送SEQ=21
的数据包。当进程B收到这个数据包后,会丢弃这段数据(因为事实上已经收到这个包了),然后再次发送一个ACK
包,直到进程A收到这个包为止。如下图所述:
在实际情况中,应用程序可能会在应用层协议中组装一个比较大的包,并发送给服务器(例如HTTP协议中的文件上传请求),这个时候TCP协议一般会将其拆分为多个TCP包并依次发送给服务器,现在的情况是,如果其中的一个数据包发生了丢包,而其它数据包成功抵达了服务器,那么TCP协议会如何处理?答案如下图:
TCP协议引入了一种快速重传机制。它允许在数据包没有连续到达时,就连续发送ACK
包(其值为最后那个没有按顺序到达的包)。如果发送方连续三次收到了同样的ACK
包,就认定这个包发生了丢包,那么会重传这个丢掉的数据包,当服务器收到后,由于后面的数据包已经正确收到,所以就会发送ACK=6000
的包。这么做的好处在于不一定要等到超时再补发缺失的数据包。
但是快速重传机制依然存在一个问题,就是客户端无法获知这三个ACK=2000
的包是不是连续的。在客户端收到三个连续的ACK
之前,可能已经连续发送了SEQ=6000
、SEQ=7000
······,也就是意味着客户端端可能需要从SEQ=2000
开始,一直重传之后所有发送的数据包。为了解决这个问题,选择确认机制(Selective Acknowledgment)出现了。
选择确认机制需要在TCP报文头(TCP选项中)中引入一个SACK字段,它使得接收方能告诉发送方哪些报文段丢失,哪些报文段重传了,哪些报文段已经提前收到等信息。根据这些信息TCP就可以只重传哪些真正丢失的报文段。需要注意的是只有收到失序的分组时才会可能会发送SACK,TCP的ACK还是建立在累积确认的基础上的。也就是说如果收到的报文段与期望收到的报文段的序号相同就会发送累积的ACK,SACK只是针对失序到达的报文段的。
此外,TCP还可以处理丢包现象。TCP协议采用了超时重传的机制来处理报文段的丢失。关于超时时间具体是如何计算的,可以参考这篇博客。
需要注意的一点是,序号不一定非要以0开始,这么做可以减少仍在网络中存在的来自两台主机之间先前已经终止的连接报文段,被误认为师后来两台主机之间新建的TCP连接所产生的报文的可能性。
TCP发送方有三个与发送和重传相关的事件:
SendBase
(即最早未被确认的字节的序号),则该ACK是在确认一个或者多个先前未被确认的报文段。之所以TCP协议能像上面例子那样能够解决数据的乱序到达和丢包问题,是因为在TCP连接中,两个进程都各自维护了一个发送缓冲区和接收缓冲区。
发送缓冲区:
发送缓冲区将其中的数据分为了四种类型:
上述两个缓冲区还可以用于TCP的拥塞控制。
TCP协议除了提供可靠的传输外,也提供了拥塞控制功能。
网络拥塞(Network Congestion)是指在分组交换网络中传送分组的数目太多时,由于存储转发节点的资源有限而造成网络传输性能下降的情况。
为了更加形象的概括,我们假设进程A和进程B对应为城市A,城市B,那么连接两个城市的高速公路就相当于网络链路,数据包就相当于高速公路上的汽车。当城市A有很多汽车需要前往城市B时,高速公路就有很大概率发生堵车,汽车行驶速度下降,也就是数据包的时延增大了。然而在网络传输中,由于IP协议的TTL限制,超过TTL的数据包会被路由器丢弃,也就是说汽车无论如何也到达不了城市B了。所以拥塞控制的关键就是要把握好什么时候要增加、减少TCP数据包发送的大小或者其次数。
因为网络层IP协议是不会向上层协议提供网络拥塞反馈的,所以TCP作为其可靠的传输层协议必须要使用端到端的拥塞控制方法。
TCP所采用的方法就是让每个发送方根据所感知到的网络拥塞程度来限制其能向连接发送流量的速率。也就是说,如果发送方能够感知到路径上没什么拥塞,那么就应当加快传输速率,如果能够感知到有拥塞,那么就应当减少其传输速率。
那么TCP是如何感知有拥塞呢?当TCP发生了“丢包事件”时,我们就可以认定发生了拥塞。丢包事件包含:
ACK
包。TCP控制流量的方式是通过连接建立时产生的变量cwnd
(拥塞窗口)来计算。
拥塞控制包含四个部分:慢启动、拥塞避免、快速重传,快速恢复。快速重传刚才已经提到过,这里就不细说了。
慢启动通俗点讲就是在TCP连接刚刚建立时,一步步地提高速度。具体实现步骤如下:
cwnd
设为MSS
的一个较小值,也就是说初始发送速率为MSS/RTT
。cwnd
以1个MSS
开始并且每当传输的报文段首次被确认就增加1个MSS
。每经过一次RTT,就将发送速率翻一倍。那么何时结束这种指数级别的增长呢?有三种方法:
cwnd
设为1并重新开始慢启动过程。它还将第二个状态变量的值ssthresh
(慢启动阈值)设为cwnd
的一半,即检测到拥塞时将ssthresh
设为拥塞窗口值的一半。ssthresh
相关联,当cwnd
等于ssthresh
时,结束慢启动并将其转换为拥塞避免模式ACK
时,就进行一次快速重传并进入到快速恢复状态。一旦进入到拥塞避免状态,cwnd
的值大约是上次遇到拥塞时的一半。TCP此时不会直接将cwnd
乘以2去加快速度,而是通过一种线性的方式增加cwnd
,即将cwnd
的值每次只增加一个MSS
。当出现超时后,TCP的拥塞避免算法行为和慢启动一样,将cwnd
的值设为一个MSS
,当丢包事件出现时,sshresh
被更新为cwnd
的一半。接下来就是快速恢复阶段。
快速恢复算法是认为,当发生了三个冗余ACK
时说明网络状况并不算那么糟,所以无需像超时那样那么强烈。快速恢复步骤如下:
cwnd
设置为sshthresh
加上3倍的MSS
ACK
指定的数据包。ACK
,那么将cwnd
加上1ACK
,那么将cwnd
设为sshthresh
,然后进入拥塞避免算法。TCP的拥塞控制可以形容为加性增,乘性减的拥塞控制方式。