1. TCP协议概述
TCP协议,全称Transmission Control Protocol(传输控制协议),是一种全双工通信、面向连接的、可靠的、基于字节流的传输层通信协议。
全双工通信:即建立TCP连接之后,通信双方都可以发送数据。
面向连接:意味着两个使用TCP的应用(通常是一个客户和一个服务器)在彼此交换数据之前必须先建立一个TCP连接。
- 这一过程与打电话很相似,先拨号振铃,等待对方摘机说“喂”,然后才说明是谁。
可靠:IP层并不保证数据报一定被正确地递交到接收方,TCP负责在超时或者传输失败后,重传没有递交成功的数据报。
- 即使被正确递交的数据报,也可能存在错序的问题,这也是TCP的责任,它必须把接收到的数据报重新装配成正确的顺序。
基于字节流:虽然应用程序和TCP的交互是一次一个数据块(大小不等),但TCP把应用程序传来的数据块看成是一连串的无结构的字节流。由TCP传递给IP的信息单位称为报文段或段(segment)
- TCP有一个缓冲,TCP发送报文时,是将应用层数据写入TCP缓冲区中,然后由TCP协议来控制发送这里面的数据,当应用程序传送的数据块太长,TCP就可以把它划分短一些再传送。如果应用程序一次只发送一个字节,TCP也可以等待积累有足够多的字节后再构成报文段发送出去。
- 发送的状态是按字节流的方式发送的,跟应用层写下来的报文长度没有任何关系,所以说是流。
面向字节流的概念,打个比方:
一个蓄水池,有出水口和进水口,开几次进水口和开几次出水口是没有必然联系的,也就是说你可以只进一次水,然后分10次出完(即一次write,可以分10次read读取)。另外,水池里的水接多少就会少多少;往里面进多少水,就会增加多少水,但是不能超过水池的容量,多出的水会溢出。
同时,作为网络协议中举足轻重的传输层协议,TCP协议有这些优秀的机制保证其最为重视的数据可靠性:
- 超时重传
- 拥塞处理
- 滑动窗口
我们将在后面的篇幅中介绍他们。
1.1 TCP和UDP的区别
和UDP相比,TCP协议有如下差异:
TCP | UDP |
---|---|
面向连接(如打电话要先拨号建立连接) | 无连接(发送数据之前不需要建立连接) |
传输可靠(通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达) | 传输不可靠(尽最大努力交付,即不保证可靠交付) |
面向字节流(把数据看成一连串无结构的字节流) | 面向报文(无脑传递上下层的报文) |
适合传输大量数据 | 适合传输少量数据 |
全双工的可靠信道 | 不可靠信道 |
首部开销20字节 | 首部开销8个字节 |
连接只能是点到点 | 支持一对一,一对多,多对一和多对多的交互通信 |
速度慢(需要建立连接、发送确认包等)) | 速度快 |
对系统资源的要求较多 | 对系统资源的要求较少 |
1.2 TCP协议应用场景
TCP协议是运输层协议,其服务对象自然是应用层。
TCP主要应用在:要求通信数据可靠时,即数据要准确无误地传递给对方如:
- 传输文件:HTTP、HTTPS、FTP等协议;
- 传输邮件:POP、SMTP等协议
- 万维网:HTTP协议
- 文件传输:FTP协议
- 电子邮件:SMTP协议
- 远程终端接入:TELNET协议
1.3 如何保证可靠性
TCP通过下列方式来提供可靠性:
缓冲区:基于缓冲区,应用数据被分割成TCP认为最适合发送的数据块。
超时重传:当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。
接收确认:当TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒。
校验:TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段(希望发端超时并重发)。
重新排序:既然TCP报文段作为IP数据报来传输,而IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序。如果必要,TCP将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层。
既然IP数据报会发生重复,TCP的接收端必须丢弃重复的数据。
TCP还能提供流量控制。TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据。这将防止较快主机致使较慢主机的缓冲区溢出。
1.4 TCP连接的实质
我们都知道TCP是面向连接的服务,所有要通过TCP进行通信的应用都要先建立连接才能通信,在通信完毕之后要记得关闭连接。但是TCP连接到底是什么东西呢?
这里先说结论,连接实际上是操作系统内核的一种数据结构,称为TCP控制块(TCB),对于linux而言是tcp_sock结构。不光连接,连数据包也是由一个数据结构来控制,linux里面称为sk_buff结构。
1.4.1 为什么要有TCB
当应用希望写数据时,
- 不是直接向网卡驱动发数据,而是经过先放入到一个socket发送缓冲区中
- 然后根据一定算法(达到一定数量或者调用flush之后),缓冲区中的数据就会被网卡从缓冲区中拷贝出来,再层层封装,最后经过物理层传输。
当网卡收到数据时
- 会通过DMA直接发送到内存缓冲区中(网卡驱动提前向操作系统申请的一块内存,并且驱动会提前告诉网卡这块内存的地址(注意是物理地址)和大小。如果没有这块内存缓冲区,那么网卡会直接将数据丢掉)
- 然后给CPU发送一个中断信号,通知操作系统一个数据包到了。
- 数据包要先经过校验,分用等操作,到了TCP层,处理程序此时根据TCP首部中的端口号选择一个socket,并将其载荷数据拷贝进socket接收缓冲区。
- 根据TCP首部中的端口号选择一个socket,如何选择呢?这里TCP是利用连接四元组<源IP地址,源端口号,目标IP地址,目标端口号>,并以这个四元组为key,查找hash表找到对应的socket的socket结构指针,并利用该指针找到对应socket的接收缓冲区,并将载荷数据拷贝进去。
所以到这里,我们就应该知道,每个socket结构必须要有自己独立的发送缓冲区和接收缓冲区
1.4.2 什么是Socket
socket是什么呢,实际上socket是对TCP/IP协议的封装,它的出现只是使得程序员更方便地使用TCP/IP协议栈而已。socket本身并不是协议,它是应用层与TCP/IP协议族通信的中间软件抽象层,是一组调用接口(TCP/IP网络的API函数)
或者换句话说,socket是TCP/IP协议实现的封装,和暴露给应用层的函数。他将网络底层复杂的实现统统隐藏,给应用程序方便的使用接口。
在网络通信方面,socket也有很多种,根据不同协议的实现,也有TCP通信的Socket,UDP通信的DatagramSocket,以及与SSL相关JSSE中的SSLSocket,以及这些非阻塞的SocketChannel,DatagramChannel,SSLEngine在安全方面,JCA,JCE,JAAS等等
1.4.3 HTTP短连接和长连接
对于HTTP 1.0的http标准而言,默认连接是短连接,什么是短连接?就是服务器当发送完最后一个字节的数据之后将关闭连接,也就是回收tcp_sock结构,这样,如果客户端再发送数据给服务器,将直接丢弃。即使此时客户端还有这样的结构,但是我们说连接已经关闭或者已经断了。
HTTP 1.1引入了长连接的概念,并把它搞成了默认的连接方式。什么是长连接?就是当完成一个业务之后,socket结构并不回收。这样,只要在socket结构还存在的时候,客户端发送的任何数据,服务器都可以收到,这就是所谓的长连接。
2. TCP的首部
TCP数据被封装在一个IP数据报中
注意:TCP的包是没有IP地址的,那是IP层上的事。TCP只负责源端口和目标端口的维护。
2.1 源端口和目的端口
源端口和目的端口:各占16位2个字节,分别存放源端口号和目的端口号。用于寻找发端和收端应用进程。这两个值加上IP首部中的源端IP地址和目的端IP地址,能唯一确定一个TCP连接。
2.2 序号
序号:简称seq(sequence number),占32位4个字节。序号范围是[0,2^32 - 1],共2^32 (即4294967296)个序号。序号增加到2^32-1后,下一个序号就又回到0。
- TCP是面向字节流的。在一个TCP连接中传送的字节流中的每一个字节都按顺序编号。
- TCP会话建立后,会话的每一端都自己维护一个32位(bit)的序号,该序号被用来跟踪该端发送的数据量。这个端每发送一个字节的数据,它维护的序号+1;
- 当一个TCP会话开启时,它两端的初始序号都是随机的,可能是0和2^32 - 1(即4,294,967,295)之间的任意值
- TCP会话的某一端在发送报文段时,会将计算出的报文段数据的第一个字节的序号写入首部的序号字段中。
- 首部中的序号字段值则是指的是本报文段所发送的数据的第一个字节的序号。
- 例如,一报文段的序号是301,而数据共有100字节。这就表明:本报文段的数据的第一个字节的序号是301,最后一个字节的序号是400。
- 显然,下一个报文段(如果还有的话)的数据序号应当从401开始,即下一个报文段的序号字段值应为401。这个字段的序号也叫“报文段序号”。
TCP会话建立后,每一端都要各自初始化一个seq,这个初始的seq称作ISN(Inital Sequence Number)
TCP会话的任意端的ISN为什么要是随机的而不是写死的呢?试想:假如连接建好后始终用1来做ISN,如果client发了30个segment过去,但是网络断了,于是 client重连,又用了1做ISN,但是之前连接的那些包到了,于是就被当成了新连接的包,此时,client的Sequence Number 可能是3,而Server端认为client端的这个号是30了。全乱了。
RFC793中说,ISN会和一个假的时钟绑在一起,这个时钟会在每4微秒对ISN做加一操作,直到超过2^32,又从0开始。这样,一个ISN的周期大约是4.55个小时。因为,我们假设我们的TCP Segment在网络上的存活时间不会超过Maximum Segment Lifetime(缩写为MSL – Wikipedia语条),所以,只要MSL的值小于4.55小时,那么,我们就不会重用到ISN。
2.3 确认号
确认号:简称Ack(acknowledgement number),占32位4个字节,是期望收到对方下一个报文段的第一个数据字节的序号。仅当控制位ACK = 1时确认号字段才有效。
- 例如,B正确收到了A发送过来的一个报文段,其序号字段值是501,而数据长度是200字节(序号501~700),这表明B正确收到了A发送的到序号700为止的数据。
- 因此,B期望收到A的下一个数据序号是701,于是B在发送给A的确认报文段中把确认号置为701。注意,现在确认号不是501,也不是700,而是701。
- 总之:B给A发送的报文确认号为= N,则表明:到序号N-1为止的所有数据B都已正确收到。
确认号(acknowledgement number)简称Ack序号,不要将确认序号Ack与下面即将介绍的控制位中的ACK搞混了。
2.4 数据偏移
数据偏移:占32位4个字节,它指出TCP报文段的数据起始处距离TCP报文段的起始处有多远。这个字段实际上是指出TCP报文段的首部长度。
- 由于首部中还有长度不确定的选项字段,因此数据偏移字段是必要的,但应注意,“数据偏移”的单位是32位字(即以4字节的字为计算单位)。
- 由于4位二进制数能表示的最大十进制数字是15,因此数据偏移的最大值是60字节,这也是TCP首部的最大字节(即选项长度不能超过40字节)。
2.4 保留
保留:占6位,保留为今后使用,但目前应置为0 。
2.6 6个控制位
6个控制位:用来说明本报文段的性质。
紧急URG(URGent) 当URG=1时,表明紧急指针字段有效。它告诉系统此报文段中有紧急数据,应尽快发送(相当于高优先级的数据),而不要按原来的排队顺序来传送。
- 例如,已经发送了很长的一个程序要在远地的主机上运行。但后来发现了一些问题,需要取消该程序的运行,因此用户从键盘发出中断命令。如果不使用紧急数据,那么这两个字符将存储在接收TCP的缓存末尾。只有在所有的数据被处理完毕后这两个字符才被交付接收方的应用进程。这样做就浪费了很多时间。
- 当URG置为1时,发送应用进程就告诉发送方的TCP有紧急数据要传送。于是发送方TCP就把紧急数据插入到本报文段数据的最前面,而在紧急数据后面的数据仍然是普通数据。这时要与首部中紧急指针(Urgent Pointer)字段配合使用。
确认ACK(ACKnowledgment) 仅当ACK = 1时确认号字段才有效,当ACK = 0时确认号无效。TCP规定,在连接建立后所有的传送的报文段都必须把ACK置为1。
推送 PSH(PuSH) 当两个应用进程进行交互式的通信时,有时在一端的应用进程希望在键入一个命令后立即就能收到对方的响应。在这种情况下,TCP就可以使用推送(push)操作。这时,发送方TCP把PSH置为1,并立即创建一个报文段发送出去。接收方TCP收到PSH=1的报文段,就尽快地(即“推送”向前)交付接收应用进程。而不用再等到整个缓存都填满了后再向上交付。
复位RST(ReSeT) 当RST=1时,表示TCP连接中出现了严重错误(如由于主机崩溃或其他原因),必须释放连接,然后再重新建立传输连接。RST置为1还用来拒绝一个非法的报文段或拒绝打开一个连接。
同步SYN(SYNchronization) 在连接建立时用来同步序号。当SYN=1而ACK=0时,表明这是一个连接请求报文段。对方若同意建立连接,则应在响应的报文段中使SYN=1和ACK=1,因此SYN置为1就表示这是一个连接请求或连接接受报文。
终止FIN(FINis,意思是“完”“终”) 用来释放一个连接。当FIN=1时,表明此报文段的发送发的数据已发送完毕,并要求释放运输连接。
ACK和SYN将在下节详述。
2.7 窗口
窗口:占16位2字节。窗口值是【0,2^16-1(65535)】之间的整数。窗口值告诉对方:从本报文段首部中的确认号算起,我目前允许你发送的数据量(以字节为单位)是这个值的量。之所以要有这个限制,是因为接收方的数据缓存空间是有限的。总之,窗口值作为接收方让发送方设置其发送窗口的依据。TCP的流量控制由连接的每一端通过声明的窗口大小来提供。
2.8 检验和
检验和:占16位2字节。检验和字段检验的范围包括首部和数据这两部分。和UDP用户数据报一样,在计算检验和时,要在TCP报文段的前面加上12字节的伪首部(具体过程可见《UDP协议分析》一文的校验过程)。伪首部的格式和UDP用户数据报的伪首部一样。但应把伪首部第4个字段中的17改为6(TCP的协议号是6);把第5字段中的UDP中的长度改为TCP长度。接收方收到此报文段后,仍要加上这个伪首部来计算检验和。若使用TPv6,则相应的伪首部也要改变。
2.9 紧急指针
紧急指针:占16位2字节。紧急指针仅在URG=1时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据排在数据的最前面,紧急数据结束后就是普通数据) 。
- 因此,在紧急指针指出了紧急数据的末尾在报文段中的位置(紧急指针是一个正的偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号。)。当所有紧急数据都处理完时,TCP就告诉应用程序恢复到正常操作。值得注意的是,即使窗口为0时也可以发送紧急数据。
2.10 选项
选项:长度可变,最长可达4字节。当没有使用“选项”时,TCP的首部长度是20字节。
- TCP最初只规定了一种选项,即最大报文段长度MSS(Maximum Segment Szie),在连接建立的时候,即在发送SYN段的时候,同时会将MSS发送给对方(MSS选项只能出现在SYN段中!!!),告诉对端他期望接收的TCP报文段数据部分最大长度。
- 注意MSS这个名词含义。MSS是每一个TCP报文段中的数据字段的最大长度。数据字段加上TCP首部才等于整个的TCP报文段。所以MSS并不是整个TCP报文段的最大长度,而是“TCP报文段长度减去TCP首部长度”。
- 窗口扩大选项:窗口扩大选项是为了扩大窗口。我们知道,TCP首部中窗口字段长度是16位,因此最大的窗口大小为64K字节。虽然这对早期的网络是足够用的,但对于包含卫星信道的网络,传播时延和宽带都很大,要获得高吞吐量需要更大的窗口大小。
- 窗口扩大选项占3字节,其中有一个字节表示移位值S。新的窗口值等于TCP首部中的窗口位数从16增大到(16+S)。移位值允许使用的最大值是14,相当于窗口最大值增大到2(16+14)-1=230-1。
- 窗口扩大选项可以在双方初始建立TCP连接时进行协商。如果连接的某一端实现了窗口扩大,当它不再需要扩大其窗口时,可发送S=0选项,使窗口大小回到16。
- 时间戳选项:时间戳选项占10字节,其中最主要的字段是时间戳字段(4字节)和时间戳回送回答字段(4字节)。时间戳选项有以下两个概念:
- 用来计算往返时间RTT。发送方在发送报文段时把当前时钟的时间值放入时间戳字段,接收方在确认该报文段时把时间戳字段复制到时间戳回送回答字段。因此,发送方在收到确认报文后,可以准确地计算出RTT来。
- 用于处理TCP序号超过2^32 的情况,这又称为防止序号绕回PAWS。我们知道,TCP报文段的序号只有32位,而每增加2 ^32 个序号就会重复使用原来用过的序号。当使用高速网络时,在一次TCP连接的数据传送中序号很可能被重复使用。
- 例如,当使用1.5Mbit/s的速度发送报文段时,序号重复要6小时以上。但若用2.5Gbit/s的速率发送报文段,则不到14秒钟序号就会重复。为了使接收方能够把新的报文段和迟到很久的报文段区分开,则可以在报文段中加上这种时间戳。
每个选项的开始是1字节kind字段,说明选项的类型。kind字段为0和1的选项仅占1个字节。其他的选项在kind字节后还有len字节。它说明的长度是指总长度,包括kind字节和len字节。
3. TCP连接的建立与终止
TCP是一个面向连接的协议。无论哪一方向另一方发送数据之前,都必须先在双方之间建立一条连接。
一个TCP连接需要四个元组来表示是同一个连接(src_ip, src_port, dst_ip, dst_port)准确说是五元组,还有一个是协议。但因为这里只是说TCP协议,所以,这里我只说四元组。
前面我们介绍的TCP的首部字段,其中有三个字段,和TCP的连接有密切关系,他们分别是
序号(sequence number):Seq序号,前面说过,tcp会话中的端会对它发送的每个字节进行编号,一个报文段的序号值=本报文段所发送的数据的第一个字节的序号。
确认号(acknowledgement number):Ack序号,占32位,只有ACK标志位为1时,确认序号字段才有效,Ack=Seq+1。
标志位(Flags):共6个,即URG、ACK、PSH、RST、SYN、FIN等。其中重点是:
- SYN:表示发起一个新连接。
- ACK:表示确认序号有效。(其实是用来确认接收到的数据)(注意,这个ACK和确认号Ack不要搞混!!!!)
- FIN:表示释放一个连接。
下图是Wireshark中截出的一段tcp交互的seq和ack变化过程。
Wireshark的seq展示的是相对序号,即以ISN=0为基准的序号相对值。并不是ISN就这么刚好是0
3.1 TCP连接的建立——三次握手
所谓的三次握手即TCP连接的建立。这个连接必须是一方主动打开,另一方被动打开的。以下为客户端主动发起连接的图解:
客户端A的TCP向服务端发出连接请求报文段,其首部中的SYN控制位应置为1,并选择序号x(前面说过,一个端的序号初始值是随机的,我们姑且认为它是x),表明传送数据时的第一个数据字节的序号是x。
- 此时客户端进入SYN-SENT状态。
服务端B的TCP收到连接请求报文段后,如同意,则发回确认:
- 服务端B在确认报文段中应将SYN置为 1,其确认号ACK置位为x + 1,同时自己这端也会给这个确认报文段写入序号=y。
- 此时服务端进入SYN-RCVD状态
客户端A收到此报文段后,向服务端B给出确认,其确认号置为 y + 1。
- 客户端的TCP通知上层应用进程,连接已经建立。客户端进入ESTABLISHED状态
- 当运行服务器进程的服务器主机B的TCP收到客户端主机A的确认后,也通知其上层应用进程,连接已经建立。服务端也进入ESTABLISHED状态
由于客户对报文段进行了编号,它知道哪些序号是期待的,哪些序号是过时的。当客户发现报文段的序号是一个过时的序号时,就会拒绝该报文段,这样就不会造成重复连接。
3.2 TCP连接的终止——四次分手
数据传输结束后,通信双方都可以释放连接。
看起来比较简单,说是四次分手,其实就是FIN-ACK的交互,由主动方和被动方先后各执行了一次而已。上图已经直观展示的过程我们不再赘述,下面说一些比较值得注意的点:
3.2.1 2MSL等待状态
TIME_WAIT状态也称为2MSL等待状态。主动关闭的端(即上图中的客户端)在发送最后一个ACK的时候,没有立刻关闭,而是等待了2个MSL的时间才关闭。
MSL(Maximum Segment Lifetime),指的是一个报文段最大的生存时间,即一个报文段被丢弃前在网络内的最长时间。我们知道这个时间是有限的,因为TCP报文段以IP数据报在网络内传输,而IP数据报则有限制其生存时间的TTL字段。不过不同的TCP实现由不同的MSL设置,我们不去管他到底是何值,只要知道它控制着报文段在网络中的最长生存时间,如果超过MSL时间报文段还没到达彼端,那么它将被丢弃。
之所以A端在发送最后一个ACK后还要等待2MSL的时间才关闭,是因为假如最后一个ACK丢失了,B端会等待ACK超时,然后再重发一个FIN过来,如果A端立刻关闭,它就可能无法响应到这个重发的FIN。只有等待2MSL后A段没有收到B端重发的FIN,A端才会关闭。
3.2.2 半关闭链接
TCP提供了连接的一端在结束它的发送后还能接收来自另一端数据的能力。这就是所谓的半关闭。
显示了一个半关闭的典型例子。假设左方的客户端开始半关闭。
初始端发出的FIN,接着是另一端对这个FIN的ACK报文段。因为接收半关闭的一方仍能发送数据(这是半关闭的基础)。
我们只显示一个数据报文段和一个ACK报文段,但可能发送了许多数据报文段。
当收到半关闭的一端在完成它的数据传送后,将发送一个FIN关闭这个方向的连接,这将传送一个文件结束符给发起这个半关闭的应用进程。当对第二个FIN进行确认后,这个连接便彻底关闭了。
3.3 总结
建连接为什么需要三次握手?
- 因为通信的双方要互相通知对方自己的ISN(也就上图中的 x 和 y)——所以需要两端各发出一个SYN(全称Synchronize Sequence Numbers,顾名思义,就是用来同步对方自己的ISN的)。ISN后续要作为数据通信的序号,以保证应用层接收到的数据不会因为网络上的传输的问题而乱序。
- 而光有SYN还不够,发端还要确认对端真的收到了我SYN,而不是我发出去SYN就了事,所以还有最后一个ACK
断连接为什么需要四次挥手
- 因为TCP是全双工的,所以,发送方和接收方都需要各一次的Fin和Ack。只不过,有一方是被动的,所以看上去就成了所谓的4次挥手。
- 其实仔细看,挥手比起握手之所以多了一次,主要是收到第一个fin包后单独回复了一个ack包,如果能回复fin+ack那么四次挥手也就变成三次了。
- 之所以被动端没有在收到fin后回复fin+ack,是因为在CLOSE_WAIT状态阶段,被动端需要去通知应用进程,会有一些时间消耗,所以先回发一个ack,等应用进程确定关闭后,再发送一个fin。否则如果应用进程没有关闭,至少还能维持一个半关闭链接。
- 如果两边同时断连接,那就会就进入到CLOSING状态,然后到达TIME_WAIT状态。
4 TCP数据的交互
我们已经在前文的叙述中大概知道了TCP的数据传输,是通过数据和ack的相互交替来确认的,TCP连接的任意一端发送数据,都会捎带一个seq(数据的第一个字节的序号),接收端接收到数据后,返回ACK(控制位)=1,Ack(确认号)=seq+len
注意,Ack=seq+len,表示的不是它要确认的data的最后一个字节的序号,而是它希望下次接收到的第一个序号。
比如,seq=90,len=1,那这唯一的1字节的data,它的序号就是90,Ack=90+1=91,表示它希望下次接受的第一个序号是从91开始。
4.1 捎带确认Delay ACK
通常TCP在接收到数据时并不立即发送ACK,相反,它推迟发送,以便将ACK与需要沿该方向发送的数据(这个数据可以是应用数据,也可以是另外一个同方向的ACK)一起发送(有时称这种现象为数据捎带确认),这样做的目的是尽量减少发往网络的报文,以提高传输的效率,节省网络资源。
为了防止产生超时重传,绝大多数情况下,这个等待时间为200ms,超过了200ms,如果没有数据要一起发送,就直接发送ACK报文。
4.2 累计确认和Duplicate ACK
在TCP会话中,发送方对段的发送速度有时候非常快,比如发送方发了A,B,C,D四个段;段A含字节0到10,段B含字节11到20,段C含字节21到30,段D包含字节31到40
接受方成功收到段A,段B,段D,只有段C丢失了。那么接受方发回一个包含确认序号21的ACK(而不是分别给段A和段B都回复一个ACK,等于是把段A和段B的ACK合并了一样),发送方收到这个合并的ACK,就知道字节0到20(段A,段B)都成功收到。
通过累积确认的方式,在发送方快速发包的场景,一个ACK可以直接确认接收方接收到的连续序号的好几个段,这样减少报文段的传输。
注意,如果段C没有收到,那么段D即便收到了,接收方也不会回复段D的ACK,因为一旦回复段D的ACK,就表示段D以前的数据都收到了,但其实段C还没收到。
因为段C丢失,之后接收端即便接收到段E,段F等数据,也只会重复回复确认序号21的ACK,这时我们可以看到,因为段C丢失,接收端重复发送了很多次Ack=21,这种ACK我们称之为冗余ACK(duplicate ACK)。
4.3 Nagle算法
在TCP传输数据流中,存在两种类型的TCP报文段,一种包含成块数据(通常是满长度的,利用缓存,使报文一次发送就携带一个报文段最多容纳的字节数),另一种则包含交互数据(通常只有携带几个字节数据)。
对于成块数据的报文段,TCP采用正常的流程发送即可,因为数据利用率很高。而对于交互数据的报文段(也就是ACK),数据利用率就显得很低(因为ACK一般就一个IP头和TCP头),在网络环境不好的情况下容易加重网络负担。所以TCP必须对交互数据单独处理
nagle算法用于处理小报文段(微小分组)的发送问题,其核心思想是允许网络中最多只能有一个小分组被发送,而待发送的其它小分组会被重新分组成一个”较大的”小分组,等收到上一个小分组的应答后再发送。
比如客户端需要依次向服务器发送大小为1,2,3,1,2字节的5个分组
在没有开启nagle算法的情况下,这些小分组会被依次发送(不需要等待上一个小分组的应答,因为没启动nagle),总共发送的报文段(分组)个数为5
当开启nagle算法时,客户端首先发送大小为1字节的第一个分组,随后其它分组到达发送缓冲区,由于上一个分组的应答还没有收到,所以TCP会先缓存新来的这4个小分组,并将其重新分组,组成一个大小为8(2+3+1+2)字节的”较大的”小分组。当第一个小分组的应答收到后,客户端将这个8字节的分组发送。总共发送的报文段(分组)个数为2。
将套接字描述符设置TCP_NODELAY选项可以禁止nagle算法
4.4 超时重传
TCP提供可靠的运输层。它使用的方法之一就是确认从另一端收到的数据。但数据和确认都有可能会丢失。TCP通过在发送时设置一个定时器来解决这种问题。如果当定时器溢出时还没有收到确认,它就重传该数据。对任何实现而言,关键之处就在于超时和重传的策略,即怎样决定超时间隔和如何确定重传的频率。
下图是一个超时重传的例子:
- 第1、2和3行表示正常的TCP连接建立的过程
- 第4行是“hello,world”(12个字符加上回车和换行)的传输过程
- 第5行是其确认。接着我们从svr4拔掉了以太网电缆
- 第6行表示“andhi”将被发送。第7~18行是这个报文段的12次重传过程,
- 而第19行则是发送方的TCP最终放弃并发送一个复位信号的过程。
连续重传之间不同的时间差,我们整理后发现他们分别为1.5、3、6、12、24、48和多个64秒。这个倍乘关系被称为“指数退避(exponentialbackoff)”。也就是说,每一次超时,等待的时间都会翻倍,直到等待时间为64秒为止。
首次分组传输(第6行,24.480秒)与复位信号传输(第19行,566.488秒)之间的时间差约为9分钟,该时间在目前的TCP实现中,大多数是不可变的。也就是大多数的TCP实现都要尝试9分钟才会放弃。
4.5 选择确认
4.5.1 SACK
我们知道累计确认,不会越过接收方未接受到的序号进行确认,如前例中的ABCD四个段,段A含字节0到10,段B含字节11到20,段C含字节21到30,段D包含字节31到40。
段C未收到,那么是不会返回确认号为41的ACK的。即便接收方已经收到了段D。
这种情况下,发送方只接收到了ack=21,那对于发送方而言,它可以有两种理解:
- 段C丢失了
- 段C和段D都丢失了
这时为了保险起见,发送方可能会重传段C和段D,但我们知道其实段D是没有必要重传的。
为了解决这个问题,TCP实现引入了选择确认机制。
选择确认全称叫做Selective Acknowledgment(SACK),这种方式需要在TCP首部的选项中里加一个SACK的字段,它的工作原理也十分简单,一目了然:
这样,在发送端就可以根据回传的SACK来知道哪些数据到了,哪些没有到。
选择确认的数据包长这样:
- 这个确认包只有tcp首部,没有数据部分
- Kind:SACK(5)用来表示这是选择确认(SACK),该字段占用一个字节。
- Length表示tcp选项长度,占用一个字节,左边界和右边界各占用4字节,也就是说这里总共用掉了10字节。
- 其中left edge表示接收方接收到的数据块中的左边界位置(起始字节)
- right edge可以理解为接收方接收到的数据块中的结束位置(右边界)
也就是说,通过左边界和右边界我们可以指明一个数据块的位置。那么我们可以根据捕获的数据报中的确认号丢失的数据块中的起始字节,也就是要重传的起始字节,再结合接收窗口,左边界和右边界。我们可以推出接收窗口中的已经接收到的数据块,和未接收到的数据块:
这样,发送方在进行选择性重传时,会从2336611189字节的位置开始重传。注意:对于已经接收到的字节数据块(2336631881 - 2336693150)是不会被重传的。
在前面学习TCP首部的时候我们知道,TCP首部中的选项部分最大是40字节,选择确认选项左边界和右边界在指明一个数据块时就用掉了8字节,那么指明4个数据块就用掉了32字节,再加上Kind:SACK(5)和Length两个字段占用的2字节,最终只剩下了6字节,换句话说,TCP选项的选择确认选项最多也就只能指明4个数据块
4.5.2 Duplicate SACK
Duplicate SACK又称D-SACK,其主要使用了SACK来告诉发送方有哪些数据被重复接收了。
作为对比,SACK是告诉发送方,接受方已经收到了哪些数据,不要混淆。
那什么样的SACK是D-SACK呢?
答案是:
如果SACK的第一个段的范围被ACK所覆盖,那么就是D-SACK
- 假如发送端已经收到了一个ACK报文,内容为:[Ack=4000, SACK=5000-5500, 4500-5500],Ack=4000表示序号为4000以前的字节都收到了,这时候再看选项中的SACK=3000-3500,那么很显然,SACK框定的字节范围是已经被确认的范围,那么这个SACK是D-SACK,3000-3500也就是被重传的数据段。
如果SACK的第一个段的范围被SACK的第二个段覆盖,那么就是D-SACK
- 假如发送端收到一个[Ack=4000,SACK=5000-5500, 4500-5500],那么第二个段的区间4500-5500,覆盖了第一个段的区间5000-5500,这表示该SACK是个D-SACK,5000-5499段是重复收到的。
D-SACK在如下场景发挥比较积极的作用:
- ACK丢包
- 如果一个发送端发送序号为3000-3999的段,却没收到对应的ACK,那么过一段时间发送端重传该段,紧接着却收到了[Ack=4000,SACK=3000-3999],那么发送端就知道,之前的那个ACK丢失了。
- 发送延误
- 发送端发送序号为1000-1499的段,这个段因为网络延迟,导致接收端迟迟没有收到。发送端继续发送后面的段,但接收端因为没有收到1000-1499的段,所以只会回复[Ack=1500]
- 发送端收到3次[Ack=1500],就会重传这个1000-1499的段,在重传的期间,接收端也收到了姗姗来迟的原来的1000-1499的段,这时面对重传的新段,接收端返回了[Ack=4000, SACK=1000-1499]
- 发送端通过这个D-SACK,就知道之前发出去的段,是因为网络延迟才迟到的。
可见,引入了D-SACK,有这么几个好处:
可以让发送方知道,是发出去的包丢了,还是回来的ACK包丢了。
是不是自己的timeout太小了,导致重传。
网络上出现了先发的包后到的情况(又称reordering)
网络上是不是把我的数据包给复制了。
5 RTT算法
从前文的TCP重传机制我们知道Timeout的设置对于重传非常重要:
- 设长了,重发就慢,丢了老半天才重发,没有效率,性能差;
- 设短了,会导致可能并没有丢就重发。于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
而且,这个超时时间在不同的网络的情况下,根本没有办法设置一个死的值。由于路由器和网络流量均会变化,因此我们认为这个时间可能经常会发生变化,TCP应该跟踪这些变化并相应地改变其超时时间。
我们把这里的超时时间命名为RTO(Retransmission TimeOut),为了动态地设置RTO,TCP引入了RTT的概念——Round Trip Time,也就是一个数据包从发出去到回来的时间。
听起来似乎很简单,好像就是在发送端发包时记下t0,然后接收端再把这个ack回来时再记一个t1,于是RTT = t1 – t0。但其实没那么简单,这只是一个采样,不能代表普遍情况。
5.1 经典算法
RFC793中定义的经典算法是这样的:
首先,先采样RTT,记下最近好几次的RTT值。
然后做平滑计算SRTT( Smoothed RTT)。公式为:(其中的 α 取值在0.8 到 0.9之间,这个算法英文叫Exponential weighted moving average,中文叫:加权移动平均)
SRTT = ( α * SRTT ) + ((1- α) * RTT)
- 开始计算RTO。公式如下:
RTO = min [UBOUND, max [ LBOUND, (β * SRTT) ] ]
其中:
UBOUND是最大的timeout时间,上限值
LBOUND是最小的timeout时间,下限值
β 值一般在1.3到2.0之间。
5.2 Karn / Partridge 算法
但是上面的这个算法在重传的时候会出有一个终极问题——你是用第一次发数据的时间和ack回来的时间做RTT样本值,还是用重传的时间和ACK回来的时间做RTT样本值?
这个问题无论你选那头都是按下葫芦起了瓢。 如下图所示:
情况(a)是ack没回来,所以重传。如果你计算第一次发送和ACK的时间,那么,明显算大了。
情况(b)是ack回来慢了,但是导致了重传,但刚重传不一会儿,之前ACK就回来了。如果你是算重传的时间和ACK回来的时间的差,就会算短了。
所以1987年的时候,搞了一个叫Karn / Partridge算法,这个算法的最大特点是——忽略重传,不把重传的RTT做采样(你看,你不需要去解决不存在的问题)。
但是,这样一来,又会引发一个大BUG——如果在某一时间,网络闪动,突然变慢了,产生了比较大的延时,这个延时导致要重传所有的包(因为之前的RTO很小,所以很容易就超时),于是,因为重传的不算,所以,RTO就不会被更新,导致包仍非常容易超时,这是一个灾难。
于是Karn算法用了一个取巧的方式——只要一发生重传,就对现有的RTO值翻倍(这就是所谓的 Exponential backoff),很明显,这种死规矩对于一个需要估计比较准确的RTT也不靠谱。
5.3 Jacobson / Karels 算法
前面两种算法用的都是“加权移动平均”,这种方法最大的毛病就是如果RTT有一个大的波动的话,很难被发现,因为被平滑掉了。所以,1988年,又有人推出来了一个新的算法,这个算法叫Jacobson / Karels Algorithm(参看RFC6289)。这个算法引入了最新的RTT的采样和平滑过的SRTT的差距做因子来计算。
我们每次采样,都能得到一个新的RTT,即RTT[新],除此之外,还引入了
- SRTT(Smoothed RTT)——平滑 RTT
- DevRTT(Deviation RTT)——滑 RTT 和真实的差距
这二者的值,每次采样之后都会更新,于是得到公式如下:
SRTT[新] = SRTT[旧] + α ( RTT[新] – SRTT[旧] ) —— 计算平滑 RTT
DevRTT[新] = ( 1-β ) * DevRTT + β * ( | RTT[新] - SRTT[旧] | ) ——计算平滑 RTT 和真实的差距(加权移动平均)
RTO= µ * SRTT + ∂ * DevRTT
其中:α、β、μ、∂ 是可以调整的参数,在 RFC6298 中给出了对应的参考值,而在Linux下,α = 0.125,β = 0.25, μ = 1,∂ = 4;
Jacobson / Karels算法在被用在今天的TCP协议中(Linux的源代码在:tcp_rtt_estimator)。
6 滑动窗口
6.1 背景
我们知道TCP正常的交互是这样的:
这带来了一个问题:吞吐量非常的低。我们发完包1,一定要等确认包1.我们才能发送第二个包。
那如何提高吞吐量?我们可不可以连发几个包等他一起确认呢?
这样确实可以提高吞吐量,发送两个包,所花的时间只是原来一个来回的时间。
但是,新的问题又来了,如果我一次把太多的包连发,超过了接收端的处理上限,导致中途一直重传超时的包,即占用了带宽,又提高不了太多的吞吐量,如何制定最优解呢?TCP实现了一种被称为滑动窗口的流控机制
6.2 发送窗口和接受窗口
滑动窗口解决的是TCP流量控制的问题,即如果接收端和发送端对数据包的处理速度不同,如何让双方达成一致。
我们知道TCP是全双工的协议,会话的双方都可以同时接收和发送数据。TCP会话的双方都各自维护一个发送窗口(本质是一个缓存) 和一个 接收窗口(本质是一个缓存) 。
- 各自的接收窗口大小取决于应用、系统、硬件的限制(TCP传输速率不能大于应用的数据处理速率)。
- 各自的发送窗口的大小,则取决于对端的接收窗口。
在TCP的首部中,我们知道有个窗口(Window Size)字段,它是指接收端的窗口大小(单位字节),即接收窗口的大小。用来告知发送端自己所能接收的数据量,从而达到一部分流控的目的。
同时,选项中还有一个窗口扩展选项(Window Scaling),前文我们也简单介绍过。窗口扩展选项和窗口字段,这二者由接收端通知发送端,最终确定了发送端的发送窗口大小。
因为接受窗口不是恒定的,所以在会话中,接收端可以不断的通知发端改变窗口大小。
假设,我们设定两边的接受窗口为20*MSS(Maxitum Segment Size),那么我们一个窗口,就可以发送20个满数据的段。如下图(方框内的数字为段的编号):
其中发送端的段可以分成以下四类
- 已发送,已收到ACK
- 已发送,未收到ACK(属于发送窗口)
- 未发送,但允许发送(属于发送窗口)
- 未发送,但不允许发送
接收端的段可以分成以下三类
- 已接收
- 未接收但允许接收(属于接收窗口)
- 未接收而且不允许接收
6.3 滑动机制
发送窗口只有收到发送窗口内的段的ACK确认,才会移动发送窗口的左边界。
接收窗口只有在前面所有的段都确认的情况下才会移动左边界。当在前面还有段未接收到,但先收到后面段的情况下,窗口不会移动,也不对窗口外的段进行确认。以此确保对端会对这些数据重传。
- 没有收到G的情况下,窗口不会左移,就算收到窗口外的段,也不会进行确认
遵循累计确认、选择确认等规则。
6.4 滑动过程
flash来自一个模拟TCP滑动窗口的动画:动画地址
假定窗口大小为4*MSS,首先发送端发送A,B,C,D四个包,但是A,B丢失,只有C,D到达接收端。
接收端没有收到A,所以不回复ACK包。发送端重传的A,B,C,D四个包。
这次发送端重传的A,B,C,D四个包全都到达了,接收端先获得A,发ACK包A,但是中途丢失;接收端获得B后,根据累计确认的原则,发D的ACK包,然后窗口滑动。再次获得C,D后,连续回复2个D的ACK包,其中C对应的ACK包丢失。
发送端连收2个D的ACK包,说明4个包对方都已收到,窗口滑动,发E,F,G,H包,其中G包丢失。现在整个序列的状态:ABCD是已发送已确认,EFGH是已发送未确认,I~S是不能发送。
收端先收到E,发E的ACK包;收到F后发F的ACK包;未收到G;收到H,还是发F的ACK包。不幸的是,后两个ACK包全都丢失。
送端收到E的ACK包,窗口向右滑动一位,发送I包,然后再发送F,G,H,其中F丢失。
接收端获得I,因为没有G,只好回复F的ACK包(不过紧接着这个包丢了)。还好,接收端相继收到G,H包。
接收端根据累计确认,连发两个I包,其中H的ACK丢失。窗口向右滑动。
发送端接收I的ACK包后,向右滑动四位。发送J,K,L,M四个包,后面不再分析。
我们之前说过,在会话过程中,窗口大小也是随时可能发生改变的,下图就展示了一次动态的滑动窗口变动过程:
我们可以看到:
- 发送方不必发送一个全窗口大小的数据。
- 正如从报文段7到报文段8中变化的那样,窗口的大小可以减小,但是窗口的右边沿却不能够向左移动
- 接收方在发送一个 ACK前不必等待窗口被填满。在前面我们看到许多实现每收到两个报文段就会发送一个ACK。
6.5 零窗口(Zero Window)
上图,我们可以看到一个处理缓慢的Server(接收端)是怎么把Client(发送端)的TCP Sliding Window给降成0的。此时,你一定会问,如果Window变成0了,TCP会怎么样?是不是发送端就不发数据了?是的,发送端就不发数据了,你可以想像成“Window Closed”,那你一定还会问,如果发送端不发数据了,接收方一会儿Window size 可用了,怎么通知发送端呢?
解决这个问题,TCP使用了Zero Window Probe技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,第一次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了。
6.6 糊涂窗口综合症(Silly Window Syndrome)
我们知道,发送端的发送窗口大小是受馈于接收端通知的接收窗口大小的,如果接收方太忙了,来不及取走Receive Windows里的数据,那么,就会导致发送方窗口越来越小。到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的window,而我们的发送方会义无反顾地发送这几个字节。
要知道,我们的TCP+IP头有40个字节,为了几个字节,要达上这么大的开销,这太不经济了。糊涂窗口综合症这个现象就像是你本来可以坐200人的飞机里只做了一两个人。 要解决这个问题也不难,就是避免对小的window size做出响应,直到有足够大的window size再响应,这个思路可以同时实现在sender和receiver两端。
- 如果这个问题是由Receiver端引起的,那么就会使用David D Clark’s 方案。在receiver端,如果收到的数据导致window size小于某个值,可以直接ack(0)回sender,这样就把window给关闭了,也阻止了sender再发数据过来,等到receiver端处理了一些数据后windows size 大于等于了MSS(以太网MSS为1500字节),或者,receiver buffer有一半为空,就可以把window打开让send 发送数据过来。
- 如果这个问题是由Sender端引起的,那么就会使用前文介绍的Nagle’s算法。我们知道这个算法的思路也是延时处理,积攒数据,等于一架飞机在等待客人,以便一次能多拉一些人。
7 拥塞控制
上面我们知道了,TCP通过Sliding Window来做流控(Flow Control),但是TCP觉得这还不够,因为Sliding Window需要依赖于连接的发送端和接收端,其并不知道网络中间发生了什么。
TCP的设计者觉得,一个伟大而牛逼的协议仅仅做到流控并不够,因为流控只是网络模型4层以上的事,TCP的还应该更聪明地知道整个网络上的事。
如果网络上的延时突然增加,那么,TCP对这个事做出的应对只有重传数据,但是,重传会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,于是,这个情况就会进入恶性循环被不断地放大。
试想一下,如果一个网络内有成千上万的TCP连接都这么行事,那么马上就会形成“网络风暴”,TCP这个协议就会拖垮整个网络。这是一个灾难。
所以,TCP不能忽略网络上发生的事情,而无脑地一个劲地重发数据,对网络造成更大的伤害。对此TCP的设计理念是:TCP不是一个自私的协议,当拥塞发生的时候,要做自我牺牲。就像交通阻塞一样,每个车都应该把路让出来,而不要再去抢路了。
拥塞控制为发送方增加了另一个窗口:拥塞窗口(congestionwindow),记为cwnd。它以字节为单位,和接收方通告发送方的窗口(我们叫做通告窗口)一起,控制发送方的窗口大小——发送方取拥塞窗口与通告窗口中的最小值作为发送上限。拥塞窗口是发送方使用的流量控制,而通告窗口则是接收方使用的流量控制。
TCP拥塞控制算法发展的过程中出现了如下几种不同的思路:
- 基于丢包的拥塞控制:将丢包视为出现拥塞,采取缓慢探测的方式,逐渐增大拥塞窗口,当出现丢包时,将拥塞窗口减小,如Reno、Cubic等。
- 基于时延的拥塞控制:将时延增加视为出现拥塞,延时增加时增大拥塞窗口,延时减小时减小拥塞窗口,如Vegas、FastTCP等。
- 基于链路容量的拥塞控制:实时测量网络带宽和时延,认为网络上报文总量大于带宽时延乘积时出现了拥塞,如BBR。
- 基于学习的拥塞控制:没有特定的拥塞信号,而是借助评价函数,基于训练数据,使用机器学习的方法形成一个控制策略,如Remy。
7.1 Reno算法
Reno现有的众多拥塞控制算法的基础,它将拥塞控制的过程分为四个阶段:
- 慢启动
- 拥塞避免
- 快速重传
- 快速恢复
这四个阶段的相关算法和处理策略不是一天都搞出来的,这个四阶段算法的发展经历了很多时间,到今天都还在优化中。
Reno算法将收到ACK这一信号作为拥塞窗口增长的依据,在早期低带宽、低时延的网络中能够很好的发挥作用,但是随着网络带宽和延时的增加,Reno的缺点就渐渐体现出来了,发送端从发送报文到收到ACK经历一个RTT,在高带宽延时(High Bandwidth-Delay Product,BDP)网络中,RTT很大,导致拥塞窗口增长很慢,传输速度需要经过很长时间才能达到最大带宽,导致带宽利用率将低。
适用场景:适用于低延时、低带宽的网络。
7.1.1 慢启动(Slow Start)
首先,我们来看一下TCP的慢启动。慢启动的意思是,刚刚加入网络的连接,一点一点地提速,不要一上来便无脑向网络发送多个报文段,把现有的传输秩序搞乱。
慢启动的算法如下:
连接建好的开始先初始化cwnd = 1个MSS,表明可以传一个MSS大小的数据。
每当收到一个ACK,cwnd+1,即相当于每轮次(一个RTT,无拥塞情况下共可收到cwnd个ACK)发送窗口增加一倍,呈指数增长
- 换句话说,一次交互cwnd * 2;
还有一个慢启动门限ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”(下文介绍)
所以,我们可以看到,如果网速很快的话,ACK也会返回得快,RTT也会短,那么,这个慢启动就一点也不慢。下图说明了这个过程:
这里需要提一下的是一篇Google的论文《An Argument for Increasing TCP’s Initial Congestion Window》(http://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/36640.pdf)
Linux 3.0后采用了这篇论文的建议——把cwnd 初始化成了 10个MSS。
而Linux 3.0以前,比如2.6,Linux采用了RFC3390,cwnd是跟MSS的值来变的,如果MSS< 1095,则cwnd = 4;如果MSS>2190,则cwnd=2;其它情况下,则是3。
7.1.2 拥塞避免
前面说过,还有一个ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”。一般来说ssthresh的值是65535,单位是字节,当cwnd达到这个值时后,算法如下:
- 收到一个ACK时,cwnd = cwnd + 1/cwnd,即相当于每轮次(一个RTT,无拥塞情况下共可收到cwnd个ACK)发送窗口+1 * MSS,呈线性增长
- 换句话说,一次交互cwnd + 1 * MSS ;
这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。很明显,是一个线性上升的算法。
所以我们可以看到:
- 当cwnd<ssthresh时,使用慢开始算法。
- 当cwnd>ssthresh时,改用拥塞避免算法。
7.1.3快速重传
在介绍拥塞发生时tcp选择的策略时,我们要先了解快速重传策略。
7.1.3.1 快速重传策略
现有的超时重传机制,还有一些问题:
- 当一个报文段丢失时,会等待一定的超时周期然后才重传分组,增加了端到端的时延。
- 当一个报文段丢失时,在其等待超时的过程中,可能会出现这种情况:其后的报文段已经被接收端接收但却迟迟得不到确认,发送端会认为也丢失了,从而引起不必要的重传,既浪费资源也浪费时间。
幸运的是,由于TCP采用的是累计确认机制,即当接收端收到比期望序号大的报文段时,便会重复发送最近一次确认的报文段的确认信号,我们称之为冗余ACK(duplicate ACK)。
如图所示,报文段1成功接收并被确认ACK 2,接收端的期待序号为2,当报文段2丢失,报文段3失序到来,与接收端的期望不匹配,接收端重复发送冗余ACK 2。
这样,如果在超时重传定时器溢出之前,接收到连续的三个重复冗余ACK(其实是收到4个同样的ACK,第一个是正常的,后三个才是冗余的),发送端便知晓哪个报文段在传输过程中丢失了,于是重发该报文段,不需要等待超时重传定时器溢出,大大提高了效率。这便是快速重传机制。
为什么需要3次冗余ACK才会触发快速重传?因为即使发送端是按序发送,由于TCP包是封装在IP包内,IP包在传输时乱序,意味着TCP包到达接收端也是乱序的,乱序的话也会造成接收端发送冗余ACK。如果阈值设置的过小,那么快速重传机制很容易被乱序引发的冗余ACK干扰。
7.1.3.2 拥塞发生时的策略选择
如果慢启动和拥塞避免算法,仍然无法避免TCP连接进入拥塞状态(发生丢包的情况),那么这时,就要采取非常手段了。
对于丢包,我们知道有两种情况会发生:
- 收到3个duplicate ACK
- 还没收到3个duplicate ACK,就RTO超时
这两种情况下,TCP选择的算法也不一样,我们先讲比较严重的情况2,再讲程度稍好的情况1。(情况1还能收到3个ACK,情况2直接连ACK都超时或者丢了,网络拥塞更严重)
- 情况2:还没收到3个duplicate ACK,就RTO超时
- 设置sshthresh = cwnd /2
- cwnd 重置为 1
- 如此一来,cwnd必然小于sshthresh,进入慢启动过程
- 情况1:收到3个duplicate ACK
- 那毫无疑问,快速重传继续,重发数据段。
- cwnd = cwnd /2
- sshthresh = cwnd
- 进入快速恢复算法——Fast Recovery
上面我们可以看到RTO超时后,sshthresh会变成cwnd的一半,这意味着,如果cwnd<=sshthresh时出现的丢包,那么TCP的sshthresh就会减少一半,然后等cwnd又很快地以指数级增长爬到cwnd=sshthresh这个地方时,就会成慢慢的线性增长。
我们可以看到,TCP是怎么通过这种强烈地震荡快速而小心得找到网站流量的平衡点的。
7.1.4 快速恢复(Fast Recovery)
这个算法定义在RFC5681。快速重传和快速恢复算法一般同时使用(都在情况1中),因为情况1还有ACK能回来,说明了网络还不是那么的糟糕,所以没有必要像RTO超时那么强烈。
注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新:
cwnd = cwnd /2
以及sshthresh = cwnd
然后,真正的Fast Recovery算法如下:
- cwnd = sshthresh + 3 * MSS (3的意思是确认有3个数据包被收到了)
- 重传Duplicated ACKs指定的数据包,这时会有两种结果
- 如果再收到 duplicated Acks,那么cwnd = cwnd +1
- 如果收到了新的Ack,那么,cwnd = sshthresh ,然后就进入了拥塞避免的算法了。
7.2 Vegas算法
该算法的论文是《TCP Vegas: End to End Congestion Avoidance on a Global Internet》
Vegas将时延RTT的增加作为网络出现拥塞的信号,RTT增加,拥塞窗口减小,RTT减小,拥塞窗口增加。
具体来说,Vegas通过比较实际吞吐量和期望吞吐量来调节拥塞窗口的大小:
期望吞吐量:Expected = cwnd / BaseRTT
实际吞吐量:Actual = cwnd / RTT,diff = (Expected-Actual) * BaseRTT
其中,BaseRTT是所有观测来回响应时间的最小值,一般是建立连接后所发的第一个数据包的RTT,cwnd是目前的拥塞窗口的大小。
Vegas定义了两个阈值a,b,当diff > b时,拥塞窗口减小,当a <= diff <=b时,拥塞窗口不变,当diff < a时,拥塞窗口增加。
Vegas算法采用RTT的改变来判断网络的可用带宽,能精确地测量网络的可用带宽,效率比较好。但是,网络中Vegas与其它算法共存的情况下,基于丢包的拥塞控制算法会尝试填满网络中的缓冲区,导致Vegas计算的RTT增大,进而降低拥塞窗口,使得传输速度越来越慢,因此Vegas未能在Internet上普遍采用。
适用场景:适用于网络中只存在Vegas一种拥塞控制算法,竞争公平的情况。