【网络】想学TCP,这一篇就够了 —— TCP理论知识详解(基于前面手搓TCP服务端博客的补充)

TCP理论

前言

本篇侧重理论,关于TCP的实践我前面博客中有,如果你想写一个简易的TCP服务端,可以看看这一篇:【网络】网络编程------带你手搓简易TCP服务端(echo服务器)+客户端(四种版本)。本篇也是基于这一篇进行理论方面的扩展,如果想要了解的同学可以看看。

  • 本篇篇幅较长,真心想学的同学耐住性子看。

本篇主要讲解:TCP报头中的字段、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制、滑动窗口、快速重传、延迟应答、捎带应答、TIME_WAIT、CLOSE_WAIT。

正式开始

对于一个协议,首先要搞定的事情就是:

  1. 如何将报文中的报头和有效载荷封装,并将真个报文传递给下层
  2. 如何将报文中的报头和有效载荷分离,并将有效载荷交付给上层

那么对于TCP而言,就是如何传递给网络层和如何交付给应用层。

我们先来讨论一下这个问题。

TCP报文如何进行分离和封装

先看看TCP报文长什么样:

相较于我前一篇讲的UDP报头可要复杂太多了。

图中可以看出,TCP报头宽度位32位,也就是4字节。

其中选项上面的为固定报头,一共20字节。然后是选项,然后是数据(有效载荷)。

选项也属于报头,但是长度不定(可以为0),也就是说TCP报文中数据上面的都是报头,且是一个变长报头:

虽然是变长,但是只是选项这个字段变长,而选项上面的字节数是固定的,也就是20字节:

那么如何确定报头中选项的长度呢?

很简单,在20字节固定长度报头中有一个4位首部长度的字段,假如说值为x,也就是说报头的总长度就是x,x - 20就能得到选项的长度,但是有个问题,x只有4位二进制,那么范围就是0000~1111,换成十进制为:0 ~ 15,那么问题来了,最大才15,怎么减去20呢?
这里的首部长度,一个单位为4字节,也就是说x的范围换算成10进制其实是0 ~ 60。那么自然也就能减过了,算上固定长度的报头,那么整个报头的范围就应该是20 ~ 60,那4位首部长度范围应该为多杀呢?两边同时除以四就行:5 ~ 15,二进制就是 0101 ~ 1111。

所以TCP解包的时候先提取出20字节,再根据标准报头提取出4位首部长度乘以4,假如说是x,若x为20就不需要再提取选项了,因为选项为空;若x大于20再让x减去20,得到选项长度,这样报头就读完了,读完了报头剩下的就都是有效载荷。

TCP没有整个报文大小/有效载荷大小,那么如何确定读取到一个完整报文呢?

UDP能保证读一次大小就是一个完整报文,但是TCP做不到,因为TCP是面向字节流的,TCP只负责读了之后向应用层传数据就行了,前面我博客中也是讲过HTTP协议了,我还自定义了一个应用层的协议,写的时候应用层只负责按行读取还是空行分隔就行。但是这里想要完全理解这一点的话有难,得后面TCP讲的差不多了才能理解这一点。这里就先将这个问题遗留在这里。

TCP报头,也是用位段实现的,和前一篇中的UDP相同,宽度为4字节,所有类型均为uint32_t,封装的时候也是拷贝,解包的时候也是用指针。如果你不太懂这里说的话,可以看我前一篇UDP的博客:【网络】对于我前面UDP博客的补充

TCP如何将有效载荷交付给上层

原理和UDP一样,还是提取出来报头,报头中的第二个字段就是目的端口号,直接查找对应的进程PCB,PCB找到了就找到对应的文件描述符,往文件描述符中写入有效载荷就行。然后应用层就可以通过文件描述符读取到数据。

如何理解TCP的可靠性

等会挑一个最重要的可靠性来说。

先来说说是什么原因造成通信的不可靠。

看图:

两个人,面对面交流,会不会很容易出现听不清对方说的话的情况?

肯定是不会的,面对面交流,正常说话很容易就能听清。

如果让这两个人相隔50米再进行说话呢?

这样说话就很费劲了,而且还容易出现听不清/听不见的情况。这种听不清或听不见的情况就是丢包。

其实通信不可靠单纯是因为二者距离变长而导致的。

而两台相隔很远的主机,远距离传输就容易丢包。

再来看看我们的电脑。

内存和cpu,内存和磁盘,之间其实也是有通信协议的,不过不像网络中这么复杂,因为电脑中的硬件相隔非常近,基本上不可能出现丢包的情况。单机内部不谈协议,不谈TCP/IP,而一旦涉及到网络就要用TCP/IP来解决距离长而带来的问题。

那么什么是可靠性呢?

两个相隔50米的人对话时照样听的清清楚楚,这就是可靠。

那么如何保证对方能够听得清呢?

首先两台主机:

某一方发起通信,这里就让A来发起通信:

此时如果B能回一个消息:

那么就能说明B收到了A的消息。若A发出消息后B未应答,那么A就无法知道B收到了没有。

当A收到了应答,能够保证的一点就是B一定收到了A的消息。

但是对于B来说,其无法知道其发送的"上号"A收到了没有,那么也简答,A也回一个消息就行:

那么当B收到了A的"好好好"之后就可以知道,B发送过去的"上号"A收到了。

但是此时又来了,对于A来说,其能确定B收到了"好好好"这个消息了吗?

答案是不能确定。还是让B再回一个消息就好。

但这样就会无线循环下去。所以网络中是不存在100%可靠的协议的。无论AB,谁都无法保证自己做为新发送方,发送的数据是否被对方收到。但是局部上,能做到100%可靠,就是单方发出的消息只要有应答,就能保证刚刚的消息对方一定收到了,这就是TCP协议的确认应答机制,只要一个报文收到了对应的应答,就能保证我发出的数据对方收到了。

这里A给B发"打游戏吗",B回应"上号",本质有两层含义:

  1. B收到了A的消息。
  2. B又给A发送了新的消息,新的消息为"上号",意思为要和A打游戏。

这里是两个含义,而非单一一种,B也可以回一个"不了,我要休息了",如果是这样的话,两层就是这样:

  1. B收到了A的消息。
  2. B又给A发送了新的消息,新消息为"不了,我要休息了",意思是不和A打游戏了。

这两个含义也可以分开来发,但是效率很低,比如说A说"打游戏吗",B回应"收到",然后又发了一个"上号",那么这样效率就很低:

得要连续发两次,相较于我们日常生活中的交流会很奇怪。

那么TCP是如何实现像日常生活中的那样的交流呢?

TCP报头中的序号和确认序号(简单过一下,后面还会详细讲)

图中为序列号和确认序列号,叫成序号和确认序号也可以。

为了能够确认应答,TCP报头中搞了序号和确认序号。(确认应答后面还会再细讲,这里只是简单用一下,没太明白的同学没有关系,只是带你看看猪跑)

实际TCP通信时并不是客户端发一个消息,服务端回一个消息,而是客户端可能一次发多个报文,如果发了多个的话,原则上,服务端要对每个报文进行应答,这样能够保证客户端 ⇒ 服务端发送信息的可靠性。 服务端 ⇒ 客户端同理。

比如说这里客户端发送三个请求:

服务端返回三个响应:

但是有一个问题,如何区分客户端发送回来的响应对应上面哪一个客户端的请求呢?

就通过刚刚说的报头中的序号和确认序号,假如说我这里将客户端发送的三个请求编上序号,从头再来:

那么服务端怎么给确认序号呢?

一般情况下,会给成所收到的序号 + 1。比如收到1000,返回的确认序号就是1001,那么看图:

故序号和确认序号的第一个作用就是将请求和响应一一对应。

确认序号还有一个功能,不光是一一对应,TCP确认序号表示了确认序号对应数字之前的所有序号对应的报文当前方全部收到了,并告诉对方下次发送时要从确认序号指明的序号发送。

听起来有点费劲,来个例子,比如说客户端还是发送了1000、2000、3000。但是此时服务端只要返回一个3001,3001对应的客户端发送来的序号为3000的报文,就能表明服务端接收到了比3000小的所有报文,也就是不仅3000收到了,2000和1000也都收到了,那么这样服务端就可以只返回一个确认序号3001,而不用返回2001和1001:

上面也可以返回1001和2001,想要表达的都是一个意思。

如果2001和1001收到了,那么就只能返回2001或者1001和2001,不能返回3001:

此时就表明3001服务端没有接收到。

如果出现了2000没收到但是3000收到了的情况,那么就不能返回3001,因为返回了3001就表明3001之前的报文都收到了,但此时实际情况是2000没收到,那么就不能返回3001,只能是返回一个1001来说明当前接收到了序号为1000的报文,也就是这样:

那么此时客户端能判断出来的就是服务端一定接收到了序号为1000的报文,序号为2000的报文一定没接收到,序号为3000的报文是否接收到了不能确定。

这样返回一个最大的确认序列,就可以表明比其大的后一个报文一定没收到,后续客户端再要发报文的时候就要从这个最大确认序号的报文往后开始发。比如这里最后一个里中返回了1001,那么后续客户端再发送报文的时候序号就要从1001开始发。

