文章目录
一、TCP协议
TCP协议是传输层中非常重要的一个协议,它的全称是传输控制协议(Transmission Control Protocol),它主要是用来解决在传输层通信的过程中可靠性的问题。
二、TCP协议段格式
TCP协议的前5行一共20个字节,是TCP的标准长度,TCP协议的报头大部分情况下是标准长度的,但当TCP协议带上选项行之后,报头长度就超过20个字节了,选项是可以选择带或不带的,标准长度指的是不带选项的报头长度。
其中TCP协议第一行表示16位的源端口号和16位的目标端口号,这两个字段不需要过多解释了,就是字面意思。下面我们详细介绍一下TCP协议报头的每个字段。
4位首部长度
4位首部长度,它是用来表示TCP协议报头长度的,它只有4位来表示,所以取值范围是0101~1111
,也就是最大值是15,但是它的单位是4个字节,所以最大值是60个字节,也就是说TCP协议报头长度的最大值是60个字节。如果TCP协议不带选项,那么4位首部长度就应该是0101
,如果TCP协议带选项,那么选项的长度就应该是4位首部长度的值减去标准长度的值。
可靠性理解
怎么判断我们发出去的消息有没有被对方成功接收呢?
只要得到了应答,就意味着我们发出去的消息被对方成功接收了。这就好比两个人A和B在桥的两端,A朝着B喊了一句话,如果B应答了A,那就说明A喊过去的话被B听到了,否则说明没有被B听到。
但是这又会存在另一个问题,A朝着B喊了一句之后B应答了,说明A的话B接收到了,但是B应答回去的话又要怎么判断A是否接收到了呢?那就必须要看A是否应答了B的话。以此类推,每一次传话都需要对方的应答来判断自己的传话是否被对方接收成功,在长距离交互的时候,永远会有一条最新的信息是没有应答的。但是,只要我们发送的信息收到了应答,就代表我们的信息被对方成功接收了,所以世界上没有完全可靠的传输,TCP协议之所以是可靠传输,它的核心思想就是只要我们的核心信息先发出去,一定能收到对方的应答,这就代表我们的信息传输成功了。这也叫做确定应答机制。
32位序号和32位确认序号
TCP是面向字节流的协议,TCP协议将每个字节的数据都进行了编号,即序列号。其实TCP协议的发送缓冲区就是一块内存空间,内存空间可以看作一块连续的存储空间,我们可以把它看成一块char类型的数组,当我们将数据拷贝到发送缓冲区的时候,每一个元素的大小就是一个字节,每一个字节的数据都有数组下标作为编号,这个编号就是序列号。
32位序号用来标定一个报文的序号,它就是上面介绍的TCP为每个字节的数据进行的编号。而32位确认序号用来标定该序号之前的所有报文都已经全部接收成功了。举个例子,假设服务端一次性给服务端发送过去了5条报文,那么每个报文的32位序号分别是1、2、3、4、5,服务器应答的时候,就将应答报文的32位确认序号设置为6,代表1、2、3、4、5这些所有报文服务器都已经接收成功了。
假设服务器收到了以下几个报文,它们的序号分别是1、2、3、5、6、7、8,此时服务器要给客户端进行响应,那么服务器的响应报文中确认序号应该填多少呢?
服务器的响应报文中确认序号应该填的是4而不是9,原因很简单,确认序号代表的是该序号之前的所有序号都已经成功接收了,如果填9的话,序号9之前的所有序号中还有序号4没有成功接收,所以不可以填9,只能填4。
这种序号与确认序号机制就是确认应答机制,可以保证数据传输的可靠性,从客户端发送数据给服务端,填充序号字段,当服务端接收到了数据之后,按照要求填充确认序号字段,就可以告知客户端哪些数据被客户端成功接收了。
为什么TCP协议要有序号和确认序号两组序号呢?一组序号不能解决应答问题吗?
因为TCP协议是全双工的,所谓的全双工指的就是一端在发消息的同时,也可以接收消息。如果服务端接收到了客户端的消息,那它就要应答客户端,但是服务端同时又想给客户端发一条消息,想要应答就必须填充响应报文里的确认序号字段,想要发送消息就必须填充响应报文里的序号字段,TCP正是采用这样两组序号来保证两端通信时全双工的确认应答机制。
总结一下就是,TCP协议的可靠性是靠确认应答机制实现的,而TCP协议的确认应答机制是靠序号和确认序号来实现的,这一套机制保证了TCP传输的可靠性。
16位窗口大小
TCP协议是有发送缓冲区和接收缓冲区的。我们在使用TCP套接字编程的时候,在用户层我们调用write函数接口时,并不是直接就将我们的数据通过这个接口写到对端主机上,而是经过了很多个缓冲区。write函数接口在用户层有它自己的缓冲区,TCP协议也有自己的发送缓冲区,调用write函数接口将数据发送出去之后,数据先从write函数的缓冲区拷贝到TCP协议的发送缓冲区,在TCP协议的发送缓冲区中,数据什么时候发给对端,怎么发给对端,发送出错了怎么处理,要不要采用什么策略来提高发送效率,这些问题完全由操作系统内的TCP协议自己决定,我们用户层并不需要关心,这也是TCP叫做传输控制协议的原因。接收缓冲区也是同理。
当两台主机都以TCP协议进行网络通信时,主机A的消息会从主机A的发送缓冲区发送到主机B的接收缓冲区,主机B的消息也可以从主机B的发送缓冲区发送到主机A的接收缓冲区。这就是TCP协议全双工的核心,两台主机之间可以同时发消息也可以同时接收消息,这两个动作相互之间互不影响。
如果发送端一次性发送非常多消息给接收端,接收端的接收缓冲区满了,后发送过来的数据无法被成功接收,只能被丢弃等待下一次重传,这样的话是不合理的,所以在数据传输的时候,发送端必须知道接收端的接受能力,这里的接受能力就是用接收端的接收缓冲区剩余空间大小来衡量的。发送端想要知道目前接收端的接收能力如何,就需要获取到接收端发送过来的应答,这个应答里包含了TCP响应报头,报头里有一个16位窗口大小字段,它保存的就是接收端的接收缓冲区当前剩余空间大小。
发送端每次在发送数据给接收端之前,都先看看目前接收端的窗口大小是多少,然后根据接收端的接收缓冲区剩余空间大小来发送对应大小的数据,这种控制发送数据大小的机制叫作流量控制机制。
TCP协议中的6个标记位
TCP协议报文是有类别区分的,比如说我们是服务端,当我们接收到多个TCP报文的时候,不同的报文可能有不同的诉求,有可能是想跟我们建立连接的,有可能是想给我们发消息的,有可能是想跟我们断开连接的,所以我们必须要能够区分接收到的TCP报文属于什么类别。TCP协议中有6个标志位用来区分TCP报文的类别,它们分别是URG
、ACK
、PSH
、RST
、SYN
、FIN
。
- SYN:用来标定该TCP报文是否是建立连接请求,接收端接收到TCP报文时,先会去看6个标记位,如果看到SYN标记位的值为1,则报文是建立连接请求的。也就是说,只要报文是建立连接的请求,SYN标记位需要被设置为1。
- FIN:如果该报文是一个断开连接的请求报文,FIN标记位需要被设置为1。
- ACK:这个叫做确认标记位,如果该标记位被设置为1,则代表该报文是对历史报文的确认。
- PSH:这个叫做数据推送标记位,用来提示接收端的应用程序立刻从TCP缓冲区中把数据读取出去。举个例子,假设客户端发送数据给服务端,服务端的接收缓冲区中存放着的就是客户端发送过来的数据,我们可以调用read函数从接收缓冲区中将数据读取上来。当服务端的接收缓冲区没有数据时,read函数底层会阻塞式地等待,直到接收缓冲区有数据了才会继续读取,这是因为read函数为我们提供了一个检测接收缓冲区是否有数据的功能。如果我们不使用read函数的这一功能,而是当接收缓冲区没有数据时或者数据还没到达我们规定的大小时,我们就不读取,一旦条件满足时,操作系统会提醒我们赶紧读取。这就需要使用到PSH标记位,当条件满足时,接收缓冲区会收到一条PSH标记位被设置为1的TCP报文,操作系统会通知上层应用赶紧读取缓冲区内的数据。
- URG:这个叫做紧急指针标记位。报文在发送的时候虽然是按序发送出去的,但是接收端接收到报文却可能是乱序的,接收到的乱序报文是不可靠的,所以我们需要让我们接收到的报文与发送时的报文顺序一致,此时就需要在接收端先按照报文的32位序号字段进行排序,排成有序的之后再进行报文的解包分用等操作。这样可以保证接收到的报文是按顺序的,但是如果有些数据的优先级比较高,希望被优先处理,但它的顺序又比较靠后,就可以将URG标记位设置为1,代表该报文内有数据需要被紧急处理。
- RST:这个叫做连接重置标记位,一旦TCP报文里的RST标记位被设置为1,就代表需要关闭TCP连接,重新建立TCP连接。TCP是面向连接的,在TCP协议通信之前必须先经过三次握手来建立连接。第一次握手是客户端发送带有设置了SYN标记位的TCP报文给服务端,发起TCP请求;第二次握手是服务端发送带有设置了SYN标记位和ACK标记位的TCP报文给客户端,是建立连接并确认应答;第三次握手是客户端发送带有设置了ACK标记位的TCP报文给服务端,代表客户端的确认应答。三次握手成功之后,服务端就为客户端的连接维护数据结构,客户端也认为连接建立成功了。但是如果第三次握手,客户端的确认应答在通信过程中丢失了,服务端没有收到客户端的确认应答,所以服务端认为连接还没有建立成功,也就不会为该连接维护对应的数据结构,而客户端并不知道服务端有没有成功接收该应答,它以为服务端成功接收了,也就认为连接建立成功了,所以就开始向服务端发消息了。服务端一看这不对呀,我们两个连接还没建立好你就发消息过来了,所以服务端赶紧给客户端发了一个TCP报文,该报文中的RST标记位被设置为1,代表着让客户端重新与服务端建立TCP连接。
16位紧急指针
如果报文的URG标记位被设置为1,则代表该报文中有数据需要被紧急处理,那么报文中的16位紧急指针字段也会被填充,这里填充的是偏移量,拿着这个偏移量到数据字段中就能找到对应的那一个字节的数据,该数据就是需要被紧急处理的数据。需要注意的是:紧急指针只能指向数据字段中需要被紧急处理的一个字节的数据。
紧急指针的应用场景非常少,一般应用在紧急获取对端状态的场景,比如说客户端给服务端发送了很多条TCP请求,服务端都没有给回应答,客户端就可以发一个紧急数据确认服务端的状态。
三、TCP三次握手和四次挥手
TCP的三次握手
TCP是面向连接的,也就是说在使用TCP通信之前必须让双方建立连接。TCP建立连接必须经过三次握手的过程,三次握手过程如下:
- 第一次握手:首先是客户端给服务端发送一个TCP报文,该报文中的SYN标记位被设置为1,表示客户端向服务端发起建立连接的请求。
- 第二次握手:然后是服务端给客户端发送一个TCP报文,该报文中的SYN标记位和ACK标记位都被设置为1,表示服务端收到了客户端发来的建立连接请求,并且服务端同意与客户端建立连接。
- 第三次握手:最后客户端给服务端发送一个TCP报文,该报文中的ACK标记位被设置为1,表示客户端的确认应答,确认该连接建立完毕,自此三次握手完成。
第一次握手时,只要客户端发送出去了带有SYN标记位的报文,客户端的状态就变成了SYN_SENT
,叫同步发送状态。
第二次握手时,只要服务端收到了客户端发送过来的报文并且给客户端发出去了带有SYN+ACK标记位的确认应答报文,服务端的状态就变成了SYN_RCVD
,叫同步接收状态。
第三次握手时,客户端只要接收到了服务端发送过来的带有SYN+ACK标记位的确认应答报文,并且客户端给服务端发出去了带有ACK标记位的确认应答报文,则客户端的状态就变成了ESTABLISHED
,叫连接状态。
最后,如果服务端成功接收到了客户端第三次握手时发送过来的带有ACK标记位的确认应答报文,那么服务端的状态也变成了ESTABLISHED
连接状态。
注意:上图TCP三次握手中的箭头是斜着画的,不是横着画的,原因就是该图还包含了时间这一元素,也就是说三次握手每一步都是需要时间的。
作为一个TCP服务器,当它有大量的客户请求的时候,就会与这些客户建立大量的TCP连接,服务端的操作系统必须维护这些建立好的连接,维护的方式就是先描述再组织。其实不只是服务端要维护好这些连接,客户端也需要。一旦连接建立成功,连接的双方必须要为维护这些连接而创建对应的数据结构。所以说维护连接是有成本的,UDP就不需要连接,所以UDP不需要为了连接而做很多额外的工作,但TCP必须要这么做。
通过三次握手的图我们可以发现,前两次握手都是会收到对方应答的,但最后一次握手是不会收到对方应答的,所以客户端并不确定服务端是否收到第三次握手发出去的报文。正是因为这个原因,三次握手不一定能成功,如果第三次握手发送的报文在途中丢失了,服务端没有收到报文,那么双方也就不能成功建立连接。
为什么是三次握手?
如果是一次握手的话,客户端只需要发送一次SYN标记位被设置为1的TCP报文,服务端只要收到了这个报文就与客户端建立连接,不需要应答客户端,也更不需要客户端的进一步应答,这种方式很容易遭受攻击,别人只需要一台主机循环地给服务器发送建立连接请求的报文(SYN洪水攻击),服务器就会因此建立大量连接,由于维护连接是需要创建数据结构是有时间和空间上的成本消耗的,所以服务器的资源就会很快被占满,最终导致服务器无法正常工作。所以这种方式明显是不可取的。
两次握手的问题其实和一次握手类似,两次握手只不过是服务端接收到了客户端的建立连接请求之后,给客户端发送回了应答报文,那么同样可以采用一台主机向服务器发送多条建立连接的请求,即使客户端接收到了服务端的应答报文,也只需要把它丢弃即可,因为客户端不需要再应答回去给服务端,所以服务端只要在第二次握手时发送出去了应答报文就认为连接建立好了,服务端就会为该连接维护对应的数据结构,当大量恶意连接到来的时候,服务端的资源一样是很容易给占满。
三次握手的好处就在于,最后一次确认连接是否建立成功的机会在服务端,因为第一次握手是客户端向服务端发起建立连接的请求,服务端收到以后,开始第二次握手,向客户端发送应答报文,表示服务端已经收到了客户端的建立连接请求,客户端收到第二次握手的报文之后,还需要给服务端发送回去最后一次确认报文,这也就是第三次握手,让服务端知道,客户端已经成功接收了服务端第二次握手发送出去的报文。所以针对一台主机向服务器发起SYN洪水攻击的情况,三次握手就能保证只要服务端创建了数据结构来维护连接,服务端也必须创建数据结构来维护连接,服务端消耗了多少资源,客户端也同样消耗那么多的资源,有种互相拉下水的感觉。但其实只能限制一台主机的情况,如果发起SYN洪水攻击的是多主机的话,三次握手依旧解决不了。三次握手让SYN洪水攻击的成本变高了,不是简简单单一台主机就能发起攻击。
还有另外一个角度的原因是,TCP是全双工的协议,两端既可以发送消息也可以接收消息,第一次握手和第二次握手实现的是客户端的发送和接收消息,第二次握手和第三次握手实现的是服务端发送和接收消息,三次握手以最小的成本验证了TCP的全双工。
TCP的四次挥手
在断开连接的时候,首先是先断开连接的一方发起断开连接请求,我们假设想要断开连接的是客户端,四次挥手过程如下:
- 第一次挥手:客户端先给服务端发送一个TCP报文,该报文中的FIN标记位被设置为1,表示客户端向服务端发起断开连接的请求。
- 第二次挥手:服务端收到客户端的断开连接请求后,给客户端发送回一个TCP报文,该报文中的ACK标记位被设置为1,表示服务端确认收到客户端的断开连接请求。
- 第三次挥手:服务端给客户端发送一个TCP报文,该报文中的FIN标记位被设置为1,表示服务端向客户端发起断开连接的请求。
- 第四次挥手:客户端收到服务端的断开连接请求后,给服务端发送回一个TCP报文,该报文中的ACK标记位被设置为1,表示客户端确认收到服务端的断开连接请求,自此,双方成功断开连接。
之所以需要四次挥手,是因为如果只有两次挥手,双方都只给对方发送包含FIN标记位的报文就可以断开连接了,但是这样不能保证双方都能成功收到断开连接请求的报文,所以四次挥手中还有确认应答的报文,是为了保证双方都能成功收到断开连接请求的报文。
三次挥手也是不可以的,如果三次挥手,第一次挥手时客户端发送断开连接请求的报文给服务端,第二次挥手服务端发送断开连接请求的报文给客户端,将第二次挥手可以看做第一次挥手的应答,第三次挥手只需要客户端再向服务端发送应答即可。这样看似可以,但其实是不合理的,因为有可能服务端此时此刻并不想和客户端断开连接,客户端首先发起断开连接请求之后,客户端收到服务端的应答之后客户端就不能向服务端发送数据了,但是有可能此时服务端还想向客户端发送数据,所以如果是三次挥手的话,必须要求两端同时断开连接,这是不合理的。
总而言之,两次挥手是一定不可以的,三次挥手是可以的,但是仅限于特殊情况,该特殊情况是客户端和服务端同时都想断开连接,就可以在客户端先发送过来断开连接请求报文后,服务端再发送一条报文,该报文中FIN标记位和ACK标记位同时设置为1,表示服务端的应答且断开连接请求。四次挥手在任何情况下都是适用的。
当客户端先发起断开连接的请求时,服务端确认了以后,会进入CLOSE_WAIT
状态,也叫作半关闭状态,该状态下服务端没有完全关闭,只是客户端单方面关闭了连接,服务端并没有发起断开连接的请求,有可能服务端仍然想给客户端发消息,所以在服务端半关闭状态这段时间内,服务端依旧可以给客户端发送消息。
当服务端也发起断开连接申请之后,服务端状态由CLOSE_WAIT
变成了LAST_ACK
,客户端在确认应答之后状态变成了TIME_WAIT
状态,也就是说,客户端在确认了服务端也要关闭连接之后,客户端并不会立马退出关闭,而是进入TIME_WAIT
状态,原因是客户端不确定第四次挥手的ACK确认应答服务端是否成功接收。如果第四次挥手的应答服务端没有收到,服务端会在特定的时间间隔进行超时重传,重新发送FIN请求,所以TIME_WAIT
状态下的客户端会在特定时间段内等待,如果该时间段内服务端没有超时重传FIN请求,客户端就认为第四次挥手的ACK应答已经被服务端成功接受了,那么客户端就可以放心地退出关闭了。
状态变化
下面这幅图是一次TCP连接与断开连接经历三次握手四次挥手的完整过程,结合这张图我们来整理一下连接与断开连接过程中两端的状态变化情况。
服务端状态转化:
- [CLOSED->LISTEN]:服务端应用层调用listen函数进入LISTEN状态,等待客户端的连接
- [LISTEN->SYN_RCVD]:一旦accept函数监听到了客户端的连接请求,就将该连接放入内核的等待队列中,服务端向客户端发送SYN的确认应答报文,发送完之后进入SYN_RCVD状态
- [SYN_RCVD->ESTABLISHED]:服务端一旦接收到客户端的确认应答报文,就进入ESTABLISHED状态,代表连接建立成功,可以进行读写数据了
- [ESTABLISHED->CLOSE_WAIT]:当客户端主动关闭连接,服务端会收到客户端的FIN请求,服务端返回ACK之后进入CLOSE_WAIT状态
- [CLOSE_WAIT->LAST_ACK]:服务端进入CLOSE_WAIT状态后说明服务端准备关闭连接(但还没关闭,有可能还要向客户端发送数据)。当服务端真正调用close函数关闭连接时,会向客户端发送FIN请求,此时服务端进入LAST_ACK状态,等待客户端的ACK到来
- [LAST_ACK->CLOSED]:服务端收到客户端的ACK,彻底关闭连接
客户端状态转化:
- [CLOSED->SYN_SENT]:客户端调用connect函数,发送SYN建立连接请求报文给服务端之后,客户端进入SYN_SENT状态
- [SYN_SENT->ESTABLISHED]:connect函数调用成功之后,接收到服务端的SYN+ACK确认报文,并给服务端返回ACK报文之后,进入ESTABLISHED状态,即正式建立好连接,可以开始读写数据
- [ESTABLISHED->FIN_WAIT_1]:当客户端主动调用close函数时,会向服务端发送FIN请求,此时客户端进入FIN_WAIT_1状态
- [FIN_WAIT_1->FIN_WAIT_2]:客户端接收到服务端的ACK确认应答之后,就会进入FIN_WAIT_2状态,开始等待服务器发起断开连接的请求
- [FIN_WAIT_2->TIME_WAIT]:客户端接收到服务端的FIN请求并给服务端发送了ACK确认应答之后,客户端进入TIME_WAIT状态
- [TIME_WAIT->CLOSED]:客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间)才会最终进入CLOSED状态
四、超时重传机制
如果主机A向主机B发数据,但该数据在发送的途中丢失了,主机B并没有接收到该数据,也就没有给主机A发送回确认应答的报文,但主机A并不知道自己的报文有没有丢失,主机B甚至都不知道主机A给自己发了数据。所以主机A在数据发出之后的特定时间内,如果还没有收到主机B的确认应答,则认为该数据已经在途中丢包了,主机A就会重新发送该数据,这就叫做超时重传机制。
这里有一个需要注意的地方就是,主机A在发送出数据给主机B之前,必须要先将该数据在主机A本地保存起来,否则万一数据丢包了,主机A没有保存数据,就没办法重传了。
除此之外,还有另一种情况,主机A发给主机B的数据,主机B接收成功了,但是主机B给主机A发回的应答报文丢包了。但站在主机A的角度,这和主机A发送的数据丢包了是同一回事,因为主机A压根就不知道到底是数据丢包了还是应答报文丢包了。那么主机A同样会在特定的时间间隔后进行超时重传,将数据再发送给主机B。
但是这种情况下,如果主机A多次超时重传给主机B,主机B的应答报文都丢弃了,主机A继续重传,那么主机B就会有多分重复的数据,如果任由主机B将这些重复的数据递交给上一层,那么传输就不具有可靠性了。所以TCP还会对接收到的数据进行去重,是根据报文的序号字段来去重的,如果接收到的报文中出现了重复的序号,则后出现的报文会被直接丢弃。
超时重传的时间如何确定?
我们知道网络的传输速度是动态变化的,比如同一个校园网内,当很多人在使用校园网上网时,网络的传输速度肯定变慢,当很少人使用校园网上网时,网络的传输速度就会变快。既然数据发送给对端的时间是动态变化的,那么超时重传的时间间隔也不能是恒定的。在Linux操作系统中(BSD、Unix和Windows也是如此),超时时间以500ms为一个单位进行控制,每次判断超时重传的时间都是500ms的整数倍。
比如说,第一次超时重传的时间是500ms,如果第一次重传之后还没有应答,那么第二次超时重传的时间是2500ms,如果仍得不到回答,下一次超时重传的时间就是4500ms,以此类推,它是以指数形式增长的。当累计到一定的重传次数之后,如果依旧没有应答,TCP会认为网络或者对端主机出现问题,从而强制关闭连接。
五、滑动窗口
我们之前讨论的确认应答机制,接收端对发送端每一个发送过来的报文,都要给一个ACK确认应答,发送端要等收到了ACK确认应答才继续发下一条报文,这种一发一收的方式有一个比较大的缺点,那就是效率特别低,尤其是在数据往返的时间较长的时候。
既然这种一发一收的方式效率很低,那么TCP采用的是一次性发送一批数据,接收端一次性接收到一批数据也响应回一批对应的确认应答,这样就能大大地提高效率(其实就是将多段的等待时间重叠在一起了)。
在发送端的发送缓冲区中,一共可以将缓冲区分成三部分:数据已经发送并且已经收到接收端确认应答的区域、数据已经发送但还没有收到确认应答的区域、数据还未发送的区域。
我们上面也提到了,TCP报文中有个16位窗口大小,指的就是无需等待确认应答而可以继续发送数据的最大值。比如窗口大小是4000个字节,那么我们就可以一次发送一批数据给接收端,这一批数据最大值就是4000个字节。滑动窗口指的就是发送缓冲区中可以在没有收到确认应答的情况下,直接发送的区域。
比如窗口大小是4000,那么滑动窗口的大小最大值就应该是4000,滑动窗口左边的区域属于数据已经发送并且收到确认应答的区域,滑动窗口右边属于数据还未发送的区域,而滑动窗口内的数据就是这一批中需要发送的数据,当这一批数据发送出去之后,滑动窗口的区域就属于数据已经发送但还没有收到确认应答的区域。
当数据已经发送但还没收到确认应答的区域内的数据被应答时,滑动窗口就会向右移动。举个例子,滑动窗口一次性发送4000个字节的数据给主机B,1001~2000段的数据收到了主机B的确认应答之后,滑动窗口向右移动一个单位。
其实发送缓冲区我们可以看作一个char类型的大数组,滑动窗口就是数组上的一块区域,维护滑动窗口只需要维护该区域的起始坐标和结束坐标即可,并且滑动窗口的坐标移动并不用担心越界问题,因为TCP的发送缓冲区是被设计成环状结构的。
滑动窗口在接收到应答之后一定会向右移动吗?滑动窗口的大小是恒定的吗?
这个问题就需要了解滑动窗口移动的本质,滑动窗口的移动不是整个区域平移,而是通过调整起始位置的坐标以及结束位置的坐标来移动的。当接收到确认应答时,起始位置向右移动,当可以发送新的数据时,结束位置向右移动。
举一个例子,如果接收端的窗口大小是4000个字节,发送端的滑动窗口大小也是4000个字节,发送端一次性将滑动窗口内的数据发送给接收端,一下子把接收端的接收缓冲区占满了,但接收端此时就是不从接收缓冲区中读取数据,只是给发送端发送回去确认应答,确认这4000个字节的数据全都收到了,那么此时发送端滑动窗口的变化是:起始位置向右移动到结束位置处。
当接收端开始从接收缓冲区读取数据时,一次性将接收缓冲区的数据全读走了,此时窗口大小恢复回了4000个字节,滑动窗口可以继续发送数据了,所以滑动窗口的结束位置向右移动。
所以滑动窗口在接收到应答之后不一定会向右移动,并且滑动窗口的大小并不是恒定的,因为滑动窗口的移动是靠起始位置和结束位置的移动,因此滑动窗口的大小也可以是动态变化的。
高速重发机制(快重传)
例如下图中的例子,当1001~2000这一段报文丢失的时候,主机B会重复发送1001的确认应答,意在告诉主机A说该段数据出现了丢包,赶快给我重传。即使主机A继续发送了很多其它段的数据给主机B,主机B响应回来的依旧是1001的应答。
当主机A连续三次收到了同样一个1001的确认应答之后,主机A会对1001~2000这段数据进行重新发送。
当主机B接收到主机A重新发送的1001~2000数据时,主机B就不再重发返回1001的确认应答了,而是返回5001的确认应答,因为主机A后续发送的报文主机B其实早就已经收到了,没有给出确认应答是因为1001的报文丢包了。这种机制就叫高速重发机制,也叫作快重传。
六、流量控制
接收端的处理数据速度是有限的,如果发送端的发送速度过快,就会导致接收端的接收缓冲区被占满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等一系列的连锁反应。因此,TCP支持了根据接收端的处理能力,来决定发送端的发送速度,这种机制叫作流量控制(Flow Control)。
在发送端和接收端握手协商的阶段,接收端会将自己的接收缓冲区大小放入TCP首部的窗口大小字段中,通过ACK来通知发送端。窗口大小字段越大,说明网络的吞吐量越大。接收端一旦发现自己的接收缓冲区快满了,就会将窗口大小设置成一个更小的值通知发送端,发送端对应的也要减慢自己的发送速度。
如果接收端的接收缓冲区满了,接收端就会将窗口大小字段设置为0,这时发送端就不再向接收端发送数据了,但是发送端会定期发送一个窗口探测数据段,让接收端把该时刻的窗口大小告诉发送端,当窗口大小不为0的时候,发送端又可以继续发送了。
七、拥塞控制
我们上面介绍的超时重传机制、流量控制等等,都是站在接收端的角度考虑问题的。如果我们发送一批数据,出现了少数的丢包情况,我们会认为这是发送端或者接收端的问题,发送端只需要在特定时间段后进行超时重传即可。
但是如果我们发送一批数据,发现有大量的数据出现了丢包情况,这时就不再是接收端或者发送端的问题了,而是网络的问题。有可能是该时间段网络拥挤,我们的数据根本就发送不出去。这个时候我们就不能进行超时重传了,原因是如果网络拥挤,一定不只是我们一台主机出现网络拥挤,一定是一群主机都出现了网络拥挤。如果此时这一群主机都进行超时重传,原本拥挤的网络就会突然又多了一批重传的数据,只会加重网络的压力。所以如果是网络出现了问题导致的大量丢包,发送端不能进行超时重传。
上述的网络拥塞问题本质是在网络中已经存在了很多待转发的数据,网络处理不过来了,所以会出现网络拥挤,新到的报文无法立即被转发,可能还在各个路由器上排着队,但发送端可能已经超时了就认为这些报文丢失了。所以要解决网络拥塞问题不是发送端重传数据,而是减少数据发送,让网络先处理待转发的数据,让网络缓一缓,当网络拥塞问题恢复了,发送端再进行数据的重新发送。
所以TCP有拥塞控制,当网络出现拥塞问题时,TCP引入慢启动机制,先发少量的数据去探探路,摸清当前网络的拥堵状态,再决定按照多大的速度传输数据。
这里需要引入拥塞窗口的概念,拥塞窗口没有体现在TCP报头当中,它相当于TCP连接当中的一个子属性字段,拥塞窗口是在慢启动时不断探测网络状态得出来的窗口大小,该窗口的含义是指在窗口发送数据量以内不会拥塞,超过了窗口发送数据量可能会导致拥塞。
当出现网络拥塞时,发送端开始慢慢发送数据去探测网络情况,第一次发送探测情况的时候,定义拥塞窗口的大小为1,当后续每次收到一个ACK应答的时候,拥塞窗口都加1。
所以TCP有了拥塞控制之后,再将前面的知识整合起来就是,滑动窗口的大小=min(接收端的窗口大小,拥塞窗口大小)
。
下图是TCP拥塞控制时拥塞窗口的变化情况,当网络进入拥塞状态,TCP开始慢启动时,前期是通过指数型增长来增大拥塞窗口的大小,不断探测网络状况。这里使用指数型增长是为了让前期慢慢增长,后期爆炸式增长。但是TCP不能让拥塞窗口的大小爆炸式的增长,所以TCP会设置一个慢启动阈值,当拥塞窗口的大小小于这个阈值时,按指数规律增长;当拥塞窗口的大小大于这个阈值时,按线性方式增长。
当又一次到达网络拥塞状态时,TCP又必须重新开始慢启动发送数据了,并且这一次会重新计算阈值,此时的阈值为上一次拥塞窗口最大值的一半,比如上图中拥塞窗口到了24就进入了网络拥塞状态,所以新的阈值应该是12,同时拥塞窗口的值重新置为1,意味着TCP重新开始新一轮的慢启动,慢慢探测网络状态。我们把这一整套慢启动、调整阈值的算法叫做拥塞控制算法。
为什么拥塞控制算法要采用指数规律增长呢?
首先是指数增长前期会很慢,只有后期会越来越快最后爆炸式增长,这就符合慢启动的规定,前期发送速度慢,可以发送少量数据去探测网络状况,一旦前期的探测接收到了响应,就按指数规律提高发送速度。同时,阈值的设定能让它不会爆炸式增长,到达一定的阈值后就变成了线性增长。
除了这个原因外,还因为指数增长前期增长速度慢,但一旦增长起来,它的增长速度会越来越快,这也就意味着我们一次能发送出去的数据量会越来越多。拥塞控制算法本身就是为了解决网络拥塞问题,它的目的是为了快速恢复到原来的发送速度,前期增长缓慢是为了探测网络状态,一旦网络状态好了,指数规律的增长能让我们的增长速度大幅提高,可以让我们更快地恢复网络。
TCP的拥塞控制,归根结底就是TCP协议想尽可能快地把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
八、延迟应答
如果我们发送端想要尽可能多地发送数据,就需要发送端的滑动窗口大小尽可能地大,滑动窗口的大小是由接收端的窗口大小影响的,所以就需要接收端的接收缓冲区有尽可能多的剩余空间,这就需要接收端的上层尽可能快地从接收缓冲区中取走数据。
这一条完整的关系链里,发送端想要一次尽可能多地发送数据,提高发送效率,最终就变成了要让接收端地上层尽可能快地从接收缓冲区中取走数据。TCP引入了延迟应答机制,意思就是说接收端在接收到发送端发送过来的数据时,接收端不会立马就给发送端进行ACK确认应答,而是会延迟应答,等待上一段时间。在这段时间里,接收端的上层有一定的概率会从接收缓冲区中读走数据,这样再确认应答回去时的窗口大小就会变大了,发送端看到接收端的窗口大小变大了,下一次发送数据的内容就可以相应地变多了。
举个例子理解一下,假如接收端的接收缓冲区最大容量为1M,接收端一次接收到了发送端发送过来的500K的数据,如果此时接收端立即给发送端确认应答,那么返回的窗口大小就只剩下500K。但实际上可能接收端上层处理数据的速度非常快,有可能10ms之内就把接收缓冲区的数据全读走了,在这种情况下,如果接收端稍微等待一会,延迟应答,比如等待上20ms再给发送端应答,此时返回的窗口大小就是1M。原先的500K窗口大小和现在的1M窗口大小对于发送端来说,发送策略就不一样了,1M的窗口大小无疑是可以提高发送效率的。
延迟应答可以提高发送效率,但是这个延迟的时间的设置也需要考虑。如果延迟时间设置得短了,达不到提高效率的目的,如果时间长了,有可能会让发送端以外数据丢包进而引发一连串的超时重传机制。所以TCP的延迟应答有两种策略:
- 根据数量进行限制:每隔N个包就进行确认,这个N的值一般取2
- 通过时间进行限制:超过最大延迟时间就应答一次,这个最大延迟时间与操作系统有关,但是这个最大延迟时间一定是小于超时重传的时间
九、捎带应答
发送端给接收端发送一条数据之后,接收端接收到了就会给发送端发送一条ACK确认应答,如果此时接收端也有消息想发给发送端,它就可以在ACK确认应答的报文上携带该数据,这样的策略就叫作捎带应答机制。其实接收端给发送端的确认应答报文本质上就是ACK标记位被设置为1并且填充好了确认序号的TCP报文,该报文是可以将数据段也填上的,再将对应的序号字段填上之后就可以发过去给发送端,这本质就是一条发送数据的报文,只不过捎带了对发送端发过来的数据的确认应答而已。
十、面向字节流
当我们创建一个TCP的套接字时,操作系统同时会为我们在内核中创建一个发送缓冲区和接收缓冲区。
对于发送端,我们在上层调用write函数接口通过TCP套接字发送数据时,实质并不是write函数接口将数据发送出去的,它只是帮我们将需要发送的数据拷贝到了TCP的发送缓冲区中。如果我们发送的数据字节数太长,会被拆分成多个TCP的数据报再发出,这个拆分工作是TCP自己完成的,我们上层用户并不关心。举个例子,比如上层用户调用write函数想要发送1M大小的数据,但是接收端的接收缓冲区目前只能接受500K大小的数据,所以TCP会将这1M的数据拆分开来,不会一次性发过去。如果发送的字节数太短,这些数据就会在缓冲区中先等待,等到缓冲区的长度差不多了,或者在其它合适的时机再发送出去。
对于接收端,数据也是从网卡驱动程序达到TCP的接收缓冲区。然后我们在上层调用read函数接口就可以将接收缓冲区中的数据拷贝到read函数的缓冲区中。
上述就是TCP缓冲区的发送流程,那么如何理解TCP的面向字节流呢?
在发送端写入的时候,上层调用write函数将数据拷贝到TCP的发送缓冲区中,假如要拷贝100个字节到发送缓冲区中,我们可以调用一次write函数拷贝100个字节到缓冲区中,也可以调用一百次write函数每次拷贝1个字节到缓冲区中。最终这100个字节在缓冲区中,如果字节数太大了,TCP会拆分成多个TCP的数据报再发出,如果字节数太小了,TCP会让这些数据在缓冲区等待一会再发送,这些都是TCP层面关心的事情,上层用户并不需要关心,上层用户只需要调用完write函数将数据拷贝到缓冲区即可,这个就叫写入的时候与写入的格式毫不相关,写入时是面向字节流的。
在接收端读取的时候,TCP的接收缓冲区会收到若干个字节的数据,我们上层调用read函数从接收缓冲区中读取数据,可以一次读取一个字节,可以一次读取两个字节,可以一次读取十个字节或者一次一百个字节,读取的时候也是与格式毫不相关的,这就叫做读取时是面向字节流的。
在网络通信基于TCP协议通信时,上层我们调用write函数怎么写入,调用read函数怎么读取,二者都是毫不相关的,这就叫做面向字节流。
TCP没有所谓的报文边界,所以在读取的时候可能会出现数据粘包问题。TCP是面向字节流的,在TCP看来所有数据都是以字节为单位的数据流,TCP不关心数据报文的边界,你上层怎么读取一次读一个字节还是一次读一百个字节,TCP不关心,但是应用层必须关心,因为它要保证读取上来的报文是完整报文,不能多也不能少,只有这样才能对报文信息进行解析。
比如下面的例子,接收缓冲区中存放的是从发送端发送过来的数据,其中前两个是完整的报文,第三个是不完整的报文,原因是TCP是面向字节流的,所以会存在这种不完整的报文。然后上层用户调用read函数从接收缓冲区中读取数据,读上来的数据中,第一个是完整报文,第二个也是不完整报文,原因还是TCP是面向字节流的。但这样read函数读取上来的数据就出现了数据粘包的问题,两个报文粘在一起了。虽然TCP是面向字节流的,它是不关心数据格式的,但是应用层必须关心数据格式,只有将数据按特定的格式分隔开,才能对数据进行解析。假设read缓冲区读取上来的两个报文是HTTP的报文,那么两个报文就要根据HTTP报文格式进行拆分,将完整的HTTP报文拆分出来,才可以对其进行数据提取以及解析。
TCP总结
本文介绍了TCP中非常多的机制,这些机制其实主要就是为了维护TCP的连接可靠性和提高TCP通信效率的。
其中,体现维护可靠性的是:校验和
、序号
、确认应答
、超时重传
、连接管理(三次握手和四次挥手)
、流量控制
、拥塞控制
。
体现在提高通信效率的是:滑动窗口
、快速重传
、延迟应答
、捎带应答
。