目录
1. TCP
TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层协议。
1.1. TCP协议段格式
我们学习TCP的思路
- 回顾两个问题。TCP协议如何区分包头和有效载荷的,TCP如何向上交付的;
- 挑一个重要保证可靠性的策略 (确认应答机制 ),作为切入点,理解一下什么叫做可靠;
- 系统学习TCP报头;
- 保证TCP可靠性的其他策略。
1.2. TCP的两个问题
对于TCP而言, 它是如何区分报头和有效载荷的呢?
TCP的报头标准长度是20字节,而TCP的报头是变长的, 选项也属于报头, 因此,我们现在怎么判定报头是标准的20字节,还是20字节 + 选项呢?
因此,我们就需要引入一个字段: 4位首部长度,这个字段就标明了报头的整体大小。
我们知道,4位首部长度的数字范围是 0000 至 1111;那么如何能表示20字节以上的数字呢?
事实上, 这个首部长度是有单位的,单位是4字节,即4位首部长度的数字范围是0 到 15,但是能表示的字节范围是:0 - 60字节。
一个TCP报头最少都要有20字节,即不包含选项。
假设首部长度的数字大小为 x,则:
报头的最小长度:4 * x = 20 ---> x = 5,即0101;
报头的最大长度:4 * x = 60 ---> x = 15,即1111;
故首部长度的实际范围: [0101, 1111];
因此,我们采用下面的步骤区分报头和有效载荷:
- 首先提取前20个字节,这是TCP报头的固定部分。
- 从这20字节中提取首部长度字段,根据首部长度字段的值来确定报头的总长度。如果首部长度字段的值乘以4等于20,则报头不包含选项,否则需要读取额外的选项部分。
- 如果报头包含选项,则根据首部长度字段的值计算选项的长度(总长度减去固定部分的20字节),并读取这部分数据 (选项)。
- 剩下的部分即为有效载荷。
这样的处理流程可以正确地区分TCP报头和有效载荷。
对于TCP而言,它是如何向上交付的呢?
很简单,当接收到TCP数据报时 (因为我们已经能够将报头和有效载荷分离),操作系统会根据目的端口号将数据交付给相应的进程或套接字。
在网络通信中,进程通过绑定端口号来监听网络上的数据,当有数据到达目标端口时,操作系统会将数据交付给绑定了该端口号的进程。这个过程通常是由操作系统内核完成的。
补充:
观察TCP协议格式,我们发现,TCP是没有整个报文的大小,或者没有字段标示有效载荷的长度。为什么呢?
因为 TCP 是面向字节流的。TCP自身无法判定数据段和数据段的边界,也不提供数据段整体的大小信息。 换句话说,TCP 负责在网络上传输字节流,并提供可靠的数据传输机制,而并不关心这些字节流数据该如何被解释,而应该是让应用层对这些字节流进行解释和处理,包括确定信息的边界、解析信息的格式等。
因此,判定不同的 (应用层) 数据包是应用层需要关心的事情,而不是TCP本身需要考虑的事情。
1.3. 如何理解可靠性
首先,为什么会有可靠性这个概念呢?
在日常生活中,正常情况下,如果两个人面对面的交谈,那么一方发出信息,另一方是一定能收到的,这个时候,我们是不会讨论可靠性这个话题的,因为双方交流时,几乎不存在另一方无法获取消息的这种情况。
可是如果我们更改一下场景, 假设如果两个人相隔一百米进行交流, 那么一方发出信息,另一方有很大概率接收不到。
而这就是由于通信距离变长,导致通信可能出现异常,而这种异常发生的概率,我们就用可靠性来度量,可靠性高,这种异常概率就很低,可靠性低,这种异常概率就高。
因此,类比到我们的计算机,对于操作系统内部单机通信而言,由于主机内部通信出现异常的概率很低,因此很少谈论可靠性这个话题,而更多谈论的是效率问题。
而一旦涉及到网络,尤其是在广域网或因特网这样的跨越大范围的网络中,通信异常的可能性增加了。这可能是由于网络拥塞、路由故障、数据包丢失等因素导致的。因此,确保数据的可靠传输就成为了一个重要的问题。
故为了解决可靠性这个问题,人们就引入了协议来保证可靠性,例如TCP协议,就是为了解决这种跨网络通信中的可靠性问题而设计的。TCP协议提供了可靠的字节流传输,通过序号和确认序号、确认应答和重传等机制来确保数据的可靠性和顺序传输。
1.4. 理解确认应答机制
在生活中,如果两个人相隔一百米进行交流:
一方发送信息, 如果另一方没有做出应答,那么发送信息的一方,是无法确定另一方是否收到了信息;
但如果发出信息的一方收到了另一方的应答,那么发送信息的一方就可以肯定,我之前发送的信息,另一方一定收到了。
- 比如, 小A和小B再进行交流:
- 小A说:吃了吗?
- 如果小B没有做出应答,那么小A无法确定小B是否收到了这条信息 "吃了吗"。
- 但如果小B说:吃了,吃的米饭。
- 即如果小B应答了,那么小A可以肯定小B一定收到了我刚刚发送的信息 "吃了吗"。
- 但是,隐藏着一个潜在的问题:
- 对于小A来说,如果他收到了小B的应答,那么他可以肯定,他之前发送得信息,小B一定是收到了。
- 但是对于小B来说,由于他发出去的信息,小A并没有做出应答,故小B无法确定小A是否收到了这条信息 "吃了,吃的米饭"。
根据上面的例子类比到网络通信过程中,我们知道,网络通信是不存在一定可靠的协议的。
因为无论是谁发送数据,都无法保证自己作为发送最新数据的一方,发送出去的数据被对方收到。但是在局部上,我们可以做到一定的可靠性。
即一方发出去的所有消息,只要有匹配的应答,就能保证,发出去的消息对方一定收到了。
我们将这种机制,称之为TCP协议的确认应答机制。
TCP协议的确认应答机制:虽然无法保证最新数据段的可靠性,但是只要一个数据段收到了对应的应答,那么就能保证之前发出的数据对方收到了。
确认应答机制可能不是TCP保证可靠性的最难的机制,但可以说它是最核心的一个机制。
永远记住: 确认应答并不是保证最新信息的可靠性,而是保证历史信息的可靠性。即如果发送的信息,得到了相应的应答,那么可以保证,之前的信息对方一定收到了。
2. TCP 报头中字段的分析
2.1. 序号和确认序号
2.1.1. 序号和确认序号的初步认识
序号(Sequence Number):在TCP中,发送方在发送数据时,为每个数据段分配一个唯一的序号。序号是一个32位的字段,它用来确保数据的顺序性和完整性。接收端使用序号来重新排序到达的数据包,以确保它们按照正确的顺序组装成完整的数据流;
确认序号(Acknowledgement Number):确认序号是接收端发送给发送端的,用来告诉发送端,接受端已经收到确认序号之前的数据。发送端收到确认序号后,就知道哪些数据已经成功传输到了接收端,可以安全地将这些数据从发送缓冲区中移除。
TCP 在通信时,并不是客户端发送一个请求数据段,服务端就响应一个响应数据段。而是有可能客户端向服务端一次性发送多个请求数据段,原则上, 服务端需要为每一个请求数据段都做出应答,那么客户端如何确认, 哪一个应答,对应哪一个请求呢?
通过序号和确认序号,客户端能够确定哪个应答对应哪个请求。
每一个数据段一定是携带了完整报头的,包含序号和确认序号等。
举例来说,如果客户端发送了三个请求数据段,分别对应的序号是1000、2000、3000,而服务端在接收到这些请求后分别做出了确认应答,并返回了确认序号2001、1001、3001。那么客户端在收到确认序号为2001的应答时,可以确定服务端已经成功接收了序号2000之前的请求数据段。
序号和确认序号的意义:
- 将请求和应答进行一一对应。序号和确认序号的配对确保了请求和应答之间的对应关系,使得通信双方能够准确地确认数据的传输状态和顺序;
- 确认序号的含义就是,确认序号之前的数据已经收到。确认序号表示已经成功接收到的数据的下一个期望序号,这意味着确认序号之前的所有数据都已经被正确接收;
- 允许部分确认应答丢失。部分确认丢失是可能的,但是对于 TCP 来说,如果确认丢失可能会触发超时重传机制,发送方会重新发送未确认的数据,以确保数据的可靠传输。
- 当收到的报文是乱序的,但是报文中会携带序号,接收端可以根据序号进行排序,以保证按序到达。TCP 协议会对收到的乱序数据段进行排序和重组,确保数据按序到达应用层。这样即使数据段在传输过程中发生了乱序,接收方也能够正确恢复原始数据的顺序,确保数据的完整性和正确性。
为什么要有序号和确认序号呢,只用一个可以吗?
TCP是全双工的, 双方在任意时刻都有可能进行收发数据。因此,为了实现可靠的数据传输和通信,序号和确认序号必须同时存在,并且在通信过程中不断更新。
举例来说,如果服务端既要给客户端发送数据 (需要有序号),又要对客户端发送的数据作出应答 (需要有确认序号) ,即往往向对方发送消息,本身就是一个应答,就需要使用序号和确认序号。发送方在发送数据时会标记一个序号,接收方收到数据后会发送确认应答,设置相应的确认序号, 指明发送方下次从哪里开始发送数据。这样,通信双方就能够有效地进行数据传输和确认。
因此,序号和确认序号必须同时存在,因为,通信的任一方,工作方式都是全双工的, 再发送确认的时候,也可能携带新的数据。
序号和确认序号是为了支持确认应答和按序到达以及去重,这就是序号和确认序号的作用。
2.1.2. 如何正确理解序号和确认序号
首先,TCP 协议是有自己的发送缓冲区的,应用层将数据 send,实质上,只是将数据拷贝到了 TCP 协议的发送缓冲区内,此时应用层就完事了 (对于发送方来说)。
TCP将每个数据都进行了编号,这个编号就是序列号。
如何理解呢?
我们知道,TCP 协议是面向字节流的, 因此我们可以将 TCP 协议的发送缓冲区看成一个 char buffer[NUM] 的字符数组,而上层将数据拷贝到发送缓冲区中,实质上就是拷贝到这个字符数组中,而我们知道,数组有一个天然的属性,就是数组下标,这里的数组下标就可以作为每一个数据的编号,即序列号。 如下图所示:
- 当TCP要将发送缓冲区的一段数据向下交付时 (封装,添加TCP报头),即将这段要发的数据作为 TCP 数据段的有效载荷,并且将这段数据中的最后一个字节数据的序列号 (数组下标) 作为序号,填充报头,形成TCP数据段;
- 接收方得到数据后,会根据数据段中的序号设置确认应答的数据段中的确认序号,怎么设置呢? 序号 + 1, 这里的确认序号之所以是序号 + 1,就是想告诉发送方,确认序号之前的数据我都收到了,下一次,你应该从确认序号开始发送数据;
我们用实例举例,假设客户端和服务端用TCP进行网络通信,过程如下:
客户端向服务端发送 1 ~ 1000 的数据 (这里的数字是序列号),并以最后一个字节数据的序列号作为序号,在这里就是1000;
服务端收到之后, 根据客户端的数据段中的序号设置自己的确认序号,并向客户端应答;
接下来,客户端在发送 1001 ~ 2000的数据,过程类似, 如图所示:
因此,我们知道:
- 首先,TCP是面向字节流的,每个数据都是一个字节, 因此它可以对每个数据进行编号,这个编号我们可以理解为一个数组下标,称之为序列号。
- 发送方在发送数据段时,这段字节流数据的最后一个数据的序列号就作为TCP数据段的序号。
- 接收方在做确认应答时,根据发送方的序号设置自己的确认序号, 序号 + 1,代表着之前的数据我已经收到了,下次你应该从确认序号这个位置发送数据,确认序号之前的数据可以从发送缓冲区中移除或者覆盖。
- 同时,由于序号的存在,接收方收到数据后,在向上交付时,可以根据序号来进行排序,让上层得到的数据是有序的, 这种机制我们称之为按序到达。TCP确保了上层应用接收到的数据是按照发送方发送的顺序进行组装和交付的,从而保证了数据的正确性和完整性。
2.2. TCP是如何做到全双工的
TCP 协议是具有接收缓冲区和发送缓冲区的。
我们要记住,例如send、recv、write、read 这些函数并不是直接去网络进行读写数据的。
例如 send,并不是将数据直接 send 到了网络中, 而是将数据拷贝到了 TCP 协议中的发送缓冲区中,然后由TCP来决定怎么发、发多少、发错了怎么办。
即TCP解决的是如何发送的问题, 因此 TCP 被称之为传输控制协议。
- 发送的本质:send 将数据拷贝给了 TCP 协议中的发送缓冲区,再通过网络发送给对端的接收缓冲区中;
- 接收的本质:接收方的接受缓冲区得到发送方的发送缓冲区的数据,上层在调用 recv 将数据拷贝到上层自己定义的缓冲区内。
因为TCP有发送缓冲区和接收缓冲区,所以,对于客户端和服务端而言,我们就要有两对接受和发送缓冲区,彼此之间互不干扰,这使得客户端和服务端可以同时进行数据的发送和接收,体现了TCP的全双工通信特性。
2.3. 16位窗口大小
- 如果客户端发送数据太快,服务端的接收缓冲区已经满了或者严重不足,此时如果继续来了客户端的数据段,服务端如果把客户端数据段给丢弃,虽然这种方案技术上是可行的,因为客户端没收到应答,会进行超时重传;
- 但是, 这个问题的原因是客户端造成的吗? 不是客户端造成的,但是却让客户端承担了这个后果,这显然不合理,并且可能导致不必要的数据重传和网络拥塞,增加了网络的负担;
- 合理情况应该是: 如果服务端的接收缓冲区已经满了,或者不足时, 服务端应该告诉客户端,告知其减缓发送速度。客户端在收到这样的信息后,会相应地调整发送速度,从而避免过多的数据拥塞在服务端的接收缓冲区中;
- 可是,当服务端告诉客户端,应该减缓发送速度时,客户端怎么知道,该减缓多少呢? 因为如果太慢了,那么也不合理,因为会影响传输数据的整体效率 ,即客户端发送数据不能太快,也不能太慢;
- 因此,服务端就需要向客户端同步自己的接受能力,可是这个接受能力由什么决定呢?
- 有人说:是接收缓冲区。不完全正确,假设接收缓冲区有64KB,但是剩余空间只有10个比特位了,那么此时的接受能力还是64KB吗?很明显,不是;
- 因此,这个接受能力,准确来说,是接收缓冲区的剩余空间的大小;
- 而根据对方的接受能力决定发送数据的速度, 这种策略称之为流量控制;
- 16位窗口大小,也就是接收缓冲区中剩余的大小,它就代表着接收方的接受能力;
- 接收方通过16位窗口大小 (自己的接收缓冲区的剩余大小) 告诉发送方,自己的接受能力是怎样的,发送方应该根据接收方发送的数据段中的窗口大小,动态调整发送数据的速率;
- 因为TCP是全双工的, 因此这里是有两个方向的流量控制, 作为一方,有可能在发数据,也有可能在收数据。站在发送数据的角度,就需要根据对方 (接受方的窗口大小) 决定我的发送速率;站在接收数据的角度,对方 (发送方) 也需要根据我的窗口大小决定他发送数据的速率。
2.4. 六个标记位
在TCP报头中有六个标志位,每个标志位用一个比特位表示,它们分别是:SYN、FIN、ACK、RST、PSH、URG, 它们都是一个个宏。
客户端和服务端在进行传输报文时, 传输的报文类型可能是不同的,例如:常规数据段、 建立连接的数据段 (同步数据段)、 断开连接的数据段、确认应答数据段、其他类型的数据段。
换言之,接收方会收到不同类型的数据段,因此接收方需要判断不同类型的数据段,故这里的六个标记位的本质:用来标记数据段类型的。
2.4.1. 标记位含义分别是什么
- SYN (Synchronous):同步标志位。表示该数据段是一个请求建立连接的数据段;
- FIN (Finish):结束连接标志位。代表是一个断开连接请求的数据段。如果有该标记,就代表着它要断开连接, 因此,建立连接的时候, 这个标记位一定是被置0的。
- ACK (Acknowledgment):确认应答标志位, 凡是该数据段具有应答特征或者具有应答属性 (应答的时候,也可以发送数据),该标志位都会被设置为1。 大部分数据段ACK都是被设置为1的。 特殊情况:第一个连接请求数据段, 不具备ACK属性,因此ACK被设置为0;
- RST (Reset) :复位标志位,用来关闭异常的连接,让对方重新连接;
- PSH (Push):督促接收方尽快将接收缓冲区的数据向上 (应用层) 交付;
- URG (Urgent):紧急标志位,代表着16位紧急指针是否有效。
接下来解释一下RST、PSH、URG这三个标志位。
RST:复位标志,通常用于关闭异常的连接,告知对方立即关闭连接,重新进行连接。
- 我们知道,在建立连接过程中 (三次握手),客户端收到了 ACK + SYN 数据段后,客户端就进入了连接状态,即ESTABLISHED状态;
- 接下来,客户端向服务端发ACK应答;
- 假设,服务端没有收到这个包含ACK的应答;
- 由于客户端认为连接建立成功,因此,接下来就向服务端发送数据段 (包含数据);
- 服务端收到之后,就感觉很奇怪,因为,服务端自身还没有完成连接,但是对方却向我发送了数据;
- 此时服务端就意识到,这个客户端连接异常了;
- 因此,服务端构建包含RST的报文,关闭掉客户端的连接,让客户端重新连接;
- 但是,这样的情景,一般很少存在,因为我们说过,大多数数据段都是有ACK标志位的,因此,在客户端向服务端发送数据时,这个数据段中也有可能存在ACK标志,因此,如果有ACK标志位,那么服务端也会进入连接状态。
PSH :接收方应该尽快将数据交给应用层。
- 当 PSH 标志位被设置时,表明发送方希望接收方将接收缓冲区中的数据尽快交给上层应用,而不需要等待接收缓冲区填满或者等待更多数据到达;
- 简而言之,这里的PSH标志位就是督促另一方尽快处理数据;
- 从这里我们也可以体会出,如果传输层数据已经就绪,那么应用层就应该尽快的将数据拷贝到应用层,避免数据在传输层等待时间过长导传输效率下降。
URG:紧急标记位,如果URG标记位置为1,那么代表着16位紧急指针有效。
因为TCP具有按序到达机制的 (该机制是用于保证可靠性的), 发送方发送的数据,被对方上层读取到,必须得有先后顺序, 但如果想让某些数据优先被读取呢?
因此,就需要URG这个标志位。
- 紧急指针是什么呢? 它代表的就是有效载荷中的偏移量,标识紧急数据的位置。如果有效载荷是100字节,紧急指针是50,那么它代表的是50字节这个位置;
- 那么访问多少个字节呢?即多少个字节的数据称之为紧急数据呢? 答案是:一个字节的数据,即从紧急指针锁定的特定位置的一个字节的数据,我们称之为紧急数据,该数据需要被高优先级处理;
因为这里的紧急数据只有一个字节,因此说它是一个数据,还不如说它是一种命令或者信号,因此,这里的紧急数据通常用来进行机器管理。
3. TCP建立连接 和 TCP断开连接
在TCP协议中,通信双方在正式通信之前需要建立连接,通信结束时需要断开连接。建立连接需要进行三次握手,而断开连接则需要进行四次挥手。
3.1. 如何理解连接
- 对于一个服务端而言,未来会存在大量的客户端向服务端发起连接请求,与服务端建立连接,换言之,服务端自身会维护大量的连接;
- 那么对于这么多的连接, 操作系统需不需要管理这些连接呢?
- 答案是: 当然要管理。 如何管理? 先描述在组织;
- 因此,站在内核的视角,所谓的连接,不过是一种数据结构类型罢了,建立连接成功的时候,就是在内存中创建一个对应的连接对象,并将多个连接对象以某种数据结构组织起来,对连接的管理工作转化为对特定数据结构的增删查改;
- 即连接本质上是一个对象,而对象是需要被创建、初始化、销毁等等管理工作,换言之,维护连接是需要成本的,体现在时间和空间上,连接的相关管理工作是需要CPU花费时间去执行的,连接的存储是需要消耗内存空间的。
总而言之, 我们需要记住一点: 连接本质是一种对象,其维护是需要成本的。
3.2. 三次握手
3.2.1. 三次握手过程
三次握手过程如下:
首先,客户端和服务端在建立连接的过程中, 难道传递的仅仅是这些标志位吗?
答案:不是,永远记住, 客户端和服务端再交互数据的时候,这个数据永远指的是完整报文 (数据段),即TCP报头和有效载荷 (有效载荷可能没有)。
为什么上面双方在交互数据段时,是斜着的呢?
很简单,当客户端发送包含SYN的数据段时,服务端收到的时候一定是滞后的,无论网络传输数据速率有多快,服务端是在客户端发送之后收到的数据段,因此,之所以斜着画,主要是为了更好地体现建立连接的过程需要经历一定的时间。
我们发现,客户端和服务端在建立连接的时候,是存在状态变化的。
客户端:
- 在未发送包含 SYN(请求连接)时,客户端处于 CLOSED 状态;
- 发送包含 SYN 后,客户端进入 SYN_SENT 状态;
- 当客户端收到服务端的确认应答后,即 SYN+ACK ,客户端进入 ESTABLISHED 状态。
服务端:
- 在未收到客户端发送的请求连接 (SYN),服务端处于 LISTEN 状态;
- 当收到客户端发送的请求连接 (SYN) 后,服务端进入 SYN_RECV 状态;
- 进入 SYN_RECV 状态后,服务端向客户端应答且包含连接请求 (ACK + SYN),
- 当服务端收到客户端的应答 (ACK) 后,服务端进入 ESTABLISHED 状态。
关于状态的一些解释:
CLOSED状态:表示 TCP 连接处于关闭状态,即没有建立连接或者已经关闭连接。在这个状态下,客户端或服务器都不在与对方通信;
SYN_SENT状态:表示客户端已经发送了一个 SYN(同步),请求建立连接,但还未收到服务端的确认;
SYN_RECV状态:表示服务端收到了客户端发送的 SYN,即建立连接请求,并在该状态下,向客户端发送 SYN+ACK,等待客户端的确认。
ESTABLISHED状态:表示一个TCP连接已经建立成功 (处于连接状态),如果通信双方都处于该状态,那么可以进行后续的数据通信了。
为什么叫三次握手呢?
首先,一般情况下,都是客户端向服务端主动发起连接请求。
我们发现:
- 对于客户端而言,客户端发送了两次TCP数据段,接收了一次TCP数据段;
- 对于服务端而言,服务端接收了两次TCP数据段,发送了一次TCP数据段。
即任何一方,都一共收发了三次,故我们称之为三次握手。
3.2.2. 三次握手一定会成功吗?
先说答案: 不一定,不能保证一定成功。
为什么?
我们观察到,客户端向服务端发送 ACK 后,客户端就已经进入了 ESTABLISHED 状态,代表客户端自身已经建立好连接, 但是由于客户端发送的这个 ACK 没有应答,因此,客户端并不确定对方 (服务端) 是否收到了这个ACK,因此, 如果这个 ACK 丢失了,即服务端没有收到这个ACK,服务端就不会进入 ESTABLISHED 状态,此时三次握手就是失败的。
总而言之,三次握手并不能保证一定成功,而是较大概率的成功。
3.2.3. 为什么要三次握手呢?
一次握手行不行?
假如一次握手双方就将连接建立好,是不可行的。
- 因为TCP是需要保证全双工的,即通信的一方既可以收数据,也可以发数据,而如果是一次握手,不能验证全双工,最多能验证接收方可以接受数据,发送方无法验证发送和接受,接受方也不能验证能否发数据,这是其一;
- 后续我们会知道,TCP为了保证传输可靠性,有流量控制机制,而流量控制需要在正式通信之前,也就是握手过程中,通信双方通过互传数据段,获得对方的接受能力,以确保后续正式通信过程中,该以什么速率发送数据,这是其二。
因此,一次握手不行。
两次握手行不行?
假如两次握手双方就将连接建立好,这种情况是存在隐患的。
- 因为是两次握手,客户端向服务端发起 SYN 连接请求,服务端收到之后,并向客户端发送 ACK + SYN ,发出后,服务端就认为自己连接建立完毕,处于 ESTABLISHED 状态;
- 那么此时问题就来了, 因为是两次握手,服务端发送 ACK + SYN后,没有收到客户端的应答,自身就已经建立好了连接,那如果,客户端将收到的数据段 (服务端发出的ACK + SYN) 给丢弃了呢?
- 那么如果,有恶意分子通过客户端向服务端发起大量的 SYN,并丢弃服务端发送过来的ACK+SYN,换言之,此时只有服务端自己在维护连接,客户端一个连接都不用维护,随着连接越来越多 (连接的维护是需要成本的),服务端的资源就会被消耗殆尽,最终导致服务器宕机;
- 这种恶意向服务器发送大量的SYN,我们称之为SYN洪水攻击。
因此, 两次握手也不可以。
三次握手为什么能行呢?
- 客户端向服务端发送包含SYN,即连接请求;
- 服务都到之后,进行确认应答 SYN + ACK;
- 客户端收到之后,自己就先进入ESTABLISHED状态了,即客户端已经完成连接了,而潜台词就是,客户端需要维护这个连接,而连接的维护是需要成本的;
- 随后,客户端在向服务端做出响应,向服务端发送 ACK;
- 服务端收到之后,才会进入 ESTABLISHED 状态,完成连接;
- 此时就达到了一种目的:服务端建立连接相对于客户端建立连接是滞后的, 即客户端完成连接后,服务端才会去完成连接。
如果此时恶意分子来了,通过客户端向服务端发起大量的包含SYN。
但是,此时就和上面不一样了,因为如果服务端完成了连接,那么客户端一定也完成了连接,即此时服务端和客户端时承担着相同的成本。
其次三次握手还可以验证全双工。
如果是三次握手,客户端和服务端既可以进行收数据,也可以进行发数据。因此可以验证全双工。
- 如果是一次握手, 那无法验证全双工, 因为是客户端发起请求,但没有应答,无法验证客户端能否发收据, 而服务端没有向客户端发任何数据, 因此也无法验证客户端能否收数据, 对于服务端而言,服务端最多能验证自己能收数据,但无法验证自己能否进行发数据。
- 如果是两次握手,也无法验证全双工。不能验证服务端能发数据,因为服务端没有收到发出数据的应答,因此,两次握手也无法验证全双工。
四次握手行不行?
如果是四次握手呢? 四次握手也不行。
因为,如果是四次握手,那么最后一次发出信息,一定是服务端给客户端发送信息,如果客户端将这个数据段丢弃了,那么此时连接成本就只有服务端一个人在承受,因此,四次握手也不行。
因此,我们发现,只要是奇数次的握手,连接成本是服务端和客户端都要承担的,且服务端具有滞后性;而偶数次的握手,客户端可以选择的将报文丢弃,导致连接只有服务端在维护。
而三次握手的目的:
- 服务端可以将同等的成本嫁接给客户端;
- 验证全双工。
因此,我们知道,连接建立首先需要奇数次握手,而为了保证服务端可以将同等的成本嫁接给客户端和验证全双工,我们选择了三次握手。
那么为什么不五次握手、七次握手等等呢?
- 首先,三次握手并不是为了保证握手一定成功的,而是为了将成本嫁接给客户端和验证全双工,只要能以最低成本做到这两点,就足够了,在进行更多次握手还有什么意义呢?只会带来不必要的成本,影响效率。
- 同时,三次握手的主要目的并不是要保证服务端安全的,由于三次握手的特性,只能防止一些人使用单主机采用SYN洪水进行攻击,那如果是多主机呢?
- 因此,三次握手并不是为了保证安全而设计的,安全应该是其他策略 (比如添加黑名单等等),三次握手主要是为了以最小成本做到将成本嫁接给客户端和验证全双工,并以较大概率完成通信双方的连接,就足够了。
3.3. 四次挥手
3.3.1. 四次挥手的过程分析
四次挥手的过程如下:
首先,建立连接,是必须一方主动发起连接请求 (一般都是客户端主动);
而对于断开连接而言,双方都需要要主动向对方发起断开连接 (客户端和服务端都有可能先向对方发起断开连接请求),体现在应用层的就是双方都需要 close,关闭相应的套接字。
我们以客户端主动向服务器发起断开连接为例:
第一次挥手:当上层调用 close 相关套接字,在内核层面,客户端会发送 FIN ,进入 FIN_WAIT_1 状态,服务端收到FIN后,并返回确认应答 (ACK),然后进入 CLOSE_WAIT 状态。此时客户端就不会再向服务端发送数据了,这里的数据指的是用户的数据,后续还会发送一些应答,操作系统完成;
第二次挥手:服务端发送 ACK 后,客户端收到后,进入 FIN_WAIT_2 状态,服务端会等待上层调用close,即关闭连接后才发送自己的 FIN ,体现在应用层就是,服务端 close 特定的套接字,才会发送自己的FIN ;
第三次挥手:服务端发送 FIN 后,进入 LAST_ACK 状态,等待客户端的确认。客户端收到 FIN 后,发送 ACK 确认,然后进入 TIME_WAIT 状态。
第四次挥手:客户端发送 ACK 确认后,进入 TIME_WAIT 状态,等待可能出现的延迟数据段,一般是等待2MSL,随后,进入 CLOSE 状态。服务端收到 ACK 后,进入 CLOSED 状态。
在 TIME_WAIT 状态,客户端等待 2MSL(Maximum Segment Lifetime)时间,以确保服务端接收到最后的 ACK,并且等待可能滞留的数据段在网络中被丢弃。完成等待后,客户端才会进入 CLOSED 状态。
3.3.2. 四次挥手的细节
我们知道,TCP是全双工的,因此,对于第二次挥手和第三次挥手,可能会合二为一,即服务端发送给客户端的这个报文中会包含 (ACK + FIN 这两个标志位),因此有些情况下,四次挥手会成为三次挥手。
这种合并的三次挥手过程在实际的TCP连接关闭中是常见的,特别是在服务端决定立即关闭连接时。这种合并可以减少挥手的次数,从而提高连接关闭的效率。
四次挥手一定能成功吗?
不一定,在四次挥手的最后一步,客户端发送最后一个 ACK 后进入 TIME_WAIT 状态,等待 2MSL 时间。在这段时间内,客户端无法确定对方是否收到了最后的 ACK 。
如果这个 ACK 丢包了,那么这次的四次挥手就失败了,需要服务端进行重传FIN,客户端收到后,会再次发送 ACK 进行确认。
3.3.3. CLOSE_WAIT 状态
同上,我们还是以客户端主动发起断开连接请求为例:
如果一个服务端,存在着大量的 CLOSE_WAIT 状态的连接,即这些连接没有从 CLOSE_WAIT->LAST_ACK,也就是服务端没有向客户端发送包含FIN,这是为什么呢?
- 要说明这个问题,我们首先要有一个清楚的认识:在内核层面,客户端向服务端发送FIN,体现在应用层上就是客户端调用了close ,关闭相关套接字,反之亦然。
- 那么如果服务端没有向客户端发送FIN,那么是因为服务端在应用层没有调用close,关闭相关套接字吗? 是的, 一般情况下,如果服务端存在着大量的CLOSE_WAIT状态的连接,是因为服务端在应用层没有调用 close。
证明如下:
sock.hpp 代码如下:
#ifndef __SOCK_HPP_
#define __SOCK_HPP_
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
namespace Xq
{
class Sock
{
public:
Sock() :_sock(-1) {}
// 创建套接字
void Socket(void)
{
_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_sock == -1)
{
exit(1);
}
}
// 将该套接字与传入的地址信息绑定到一起
void Bind(const std::string& ip, uint16_t port)
{
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(ip.c_str());
int ret = bind(_sock, reinterpret_cast<const struct sockaddr*>(&addr), sizeof(addr));
if(ret == -1)
{
exit(2);
}
}
// 封装listen 将该套接字设置为监听状态
void Listen(void)
{
int ret = listen(_sock, 10);
if(ret == -1)
{
exit(3);
}
}
//封装accept
// 如果想获得客户端地址信息
// 那么我们可以用输出型参数
// 同时我们需要将服务套接字返回给上层
int Accept(std::string& client_ip, uint16_t* port)
{
struct sockaddr_in client_addr;
socklen_t addrlen = sizeof client_addr;
bzero(&client_addr, addrlen);
int server_sock = accept(_sock, \
reinterpret_cast<struct sockaddr*>(&client_addr), &addrlen);
if(server_sock == -1)
{
return -1;
}
// 将网络字节序的整数转为主机序列的字符串
client_ip = inet_ntoa(client_addr.sin_addr);
// 网络字节序 -> 主机字节序
*port = ntohs(client_addr.sin_port);
// 返回服务套接字
return server_sock;
}
// 向特定服务端发起连接
void Connect(struct sockaddr_in* addr, const socklen_t* addrlen)
{
int ret = connect(_sock,\
reinterpret_cast<struct sockaddr*>(addr), \
*addrlen);
if(ret == -1)
{
perror("connect error");
}
}
// 服务端结束时, 释放监听套接字
~Sock(void)
{
if(_sock != -1)
{
close(_sock);
}
}
public:
int _sock; // 套接字
};
}
#endif
服务器代码如下:
#include <iostream>
#include "sock.hpp"
int main()
{
Xq::Sock sock;
sock.Socket();
std::string server_ip = "";
uint16_t server_port = 8080;
sock.Bind(server_ip, server_port);
sock.Listen();
while(true)
{
std::string client_ip;
uint16_t client_port;
int fd = sock.Accept(client_ip, &client_port);
printf("[%s:%d]: %d\n",client_ip.c_str(), client_port, fd);
// 注意,我没有关闭套接字
}
return 0;
}
我们可以通过 telnet 命令,连接我们的服务器,然后退出,我们就会发现,服务端就会存在大量的 CLOSE_WAIT 状态,其根本原因就是因为,服务端没有主动关闭套接字,即accept返回的套接字。
由于连接的维护是需要成本的,因此,如果服务端存在着大量的CLOSE_WAIT状态,会导致维护成本变高,服务器可用资源变少,长此以往导致服务器卡顿,甚至宕机。
3.3.4. TIME_WAIT 状态
主动发起断开连接的一方,当完成第三次挥手后,主动方需要维持一段时间的 TIME_WAIT 状态。在 TIME_WAIT 状态下,主动方会等待一段时间(通常是 2 倍的最长报文段寿命,也就是 2MSL - Maximum Segment Lifetime),确保网络中所有的数据包都已经消失,然后才会释放资源并关闭连接。
我们还是用上面的代码,服务端不关闭文件描述符,验证如下:
关闭服务端,我们观察现象:
我们发现,服务器此时就处于 TIME_WAIT 状态,为什么呢?
我们之前说过,进程一旦退出,会释放进程所创建的资源,包括文件描述符, 因此,这里服务器主动退出,就作为先主动断开连接的一方,因此,当完成第三次挥手后,服务端会自动进入TIME_WAIT状态。
理解了上面,然后我们再启动服务器,如下:
观察我们的 sock.hpp ,发现错误码为2是绑定的错误码,因此,这里就是因为绑定失败了,
绑定为什么失败呢?这是因为,上次的服务器依旧处于 TIME_WAIT 状态,地址信息IP、端口号依旧是被占用的,因此 bind 失败。
这种情况在有些场景下是非常不好的,例如,春节购买车票,如果售票的服务器挂了,并且由于服务器自身挂掉,即主动发起断开连接的一方,还需要维持一段时间 (TIME_WAIT),这段时间服务器无法重启,这就尴尬了。
因此,在有些场景下,我们需要让服务器具备一种能力,服务器如果挂掉了,需要立刻能够重启。
系统提供了一个系统调用接口,socksetopt 系统调用,具体如下:
int setsockopt(int sockfd, int level, int optname,
const void *optval, socklen_t optlen);
- sockfd:套接字描述符,即要设置选项的套接字。
- level:选项所属的协议层或者协议族,可以是 SOL_SOCKET 表示通用套接字选项,也可以是特定的协议层比如 IPPROTO_TCP 表示 TCP 协议选项。
- optname:要设置的选项名,具体选项名可以是通用的如 SO_REUSEADDR(允许地址重用),也可以是特定协议的选项名比如 TCP 中的 TCP_NODELAY(禁用 Nagle 算法)。
- optval:指向要设置选项值的指针,这个指针指向的数据类型和具体的选项名有关,通常是一个指向某个特定类型数据的指针,比如 int 类型的指针。
- optlen:指定 optval 指针指向的数据的长度,用于确保正确设置选项值的大小
如何使用,我们需要在创建套接字后,设置套接字属性,更改后的sock.hpp 如下:
namespace Xq
{
class Sock
{
public:
Sock() :_sock(-1) {}
// 创建套接字
void Socket(void)
{
_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_sock == -1)
{
exit(1);
}
// 设置套接字属性
int optval = 1;
setsockopt(_sock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &optval, sizeof(optval));
}
// 省略
public:
int _sock; // 套接字
};
}
#endif
此时,当使用 setsockopt 函数设置了 SO_REUSEADDR 或者 SO_REUSEPORT 选项后,即使服务器挂了并处于 TIME_WAIT 状态,操作系统也会允许绑定到相同的地址和端口上,从而可以尽快地重新启动服务器。
3.3.5. 为什么要有TIME_WAIT状态呢?
为什么主动发起断开连接的一方,在第三次挥手结束后, 会进入一个 TIME_WAIT 状态?
- 当通信双方在协商断开连接的时候, 可能在历史通信中会有数据段滞留在网络中, 还没有将数据传输给服务端或者客户端,维持一个TIME_WAIT状态, 这个TIME_WAIT时间至少是2MSL(Maximum Segment Lifetime --- 最大数据段生存时间),MSL定义了一个TCP报数据在网络中可以存在的最长时间,通常以秒为单位 , 因此之所以要维持一个 TIME_WAIT,是为了尽最大可能让历史通信中的数据从网络中消散。
上面这个理由可能不太好理解,接下里的这个理由可能就更好理解了:
- 其实我们观察四次挥手,我们就能看出,第四次挥手是没有应答的,换言之,主动发起连接的一方在进行最后一次挥手时,无法确定对方能否收到这个 ACK 应答, 如果这个 ACK 应答丢包了,此时这个 TIME_WAIT 就有必要存在了,因为如果没有这个 TIME_WAIT 状态,主动断开连接的一方,在第三次挥手结束后,就直接进入了CLOSED 状态,那么如果第四次挥手的 ACK 丢包了,接收方进行超时重传,向主动方重传 FIN 报文,但是此时主动方已经进入了 CLOSED 状态,这就很尴尬了,因此,就这个场景而言,TIME_WAIT 是很有必要存在的,即第四次挥手 ACK 如果丢包了, 另一方超时重传 FIN ,主动方仍旧能够再发起 ACK,以完成整个四次挥手过程,这样可以确保连接的可靠关闭,避免出现未正常断开连接的情况;
总而言之,TIME_WAIT状态的存在有两个主要目的:
尽可能让历史通信中的数据从网络中消散:在TIME_WAIT状态下,主动关闭连接的一方会等待一段时间(2MSL --- 2倍的最大报文段生存时间),之所以是2MSL,是为了确保在两个传输方向上的尚未被接收或者可能迟到的数据段已经消失,避免新的连接可能会收到来自上一个连接的迟到数据,因为这些数据可能是已经失效或者非法数据;
确保四次挥手的可靠完成:有了这个TIME_WAIT状态,可以以较大概率让第四次挥手的ACK被对方成功接受到。TIME_WAIT状态可以让主动关闭连接的一方在等待期间,继续接收可能的重传FIN,以较大可能让对方成功接收到最后一个ACK报文,从而完成整个四次挥手过程。这样可以确保连接的正常关闭,避免出现未正常断开连接的情况;