对于对端一定没接收到的报文,客户端后续会做重传的工作,不过相关的工作后面再细说,这里就接着说序号和确认序号。

那么序号和确认序号的第二个功能就是:确认序号表示的含义为确认序号之前的数据已经全部收到。

第三个功能就是允许部分确认丢失或者不予应答(不是没收到请求,而是收到了但是其他大于该序号的请求也受到了,小的可以发,也可以不发)。

只要序号不要确认序号行不行

问:为什么要有一个序号和一个确认序号呢?不能只给一个序号用吗?一个序号,请求的时候给序号,响应的时候表示确认序号不也可以吗?

其实我前面给的AB例子中已经讲了。刚刚说B回应的"上号"有两种意思,一种是收到了,一种是发了新的消息。还说了B也可以发送一个"收到",然后再发送一个"上号"。此时收到用的就是确认序号,"上号"表示的就是序号。这样带来的问题就是通信效率很低。

一个序号 + 一个确认序号就能同时表示出"收到"和"上号"两个含义,"收到"用确认序号,"上号"用序号,这样效率就大大提升啦。

客户端发送请求后一般都是要从服务端处获取某些数据的,那么服务端不光是要说自己收到了信息,还要返回客户端想要什么数据,收到了信息就用确认序号,返回数据就用序号,后面客户端收到之后就能再根据返回的数据对应的序号向服务端发送对应的确认序号,如图:

所以如果server端想要应答,就要有一个确认序号,想要应答的同时也发送消息就要有一个序号,故这种情况下序号和确认序号必须同时存在。效率能高一点,个人感觉是以空间换时间的做法。

乱序问题

可能客户端发送过去的数据,服务端响应回来之后对应的报文顺序可能不一样。

比如说客户端发送了序号为1000、2000、3000的报文,但是可能因为距离、网络、硬件等方面的问题而导致不同数据到达目的地的时间不相同,当服务器进行响应时即使是同时返回1001、2001、3001也可能会出现客户端接收到的报文确认序号的顺序为2001、1001、3001,这种情况在我们日常生活中也存在,比如说你同一天在某宝上下了5个单,但是5个单返回的时间我们是无法确定的,有的返回快,有的返回满。

但是没有关系,TCP报头中有序号,接受的时候按照序号排序就可保证报文是按序到达的。

所以序号和确认序号时为了支持确认应答和按序到达的,后面还会降丢包重传和快重传机制。

到这里已经讲了报头中的如下字段了:

再来说说保留位,保留位就是暂时不需要的字段,后面需要的时候再进行扩展就行。

后面URG、ACK等6个标志位等会再说,下面细说一下16为窗口大小是啥。

16位窗口大小

TCP的全双工通信方式

TCP任何通信的一方,工作方式都是全双工的。

在计算机网络中,半双工和全双工是指数据传输的方式。半双工指在同一时间内只能进行数据的发送或接收,不能同时进行;而全双工则可以在同一时间内进行数据的发送和接收。举个例子,像对讲机就是半双工通信,因为同一时间内只能有一方说话,而不能同时说话;而电话则是全双工通信,因为双方可以同时说话和听对方说话。在网络中,如果能够在同一时间内进行数据的发送和接收,那么这个网络就是全双工网络;如果不能,则是半双工通信。

我前面UDP的博客中讲过,TCP和UDP都是全双工通信的,UDP不需要发送缓冲区,但是TCP有发送缓冲区,TCP在通信的时候两端主机都会产生对应的发送缓冲区和接收缓冲区,如图:

如果看过我前面手搓简易TCP服务器或者自行实现过TCP服务器的同学应该是懂我上面这张图的。

发送缓冲区和接收缓冲区都是传输层自动生成的,这两个缓冲区负责将发送本机的数据和接收对端主机的数据。在服务器中调用send/write和recv/read等函数,本质上都是拷贝函数,send/write负责将本机中我们自己搞的数据拷贝到本机的发送缓冲区中,recv/read负责将发送到本机接收缓冲区中的数据拷贝到我们定义的buff中。仅此而已,所以send和wirte并不是直接将数据发送到网络当中,recv和read也并不是直接从网络当中读取数据。UDP中的sendto和recvfrom同理。

所以TCP为什么叫做传输控制协议呢?

就是因为我们写好的数据只需要调用那些拷贝函数然后将数据交给TCP就行了,后续的过程不需要我们来管,是os自动帮我们做的。

某一方正在处理某些事情时,又接受到了一些数据,那么这些数据就会先被放到接收缓冲区中。

上面这段话已经是我讲的第三遍了,比较重要,各位尽量理解。

当发送数据的时候原理如下:

画的有点丑,能看就行。

这样发送发送缓冲区和接收缓冲区互不干扰,就可以实现全双工通信。

16位窗口大小的用途------流量控制

TCP,发送方如果发数据过快,对端如果来不及处理而放到了接收缓冲区中,如果接收缓冲区也被放满了,那么就会导致再过来的报文被丢弃。就像上课一样,老师讲的太快,学生跟不上,听的东西记不到脑子里。

这些被丢弃了的报文还会再由发送方重传,如果TCP不控制发送方的发送速度,这样就会导致发送的不少有用报文被丢弃掉重新传,数据是千里迢迢传过来的,直接丢弃就会产生大量的浪费(发送一个报文也是要消耗CPU、内存等资源的,中间还要耗电),重传虽然没有问题,但是大量的报文重传不合理。

什么叫合理呢?

对方已经来不及接收了,那么就不要再发或者发慢一点,所以应该让发送方在对方来不及接收/接收不了的时候慢一点。

慢一点,应该多慢呢?按自己意愿来决定发送速度吗?

肯定不行,如果突然很慢但接收方的接受能力非常强,也会影响效率。老师讲课太慢,进度就会拖延。

那如何保证发送速度不快不慢正正好呢?

只需要不断获取接收方的反馈就行,就像上课的时候老师要问同学们听懂了没有。

服务端要向客户端同步发送自己的接收能力,接收能力由什么决定呢?

就是由接收缓冲区中剩余空间的大小来决定,注意是剩余空间大小,而不是接收缓冲区的总大小。就像一个很有钱的人如果是妻管严,钱再多也是在老婆手上,自己掏不出来多少,那么就可以说这个人虽然接收缓冲区很大,但是剩余空间很小。一个不太有钱的人,自己的钱都是自己管的,就算没那么有钱,但是至少能掏出来的还不少,这就可以说是接收缓冲区不大,但是剩余空间不少。

这里接收缓冲区剩余空间的大小就是由16位窗口大小来表示:

2的16次方就是65535,就是65535个字节,也就是64KB。所以接收缓冲区最大就是64KB吗?

实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位,也就是说可以更大。但这里就不详细讨论了,想要了解的同学自行搜索。

当发送方发送了一个数据后,接收方就要返回一个报文报文中的16为窗口大小就是当前节后缓冲区中的剩余大小。

当接收缓冲区剩余4KB的时候,那么发送方就心里要有数,最多只能发送4KB就不能再发了,这样根据接收缓冲区的大小来决定发送的速度就叫做流量控制。

同理,服务端给客户端发送数据时,客户端也要响应其接收缓冲区的剩余大小,所以正常通信时,要有两个方向上的流量控制,发了数据就必须要应答,不断进行报文交换,这样双方都可以获取对端最新的接受能力,这就是TCP双方能够进行流量控制的原因。

再看报头,红框的都讲了:

再来简单说说16位检验和,检验和就是对报文整个数据做检验,如果检验和无法成功检验,那么整个报文就丢弃了,丢了不怕,TCP可以重传。

选项和有效载荷(数据)本篇不做讨论。那么就剩下6个标记位和16位紧急指针了:

下面来说说6个标记位。

6个标记位

标记位在不同版本的TCP下个数略有差异,有的可能是8个标记位,不过这里就将最标准的6个标记位。

6个标记位,每一位都能表示报文的不同含义。都是用位段实现,每个都是uint32_t xxx:1;

为什么要搞这么多标记位呢?

因为报文也是分类型的,不同类型的报文处理方式是不一样的。

看图:

注意:上面的一个个标记位字母意思可不是客户端只发送了一个SYN、ACK这样的一个bit位,而是一个完整的报文,报文中这些具体字母的标记位被置一了。

服务端可能收到一个建立连接的报文(SYN),这也是一个请求,但请求的具体功能室建立连接。这种特殊的报文与正常数据读取的报文在处理方式上有点不太一样。

服务端工作的时候会收到不止一种报文,而是大量不同种类的报文,标记位的本质就是标记报文类型的。

那么各个报文的含义都是什么呢?

下面我会根据这张图来讲:

SYN、ACK、FIN

先来说说标红的这三个:

SYN表示报文是一个连接请求报文。

FIN表示该报文是一个断开连接请求报文。

