文章目录
TCP基础概念
TCP,Transmission Control Protocol(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的【RFC 793】 定义。
TCP协议是面向连接的,这是因为在一个应用进程可以开始向另一个应用进程发送数据之前,这两个进程必须先相互“握手”,即它们必须相互发送某些预备报文段,以建立确保数据传输所需的参数。
博主看很多人说TCP连接是建立在端到端系统之间的一条虚拟链路。但是,这样说并不是十分准确的,准确的来说,TCP连接的建立是端到端系统之间在各自的系统中维护了一些信息,以保留连接状态。
另外,TCP连接提供的是全双工服务,关于全双工是什么,博主已经在物理层的博客中介绍过了。TCP的连接也总是点对点的,即在单个发送方和单个接收方之间的连接。所谓的“多播”是指在一次发送中,从一个发送方将数据传送给多个接收方,这对TCP来说是不可能的。但是对UDP来说是可以的。
TCP报文结构
TCP报文段由首部字段和一个数据字段组,数据字段包含有一块应用数据。
下图给出了TCP报文段的结构。和UDP一样,首部包括源端口号和目的端口号,它被用于多路复用/多路分解。另外,和UDP一样,TCP也包括校验和字段。除了这些,它还包括以下字段:
- 序号字段(32bit): 其和下面的确认号字段一起完成TCP可靠数据传输服务。
- 确认号字段(32bit): 其和上面的序号字段一起完成TCP可靠数据传输服务。
- 接收窗口(16bit): 该字段用于流量控制,指示接收方愿意接受的字节数量。
- 首部长度字段(4bit): 只是了以32bit 的字为单位的TCP首部长度。
- 标志字段(6bit): ACK比特用于确认报文。RST、SYN和FIN比特用于连接建立和断开。URG比特用于只是报文中存在着紧急🆘数据,PSH比特指示数据不应该在缓冲区中,而是直接被交付给上层。
序号和确认号
TCP报文首部两个最重要的字段就是序号字段和确认号字段。这两个字段是TCP可靠传输服务的关键部分。
之前说过,TCP是字节流服务。TCP把数据看成一个无结构的但是有序的字节流。我们其实可以从TCP对序号的使用上可以看出这一点,这是因为序号是建立在传送的字节流之上,而不是建立在传送的报文段的序列上。一个报文段的序号因此是该报文首字节的字节流编号。
例如,假设主机A上的进程想通过一条TCP连接向主机B上的一个进程发送一个数据流。主机A中的TCP将隐式地对数据流中的每一个字节进行编号。假定数据流由一个包含5000字节的文件组成,MSS的值为100字节,数据流的首字节编号是0。那么TCP将给这个数据流构建50个报文段。第一个报文段的序号就被赋值为0,第二个报文段序号就被赋值为100,以此类推。每个序号被填入到相应的TCP报文段首部的序号字段中。
接着我们来考虑一下确认号字段。确认号字段比序号要难处理一点。前面讲过,TCP是全双工的,因此主机A也许在向主机B发送数据的同时,也接受来自主机B的是数据。从主机B到达的每个报文段中都有一个序号用于从B流向A的数据。主机A填充进报文段的确认号是主机A期望从主机B收到的下一字节的序号。举个例子,加入主机A已经时候到了来自主机B编号为0~535的所有字节,同时假设他要发一个报文段给主机B。主机A等待主机B的数据流中字节536即后续的所有字节。所以,主机A会在它发往主机B的报文段的确认号字段中填上536。如图所示:
TCP连接的建立
三次握手
作为一个面向连接的协议,其必经过“握手”的阶段。而TCP的连接建立,我们常常称为三次握手。
其大致流程如下,我们这里称发起连接的为客户端,接受连接的为服务端。起初,双端的状态都处于close状态,也就是无连接状态。
- 客户端发出一个SYN报文,之后客户端进入SYN_SEND状态,TCP报文段被交付给网路
- 服务端在收到SYN报文之后,进入SYN_RECV状态,并将此连接放入一个内核维护的半连接表
- 服务端回复一个SYN+ACK报文
- 客户端收到回复ACK报文后进入ESTABLISH状态,并回复一个ACK报文
- 服务端在收到ACK报文之后进入ESTBLISH状态,连接建立
SYN洪泛攻击:
详情
数据发送
一旦TCP经过三次握手,两个应用进程之间就可以相互发送数据了。一旦客户机进程通过套接字传递数据流,之后数据就交给TCP控制了。TCP将这些数据引导到该连接的发送缓存里面,发送缓存是在三次握手初期设置的缓存之一。之后TCP就会时不时的从发送缓存里面取出数据(也是依赖协议规定)。
TCP从缓存中取出并放入报文段中的数据量受限于最大报文段长(Maximum segment size,MSS)。MSS通常都根据最初确定的最大链路层帧MTU长度来设置。接着设置该MSS以保证一个TCP报文段适合单个链路帧。MTU常见值是1460字节、536字节和512字节。一半来说,当TCP传输大文件的时候,TCP通常是将文件划分为长度为MSS的若干块(最后一块小于MSS)。
TCP将每块客户机数据加上一个TCP首部,从而形成多个TCP报文段。这些报文段被下传给网络层,网络层将其分别封装在网络层IP数据包中。然后这些放入网络中。
TCP连接的断开
一开始客户端和服务器都处于ESTABLISH状态,现在假设客户端主动尝试去断开连接,其交互过程如下:
- 客户端给服务端发送一个FIN,之后客户端进入FIN_WAIT_1状态
- 服务端收到FIN报文后,知道了客户端要关闭连接。如果此时服务端还有数据要发送,其就先回复客户端一个ACK报文,其进入CLOSED_WAIT状态,之后在发送FIN报文,发送之后进入LAST_ACK报文。如果没有数据发送,就将FIN+ACK一起发送。
- 客户端在收到ACK报文之后,其进入FIN_WAIT_2状态,之后再收到服务端的ACK报文之后,其进入TIME_WAIT状态,并回复服务端FIN报文的ACK报文。TIME_WAIT状态会持续2*MSL,期间如果没有报文,客户端就会CLOSED状态。
- 服务端在收到客户端ACK报文之后,其进入CLOSED状态。
注:
这里TIME_WAIT的时间被设置为2MSL,MSL(Maximum Segment Lifetime)是报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。之所以设置为这个时间是怕最后客户端回复的ACK报文服务端没有收到会重发FIN+ACK报文。
超时重传机制
往返时延和超时
TCP采用超时/重传机制来处理报文段的丢失问题。但是这个超时的时间间隔确实是一个很困扰的问题。如果时间太长,那么就是浪费网络带宽。如果时间太短就会造成频繁重发报文,造成网络拥堵。那么又该设置成多少为合适呢?
往返时延
首先一点就是要估计往返时延(RTT)。估计往返时延就需要一个样本RTT,其是从某报文段被发出到收到对该报文段的ACK回复之间的时间量。
显然,由于路由器的拥塞和端系统负载的变化,这些报文段的样本RTT值会随之波动。因此为了估计样本RTT,我们就需要对样本RTT取一个平均值,同时我们还要考虑到最近RTT对这个值的影响:
EstimatedRTT = (1-α).EstimatedRTT + α.LastestRTT
除了估算RTT,测量RTT的变化也是有意义的。【RFC 2988】定义了RTT偏差DevRTT。
DevRTT = (1-β).DevRTT + β.|LastestRTT-EstimatedRTT|
重传超时间隔公式
假设已经给出了估计RTT EstimatedRTT和RTT偏差 DevRTT,那超时时间间隔应该用什么值呢?很明显,超时间隔应该大于等于估计RTT EstimateRTT,否则就会造成不必要的重传。但是超时间隔也不应该比EstimatedRTT 太长时间,否则TCP不能及时重传并且也会浪费带宽。
因此,要求将超时间隔设为EstimateRTT加上一定余量。当SampleRTT值波动较大时,这个余量应该大些;当波动比较小的时候,这个余量应该小一些。因此DevRTT的值就应该在这里起作用了。所有这些考虑都被用到决定TCP超时重传间隔的方法中:
TimeOutInterval = EstimatedRTT + 4.DevRTT
回到超时重传
TCP在IP的不可靠的服务上建立了一种可靠数据传输服务。其可靠数据传输服务确保一个进程从其接受缓存中读出非损坏的、无间隔的、非冗余的和按序的字节流。
在TCP中使用了单一的重传定时器。在TCP发送方主要有三个与发送和重传相关的主要事件:
- 从上层应用程序接收数据
- 定时器超时
- 收到ACK报文
一般来说,一旦第一个主要事件发生,TCP就从应用程序接收数据,将数据封装在一个报文中,并将该报文交给IP,这个时候TCP就会启动定时器。该定时器的过期间隔时TimeOutInterval
。
第二个主要事件时超时,TCP通过重传引起超时的报文段来响应超时事件,然后TCP重启定时器。
第三个主要事件时接受来自接收方的确认ACK报文段的到达。当该事件发生时,TCP将ACK的值y和SendBase(最早未被确认的字节的序号)进行比较。TCP会采用累计确认机制,所以如果y被确认了,与之前的序号其实都是被确认的。如果y > SendBase,则该ACK确认一个或多个未被确认的字段。
加倍超时间隔
现在假设一个问题,假设我们超时之后。是不是就代表了网络现在发生拥堵了。那如果我们此时过了超时间隔就重传,是不是并不能缓解网络拥堵。那么超时时间间隔就会加倍。例如,一开始的TimeOutInterval是0.75秒,重传之后,将其设置为1.5秒,如果还是重传同一报文,就设置为3秒。这样,超时时间间隔在每次重传后都会呈指数型增长。
快速重传
超时触发重传存在的一个问题就是超时周期可能相对较长。当一个报文段丢失的时候,这种长超时周期迫使发送方等待很长时间才重传丢失的分组,因为增加了端到端的时延。幸运的是,发送方通常可在超时事件发生之前通过注意所谓冗余ACK来检测丢包情况,冗余ACK就是再次确认某个报文的ACK,而发送方先前已经收到对该报文段的确认。
因为发送方经常发送大量的报文段,如果一个报文段丢失,就可能引起许多一个接一个冗余ACK,他就认为跟在这些已被确认过3次的报文段之后的报文段已经丢失。一旦收到3个冗余ACK,TCP就执行快速重传【RFC 2581】,即在该报文段的定时器过期之前重传丢失的报文段。
流量控制与拥塞控制
刚刚讲过,TCP会维护这端到端的连接信息,其中就包括了接收缓存。当TCP连接收到正确、按序的字节后,他就将数据放入接受缓存。相关联的应用进程也会从该缓存中读取数据。但是,一般来说没有必要数据刚一到达就立刻读取数据,因为也许应用进程正在忙于其他任务。如果应用程序读取数据时相当缓慢,而发送方发送数据太多、太快,会很容易使该连接的接受缓存溢出。
TCP为应用程序提供了流量控制服务以消除发送方使接收方缓存溢出的可能性,其是一个速度匹配服务。
而且TCP跟少年般的UDP不一样,其更像一个成熟稳重的大叔。当面对网络拥堵的时候,其就会遏制本机的发送速度,这被称为拥塞控制。很多时候流量控制和拥塞控制常常被混为一谈,今天就来讲讲流量控制和拥塞控制以及他们的区别。
流量控制
TCP通过让发送方维护一个称为接收窗口的变量来提供流量控制,其被接收方用来告诉发送方自己还有多少可用的缓存空间。因为TCP是全双工通信的,所以在连接两端都各自维护一个接收窗口。
现在假设主机A通过一条TCP连接向主机B发送一个大文件。主机B为该连接分配一个接收缓存,并用RecvBuffer
来表示其大小。主机B上的应用进程不时地从该缓存中读取数据。定义以下变量:
- LastByteRead:主机B上的应用进程从缓存读出的数据流最后一个字节的编号
- LastByteRcvd:从网络中到达的并且已放入主机B接收缓存中的数据流最后一个字节的编号
由于TCP不允许已分配的缓存溢出,所以下列等式必须成立:
LastByteRcvd - LastByteRead <= RecvBuffer
接收窗口用RecvWindow
表示,根据缓存可用空间的数量来设置:
RecvWindow = RecvBuffer - [LastByteRcvd - LastByteRead]
该空间是随着时间变化的,所以接收窗口RecvWindow
是动态变化的。
主机B通过把当前的RecvWindow值放入它发给主机A的报文段接收窗口字段中,通知主机A它在该连接的缓存中还有多少可用空间。开始时,主机B设定RecvWindow = RecvBuffer
。主机A轮流跟踪两个变量LastByteSend
和LastByteAcked
,这两个变量的意义很明显。注意到这两个变量之间的差LastByteSend-LastByteAcked
,就是主机A发送到连接中但未被确认的数据量。通过将未确认的数据量控制在值RecvWindow以内,就可以保证主机A不会使主机B的接受缓存溢出。所以主机A必须保证:
LastByteSend - LastByteAcked <= RecvWindow
注:
这里存在一个问题,假设主机B的接收缓存已经存放满了,此时RecvWindow=0
。在将RecvWindow=0通告给主机A之后,还要假设主机B没有要发送的数据了。此时随着主机B上的应用进程将缓存清空,TCP并不向主机A发送带有RecvWindow新值的新报文段;这样,主机A就不会知道主机B的接受缓存已经有新的空间了,即主机A被阻塞而不能再发送数据。为了解决这个问题,TCP规约中要求:当主机B的接收窗口为0时,主机A继续发送只有一个字节数据的报文段。这些报文段将会被接收方确认。最终缓存将开始清空,并且确认报文里将包含一个非0的RecvWindow。
注:
UDP并没有流量控制手段,其套接字所对应的是一个有限大小的缓存。进程每次从缓存中读取一个完整的报文段,如果缓存溢出这个报文段就会被丢弃。
拥塞控制
控制原理
在TCP规约中,TCP必须使用端到端拥塞控制而不是网络辅助的拥塞控制,因为IP层不向端系统提供显式的网络拥塞反馈。
TCP采用的方法是让每个发送方根据所感知到的网络拥塞的程度,来限制其能向网络发送流量速率。即如果一个TCP发送方感知从它到目的地之间的路径上没什么堵塞,则该TCP发送方就会增加其速率,如果该发送方感知在路径上有拥塞,则该发送方就会降低其发送速率。
那么TCP是如何限制向其连接发送的流量的。刚刚也提到了,TCP连接的每一端都会维护一个接收缓存,一个发送缓存和几个变量(LastByteRead、RecvWindow等)。TCP拥塞控制机制让连接的每一端都记录一个额外的变量,即拥塞窗口。拥塞窗口表示为CongWin
,它对一个TCP发送方能向网络中发送流量的速率进行了限制。在一个发送方中未被确认的数据量不会超过CongWin与RecvWindow中的最小值,即:
LastByteSend - LastByteAcked <= min(CongWin,RecvWindow);
为了和流量控制形成对比,我们后面假设TCP接收缓存足够大,以至于可以忽略接受窗口的限制,因此在发送方中未被确认的数据量仅受限于CongWin。
因此,我们粗略的讲,在每个往返时延RTT的起始点,上面的限制条件允许发送方向连接方发送CongWin个字节的数据,在该RTT结束时发送方接受对数据的确认报文。因此该发送方的发送速率大概是:CongWin/RTT(byte/s)
。所以说,通过调节CongWin的值,我们就可以调节发送速率。
TCP拥塞控制算法
TCP根据其拥塞控制算法来调节发送情况,其基本思想就是:
- 加性增、乘性减
- 慢启动
- 超时反应
加性增、乘性减
TCP拥塞控制的基本思想,当出现丢包事件时,让发送方降低其发送速率(减小拥塞窗口的大小)。对于发生拥堵的情况,TCP应该如何降低其发送速率呢?TCP采用了一种所谓“乘性减”的方法,即每发生一次丢包事件就将当前的CongWin窗口减半。
注:
CongWin每次都会减半,最小值为1个MSS
描述了TCP降低发送速率之后,就应该考虑如何增大速率的问题了。其基本原理就是:如果没有检测到拥塞,则有带宽可被其使用。在这种情况下,TCP缓慢地增加其CongWin的长度,谨慎的探测端到端路径上的可以带宽。
而次算法常被称为加性增、乘性减算法。TCP拥塞控制协议的线形增长阶段被称为拥塞避免。
慢启动
当一个TCP连接开始的时候,CONgWin的值初始值为一个MSS,这就使得初始发送速率大约为MSS/RTT
。在大部分情况下,对于该连接,可用带宽可能比MSS/RTT大得多,因此仅仅线形增加,还是会经历很长时间的。因此,TCP发送方在初始阶段是以指数增加的。即每过一个RTT就将CongWin的值翻倍。TCP发送方继续以指数增加其发送速率,直到发生一个丢包事件为止,此时CongWin将被降为一半,然后就会进行加性增。
超时反应
超时反应分别两种,一般来说,收到三个冗余ACK,TCP才会将拥塞窗口减小为一半,即上面的锯齿状的情况,这个也叫做快恢复。 但是,一旦有超时事件发生,TCP发送方就进入一个慢启动阶段,即它将进入一个慢启动阶段,即他将拥塞窗口设置为1MSS,然后窗口长度在以指数速度增长。拥塞窗口持续以指数增长,直到CongWin达到超时事件前窗口值的一半为止。此时,CongWin以线形速度增长。
TCP通过维持一个称为阈值的变量来管理这些较复杂的动态过程。每当发生一个丢包事件时,就会记录下当前CongWin,然后阈值就会被设置成当前CongWin值的一半。
参考文献
[1] 计算机网络自顶向下方法.第三章
[2] 趣谈网络协议.刘超.极客时间