ACK确定应答标志位,凡是该报文具有应答特性,无论是纯应答或者是带数据的应答,该标志位都会被置为1,大部分网络报文ACK都是被置为1的(第一个发起连接的报文一定是0,因为在这之前二者没有什么通信)。

剩下的三个等会再讲,现在只是让你对这三个标志位留个印象,再往下硬讲是讲不明白的,得要结合着三次握手和四次挥手来说。

如何理解连接

因为有大量的client未来可能连接server,所以server端一定是会存在大量连接的,os也要需要对这些连接进行管理,因为两台主机之间的连接也是有很多属性的,比如说数据发到哪里了,当前对端的主机是谁,现在连接的状态(正常还是异常)等等。

那么如何管理连接呢?

还是前面我讲进程中的那样,先描述再组织。

所谓的连接,本质上其实就是内核的一种数据类型,建立连接成功的时候就是在内存中创建对应的连接对象,再对多个连接对象用某种数据结构组织起来,所以对连多个连接的管理就是对某种数据结构的增删查改,所以维护连接也是有成本的(内存消耗、CPU资源等)。

TCP保证可靠性,除了刚刚说过的确认应答、按序到达,还是后面要讲的超时重传、连接管理等工作,都是为了TCP的可靠性和效率而定制的,但是这里就可以明显的感觉到协定制上,或者是编码实现上,还是维护成本上一定是更复杂的,所以可靠是一个中性词,UDP不可靠,但也更简单。

TCP三次握手 ⇒ 通信 ⇒ 四次挥手过程

下面的讲解基于下图(稍微过一眼就行,等会细讲,这里需要你已经写过TCP服务器,且知道整个TCP中用到的各个接口,如果你不懂,建议先去看看我最开始给的手搓TCP服务器的博客):

中间通信的过程先不考虑,就说三次握手和四次挥手过程中用到的标记位。

三次握手

看看三次握手的过程:

这里首先客户端和服务端都还没有建立链接:

服务端首先进行的工作有:创建套接字,绑定端口,设置监听状态,调用accept进入阻塞状态等待客户端连接,对应接口就是socket、bind、listen、accept。

客户端首先做的工作有:创建套接字。对应接口就是socket。

然后二者就开始通信了。

首先客户端调用connect来主动和服务端建立连接,此时客户端只要调用connect了就会向服务端发送一个SYN置一的完整报文,并进入SYN_SENT状态:

什么叫做状态呢?

先来回顾一下进程状态,比如说运行、阻塞等等,在代码上的体现就是一个整数而已,这些整数就能表示出不同的状态。刚刚说了连接时可以有属性的,所以尽管连接的结构体还未建立,但是并不排除有些数据可以先维护起来,所以状态在代码上就可理解成一个整数,还没有发出SYN的时候,默认状态就是0,但是发出后状态就变成了整数SYN_SENT(很多大写的字符串一般都是宏,像这里的SYN_SENT就是宏),就是一个数字而已,状态变成SYN_SENT,就能表明数据被发送出去了。

然后客户端accept接收到了连接,服务端收到了SYN,就要对客户端发送来的SYN进行响应,所以会返回一个ACK,同时也会发出一个SYN来表示服务端要和客户端建立连接,也就是接受SYN并返回一个ACK和SYN同时置一的报文,做了这个工作后,服务端就进入了SYN_RCVD状态,这个状态被称为同步收到状态:

当客户端收到SYN + ACK的报文并返回一个ACK报文后,客户端的状态就变成了ESTABLISHED状态:

注意这里此时客户端已经变成了ESTABLISHED状态,也就是说就算服务端还没有接收到ACK,在客户端眼里二者已经连接成功了,所以说此时客户端只要发出了ACK就会在本端创建对应的连接对象来维护。

然后当服务端接收到客户端发来的ACK后状态就变成了ESTABLISHED状态:

只有服务端接收到了ACK后,才会认为二者连接上了,也就是此时才会在本端创建连接对象并维护。

至此,三次握手就完成了,客户端和服务端也就连上了。

说点小细节,为什么上面握手的时候发送报文的线是斜着的,为啥不给成直的?

因为更方便判断哪一方先进行哪一方后进行,竖直向下就是时间线,整个流程二者状态变化顺序一定是客户端SYN_SENT ⇒ 服务端SYN_RCVD ⇒ 客户端ESTABLISHED ⇒ 服务端ESTABLISHED,这样完整流程是有顺序的,按照时间顺序。

三次握手时客户端和服务器都要起效,也就是客户端和服务端都要保证经历了三次握手,客户端经历发收发,服务端经历收发收:

不能够完全保证三次握手都成功,因为最后一次ACK不能确定服务端是否收到客户端的ACK,前两次报文丢了都会有反馈,丢包能够发现。可能客户端发了ACK后状态直接变成了ESTABLISHED后服务端没有接收到ACK,那么就会导致客户端认为连接上了但是服务端认为没有连接上的情况。

为什么要三次握手,能不能是一次握手/两次握手/四次握手

我先直接给结论:

  1. 验证全双工。
  2. server可以嫁接同等成本给另一端。

你应该是听不懂的,每关系,我来讲。

验证全双工

怎么验证当前通信是全双工的呢?

得要知道是不是客户端能发也能收,服务端能发也能收。

也就是说:

客户端 ⇒ 服务端发送信道是好的,且客户端也能收到。

服务端 ⇒ 客户端发送信道是好的,且服务端也能收到。

  • 如果是一次握手

意思就是客户端直接发一个SYN就直接变为了ESTABLISHED状态,服务端接收到了SYN后也直接变为ESTABLISHED状态:

.

那么这样只能验证服务端能收,客户端的收发,服务端的发都验证不了,因为服务端没有对应的响应,那么客户端就没法知道自己的发出去的SYN是否被收到了,所以验证不了客户端的发。其他不能验证的就不说了。

  • 如果是两次握手

那么就是这样:

.

这样能验证的是客户端的收和发,服务端的收,因为客户端没有回应,所以无法验证服务端的发。

但是三次握手,客户端经历了发收发,服务端经历了收发收,这样二者的收发就都能够验证。

如果是四次呢?

当然能验证,但是三次都已经能验证信道是ok的,连接已经建立了,建立的前提就是验证一下信道是顺畅的,建立之后在再握手也只是能验证一下信道更加稳定,并没有什么更好的效果。

嫁接同等成本给另一端

什么意思呢?

看一下我刚刚给的一次握手:

这里想要让服务端建立连接就必须先让客户端建立好连接,也就是客户端ESTABLISHED一定在服务端ESTABLISHED之前。

再来看看两次握手:

能发现什么?

这次是服务端ESTABLISHED在客户端的ESTABLISHED之前了,这样就会有点问题,什么问题呢?

如果客户端不断发送SYN报文,但是对于服务端发回来的SYN+ACK报文不管,那么就会出问题,客户端这边只是建立了半链接状态,而服务端已经全部连接都建立了,如果服务端此时搞出来大量的伪IP,这样产生大量的虚假客户端发送SYN请求,服务端这里会产生非常多的连接,那么就会占用大量的内存空间,这样不断吃内存就直接导致服务器崩掉了,这种产生大量伪IP并发送SYN的行为就叫做SYN洪水,是DDoS攻击的一种,非常危险。(虽然刚刚的一次握手遭遇DDoS攻击也没有什么办法。)

再来看看三次握手:

这里三次握手和一次握手一样,一定是客户端先进入ESTABLISHED状态,然后服务端才会进入ESTABLISHED状态。

三次握手也是不能避免DDoS攻击的,只要有一个客户端SYN请求,服务端接收到SYN并发送SYN+ACK后就要必须进入SYN_RCVD状态,进入SYN_RCVD状态后也是要维护一个半连接状态的(刚刚前面说过,客户端的某些属性也是要保存的),但是有办法稍微阻止一下,分别是源认证和首包丢弃策略,能想出来的人也是挺牛的,我这里就不细讲这两个方法了,给大家贴个博客,感兴趣的同学可以看看:什么是SYN Flood?。里面源认证和首包丢弃的方法真的很妙。

那么四次呢?

会和两次一样,一定会出现服务端比客户端先ESTABLISHED的情况,这种情况是一定要避免的。不能说先让服务端认为自己已经建立好连接了,客户端不接受连接,客户端处于半连接的状态,维护的成本比服务端低,那么相对服务端压力更大。所以要避免这种情况。

那么就可以总结出规律:

奇数次的握手一定会出现客户端比服务端先ESTABLISHED的情况。

偶数次的握手一定会出现服务端比客户端先ESTABLISHED的情况。

要避免服务端比客户端先ESTABLISHED的情况就一定要用奇数次握手。

RST标记位

RST,就是reset,这个标记位作用是让对端重新连接。

看图:

当某一段连接出现异常时,比如说服务端网络故障了,重启了一下,那么此时服务端之前所维护的连接都已经失效了,但此时客户端并不知情,还觉得是当前是连接的状态的,所以客户端觉得自己还可以发送报文,当客户端一发送报文,服务端接收到之后发现当前和这个客户端并没有连接上,就会向这个客户端发送一个RST置一的报文,此时客户端接收到这个报文后就会重新发起连接请求,二者就会重新进行三次握手。

PSH

PSH就是push,作用是督促对方尽快将其接受缓冲区中的报文交付给上层。

当服务端接收缓冲区满了之后,客户端还想发数据,就得不断询问当前缓冲区剩余空间大小,如果当前服务端在计算一个很大的数据,来不及处理缓冲区中的数据,那么服务端返回的应答报文中窗口大小一直是0,如果此时客户端等不及了,就可以发送一个PSH置一的报文,这样就可以让对端OS尽快将有效载荷交付给上层。

至于OS是怎么做的,这里得后面再说,我们只能影响应用层,也就是只能是调用read之类的读取函数,OS该干啥我们管不到的。

URG

URG就是urgent,作用是表示当前报文中有一个非常紧急的数据需要处理,OS要优先交付这个报文中的某个 数据。

其实URG是要搭配着16为紧急指针用的,所以这里要说的是报头中的两个字段:

指针不一定必须体现在我们日常代码中的。

这里的紧急指针,表示的是有效载荷(数据)中的某一个位置,也就是相对于有效载荷起始位置的一个偏移量。

这里紧急的数据并不是整个有效载荷,而是偏移量位置处的一个字节的数据。比如说这里紧急指针的值是30,那么就是第30个字节的数据会被提交上去。

那么这一个字节的数据有什么用呢?

我们一般是用不到的。这一个字节主要用处是查看当前服务器状态。

假如说服务器现在压力很大,用一个客户端发送一个请求都接受不到服务器的响应。那么就可以通过发送一个URG置一的报文来询问当前服务器是否还顶得住,服务器收到这个URG的报文就会根据自身情况来回应当前状态咋样。

ok,到这里就剩一个标记位FIN没有说了。

FIN

FIN就是finish,用于终端当前连接的,这里就和四次挥手放到一块讲。

如何理解四次挥手

建立连接的时候一方要主动,大部分情况下是客户端主动。

但是断开连接就不一样了,双方都有可能优先断开,且断开连接是两个人的事情,二者都要征得对方同意。

来看看四次挥手(过一眼就行,等会细讲):

当客户端发送带有FIN的报文时只是客户端方向上要断开连接了,先给服务端提个醒,所以只是client表明client端不会再给server端发送带有正常数据的报文了,此时只要一发送就会进入FIN_WAIT_1状态:

服务端接收到后会响应一个ACK确定收到的报文,并进入CLOSE_WAIT状态:

客户端接收到ACK后会进入FIN_WAIT_2状态:

此时只是单纯一方同意了断开,还要有另一方也同意断开才行:

就像离婚协议一样,签字得要双方都签,不能说单方签了就决定了,这样是有问题的。

然后服务端就再发送一个FIN并进入LAST_ACK状态,此时就是另一方开始签字:

然后客户端接收到并发送ACK后就进入TIMEWAIT状态:

服务端一收到ACK就会直接进入CLOSED状态,此时响应的内存也就释放掉了:

客户端进入TIME_WAIT后,过一段时间就会进入最后的CLOSED状态:

有没有可能服务端的ACK和FIN在同一个报文中发送给客户端,可能,两个选项是可以同时设置的,所以四次挥手在某种情况下也可以是三次挥手,后面再说。

断开连接一定能成功吗?

和三次握手一样,不一定,两个ACK对端是否接收到是不清楚的,所以是否成功不能确定,但即使最后一方断开了但另一方没有完全断开连接也不影响,某方断开连接后数据就不会发送了,时间长了连接会自动关闭。

CLOSE_WAIT 和 TIME_WAIT
CLOSE_WAIT

前面我手搓TCP服务器的博客讲过,一端调用close函数就会发送FIN,并且对端回应ACK。

如果发现服务器存在大量的CLOSE_WAIT状态的连接,出现这种情况就是以你为应用层在写服务器代码的时候写出BUG了,就是服务端和客户端通信完毕后服务端忘记了调用close(sockfd)来进行服务端的"签字",那么就不会有后续的发送FIN,等操作,服务端就会卡在CLOSE_WAI状态。

如果说以后你写的服务器用起来越来越卡,一定要用netstat来查看一下是不是挂了大量的CLOSE_WAIT连接(这个就像半连接一样,也会占内存,所以多一个客户端就会吃一点内存,就会越来越卡)。

下面我就来写一个忘记调用close的服务器。

这里我对套接字相关的操作进行了封装,先给出封装的代码:

cpp 复制代码
#include "LogMessage.hpp" // 这里打印日志的代码等会给

#include <iostream>
#include <string>
#include <memory>

#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>

#include <unistd.h>

// 对套接字相关的接口进行封装
class Sock
{
private:
    const int gBackLog = 20;

public:
        // 1. 创建套接字
    int Socket()
    {
             /*先AF_INET确定网络通信*/  /*这里用的是TCP,所以用SOCK_STREAM*/
        int listenSock = socket(AF_INET, SOCK_STREAM, 0);
            // 创建失败返回-1
        if(listenSock == -1)
        {
            LogMessage(FATAL, _F, _L, "server create socket fail");
            exit(2);
        }
        LogMessage(DEBUG, _F, _L, "server create socket success, listen sock::%d", listenSock);


        // 创建成功
        return listenSock;
    }

        // 2. bind 绑定IP和port
    void Bind(int listenSock, uint16_t port, const std::string& ip = "0.0.0.0")
    {
        sockaddr_in local; // 各个字段填充
        memset(&local, 0, sizeof(local));
                                        // 若为空字符串就绑定当前主机所有IP
        local.sin_addr.s_addr = inet_addr(ip.c_str());
        local.sin_port = htons(port);
        local.sin_family = AF_INET;
                                            /*填充好了绑定*/
        if(bind(listenSock, reinterpret_cast<sockaddr*>(&local), sizeof(local)) < 0)
        {
            LogMessage(FATAL, _F, _L, "server bind IP+port fail :: %d:%s", errno, strerror(errno));
            exit(3);
        }
        LogMessage(DEBUG, _F, _L, "server bind IP+port success");
    }

        // 3. listen为套接字设置监听状态
    void Listen(int listenSock)
    {
        if(listen(listenSock, gBackLog/*后面再详谈listen第二个参数*/) < 0)
        {
            LogMessage(FATAL, _F, _L, "srever listen fail");
            exit(4);
        }
        LogMessage(NORMAL, _F, _L, "server init success");
    }

        // 4.accept接收连接           输出型参数,返回客户端的IP + port
    int Accept(int listenSock, std::string &clientIp, uint16_t &clientPort)
    {
            /*客户端相关字段*/
        sockaddr_in clientMessage;
        socklen_t clientLen = sizeof(clientMessage);
        memset(&clientMessage, 0, clientLen);
        // 接收连接
        int serverSock = accept(listenSock, reinterpret_cast<sockaddr*>(&clientMessage), &clientLen);

        // 对端的IP和port信息
        clientIp = inet_ntoa(clientMessage.sin_addr);
        clientPort = ntohs(clientMessage.sin_port);

        if(serverSock < 0)
        {
            // 这里没连接上不能说直接退出,就像张三没有揽到某个客人餐馆就不干了,所以日志等级为ERROR
            LogMessage(ERROR, _F, _L, "server accept connection fail");
            return -1;
        }
        else
        {
            LogMessage(NORMAL, _F, _L, "server accept connection success ::[%s:%d] server sock::%d", \
                                                                clientIp.c_str(), clientPort,serverSock);
        }

        return serverSock;
    }


    // 客户端连接服务端
    bool Connect(int sockfd, const std::string& serverIp, uint16_t serverPort)
    {
        sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_addr.s_addr = inet_addr(serverIp.c_str());
        server.sin_port = htons(serverPort);
        server.sin_family = AF_INET;

        // 连接
        int res = connect(sockfd, reinterpret_cast<sockaddr*>(&server), sizeof(server));
        if(res == -1)
        {
            return false;
        }

        return true;
    }
};
cpp 复制代码
#include"Sock.hpp" 

int main()
{
  Sock c;
  // 创建套接字
  int listenSock = c.Socket();
  // 绑定当前主机
  c.Bind(listenSock, 8080);
  
  // 设置套接字为监听状态
  c.Listen(listenSock);
  
  if(listenSock > 0)
  {
    while(1)
    {
      std::string clientIP;
      uint16_t clientPort;
      // 接收连接
      c.Accept(listenSock, clientIP, clientPort);
      printf("connect ::[%s:%d]\n", clientIP.c_str(), clientPort);
      
      // 进行通信
      // 这里就睡10s来代替
      sleep(10);

      // 通信完毕后不关闭连接
      // close(sockfd)
    }

  }

  return 0;
}

打印日志的代码:

cpp 复制代码
#pragma once
#include <cstdio>
#include <cstring>
#include <ctime>
#include <cstdarg>

#include <unistd.h>

#include <vector>

// 文件名
#define _F __FILE__
// 所在行
#define _L __LINE__

enum level
{
    DEBUG, // 0
    NORMAL, // 1
    WARING, // 2
    ERROR, // 3
    FATAL // 4
};

std::vector<const char*> gLevelMap = {
    "DEBUG",
    "NORMAL",
    "WARING",
    "ERROR",
    "FATAL"
};

#define FILE_NAME "./log.txt"

void LogMessage(int level, const char* file, int line, const char* format, ...)
{
#ifdef NO_DEBUG
    if(level == DEBUG)  return;
#endif

    // 固定格式
    char FixBuffer[512];
    time_t tm = time(nullptr);
    // 日志级别 时间 哪一个文件 哪一行
    snprintf(FixBuffer, sizeof(FixBuffer), \
    "<%s>==[file->%s] [line->%d] ----------------------------------- time:: %s", gLevelMap[level], file, line, ctime(&tm));

    // 用户自定义格式
    char DefBuffer[512];
    va_list args; // 定义一个可变参数
    va_start(args, format); // 用format初始化可变参数
    vsnprintf(DefBuffer, sizeof DefBuffer, format, args); // 将可变参数格式化打印到DefBuffer中
    va_end(args); // 销毁可变参数

    // 往显示器打
    //printf("%s\t=\n\t=> %s\n\n\n", FixBuffer, DefBuffer);
    
    // 往文件中打
    FILE* pf = fopen(FILE_NAME, "a");
    fprintf(pf, "%s\t==> %s\n\n\n", FixBuffer, DefBuffer);
    fclose(pf);
}

启动起来:

这里我用的云服务器启动的我这里的服务端,再用我Windows本地的telnet就可以连接上:

如果此时我取出telnet,也就是关闭客户端:

就会出现一个CLOSE_WAIT的连接。。。

再用我Windows的telnet连一下:

又出现了一个:

再退出telnet:

又出现了一个CLOSE_WAIT的连接。

所以说,通信完成后一定要记得调用close关掉连接,不然连一个客户端吃一点内存,连一个客户端吃一点内存,早晚服务器要崩掉。非常危险。

服务器一关这些连接就自动没了,OS自动回收进程的空间:

TIME_WAIT

再来看看4次回收过程:

可以看到先发起FIN的一端会先进入TIME_WAIT状态,而后发起的会直接进入CLOSED状态。

上面我写的服务器代码中是让客户端先主动退出,但是服务端没有调用close函数,就卡在了CLOSE_WAIT状态。

这里再把代码改改,让这次让服务端先退出:

cpp 复制代码
#include "Sock.hpp"

int main()
{
  Sock c;
  // 创建套接字
  int listenSock = c.Socket();
  // 绑定当前主机
  c.Bind(listenSock, 8080);

  // 设置套接字为监听状态
  c.Listen(listenSock);

  if (listenSock > 0)
  {
    while (1)
    {
      std::string clientIP;
      uint16_t clientPort;
      // 接收连接
      int sockfd = c.Accept(listenSock, clientIP, clientPort);
      printf("connect ::[%s:%d]\n", clientIP.c_str(), clientPort);

      // 进行通信
      // 这里就睡10s来代替
      sleep(10);

      // 通信完毕后直接关闭连接
      close(sockfd);
    }
  }

  return 0;
}

运行服务器:

客户端连接:

服务端10s后调用close:

此时该连接进入TIME_WAIT状态。

验证成功。

再连一次试试:

注意刚刚的TIME_WAIT连接进入CLOSE状态就没了,两次客户端的端口号是不一样的。

过一会第二个也没了:

如果是刚刚演示CLOSE_WAIT中的代码也能产生上面的情况,我只要主动按下ctrl + c就可以,这里我再换成刚刚的代码:

启动服务器并让客户端连上:

此时如果我手动对服务器ctrl+c,就会直接将服务器关掉,此时该客户端与服务器通信的文件描述符就会关闭,因为文件描述符随进程,进程一退出文件描述符也就没了,此时就相当于是服务端自动触发四次挥手小连招:

同一主机下处于TIME_WAIT状态的端口无法再次被绑定,所以此时如果我再次运行起来服务器就会绑定失败:

echo $?是返回上一个进程的退出码,这里退出码为3,我的Sock.hpp中,bind绑定失败就会exit(3):

处于TIME_WAIT状态的连接不能再次连接,因为处于TIME_WAIT状态的连接,地址信息IP和port依旧是被占用的,这里连接会持续二倍的MSL(等会说)的时间再进入CLOSED状态。

TIME_WAIT的时间在不同的os下不一样,有的是两分钟,有的更久。也会受我们用户配置的影响。

如果服务器在高峰期间的时候挂掉了,还要等TIME_WAIT后才能重新连接服务器,那是万万不能的,想一想双十一的时候如果淘宝的服务器崩了,两分钟能造成多大的损失。

所以服务器挂掉了之后要有立即重启的能力,系统提供了一个接口,在创建套接字的时候可以设置一下listen套接字的属性就能在服务器挂了之后直接重连:

参数

  • 就是socket的返回值(不是listen的返回值)。
  • level是设置一个层,这里设置的就是套接字层。
  • optname是设置地址复用的功能。
  • optval指向的是一个选项值,指向的数据等会给1就行
  • optlen就是选项的大小

我这里用一下,直接在Sock.hpp中的socket后面调用一下就行:

这里level赋值的是SOL_SOCKET,L就是layer,层的意思,这里就表示是套接字层。还有其他层:

optname赋值为SO_REUSEADDR,就是重新使用地址,这里其实后面的SO_REUSEPORT加不加都行。

optval和optlen就不说了,这个函数意思就是在套接字层打开SO_REUSEADDR选项,讲地址复用的功能打开,此时即使服务器挂掉,也可以绕过TIME_WAIT状态的判断而直接让服务器绑定成功。

此时我再运行起来服务器并连一个客户端:

此时我对服务器按下ctrl+c再直接连接:

连上了,虽然原先ctrl+c后TIME_WAIT的连接还在,但是也能直接连上,TIME_WAIT的连接和服务端的端口号和IP都是相同的。但是直接绕过TIME_WAIT的判断了。

为什么要有TIME_WAIT

当双方协商断开连接的时候,网络中可能存在一些滞留的报文还未发送到对端,所以维持一个TIME_WAIT状态。

FIN/ACK这种纯报头的报文,没有数据交互,不牵扯数据排队的问题,在底层会直接处理,这样就会导致纯报头的报文处理的更快,处理四次挥手的时候可能还有一些报文在路上/等待,所以要等一个TIME_WAIT时间,TIME_WAIT时间至少是二倍的MSL。

MSL,Max Segment Life,就是最大报文生存时间,意思就是不论是报文从客户端到服务端还是从服务端到客户端,其中到达对端花费时间最长的报文所花费的时间就是MSL,也就是不论谁发给谁,只要最大的就行。

  • TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态.

  • 我们使用Ctrl+C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听同样的server端口;

  • MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;

  • 可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值

2MSL有很大概率能保证报文能够跑一个来回,也就是一次确认和应答,这样就能最大可能的保证历史数据从网络中消散。消散就是让报文被某一端接收到,该端接收到后可以选择保留,也可以选择丢弃。

如果不消散,可能会影响后续通信。 比如说客户端断开后又立即和服务端重连,那么原先未消散的报文可能影响到某一方后续的判断,比如三次握手的时候某一方收到了未消散的,可能会出现连接堵塞的问题。

所以未消散报文要尽量让其消散,MSL也只是一个预测时间,报文在网络中存在多少时间我们是无法确定的,2MSL不一定能完全消散,但是会大大降低出现未消散的概率。

如果没有TIME_WAIT可能会导致最后一个ACK报文出现丢包的情况但客户端此时已经CLOSED的情况,此时丢包:

但是这里的情况,重传FIN一直都接收不到ACK,重传到一定次数的时候就会自动断开连接,也没啥事。

但上面重传FIN的情况也可能是服务端网络突然抖动了,FIN怎么样也发布出去:

此时客户端永远也接收不到,这样也会导致服务端一直在发送FIN请求,只要网络变回正常且客户端还没有从TIME_WAIT变为CLOSED,客户端就能接收到FIN请求,后续工作就是正常的挥手了。

不过也不用担心FIN发不出的问题,也是会重传一定次数后就会自动断开连接。

所以说网络水很深,我把握不住,但是正常情况下上面的问题基本不会存在的。

再谈确认应答

前面说了:为了能够确认应答,TCP报头中搞了序号和确认序号。前面的那些内容就不再讨论了,再补充一点东西,先带你简单过一下前面说过的确认应答。

看图:

对于历史有应答的数据,就能保证其可靠性,A只要发出的数据都有应答,就能保证从A向B发送的数据的可靠性。

而因为TCP是全双工的,AB双方地位对等,所以主机A和主机B通信的时候能够保证A的可靠性,以同样的方式,B给A发消息,A也给B应答,B发出的数据也就可靠性也就能保证。

这样双方在两个方向上的可靠性都能够保证。

另外,为了按序到达,每个报文都要带上序号。

说白了数据发送的时候可以给每一个数据携带序号,报头当中填好即可,当对方确认的时候只需要在确认序号中填入发送方的序号+1就可以了,确认序号的含义并不是某一个序号的数据收到了,假如说确认序号是1001,那么返回1001是指1~1000的数据都收到了。可能你不太明白1~1000的数据是什么意思,没关系,马上就讲。

给各位补充一个小知识,TCP层所有发出的报文,通常称其为数据段,数据段包含两个方面,一方面报头,一方面数据,也就还是最开始的TCP报文结构 的那张图。说报文不是不可以,只是数据段说起来更专业一点。数据段听起来就像是某一个范围(段)。

缓冲区与序号的理解

看图:

这张图前面也给了,我简化一下:

一个单方的,只画了一个缓冲区,假如说是发送方的发送缓冲区。

我们可以把缓冲区想象成一个char类型的数组:

这样的话,每一格就表示是一个字节。应用层发下来的数据只要依次拷贝进入这个char类型的sendbuffer数组中就行了,那么这样的话,每一个字节的数据就天然拥有了一个序号,而这个序号就是对应数组的下标。

所以发送数据的时候,可以直接根据下标来确定序号,如果是想发送多个字节,就把最后一个元素的下标作为序号来填充到报头中即可。

发送给对端的时候,对端接收缓冲区也可以看成一个char的数组,一字节一字节的拷贝就行。想一想前面博客中为什么说TCP是面向字节流的,这里也就能更好理解一点,发送的时候就是一字节一字节的发送,这种以char/字节流类型的方式保存到发送缓冲区中的数据。对方发送的时候同样如此。当然单从这里理解面向字节流的话也无法完全理解,后面还会在解释面向字节流的。

所以并不是说给每个报文添加序号,而是给要发送的数据的每个字节都编上序号,想要发送某一段数据时,就要这段数据拿出来,最后一个元素的下标就作为序号,填充到报头里,让数据和报头拼接,形成TCP报文,再发出去就可以了。确认序号是序号加一,所以收到确认序号时,本质上就是表示从字符数组的1001下标前的数据都收到了,发送方下次从1001开始向后挑选数据来进行发送,这就是确认应答机制中序号和确认序号的概念。

超时重传机制

前面一直在说重传,这里就开讲。

我们在TCP连接的情况下,怕不怕丢包?

一点都不怕。

因为有超时重传机制:

当A给B发数据时,A无法确定数据是否被对方收到,除非是A收到了B的应答,可是万一数据真的丢了呢?那么B主机就根本不会收到数据,没有收到数据也就不会有应答。

如果A发出数据后没有收到B的应答,那么A就无法笃定数据是丢了还是怎么样,所以要给A设定一个未收到B应答超时时间间隔,如果在时间间隔内收到了B的应答,就认为数据没丢,如果时间间隔后没有收到B的应答,就认为当前数据段超时了,超时就断定该报文已经丢了,丢失之后会重传这个数据,这种机制就叫超时重传机制。

但是重传有两种情况,一种是报文真的丢了,就是刚刚上面这张图。另一种是B收到了数据但是B的应答丢了:

主机A也无法判定是哪一种情况,但是也不需要关心是哪种情况。

但是第二种情况会有一个小问题,就是B端会收到重复的报文的问题,可能B的网络抖动了,导致确认应答一直发不出去,A却一直在发送重复报文,那么对于B来说,要对数据进行去重,如果收到重复报文,也是可靠性不满足的一种。

所以说B是否能甄别出哪个报文重复了呢?

能,因为B收到的报文报头中是有序号的,所以当因为丢包问题而导致重传了,按序号去重就行。

超时重传的超时时间应该如何设置

短了不行,确认还在路上A就认为丢包了,太急了,会导致频繁发送重复的包。

长了不行,太长会导致A补发报文的效率降低,进而引起AB通信效率降低。

超时时间能固定吗

不能,丢包的相当部分原因是网络问题,网络有时会好,有时会不好,当网络好的时候就把时间设置的短一点,网络不好就设置的长一点。所以说超时时间是会动态变化的。

TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.

  • Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍.

  • 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.

  • 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.(2、4、8......)

  • 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接.

上面也有个和时间相关东西,就是那个MSL,不要和这里的重传时间间隔搞混了。

再谈流量控制

TCP的三大机制:流量控制、拥塞控制、滑动窗口。

这里再来说说流量控制。

接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的接收缓冲区放不下了, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制。

上面这段话是前面讲过的。

问题:发送方第一次发送数据的时候是如何得知对端接收缓冲区剩余空间的大小的?

第一次是指第一次发送客户端要发的数据,而不是第一次交换报文,第一次交换报文是三次握手的时候。

三次握手时前两次握手一定不能携带任何数据,但能携带报头窗口大小,这样就能得知对端接收能力,就算你上层第一次拷贝了10000个字节,交给下层的TCP时,还会根据对端接收能力来有控制的发送。

如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端:

上图中1000~4000能够同时发送多个数据段,这里就涉及到了点滑动窗口的知识,下面就来说说滑动窗口。

滑动窗口

刚才我们讨论了确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答。收到ACK后再发送下一个数据段,这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候:

从严格意义上讲,上方的做法就是标准的确认应答机制。不过这样收到应答才发送下一个报文,所有的发送过程都是串行的,意思就是发一个报文不能接着发,必须等到这个报文的应答之后才能接着发,效率比较低。

可以一发多条数据,让多个IO重叠在一起,一批报文并行的在网络中传输过去,确认应答的报文也是并行的回来,这样就可以减少多次的等待时间(不需要每个报文都等一个发送来回):

所有发出去的报文都必须要有应答(暂时这样记,不准确,应答可能会丢失或者暂时不发,后面再说)。

A发送数据的总量一定要在B的承受范围之内(流量控制)。

想要实现多条数据并行,就得允许向对方发送数据,但是暂时不需要对方确认就可立马发送下一个数据。有多少条并行的数据,滑动窗口就有多大。

有数据的发送缓冲区可以分为三个区域,还是以发送缓冲区来说:

这里还是只画一个发送方的发送缓冲区,简易画一个滑动窗口:

红色部分的是指发送出去且已经收到确认的数据。

绿色部分是指可以直接发送,不需要应答的数据。

蓝色部分是指暂未发送的数据。

白色的是还未使用的空间。

其中绿色部分就是滑动窗口。滑动窗口在自己的发送缓冲区中,属于自己发送缓冲区的一部分。

滑动窗口的本质是发送方一次性向对端推送数据的上限,而上限暂时可以认为由对端的接受能力决定(目前认知水平来看,还有别的因素)。

滑动窗口既想要给对方推送更多的数据,又想要保证对方来得及接收。

再来看这张图:

我来编个号(这里编号应该是从1开始的,后面画着画着才发现的,不过问题不大,你懂我意思就行):

当序号为3000的报文接收到ACK后,滑动窗口就会向后移动,继续发送序号为8000的报文:

操作系统内核为了维护这个滑动窗口, 需要开辟发送缓冲区来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉,也就是说红色部分的内容直接可以作废了,后面都不会再用了。

很多教材到这里就讲完了,但是想要理解滑动窗口的话是远远不够的。

问几个问题:

  1. 滑动窗口必须要向右滑吗?
  2. 滑动窗口大小是固定的吗?
  3. 上图中接收到6001之后还是往后划一格吗?

等等问题,下面来进行解答。

模型

讲发送缓冲区想象成一个char数组,滑动窗口就可以看作是指针或者下标,就是用来标定一个范围的东西。

比如int win_start, win_end:

这样就可以获取一端无需应答的数据范围,就定出了滑动窗口的大小,所以窗口的右移其实就是在做下标的运算。

假如说上图中一格是一个512字节的数据,对端接收缓冲区中剩余了1024字节,但是对端现在忙着计算一个东西,接收缓冲区中的内容来不及处理,当本端发送一个512字节的数据后,还堆积在对端的接收缓冲区中,此时对端接收缓冲区剩余大小就剩下512了,并没有新的空间产生,那么当前发送方的发送缓冲区中win_end是不能向后移动的:

所以根据上述的情况,不是说发了数据滑动窗口的两端都要向右移动,对端的接受能力很重要。所以滑动窗口也可能存在大小变为0的情况。这种情况就能表明对方的接受能力一直在减少。所以滑动窗口不一定非得向右移动。

那么更新滑动窗口的策略是啥呢?

很简单,win_start = 收到应答报文中的确认序号,win_end = win_start + 收到应答报文中的窗口大小。

看图:

比如说这收到了确认序号为5001的报文,窗口大小为5000,那么win_start = 5001,win_end = 10001:

再来一个:

  • 没有收到前面部分的报文的应答,而是收到后面的,会有影响吗?

不会,因为确认序号的含义是在当前序号之前序号的报文都收到了,如果没有前面的应答也没有影响,比如说:

这里四个报文都发出之后,如果说收到了确认序号为4001的报文,就说明前面1000~4000的数据对端都接收到了,那么此时1000~4000的数据就可以作废,然后让win_start直接跑到4001的位置,且如果响应报文中窗口大小为4000,就会让win_end跑到8000的位置:

也就是这样:

上图中只要接收到了确认序号为6001的应答,那么前面的应答就可以说是没什么太大用。直接就开始从6001开始发送报文了。

这种情况下, 部分ACK丢了并不要紧, 因为可以通过后续的ACK进行确认。

  • 中间ACK丢失

还是这张图:

如果只收到了确认序号为3001的报文但并没有收到后面4001和5001的报文,那么就会让win_start只指向3001(假如应答的报文中窗口大小为4000,win_end就指向了):

快重传

如果发送方的报文直接丢了,怎么办?

看图:

比如说这里3001~9000的数据都发送出去了,但是4001~5000的报文丢了。

那么此时接收方接收到的就是3001~4000、5001~9000的报文。但即使接收到5001~9000的报文了,也不能返回6001、7001、8001和9001的报文,因为返回这几个报文就说明4000~5000的报文接收方一定收到了,但是这样就和实际情况矛盾了,所以不能返回确认序号为6001和7001的报文。

当接收方收到了序号为6000~9000的所有报文的时候只能返回一个确认序号为4001的报文,表明当前序号为4001~5000的报文丢失了。所以此时win_start就应该变为4001,同时如果应答报文窗口大小为6000,那么win_end就跑到了10000:

如果出现了这种多个(3个或以上)重复的确认应答报文,就会立即确定,这个确认序号对应的报文丢了,不用再等待超时重传,直接发送确认序号对应的数据,而该报文后面序号对应报文对端已经收到并保存了(5001~9000),此时只要发送方发送了4001~5000,接收方一旦接受到就会直接返回9001的确认序号,因为前面通信的时候5001~9000的数据接收方已经保存了,所以win_start就会一下跳到9001,若应答窗口大小为4000,win_end就跑到了12000:

这样就是高速重发控制,也就是快重传,本人不会搞动图,可能上面讲的比较拉,看看这张图:

这张图表达挺清晰的。

这里意思就是

  • 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001" 一样;

  • 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;

  • 这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中。

那么问题来了:

快重传又快又能重传,为什么还要有超时重传呢?

因为快重传是有触发条件的,必须是三个以上的相同确认应答被收到才会进行,万一滑动窗口中只能提供发一个或者两个报文那就不可能触发的,或者说本来三个ACK应答有一个ACK丢包了,那么也无法触发,这种情况下只能用超时重传。

所以超时重传和快重传不是对立关系,而是协作的。

拥塞控制

上面的超时重传、快重传、流量控制、连接管理、滑动窗口、报文去重和按序到达等等机制都是为了解决服务端和客户端两端发送数据和接收数据的可靠性和效率问题,但是不光要考虑两端的问题,还需要考虑网络的健康状态。

举一个例子,拿考试来说,假如说计算机学院有500人,此时举行完了计算机网络的考试。

如果说考完后,整个院有10人挂科,这种情况应该是很正常的。

但是如果说考完了整个院挂了460人,那这种情况就不正常了。

第一种情况,是老师出的题很正常,挂的10个人是哪些无力回天的。

但是第二种情况可能就是老师出的题都是那种偏怪难的题,院里面挂科率高达90%以上,那么这就是教学事故,学院会自动找老师谈话的。

所以第一种情况挂科是个人问题,第二种情况挂科就是老师的问题了。

再来说网络,如果出现少量丢包时,就是两端发送和接收的问题,此时重传就可以解决。

但是如果出现大量丢包,那就是网络的问题了,此时重传还能解决吗?

肯定不能啦,此时网络已经拥塞了,就是网络用的人太多了,不止你一台主机:

此时如果拥塞,那么就可能是很多主机都拥塞了,如果一重传就会有非常多的报文同时发送到网络当中,那么就会使得拥塞更加严重。

那么应该如何解决呢?

慢启动机制,先发送少量数据探探路,先发一个报文,看接收端能应答不,如果能应答就接着发两个报文,两个也能应答,就接着发4个,如果4个也能应答,就继续发8个,这样先按照 指数级的增长:

  • 此处引入一个概念程为拥塞窗口(就是一个数,拥塞的时候发送报文的数量)
  • 发送开始的时候, 定义拥塞窗口大小为1;(先发一个)
  • 每次收到一个ACK应答, 拥塞窗口加1;(指数级增长)
  • 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小(对端接受能力)做比较, 取较小的值作为实际发送的窗口;

前面讲滑动窗口的时候,发送方发送数据报时要根据互动窗口的大小来选择同时最大发送报文数量,那么这里将取拥塞窗口和接收端主机反馈的窗口大小(对端接受能力)的较小值,就是滑动窗口的大小,所以前面讲滑动窗口的时候说滑动窗口的决定因素并不止对端接受能力的大小,还要受这里拥塞窗口大小的影响。

但上面慢启动只是指初始的时候发送慢一点,不能说光是指数级的增长,指数增长是非常快的。但是如果指数增长也没有啥问题,后续网络通畅后,增长到一定程度决定因素就变成对端接受能力了。不过真正实现的时候并不是光指数级别增长,而是到了一个阈值之后就变成线性增长了:

图中传输轮次为12的时候有发生了网络拥塞,于是又开始慢重传等后续工作。

为什么拥塞之后前期用指数级增长方式来发送报文?

一旦拥塞后,前期要让网络有缓一缓的机会,所以尽量少发报文,慢慢增长,中后期网络恢复后,就尽快回复正常通信,先慢后快,指数增长非常合适。

当网络拥塞之后,大量主机都开始慢启动,这样短时间内网络压力会减少很多,就能够给网络一个缓一缓的机会。

延迟应答

接收端接受能力足够强是发送端滑动窗口足够大的必要条件。

意思就是发送方滑动窗口想要大,首要条件是得要接收端的接收缓冲区中剩余空间要大,毕竟网络我们控制不了多少,想象一下接收缓冲区特别大,那么发送方的滑动窗口大小就会完全取决于拥塞窗口的大小,效率也就上来了。

那如何保证接收缓冲区足够大呢?

延迟应答,意思就是接收方接收到报文后先处理处理接收缓冲区中的数据,过一会再给发送方应答。

这样有什么好处呢?

接收方在应答的时候缓冲区的剩余空间相较于立刻应答要多一点。

那么相对来说接收缓冲区的空间就大了,这样就能间接的提高发送方的滑动窗口的大小。

如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.

假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;

但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;

在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;

如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
那么所有的包都可以延迟应答么? 肯定也不是;

数量限制: 每隔N个包就应答一次;

时间限制: 超过最大延迟时间就应答一次;

注意最大延迟时间可不是前面讲的超时重传的时间间隔,二者不是一个东西,不能说延迟到对方都开始重传了再应答,那反而会导致网络中存在很多重复报文,效率反而降低了。

具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms;

捎带应答

其实上面都已经说过了,只不过是没有提这个名词而已。

就是应答的时候带数据。也就是前面讲A给B发"打游戏吗",B回应"上号"的那段。

就不细说了。

小总结

当数据拷贝到内核缓冲区后,数据什么时候发(拥塞控制、流量控制),发多少(滑动窗口),出错了怎么办(快重传、超时重传),全部都由TCP决定,所以这种协议才叫传输控制协议。为我们传输数据提供策略和机制,这就是TCP。UDP不会做这些工作,所以更简单。

来总结一下前面讲的一些策略。

可靠性:

校验和

序列号(按序到达)

确认应答

超时重发

连接管理

流量控制

拥塞控制

提高性能:

滑动窗口

快速重传

延迟应答

捎带应答
其他:

定时器(超时重传定时器, 保活定时器(上面捎带了一点,如果没懂请自行查阅一下), TIME_WAIT定时器等)

TCP不光是可靠,只是因为可靠性很强,强到掩盖了其效率,其实TCP效率也很好,像滑动窗口就是为了提升效率而搞的,所以以后谈到TCP时要知道其维护可靠性的策略有哪些,提升效率的策略有哪些。

面向字节流

什么叫面向字节流?

用户调用write,前面说了这是一个拷贝函数,os不一定是用户调用几次就向对端发送几次。

啥意思呢?

看图:

上图中画的是拷贝三次后再将数据发给对端。

出现这种情况可能是网络拥塞了或者对方接收缓冲区不够等原因,os就会控制自身的发送速率或发送数据的大小。

所以对于应用层来说,只管调用wirte、send这样的拷贝函数,将数据拷贝到发送缓冲区中,os将发送缓冲区中的数据按照1次全发送,还是按照5次分开发,还是分1000次发,完全不用关心。

对端应用层在read进行拷贝的时候也是直接读,不用管接收缓冲区中的数据是由对方分了多少次发过来的,反正发过来的数据都放在一块,read这样的函数,参数中可以设定读取字节大小,也就是说我们应用层想取多少字节就取多少字节(足够的前提下,不够就先全部拷上去)。想读取1字节或是10字节或是114514字节,完全由应用层自行决定。

那么这种通信方式就称之为面向字节流式的通信。

想要完全理解,就要和前面我讲过的UDP来对比一下。

UDP就像收快递,收到一个快递就是对方发了一个快递,是一块完整的数据。

而TCP就像我们接自来水,供水厂中的水有很多,我们想要接多少完全是我们自己来决定的,可以用碗,可以用瓢,可以用盆,可以用桶。

TCP不管发送方和接收方的数据格式,只管发和读,上层传下来的数据只管按照字节发,接收到的数据只管按照字节拷贝给上层。

上层接收到了后就按照应用层规定的协议去读取。

所以面向字节流是没有边界区分度的,完完全全是按需所选,前面我的那篇网络版本计算器(在这篇博客中:【网络】用代码讲解协议 + 序列化和反序列化 + 守护进程 + jsoncpp)中,规定了客户端和服务端读写的固定格式,因为那篇博客中用传输层用的协议就是TCP协议,通过特殊字符\r\n来先搞到后面一端数据的长度,读取之后就得到了一个完整的客户端发来的数据。

再比如说我前面讲的HTTP协议(【网络】用代码讲解HTTP协议 )。通过空行来区分报头和有效载荷。

所以说TCP只关心传输的稳定和效率。数据是什么格式,由应用层自行决定。而UDP是发送一个报文就接受一个报文,接收到的时候一定能保证是一个完整的报文,所以这就是TCP报头中没有能够判断有效载荷大小的字段,只管将报头和选项一拆,直接将数据交给上层,不对数据做任何处理,按照序号把数据正确的按顺序拼一块,后续工作就完全是应用层自己的事情了。

前面我也讲过文件,文件读写的时候也叫文件流,同理,调用write、read这类的拷贝函数后,只是让数据在缓冲区和用户定义的缓冲区进行来回拷贝,拷贝之后后续的工作是由OS来做的,用户不用管。数据什么时候刷新,刷新多少,由OS决定,同样的,数据的格式OS不关心。

管道也是面向字节流的,我前面的博客中也演示过,让写的进程分多次把管道写满,读的进程一次读一大堆,也就是读写次数和读写的大小没有啥关系,写了多次,一次就能读完,写一次,也可以分多次读完。

粘包问题

简单一说。

比如说现在接收缓冲区中有两个512字节的完整数据,但此时如果调用read的时候给参数count设置为1024,那么就会直接将两个完整的数据都拷贝上来,这就是两个包粘到一块了。也就是粘包问题。

那么如何解决呢?

其实刚刚都讲了,就是让应用层协议来解决。按照格式来读写,无论是用空行来区分数据和报头还是

用定长来区分数据和报头,只要能把二者分开就行。本质上就是明确报文和报头的边界。

UDP不存在粘包问题,因为UDP报文间的边界是明确的。

TCP异常

进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.

机器重启: 和进程终止的情况相同.

机器掉电/网线断开: 被断开的一方没有机会进行四次挥手。断了之后,被断一方连接就没了。但是没有断的一方认为连接还在,如果是发送端断了,一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP自己也内置了一个保活定时器 , 会定期询问对方是否还在. 如果对方不在, 也会把连接释放。如果是接收端断了,发送端后续进行超时重传就行了。

另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ断线之后, 也会定期尝试重新连接。

一个面试题

UDP如何实现可靠性传输?

面试官问你这句话的时候,不要直接回答,先反问一下要可靠到什么程度。

然后根据TA所需要的程度,去想TCP相关的策略就行。

例如:

引入序列号, 保证数据顺序;

引入确认应答, 确保对端收到了数据;

引入超时重传, 如果隔一段时间没有应答, 就重发数据;

全连接队列

accept参不参与三次握手

accept要不要参不参与三次握手?

不需要,虽然上面那张图里也画了accept函数:

accept只是从底层直接获取已经建立好的连接对应的文件描述符,三次握手没好的时候就阻塞等待OS建立连接,好了就直接从底层获取到应用层。

所以应该是先建立起连接,才能accept获取对应连接。

如果不调用accept,连接能建立成功吗?

根据上面的说法,当然可以。等会演示。

如果上层来不及调用accept,还来了大量的客户端连接,这些连接都要被建立吗?

先不说。

先来举一个例子:

吃饭的例子。假如说现在有一个自助餐餐厅,生意非常火爆。天天晚上都是爆满状态。

如果说现在餐厅桌子都坐满了,有别的顾客想要来这家吃自助,老板有两种选择。

  1. 给顾客说店里满了,坐不下啦,让顾客去其他地方吃去。
  2. 在前台搞一些小板凳,让新来的顾客先做板凳等等,给能在一桌吃饭的顾客一个号,让这些顾客先排队等等,别的顾客吃完了就让先来的顾客吃。

很明显,第二种方式更好一些,日常生活中也是这么干的。

第一种方式就相对来说很挫了,可能刚把新来的顾客请走,就有一桌吃好了,此时新的顾客已经走了,白白损失一桌子的顾客,正常老板一般不这么干。

但是第二种方式也需要稍作补充,让顾客排队,不能说队列非常长,假如说就30张桌子,都有顾客排到200号了,那就有点太远了,可能200号的顾客等到的时候都已经凌晨一两点了,不如说把那些买板凳的钱生下来多租一个隔壁的店铺,多搞几张桌子。

所以说队列不能没有,没有了会损失收入,队列不能太长,太长了消耗成本。

再说会服务器,当服务器收到一定量的客户请求的时候,可能此时服务器很忙,在接收新的用户连接时都已经来不及应对了。此时再有更多的用户到来时,服务器就会在底层维护一个连接队列,该队列不能没有,也不能太长。

没有该队列,新来的客户端就直接连不上了。万一分期刚处理好一个客户,对刚走的客户端accept已经没用了。这样服务端资源使用的就并不是那么高效。

如果维护队列太长,假如说一个客户100ms,队列长度为10000,后面的客户就要等1000s,实际上是不可能的。

所以如果上层来不及调用accept,还来了大量的客户端连接,这些连接都要被建立吗?

这个问题现在就可以回答了。根据到来的先后顺序,把能进队列的先排队,排不进的就直接拒绝,连接队列的长度和listen的第二个参数有关。

理解 listen 的第二个参数

listen函数,我前面手搓TCP服务器的博客中,当时写的时候说先不讲这个参数,因为那时候我还没有详细讲TCP的博客,这里TCP都讲的差不多了,可以说说了。

前面我用这个参数的时候都给的是20:

代码和前面的差不多,就把gBackLog改成1:

然后服务端listen之后就啥也不干,光while(1)sleep;就行:

cpp 复制代码
#include "Sock.hpp"

int main()
{
  Sock c;
  // 创建套接字
  int listenSock = c.Socket();
  // 绑定当前主机
  c.Bind(listenSock, 8080);

  // 设置套接字为监听状态
  c.Listen(listenSock);

  while(1)
  {
    sleep(1);
  }

  return 0;
}

运行起来服务器:

连一个客户端:

可以看到,这里虽然没有accept,但是照样ESTABLISHED了。

再连一个:

再连一个:

再连一个:

等一段时间:

第一次和第二次连接都能够成功连接,但是后面两次建立的连接状态都是SYN_RECV状态,再来看看这张图:

连接处于半连接状态。过一小会就自动断开了。

但是前面两次ESTABLISHED的状态的连接仍然还在。

所以listen的的第二个参数的意义就是:

底层全连接队列的长度 = listen第二个参数 + 1。

上面我将第二个参数赋值为1,所以全连接长度就是2,刚刚验证的时候就是最多两个ESTABLISHED但是没有accept的连接。

其实有两个队列,一个是刚刚的全连接队列,一个是半连接队列。

  1. 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)
  2. 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)

全连接建立好后可以维护很长时间,以供上层在合适的时候调用accept来获取对应文件描述符,如果全连接队列满了就会进半连接队列,但是放到半连接队列中维护时间会很短,过一会就断了。

这些内容已经够了,很长了,屏幕前的你好好消化消化。

到此结束。。。

相关推荐
我曾经是个程序员5 分钟前
鸿蒙学习记录之http网络请求
服务器·学习·http
0zxm7 分钟前
06 - Django 视图view
网络·后端·python·django
真真-真真34 分钟前
WebXR
linux·运维·服务器
୧⍢⃝୨ LonelyCoder1 小时前
FreePBX修改IP地址和端口以及添加SSL证书开启HTTPS访问
tcp/ip·https·ssl
轩辰~1 小时前
网络协议入门
linux·服务器·开发语言·网络·arm开发·c++·网络协议
燕雀安知鸿鹄之志哉.1 小时前
攻防世界 web ics-06
网络·经验分享·安全·web安全·网络安全
wanhengidc2 小时前
短视频运营行业该如何选择服务器?
运维·服务器
雨中rain2 小时前
Linux -- 从抢票逻辑理解线程互斥
linux·运维·c++
ProcessOn官方账号2 小时前
如何绘制网络拓扑图?附详细分类解说和用户案例!
网络·职场和发展·流程图·拓扑学
Bessssss2 小时前
centos日志管理,xiao整理
linux·运维·centos