超全!!TCP网络编程常考知识汇总

前言:

本文章主要介绍TCP基础知识,以及做为程序员学习网络必考的八股,主要分为TCP三次握手过程以及常问考点、TCP的重传机制、TCP的滑动窗口、TCP的流量控制、TCP的拥塞控制、以及TCP的四次挥手及其相关考点。以及在Linux下的网络编程过程。本文章不深究内核的某个参数,不深究底层代码刨析,主要是讲述原理,以及提及部分字段和函数,能够知道是某个字段或者函数实现的某个功能就可以了,具体想继续刨析底层原理,可以网上搜索。本文章某些部分不会写的太细,主要让读者对TCP重点有个系统框架,当然重点部分还是会写详细滴!!!

本文章不适合毫无经验的小白,适合对网络有一定了解的读者。本文章是根据作者我自己的学习经历总结的,我主要是对TCP的主体部分进行逐层向深度去刨析,不是从广度研究,知识学习就像一颗大树,从树干可以延申到枝叶,而每一个知识点分支之间又有联系。如果你看过《数据组织分析与原理》类似的书籍,你就能理解知识组织对我们学习的重要性了。

TCP学习不能只是单单看某一个模块,可能某一个模块并不完美,但是它是多机制相互协调的,我们所学的基础可能和实际用的有所区别,那是因为不同版本都对TCP或多或少有些修改,所以我们只需要了解基本的原理和框架,至于版本更新内核参数等略有不同,无关紧要。本文章参考了网络上的博客以及参考RFC官方文档,以及《Linux高性能服务器编程》这本书。

目录

一:TCP基础认识

1、TCP报文格式

2、TCP的特点

3、TCP与UDP的区别

4、UDP包长度字段多余吗?

5、TCP和UDP引发的端口问题

             (1)TCP和UDP可以使用同一个端口吗?

              (2)多个TCP进程可以绑定同一端口吗?

              (3)客户端可以重复使用端口吗?

              (4)像这样的问题还有很多        

二:TCP的握手过程

1、TCP与UDP的socket编程过程

(1)listen函数中的backlog参数的意义

(2)accept函数与connect函数发生在握手的那个过程中

(3)没有accept能建立连接吗?

(4)没有listen函数能建立连接吗? 

(5)服务器端没有listen,客户端发起握手会发生什么? 

2、握手为什么是三次,不是两次,不是四次?

        (1)三次握手才能确定双方的收发能力

        (2)三次握手才能同步双方的初始序列号

        (3)三次握手才能阻止重复历史连接的初始化

3、为什么每一次连接初始序列号都要求不一样?

        (1)防止历史报文被下一个相同四元组连接接收

        (2)防止伪造TCP序列号报文被接受。

4、初始序列号ISN是如何产生的?

5、三次握手,分别丢失会发生什么?

      (1)第一次握手丢失会怎么样?

       (2)第二次握手丢了会怎么样? 

        (3)第三次握手丢了会怎么样?

6、SYN攻击与半连接队列

(1)什么是SYN攻击 

​编辑

(2)如何避免SYN攻击

         a.增大TCP的半连接队列

         b.增大netdev_max_backlog

         c.减少SYN+ACK的重传次数

         d.使用tcp_syncookies

(3)为什么不用 tcp_syncookies取代半连接队列

三、TCP的重传机制 

 1、超时重传

 2、快速重传

 3、SACK机制

4、D-SACK机制

四:TCP的滑动窗口

1、窗口的实现以及大小

2、窗口大小如何决定?以及双方的窗口

 (1)、发送方的滑动窗口

        这四个区域是如何表示的呢?

 (2)、接收方的滑动窗口

3、接收窗口和发送窗口大小是相等的吗?

五、流量控制

1、操作系统缓冲区与滑动窗口的关系

 2、窗口关闭(win = 0)时的风险

        如何避免窗口关闭的【死锁】风险?

3、糊涂窗口综合症(小窗口问题) 

         如何解决糊涂窗口综合症?

 六、TCP拥塞控制机制

1、如何判断网络拥塞

 2、慢启动算法

 3、拥塞避免算法

 4、拥塞发生算法

(1)超时重传时的拥塞发生算法

(2)快速重传的拥塞发生算法

 5、快速恢复算法

 七:TCP四次挥手

 1、挥手为什么是四次?

2、优雅关闭VS粗暴关闭

 (1)close粗暴关闭

 (2)shutdown优雅关闭

 3、什么情况下会出现三次挥手

什么是延迟确认机制?

 4、四次挥手分别丢失会发生什么? 

(1)第一次挥手丢失会发生什么?

(2)第二次挥手丢失会发生什么?

(3)第三次挥手丢失会发生什么?

(4)第四次挥手丢失会发生什么?

5、为什么要有TIME_WAIT状态?

(1)防止历史连接的数据被错误接收

(2)保证被动关闭方能正确关闭

6、为什么TIME_WAIT状态持续时间是2MSL?

(1)先了解什么是MSL?

 (2)等待2MSL是合理的

 7、TIME_WAIT状态一定好吗?

(1)客户端TIME_WAIT状态过多的危害

(2)服务器TIME_WAIT状态过多的危害

8、如何优化TIME_WAIT? 

 (1)打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项

 (2)net.ipv4.tcp_max_tw_buckets选项

 (3)程序中使用 SO_LINGER 强制使用 RST 关闭

9、服务器出现大量TIME_WAIT状态的原因是什么?

(1)HTTP没有使用长连接

(2)HTTP长连接超时

(3)HTTP的长连接请求数量达到上限

10、服务器出现大量的CLOSE_WAIT状态的原因是什么?

11、连接已经建立客户端/服务器分别出现故障 

(1)进程崩溃和宕机/断电的区别

(2)连接已经建立,客户端出现故障会发生什么?

(3)连接已建立,服务器崩溃会发生什么?

(4)突然拔掉网线会怎么样?

八:TCP相关的其他问题 

1、既然IP层会分片,为什么TCP还要设置MSS呢? 

2、什么情况下SYN会被丢弃 与 PAWS检测机制

 (1)tcp_tw_recycle 参数,在 NAT 环境下,造成 SYN 报文被丢弃

a.什么是PAWS机制?

b.什么是per-host的PAWS机制?

(2)全/半连接队列满了导致SYN丢包 

3、 TIME_WAIT下收到SYN如何处理?

(1)对于合法SYN的处理

(2)对于非法SYN的处理

4、使用TCP通信数据一定不会丢吗?

5、四次挥手中数据比FIN先到怎么办?

一:TCP基础认识

1、TCP报文格式

 序列号(seq):初始值随机,根据发送数据包的大小累计,Linux下一般是32位,满了就回绕从头开始。用来解决网络包乱序问题

确认应答号(ack):指的是下一次期望收到的数据包的序列号应该从哪里开始,用于确认数据包被成功接受,解决丢包问题

  • SYN控制位:为1时表示希望建立连接。
  • ACK控制位:为1时表示确认应答有效。
  • FIN控制位:为1时表示希望断开,没有数据再发送了。
  • RST控制位:为1时表示出现异常,必须强制断开。

 注意:有人可能在网上看到过,大写的ACK和小写的ack有区别。区别就是大写的表示TCP头部字段里面的标志位。小写的表示实际网络包中的ack编号,如ack = 1024 ,但是本文章在书写时不区分大小写,不用纠结了,知道他们的区别就行。

2、TCP的特点

面向连接一对一的(UDP可一对多)可靠的传输、基于字节流的(这里可能有疑问,网络包不是一个一个包的吗,没有说像流水一样传输啊,注意我们说是TCP自身是基于流实现的,但是到了其它层协议要对数据切割分片不关TCP的事,不如IP层对大于MTU的数据进行分片,那是IP的事情。)。

3、TCP与UDP的区别

先看UDP报文格式如下图:

 区别:

        (1).连接

                TCP必须建立可靠连接才可传输数据,确保数据无差错、有序、不重复、不丢失。

                UDP不需要建立连接即可传输数据,只管最大努力交付数据,不管可靠到达(思考:基于UDP实现的协议一定就是不可靠的吗?)。

        (2).头部字段

                TCP的头部较长,20字节的固定长度加上可选项,所以TCP头部不定长。

                UDP头部只有8字节固定长度。

        (3).网络传输方面

                TCP有流量控制、拥塞控制,所以网络情况不好时会影响到TCP的传输。TCP是流式传输(思考:流式传输所以有粘包问题,没有边界,但保证有序可靠。

                UDP没有流量控制、拥塞控制,所以网络情况不影响UDP的传输。UDP没有边界,一个包就是一个完整的消息,一个包一个包的发送。但可能发生乱序和丢包。

        (4).分片时机不同

                TCP:如果数据大于MSS,则在传输层(TCP层)进行分片,同时也在传输层组装,如果某一个分片丢了,只需要重传丢失分片。(思考:)

                UDP:数据大于MTU在IP层分片,IP层组装。

4、UDP包长度字段多余吗?

        先看一下TCP数据部分长度的计算公式:

        TCP数据部分长度 = IP报文总长度 - IP首部长度 - TCP首部长度。IP总长度和IP首部长度,在IP的首部格式里面有字段记录,是已知的。TCP首部长度从“首部长度”字段里可知。

UDP和TCP是同一层的,都是基于IP的,所以也可以用以上公式计算UDP数据部分长度,那UDP的 “包长度” 字段是不是多余了?

        解释:(1).可能是为了字节对齐,需要首部长度为4的整数倍,如果不设置 ”包长度“ 不是4的整数倍。所以是为了补全。

                   (2).现在UDP协议是依据IP的,更早时候可能不是,所以需要有 ”包长度“ 来指定数据部分大小。

5、TCP和UDP引发的端口问题

             (1)TCP和UDP可以使用同一个端口吗?

              答案:可以。传输层的端口号是用来区分同一主机的不同应用程序。UDP和TCP在内核中就是完全独立的两个应用模块,在IP包头的 ”协议号“ 中可以知道上层用的是TCP还是UDP,所以即使UDP应用程序和TCP应用程序绑定了同一个模块,IP也能通过准确送达指定模块,再由指定模块通过端口送给对应应用程序。TCP和UDP的端口是相互独立的

              (2)多个TCP进程可以绑定同一端口吗?

               答案:分情况。如果同一个IP下,比如196.168.24.2,这一个IP地址下的多个TCP进程都绑定了6666端口,则会报错。如果是不同的IP地址下,(一台主机可能不止一个IP地址),多个进程绑定同一个端口,可以。因为TCP确定一个连接是通过一个四元组确定的嘛。(四元组:源IP+源端口,目的IP+目的端口),任何一个变了都代表不同连接。注意:如果非要想同IP下绑定同端口,也是可以的,对socket设置SO_REUSEPORT属性(内核3.9版本提供的新特性。)。

              (3)客户端可以重复使用端口吗?

                答案:可以。前面说了TCP确定一个连接是通过四元组确定的,如果客户端使用6666端口连接服务器A,那么连接服务器B时依然是可以用6666端口。TCP在connect函数调用的时候发生三次握手,如果不执行bind绑定指定端口,connect会随机选一个可用的。bind意义不大,connect自己随机选比较好。

              (4)像这样的问题还有很多        

                比如,多个客户端进程可以绑定同一个端口吗? 客户端连接有太多TIME_WAIT状态,会导致端口资源用完而无法连接吗? 不管端口类的问题如何变幻方式问你,你只需要记住,TCP确定一个连接是靠四元组就可以了,你就能解决以上关于端口的所有问题。

                注意:这里还要说一个就是出现 “ Adress is  use” 的情况。这种情况可能是服务器连接后突然重启,这是因为服务器是主动断开方,主动断开方有TIME_WAIT状态,TIME_WAIT会持续一段时间,所以重启执行Bind函数时出现“ Adress is  use”地址被使用。不能快速重启。

               解决办法:在调用 bind 前,对 socket 设置 SO_REUSEADDR 属性。SO_REUSEADDR 作用是:如果当前启动进程绑定的 IP+PORT 与处于TIME_WAIT 状态的连接占用的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功。

二:TCP的握手过程

1、TCP与UDP的socket编程过程

这里有个长长的图,来展示从Linux的socket编程到三次握手时机,及其握手到挥手这一过程通信双方的状态变化。

客户端:socket创建通信套接字,返回一个文件描述符号fd。(可以调用bind指定端口,不指定则connect随机选取)再进行connect建立连接,connect调用的时候进行三次握手操作。之后调用wait和read函数进行读写操作,最后调用close函数发起四次挥手关闭连接。

服务器:socket创建通信套接字,返回一个文件描述符fd,调用bind函数绑定监听端口,调用listen函数监听端口,accept函数返回时三次握手建立成功,accept函数返回一个完整的连接,用文件描述符fd表示。注意这里的accept返回的fd与socket返回的fd不是同一个。

TCP三次握手过程:

  • 首先双方都处于关闭状态,这时服务器执行listen函数监听是否有连接,客户端调用connect发送SYN报文,第一次握手发送后客户端变为SYN_SEND状态。
  • 服务器接收第一次握手后,变为SYN_RECV状态。同时把该连接放入半连接队列里面(思考:半/全连接队列满了怎么办?SYN攻击是什么?以及半/全连接队列数据结构是什么?可以看这篇博客:SYN攻击/ACK攻击/半/全连接队列_小牛不爱吃糖的博客-CSDN博客),此时accept函数是阻塞,发送SYN+ACK,第二次握手发送完成。
  • 客户端接收到SYN+ACK报文(第二次握手)后由SYN_SEND状态变为ESTABLISTEN状态,此时connect函数返回(注意connect是在收到第二次握手后就返回),然后发送第三次握手的ACK报文。第三次握手是可以携带数据的。
  • 服务器收到客户端的ACK报文(第三次握手)后,将该连接从半连接队列里删除,放入全连接队列里,等待accept函数取出返回。之后服务器也进入ESTABLISTEN状态。

        之后双方就可以进行通信啦!!!四次挥手先看着图吧,别急后面细说。看了握手过程你有没有什么思考和疑问呢??要向深度思考哦!!!。这里补一个图:

(1)listen函数中的backlog参数的意义

Linux内核中会维护两个队列:

  • 半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
  • 全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;
int listen (int socketfd, int backlog)
  • 参数一 socketfd 为 socketfd 文件描述符
  • 参数二 backlog,这参数在历史版本有一定的变化。

早期backlog参数指的是半连接队列大小。Linux2.2之后指的是全连接队列大小。但是全连接队列的大小又受到内核参数 somaxconn 的大小限制。所以accept全连接队列大小 = min (backlog, somaxconn)。

(2)accept函数与connect函数发生在握手的那个过程中

 客户端调用connect函数发起连接,所以connect函数调用时三次握手发起,服务器接收到SYN时accept阻塞,当客户端收到服务器发送的SYN+ACK,后connect就返回了,之后进入ESTABLISHED状态。而服务器收到ACK时,accept函数就可以返回,从全连接队列里面取一个完整连接。 

  • connect:第二次握手成功后返回。
  • accept:三次握手成功后返回(它是从全连接队列里面取一个连接使用)

(3)没有accept能建立连接吗?

 可以,accept函数它本身并不参与TCP的握手连接过程,它是从全连接队列里面取一个连接使用,所以没有它TCP握手可以建立。

(4)没有listen函数能建立连接吗? 

可以。listen函数是被动连接方用于监听端口的。我们知道执行 listen 方法时,会创建半连接队列和全连接队列。三次握手的过程中会在这两个队列中暂存连接信息。所以形成连接,前提是你得有个地方存放着,方便握手的时候能根据 IP + 端口等信息找到对应的 socket。虽然因为客户端没有执行listen,(因为半连接队列和全连接队列都是在执行 listen 方法时,内核自动创建的。)所以没有半连接队列和全连接队列,内核还有个全局 hash 表,可以用于存放 sock 连接的信息。(这个全局 hash 表其实还细分为 ehash,bhash和listen_hash等,但因为过于细节不讨论)。

在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局 hash 表中,然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接。

客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen,就能 TCP 建立连接。 

(5)服务器端没有listen,客户端发起握手会发生什么? 

如果服务器端,只是执行了bind绑定了端口和IP,而没有进行listen监听操作,这时客户端发起连接会发生什么。

先说结论:服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。

分析: Linux 内核处理收到 TCP 报文的入口函数是 tcp_v4_rcv,在收到 TCP 报文后,会调用 __inet_lookup_skb 函数找到 TCP 报文所属 socket 。

int tcp_v4_rcv(struct sk_buff *skb) // 处理TCP报文的入口函数
{
 ...
  
 sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest); // 寻找TCP报文所属的socket
 if (!sk)  // 如果找不到socket就执行
  goto no_tcp_socket;
 ...
}

 __inet_lookup_skb 函数首先查找连接建立状态的socket(__inet_lookup_established),在没有命中的情况下,才会查找监听套接口(__inet_lookup_listener)。查找监听套接口(__inet_lookup_listener)这个函数的实现是,根据目的地址和目的端口算出一个哈希值,然后在哈希表找到对应监听该端口的 socket。本次的案例中,服务端是没有调用 listen 函数的,所以自然也是找不到监听该端口的 socket。所以 __inet_lookup_skb返回的socket不存在满足if条件。跳转到no_tcp_socket,在no_tcp_socket中,只要收到的报文(skb)的「校验和」没问题,内核就会调用 tcp_v4_send_reset 发送 RST 中止这个连接

2、握手为什么是三次,不是两次,不是四次?

        答案:不是四次以及更多次,因为,三次能做到为什么要浪费资源用更多次呢?这里主要考虑为什么不能比三次更少,不是两次。有三个原因:

  •  三次握手才能确定双方的收发能力。
  •  三次握手才能同步双方的初始序列号。
  •  三次握手才能阻止重复历史连接的初始化

        (1)三次握手才能确定双方的收发能力

         第一次握手,客户端发送SYN,服务器接收到,说明服务器知道客户端发送能力没问题。 接着服务器发送第二次握手,客户端收到,说明客户端知道服务器的发送能力和接收能力都没有问题。如果只有两次握手,没有第三次握手,服务器收不到ACK,服务器就不能确定客户端是不是收到了服务器发送的第二次握手报文,就不能知道客户端是不是接收能力没问题。

        (2)三次握手才能同步双方的初始序列号

         TCP通信双方都必须维护序列号,序列号是实现可靠通信的关键因素。

        序列号的作用:

  • 接收方可以根据序列号去除重复报文。
  • 接收方可以根据序列号进行按序接收,避免乱序。
  • 可以标识发送的报文哪些是以及被接收的。

        服务器接收到第一次握手,知道了客户端的初始序列号,但此时客户端不知道对方收到没有。客户端接收到第二次握手说明服务器已经收到自己的初始序列号啦,客户端也收到了服务器的初始序列号,但是服务器不知道客户端收到自己的没有。所以第三次握手的ACK报文被服务器接收,服务器才能确定自己的初始序列号被成功接收。

        (3)三次握手才能阻止重复历史连接的初始化

 上图是正常的阻止了历史连接的过程。如果是两次握手,服务器在收到旧SYN,即蓝色的SYN,就会进入ESTABLISTEN状态,进入了该状态说明服务器可以发送数据,但是客户端收到第二次握手的ACK后,肯定发现序列号不对是要RST终止的。因为服务器已经进入了ESTABLISTEN状态,在没有收到RST前发送了数据咋办??

        TIP:有人可能会问,在RST前新SYN到了服务器咋办?

        第一次收到旧SYN时服务器回复的ACK=91,在RST之前收到新的SYN,此时服务器回复的ACK还是=91,不是101。这个ACK被称为Challenge ACK。所以客户端收到ACK=91时会再次发送RST。

         TIP:有人问客户端接收到第二次握手后处于ESTABLISTEN状态,可以发送数据,如果此时客户端的数据和第三次握手ACK发出,但是ACK丢了,数据没有丢,那数据岂不是白白浪费?

        数据包里面是有ACK标识位的也有确认号,这个确认号是确认了第二次握手,服务器收到这个数据包也是可以建立连接的,也可以正常接收这个连接。

3、为什么每一次连接初始序列号都要求不一样?

        (1)防止历史报文被下一个相同四元组连接接收

         如果初始序列号固定,历史报文的序列号就很有可能在新的连接的接收窗口内,造成问题。比如初始序列号都从0开始,第一次连接发送了一个seq=32的报文,这个报文因为网络问题一直没到服务器。然后客户端断开又重新建立连接,或者说序列号回绕,新的连接服务器可接收的窗口为0-1000,那么旧报文的seq=32在0-1000内,就被接收了。所以初始序列号不同则历史报文有很大概率不在接收方的接收窗口内而被丢弃。注意是很大概率,还需要结合时间戳来控制(初始序列号如何产生下面会说)。

        (2)防止伪造TCP序列号报文被接受。

如果每次初始序列号都一样,那黑客完全可以在你连接的时候伪造报文。如果seq每次都不一样,那黑客很难发现规律,很难伪造。

4、初始序列号ISN是如何产生的?

 基于3中的很大概率还是有概率,需要结合时间戳。我们来说一下ISN是如何产生的。

ISN初始序列号是基于时钟的,每4微秒+1,转一圈要4.55小时。RFC793说的初始化序列号ISN随机生成算法:ISN=M+F(local host,local port,remote host,remote port)。

  • M:是一个计时器,每4微秒+1
  • F:是一个hash算法,根据源IP+端口,目的IP+端口生成的随机数。使用MD5是一个好的选择。

 随机数是随着时间递增的,所以基本上不可能产生相同的初始序列号。

5、三次握手,分别丢失会发生什么?

      (1)第一次握手丢失会怎么样?

         客户端发起第一次握手,发送SYN,然后进入SYN_SEND状态。若时间截至没有等到服务器回复的ACK说明丢包了,触发超时重传。如果重传的包再次丢了,则再次触发超时重传(思考:TCP的重传机制是如何实现的?)。一直达到系统定义的允许重传的最大次数后断开连接。

        Linux下客户端SYN报文最大重传次数由tcp_syn_retries内核参数控制,默认为5.可以自定义。每次重传的时间间隔是成2倍增长的,一般第一次为1s,则第二为2s,第三次为4s。五次重传大约需要63秒。(注意:客户端的SYN重传,和SYN_ACK重传,以及服务器的SYN重传,SYN_ACK重传的次数,由各自的操作系统内核参数决定。默认值可能不是一样的。ACK报文不会重传

       (2)第二次握手丢了会怎么样? 

         服务器收到第一次握手后进入SYN_RECV状态,同时发送SYN+ACK报文。因为第二次握手携带着对第一次握手的确认以及第二次握手的SYN。所以它丢包了,客户端收不到ACK会认为自己的第一次握手SYN丢了,触发超时重传。同时客户端也没办法回复第二次握手的ACK,服务器也会触发超时重传Linux下,SYN-ACK报文的最大重传次数由tcp_synack_retries内核参数决定,默认为5,可自定义。重传的序列号都是一样的,所以只要一个重传的包被接收都算连接成功。如果重传此时用完则不在重传,断开连接。

        (3)第三次握手丢了会怎么样?

        第三次握手丢了,服务器收不到来自客户端对第二次握手的确认信息,则会认为是自己丢包了,所以服务器重传第二次握手报文。注意:ACK报文不会重传。第三次握手丢失只有服务器触发超时重传第二握手报文。

6、SYN攻击与半连接队列

(1)什么是SYN攻击 

        当服务器收到SYN请求的时候,即第一次握手时,会把连接放入半连接队列里面,同时回复SYN_ACK确认报文,在收到第三次握手的ACK后,又会将连接从半连接队列里面取出,然后创建一个新的连接放入全连接队列,等待accept函数调用返回。TCP在建立连接时系统会维护半连接队列和全连接队列(思考:什么是半/全连接队列,他们的数据结构是什么?看这里:SYN攻击/ACK攻击/半/全连接队列_小牛不爱吃糖的博客-CSDN博客),这两个队列都是有大小的。SYN攻击就是将目标服务器的半连接队列打满,导致真正的用户连接无法接收而被丢弃。

(2)如何避免SYN攻击

        想要避免SYN攻击,不单单只是考虑半连接队列大小的情况,即使半连接队列足够,全连接队列太小也不行。Linux内核对于全连接队列的处理动作由内核参数tcp_abort_on_overflow控制。

 tcp_abort_on_overflow:

  • 设置为0:如果全连接队列满了,服务器丢弃客户端ACK,(客户端可以重传)
  • 设置为1:如果全连接队列满了,服务器发送RST包给客户端,表示废弃这个握手过程和这个连接。

避免SYN攻击有四种办法:

  •  增大TCP的半连接队列。
  •  增大netdev_max_backlog。
  •  减少SYN+ACK的重传次数。
  •  使用tcp_syncookies。
         a.增大TCP的半连接队列

        增大TCP的半连接队列需要同时增大内核的三个参数:只单单增大tcp_max_syn_backlog是无效的。

  • 增大net.ipv4.tcp_max_syn_backlog。
  • 增大listen()函数的backlog参数。(思考listen函数的backlog参数是干什么的?二.1.1中写了
  • 增大net.core.somaxconn。
         b.增大netdev_max_backlog

网卡接收数据包也会有一个队列先把数据缓存起来, 等待内核处理,我们可以调大这个队列,使得该队列能够多存一些SYN包,等待内核慢慢处理。这个队列由内核参数netdev_max_backlog控制,默认1000。

         c.减少SYN+ACK的重传次数

当服务器受到SYN攻击时,服务器有大量的半连接,服务器出现大量的SYN_RECV状态,因为SYN攻击不会给服务器回复第三次握手,所以处于SYN_RECV状态的服务器收不到ACK就会重传第二次握手即SYN+ACK包,如果我们把SYN+ACK包的最大重传次数改小一点,由内核参数tcp_synack_retries决定,默认为5。那么就能更快断开连接。 

         d.使用tcp_syncookies

        开启了cookies可以在不使用半连接队列的情况下建立连接,也就没有半连接队列满一说。

        net.ipv4.tcp_syncookies参数有三个值:

  • 0:表示关闭该功能。
  • 1:表示半连接队列满了,装不下时再启用它。
  • 2:无条件启用它。

         cookies=1时,半连接队列满了处理如下:

   具体过程:

  • 当SYN队列满了,服务器再收到SYN包,不会丢弃。根据哈希算法计算出一个cookie值,将这个cookie值放入第二次握手的报文的序列号里,然后发送第二次报文给客户端。
  • 然后第三次握手客户端的ACK带着这个cookie值一起发送,服务器接收到了会验证cookie的合法性,如果合法就把该连接放入全连接队列,等待accept函数取走。

(3)为什么不用 tcp_syncookies取代半连接队列

 前面说使用cookies就不怕SYN攻击了而且还能节约空间,不怕半连接队列满的情况,那为什么还要有半连接队列呢?直接用cookies模式不就好了。

虽然使用cookies模式可以节省一个半连接队列的空间,还可以防止SYN攻击,但是cookies的生成和验证是需要算法支持的,为了确保cookies的唯一性,不可被轻易防止,所以使用的算法都比较复杂。频繁的进行cookies验证和生成,计算量很大非常耗费CPU,而且服务器在没有解析cookie之前根本不知道该cookie是不是合法的,所以可以编造很多带上不合法cookies值的第三次握手ACK让CPU解析,如果解析完发现不合法,等于做了很多无用功消耗掉CPU资源,这就是ACK攻击。

三、TCP的重传机制 

 TCP的重传机制主要是依靠序列号和确认应答号来实现。重传机制主要是防止丢包现象,重传机制是保障TCP可靠传输的重要机制之一。常见的TCP重传机制有四个:

  • 超时重传
  • 快速重传
  • SACK
  • D-SACK

 1、超时重传

 超时重传机制会在发送数据的时候设置一个定时器,当超过指定的时间,没有收到ACK确认应答报文,则触发超时重传机制,重传数据包。触发超时重传有两种情况:数据包丢失和ACK丢失。这两种情况都会造成定时器超时。

超时时长(RTO)如何设置?

  • RTT(往返时延):指的是一个数据包从发送到收到确认应答报文时刻的差值。
  • RTO(超时重传时间):值超时重传的超时时长。RTO应该略比RTT大。过大过小都不好。

RTT和RTO都不是一个固定值,网络情况随时都在变化,RTT也要动态计算。至于Linux是如何去计算RTT和RTO的值感兴趣自己查RFC6289的公式吧。

缺点:如果超时重传重发的数据,再次超时时,又需要重发数据,对于超时重传的时间间隔,TCP采用是逐一加倍的方式,即后一次是前一次的两倍。这会有一个问题就是超时周期太长了,等的时间太长影响效率。如何解决?——快速重传。

读到这里有人会联想到,数据包在网络中的最大生存时间MSL。注意:数据包往返时延RTT不一定等于MSL,MSL还会根据IP的TTL等情况去计算,所以发生了超时重传时,该数据包可能还在网络中活着,只是它在网络中太久了,后面还可能会到达,当然出现重复的包,TCP也会有机制删除重复包。(发明TCP的人真的是个天才,把什么情况都考虑到了,每一TCP机制都很繁杂,学起来都可能让人不那么容易理解,TCP是多个模块相互运作相互协调,共同实现可靠传输。)

 2、快速重传

前面说超时重传会有重传周期过长的问题,快速重传主要就是解决这一问题。快速重传不以时间为驱动,而是以数据为驱动。

 当收到三个相同的ACK时会触发快速重传,在定时器过期之前重传丢失报文。(为什么是在定时器过期之前,TCP实际使用过程中不能使用快速重传就不使用超时重传了,况且我们说快速重传是解决超时重传周期长的问题。快速重传肯定要在定时器过期前重传,要不然定时器过期了直接超时重传就行了,再快速重传没有意义。)

缺点:快速重传只是解决了超时重传周期长的时间问题,但是没有解决重传一个还是多个的问题。如果只重传一个,即上图中丢失的seq 2 ,那么如果seq 3 也丢失,那收到三个冗余的ack 2时,快速重传seq 2 ,然后紧接着就会收到三个冗余的seq 3 又得重传seq 3。效率太低了。如果重传多个,即把seq 2 之后的3,4,5都重传了,又会出现相同的包传两次,浪费资源。想要解决这个问题可以使用SACK方法。

 3、SACK机制

 SACK:叫选择性确认。开启它之后,它可以将已经收到的数据包信息发送给“发送方”,这样发送方就知道了哪些数据被接收了,哪些数据没有被接收。就可以只重传丢失的数据。

SACK需要再TCP的头部选项字段里面添加一个SACK的东西。如果使用SACK必须通信双方都支持。Linux下通过net.ipv4.tcp_sack参数打开(Linux2.4后默认开启)。

4、D-SACK机制

 D-SACK全程:Duplicate SACK。前面说的都是数据包丢失的情况,D-SACK主要是应对ACK丢失和网络延迟的情况。这种情况下,接收方收到了数据,但是发送方没有再规定时间内收到ACK,就会触发重传,D-SACK就是通过SACK来告诉发送方自己收到了哪些重复的数据。

D-SACK的好处:

  • 可以让发送方知道是发出去的包丢了还是接收方的ACK丢了
  • 可以知道是不是发送方的数据包被网络延迟了
  • 可以知道网络中是不是把发送方的数据包给复制了

 在Linux下可以通过net.ipv4.tcp_dsack参数开启/关闭该功能(Linux2.4后默认打开)。

四:TCP的滑动窗口

 TCP通信是一个消息发出,收到一个回复再发下一个消息,这种模式当数据包在网络中的往返时间越长,效率越低。而有了滑动窗口就能很好的解决这个问题。(是不是感觉还有一个什么窗口? 对,拥塞窗口后面会说,实际发送数据的窗口大小 = min(滑动窗口,拥塞窗口)

1、窗口的实现以及大小

窗口的实现:在内存中开辟的一块缓冲区(思考:缓冲区的大小固定吗?五.1会说),在发送方收到确认报文之前发送出的数据需要暂存在这个区域,收到确认报文后再从窗口中移除。

窗口的大小:指的是无需等待确认应答即可发送数据的最大值。

图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答

2、窗口大小如何决定?以及双方的窗口

 TCP报文的头部有window字段,接收方通过window字段告诉发送方当前可接收的数据长度是多大。发送方根据接收方的window大小来发送数据。所以窗口的大小由接收方决定(win大小由ACK报文一起携带)

 (1)、发送方的滑动窗口

 下图就是发送方的窗口缓冲区,其中红色框为发送窗口,蓝色框为可用窗口。

  • 1、代表已发送且收到接收方ACK确认的数据。
  • 2、代表已发送暂时还没有收到ACK确认的数据。
  • 3、代表总大小在接收方可接收范围内,目前可以发送但还没有发送的数据。
  • 4、代表没有发送且总大小超过接收方可接收范围,目前不能发送的数据。

当我们发送6个字节时,这时可用窗口消耗殆尽,可用窗口等于0,在没有收到ACK确认前,不能再发数据。

 如果之后收到了5个ACK确认应答则【发送窗口】向后移动5位,此时处于3区域的5个数据变为可用窗口里的数据,等待发送。

        这四个区域是如何表示的呢?

         TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。

  • SND.WND表示发送窗口的大小(大小是由接收方指定的);
  • SND.UNASend Unacknoleged):是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 【2】 的第一个字节。
  • SND.NXT也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 【3】 的第一个字节。
  • 指向 【4 】的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 【4 】的第一个字节了。

        那么可用窗口大小的计算就可以是:

        可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)

 (2)、接收方的滑动窗口

接收方的窗口主要分为三个部分同样采用指针来表示这三个区域。一个绝对指针,一个相对指针。

 这三个部分可用用两个指针和一个变量表示:

  • RCV.WND表示接收窗口的大小,它会通告给发送方。
  • RCV.NXT是一个绝对指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 【2】的第一个字节。
  • 指向 【3】的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 【3】 的第一个字节了。

注意:不要被误导,TCP通信是双方都可以进行数据的收发,所以客户端服务器方都有接收窗口和发送窗口。看了上面的两个窗口,千万不要认为发送窗口就是客户端的窗口,接收窗口就是服务器的窗口。他们都有。 

3、接收窗口和发送窗口大小是相等的吗?

发送窗口不完全等于接收窗口(反正发送窗口是不可能大于接收窗口的),很可能某一段时间,接收方的处理速度比较快,接收窗口一下子就空出来了,而新的窗口大小需要接收方win字段通告给发送方,这个过程是需要时间的。  

五、流量控制

 流量控制离不开滑动窗口,建议把这两者结合起来,流量控制的就是依靠滑动窗口来实现的。而流量控制主要是防止发送方无脑发送数据,导致接收方处理不过来而丢包。(下面的拥塞控制是考虑到网络这个客观条件)。

为了解决发送方的无脑发送数据,TCP让发送方根据接收方的接受能力来控制数据发送的速度,这就是流量控制机制。

1、操作系统缓冲区与滑动窗口的关系

 场景:接收方会把接受到的数据暂存于缓存,缓存的大小由操作系统指定,且缓存是动态调整的,缓存变小会影响窗口的大小。正常情况下,TCP控制窗口大小的机制程序,当窗口发生变化时是可以通告给发送方的。假如接收方刚告诉发送方自己的接收窗口win = 10,马上就受到外部因素比如操作系统强制减小了缓冲区,而导致接收窗口变小 win  = 1,这种情况,TCP控制窗口大小的机制程序来不及把接收窗口变小这件事告诉发送方,发送方还是以为接收方win=10,导致发送方发了大量数据产生丢包。

对于以上这种因为缓存减小而导致窗口减小出现的情况,TCP解决办法是,规定不允许减小缓存的同时缩小窗口,应该先减小窗口,后减小缓存。

 2、窗口关闭(win = 0)时的风险

当接收方的接收窗口win = 0时,发送方就不能发送数据,等待接收方的窗口重新打开。这是接收方处理了数据,接收窗口win>0了,然后接收方通过回复数据的ACK报文携带着win>0的信息发送给发送方,但是此ACK包丢了(ACK包是没有重传的),如果不采取措施,那双方就只有死等下去,接收方等着发送方发数据,发送方等着接收方告诉它接收窗口大于0。

        如何避免窗口关闭的【死锁】风险?

         TCP设置了持续计时器,只要其中一方收到了win = 0 ,的消息,计时器就启动,超过时间就发送窗口探测报文,探测对方win是不是有变化,而对方收到这个探测报文时,就会给出自己的接收窗口大小(通信双方都可以向对方发送数据,双方都有接收窗口)。如果窗口依然为0,收到win依然为0的一方重新启动计时器。如果窗口不为0,则死锁局面打破。

        窗口探测的次数一般为3次,每次大约30-60秒(可能有所不同)。如果三次之后win还是等于0,TCP就会发送RST报文来中断连接。

3、糊涂窗口综合症(小窗口问题) 

上面2是解决了win=0时的死等问题,那就没有其他问题了吗?咱们先看看这个场景:刚开始双方的窗口都是1500,发送方的发送没有任何问题,但是接收方处理有点慢啊,每次只能处理发送方发来数据的1/10,即发送方发10个数据来,只马上处理了一个,其他就暂存了。这样总有某个时刻接收窗口会变为0。如果这时接收方处理了一个字节,就告诉发送方我有1个字节的空位可以接收,你发一个字节来吧,于是发送方就发一个字节。你想想TCP+IP的头就有40字节,你让这40字节的头陪着你1字节的数据,是不是效率太低了啊。接收方腾出几个字节的空间就通告给发送方现在有几字节的窗口,而发送方就义无反顾的发送几个字节,这就是糊涂窗口综合症。

         如何解决糊涂窗口综合症?

        (1)让接收方不通告小窗口

        接收方:当【窗口大小】小于min(MSS,缓存空间/2),也就是小于MSS和缓存空间一半的最小值时,就会直接向发送方通告接收窗口为0。也就阻止了发送方在发送数据。

        (2)让发送方不发送小窗口(Nagle算法)

         发送方:通常使用Nagle算法。该算法的思路是延迟处理。Nagle算法是默认开启的,对于一些小数据交互场景,比如SSH和telnet需要关闭Nagle算法。可以在socket设置TCP_NODELAY选项来关闭这个算法。

   Nagle算法:

  • 条件一:窗口大小>=MSS,并且数据大小>=MSS
  • 条件二:收到之前发送数据的ACK回包。

 只要满足上面的任意一个条件,就可以发送数据,两个条件都不满足时,发送方才会暂停发送,囤积数据。

注意:如果接收方不能满足「不通告小窗口给发送方」,那么即使开了 Nagle 算法,也无法避免糊涂窗口综合症,因为如果对端 ACK 回复很快的话(达到 Nagle 算法的条件二),Nagle 算法就不会拼接太多的数据包,这种情况下依然会有小数据包的传输,网络总体的利用率依然很低。所以接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症。

 六、TCP拥塞控制机制

 流量控制是避免发送方发送太多数据导致填满接收方的缓冲区,从而导致丢包。而拥塞控制主要是考虑到网络的情况,根据网络的好坏来控制发包的速度,尽可能的减小因为网络卡顿造成数据丢包。为了方便实现拥塞控制机制,引入了【拥塞窗口】(CWND)这一概念。

【拥塞窗口】:只要网络中没有出现拥塞就增大。出现拥塞就减小。

1、如何判断网络拥塞

TCP拥塞控制中判断网络是否拥塞的条件通常有两个:

  • 1. 拥塞窗口达到阈值(即慢启动门限值ssthresh):拥塞窗口是TCP在发送数据时允许的最大发送量。当拥塞窗口达到一个阈值时,表示网络中有足够的数据包在传输过程中未被确认,可能导致网络拥塞。
  • 2. 丢包事件:当TCP的数据包在传输过程中出现丢失时,可能表明网络已经发生了拥塞。丢包事件可以通过超时重传或接收到重复ACK(快速重传)来检测。

当网络出现上述条件之一时,TCP会判断网络为拥塞状态,并采取相应的拥塞控制算法,如减小拥塞窗口大小、进行拥塞恢复等。

 拥塞控制算法四个:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

 2、慢启动算法

 慢启动算法:当发送方收到一个ACK回复,则CWND的大小就加1。即发送方收到n个ACK,CWND+=n。慢启动算法发送数据包个数成指数增长,当慢启动算法使得CWND增长到慢启动门限值ssthresh时,这时认为网络可能会出现拥塞了,要避免指数增长了。就会启动拥塞避免算法。

 

 上图中【轮次1】的CWND=2,则发送方发送2个数据,也会收到2个ACK,所以下一次【轮次3】的CWND=2+2=4,则可以发送4个是数据包,同时也会收到4个ACK,所以【轮次4】的CWND=4+4=8。成指数增长。

        慢启动增长到多大呢?

         设置了一个慢启动门限ssthresh(slow start threshold)状态变量,一般来说ssthresh的大小是65535字节:

  • 当CWND < ssthresh时,使用慢启动算法。
  • 当CWND >= ssthresh时,使用拥塞避免算法。

 3、拥塞避免算法

 当CWND >= ssthresh时,启动拥塞避免算法。此时发送方收到n个ACK确认应答,则CWND+=n/CWND。即每一次增加 n/CWND 。这样就减缓了CWND的增长速度,使得它呈线性增长。

 一直增长后网络就会慢慢进入拥塞状态,就会发生丢包,一旦发生丢包现象,就会触发重传。之后就会进入【拥塞发生】阶段。

 4、拥塞发生算法

当发生了拥塞时, 就会启动拥塞发生算法,TCP对于由超时重传检测到的拥塞发生,和由快速重传检测到的拥塞发生处理动作不一样。

(1)超时重传时的拥塞发生算法

 对ssthresh和CWND做如下调整:

  • 先,ssthresh = 1/2*CWND
  • 后,CWND 重置为初始值

Linux 针对每一个 TCP 连接的 cwnd 初始化值是 10,也就是 10 个 MSS,我们可以用 ss -nli 命令查看每一个 TCP 连接的 cwnd 初始化值。

当ssthresh和CWND经过算法重新设定后,就重新开始慢启动,这种方式会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。这种方式太激进了,反应也很强烈,会造成网络卡顿。 

(2)快速重传的拥塞发生算法

  对ssthresh和CWND做如下调整:

  • 先,CWND = CWND/2
  • 后,ssthresh = CWND

对于这种方式检测到的拥塞发生,TCP会认为此时网络情况不严重,只是少部分数据丢了,绝大部分没丢。然后进入快速恢复算法。 

 5、快速恢复算法

 快速恢复算法一般和快速重传同步使用,即发生快速重传时,执行了快速重传的拥塞发生算法后进入到快速恢复算法。TCP也是认为这种情况网络情况不严重,所以能够快速恢复。

 【快速恢复】算法之前已经完成了【快速重传的拥塞发生算法】,因此:

  • 先,CWND = CWND/2
  • 后,ssthresh = CWND

 我们结合下图来详细说明这个过程:

第一步:图中【红色】折点,收到三个冗余ACK,触发【快速重传的拥塞发生算法】,调整ssthresh和CWND的值。此时CWND = 6(你看图中第一个绿色点是9),ssthresh = 6。

第二步:到了图中【第一个绿色】折点,这个点的CWND = 9,为什么是9? 因为收到三个冗余的ACK之后才会触发拥塞发生算法,所以9是CWND = ssthresh+ 收到的三个冗余ACK(收到几个ACK,CWND增加几)。

第三步:其实完成了拥塞发生算法到收到新的ACK这一段,CWND的增长也是和【慢启动算法】对CWND增长相似的。收到几个重复的ACK,CWND就增加几。所以才有上面CWND = ssthresh +3嘛,这个三就是因为收到三给冗余ACK所以才CWND+3的嘛。

第四步:收到新的ACK包,将CWND重新调回【拥塞发生】时的ssthresh值,之后进行【拥塞避免算】。原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;

 这种方式也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。

 七:TCP四次挥手

 当客户端没有数据要发送时,想要断开和服务器的连接,于是主动发起断开请求。注意:通信双方都可以主动发起断开。之后二者进行四次挥手完成断开连接。四次挥手的过程如下:

  • 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
  • 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSE_WAIT 状态。
  • 客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。
  • 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。
  • 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
  • 服务端收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
  • 客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。

 注意:只有主动关闭方才有 TIME_WAIT状态。

 1、挥手为什么是四次?

 (以客户端主动关闭为例)

客户端调用关闭函数,服务器的端口流程:

  • 首先,客户端发送FIN报文。
  • 服务器接收到FIN报文后,马上回复ACK报文,然后TCP协议栈会在该FIN末尾插入【EOF】结束符放到读缓冲区中(当然必须放在读缓冲区中队列的最后,让应用程序把数据处理完再处理该报文),等待应用程序读取。应用程序通过read函数读到【EOF】后就会发现是FIN报文。
  • 服务器应用程序read到【EOF】后,read返回0。若此时服务器应用程序还有数据要发送,则先发送数据,发完后应用程序再调用关闭函数,发送FIN报文。若没有数据发送直接调用关闭函数发送FIN报文。所以服务器发送FIN报文的控制权在应用程序手中,应用程序根据是否有数据发送来判断何时调用关闭函数

所以,通过四次挥手才能确保双方都能关闭连接,避免数据的丢失和重复发送,这也是在保障TCP的可靠性。 

 注意:不是说只有调用了关闭函数才会发FIN报文。如果进程退出了,不管是不是正常退出(如进程崩溃),内核都会发送FIN报文,与对方完成四次挥手。

 前面提到了【关闭函数】这里就先延申一下:

  • close函数:粗暴关闭
  • shutdown函数:优雅关闭

2、优雅关闭VS粗暴关闭

 (1)close粗暴关闭

概念:close 函数,同时 socket 关闭发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力。如果有多进程/多线程共享同一个 socket,如果有一个进程调用了 close 关闭只是让 socket 引用计数 -1并不会导致 socket 不可用,同时也不会发出 FIN 报文,其他进程还是可以正常读写该 socket,直到引用计数变为 0,才会发出 FIN 报文

如上图,客户端:是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的数据(思考一下,FIN_WAIT_2状态下如果服务器发的是FIN报文不是数据又该如何?),由于客户端已经不再具有发送和接收数据的能力,所以客户端的内核会回 RST 报文给服务端,然后内核会释放连接,这时就不会经历完整的 TCP 四次挥手,所以我们常说,调用 close 是粗暴的关闭。 

服务端:收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就能感知到连接已经被释放了:

  • 如果是读操作,则会返回 RST 的报错,也就是我们常见的【Connection reset by peer】。
  • 如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。

思考一下:客户端收到服务器的ACK后进入FIN_WAIT_2状态,如果该状态下收到服务器发来的数据,因为客户端不具有收数据的能力,则回复RST报文。如果服务器此时发的是FIN报文呢?

解答:对于采用close关闭连接的客户端,由于客户端不具备收数据的能力,所以FIN_WAIT_2状态不会持续太久,默认是60秒 ,由tcp_fin_timeout内核参数控制。如果60s内没有收到数据也没有收到FIN报文,则直接关闭连接进入close状态。跳过TIME_WAIT状态。如果收到了FIN报文则完成四次挥手。

 (2)shutdown优雅关闭

概念:shutdown 函数,可以指定 socket 只关闭发送方向而不关闭读取方向(也可以只关闭 读取方向 而不关闭 发送方向),也就是 socket 不再有发送数据的能力,但是还是具有接收数据的能力。如果有多进程/多线程共享同一个 socket,shutdown 则不管引用计数,直接使得该 socket 不可用,然后发出 FIN 报文,如果有别的进程企图使用该 socket,将会受到影响

 shutdown 函数可以指定只关闭 发送方向 而不关闭 读取方向 所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手(思考2,客户端只关闭发送方向如果服务器不发FIN会怎么样),所以我们常说,调用 shutdown 是优雅的关闭。

思考1:有人可能会问,你不是说shutdown可以指定关闭某一个方向吗,上图中指定了关闭了【发数据方向】代表着客户端没有发送数据的能力,如果服务器还有数据要发,那客户端会不会回ACK,回了那客户端回的ACK不算是在发数据???? 

解答:其实【收发能力】指的是应用程序还要不要发送数据和要不要读取数据,指的是应用程序的收发能力,ACK,和FIN都不是应用程序的行为,是TCP的行为,TCP头部的字段设置为1。

思考2:对于shutdown关闭的连接,客户端收到服务器的ACK报文就会处于FIN_WAIT_2状态,如果此时服务器就是不发FIN来,客户端会一直处于FIN_WAIT_2状态【死等】FIN的到来。这与close等待60s关闭不同。

 3、什么情况下会出现三次挥手

 想要三次挥手,那一定是第二次挥手和第三次挥手变为一次,即使服务器的ACK和FIN一起发送。所以想要TCP出现三次挥手,必须要求两点:

  • 服务器没有数据发送
  • 服务器开启了延迟确认机制(默认开启)

什么是延迟确认机制?

        当发送没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。 为了解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。 TCP 延迟确认的策略:

  • 我方现在就有数据发送时,让数据携带ACK一起发送过去。
  • 我方暂时没有数据要发送,回复的ACK就等一段时间看看我方有没有数据可以一起发,如果没有则等待时间结束,ACK发送。
  • 如果在ACK等待数据的这一段时间里,对方新的数据又发来了,即使等待时间没完,也不等了马上发出。

 如果要关闭 TCP 延迟确认机制,可以在 Socket 设置里启用 TCP_QUICKACK。

 4、四次挥手分别丢失会发生什么? 

(1)第一次挥手丢失会发生什么?

 客户端主动断开连接,调用关闭函数,发送FIN报文,如果该FIN报文发生丢包。则会触发超时重传,重传FIN报文。重传的次数由 tcp_orphan_retries 内核参数控制。当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么直接进入到 close 状态。

(2)第二次挥手丢失会发生什么?

 服务器收到客户端的FIN报文后进入CLOSE_WAIT状态,如何恢复ACK报文。如果ACK报文丢失了,因为ACK报文是不会重传的,所以客户端一直等待ACK结果超时了都没等到,客户端会以为是自己的FIN报文丢了,所以客户端会重新发送FIN报文。重传次数由 tcp_orphan_retries 内核参数控制。达到最大重传次数后还是没有收到ACK,则断开连接关闭。

(3)第三次挥手丢失会发生什么?

 客户端收到服务器的ACK后就会进入FIN_WAIT_2状态,该状态下,对于采用close关闭和shutdown关闭的连接,处理动作不一样(前面的粗暴关闭和优雅关闭思考部分有说明)。

        对于close关闭的连接:客户端会在FIN_WAIT_2状态下等待60s,(我们在考虑第三次挥手丢失的情况,所以这60s里服务器没有数据发送,因为有数据发送客户端会回RST,根本就等不到第三次挥手),这60s内服务器的应用程序调用关闭函数,发送FIN报文。(服务器是没有权力主动关闭连接的,必须由应用程序主动发起,异常情况除外)如果服务器在规定时间里没有收到ACK,则再次重发,直到达到重传最大次数,依然由tcp_orphan_retries 内核参数控制。重传次数用完没有收到ACK,服务器就会断开连接。当然客户端也只能等60s,60s过了没收到FIN报文也会断开来连接。

        对于shutdown关闭的连接:客户端会在FIN_WAIT_2状态下死等,而服务器在重传FIN报文达到上限时,断开连接。此时客户端还是在死等。

(4)第四次挥手丢失会发生什么?

 当服务器没有数据要发时,应用程序就会调用关闭函数发送FIN报文,服务器由CLOSE_WAIT状态变为LAST_ACK状态。客户端收到FIN报文后,客户端由FIN_WAIT_2 状态变为TIME_WAIT状态。如果ACK丢失,因为ACK没有重传,所以服务器一直收不到ACK包,就会触发超时重传,服务器重传第三次握手。达到重传最大次数后(还是由tcp_orphan_retries 内核参数控制)还没有收到ACK就断开连接关闭。

5、为什么要有TIME_WAIT状态?

当客户端主动关闭方,收到服务器发来的FIN报文后(我们一直以客户端主动发起关闭为例)就会从FIN_WAIT_2 状态变为TIME_WAIT状态。注意:主动关闭方才有TIME_WAIT状态。TCP设置 TIME_WAIT状态的原因有两个:

1、防止历史连接中的数据,被后面相同四元组的新连接接收。

2、保证【被动关闭连接】的一方,能被正确关闭。

(1)防止历史连接的数据被错误接收

 场景:如果客户端的一些数据因为网络延迟没能到达服务器,但是还存活着。这时客户端因为一些情况主动发起断开连接,然后经过三次挥手处于TIME_WAIT状态,假设没有TIME_WAIT状态,即使客户端收到服务器的FIN后直接进入CLOSE状态。然后客户端马上同服务器建立相同的连接,【四元组】相同,这时上一次因为网络延迟的旧数据包到达了服务器,且刚好在服务器的接收窗口里,那么这个历史数据就被错误接收了,就会对新的连接产生影响。

所以,TIME_WAIT状态会持续2MSL的时间,这个时间足以让两个方向上的数据包被丢弃思考:为什么是2MSL,是哪两个方向的数据包。下面会说),使得原来连接的数据包在网络中都消失,再出现的数据包一定都是新建立连接所产生的。

(思考内容解答请看下面的【6】为什么要持续2MSL) 

(2)保证被动关闭方能正确关闭

RFC793指出的TIME_WAIT的第二个作用,是等待足够的时间以确保最后的ACK能让被动关闭方接收,从而帮助其正常关闭。

如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。

假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSE 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。

6、为什么TIME_WAIT状态持续时间是2MSL?

为了防止上面【5】所说的两种情况发生,因此TIME_WAIT必须持续足够的时间,让历史报文在网络中消失,以及让服务器有足够的时间接收ACK。 

(1)先了解什么是MSL?

MSL (Maximum Segment Lifetime):报文最大的生存时间。指任何报文在网络中生存的最大时间,超过这个时间,报文将被丢弃。默认是30s.

TTL :IP层的TTL指的是可经过的最大路由数,默认是64。每经过一个路由器TTL减少1,直到TTL=0被丢弃,然后由ICMP报文告知源主机报文被丢弃。

 TCP是基于IP层的,TTL和MSL的最大区别就是,MSL是以时间为单位,单位为秒。而TTL指的是跳数。MSL应该要大于TTL消耗为0的时间。Linux将MSL设置为30s,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。

 (2)等待2MSL是合理的

 先说为什么不能等待更久,等待2MSL能完成的事情,为什么要等待更久,更久的占用着端口,造成资源的浪费。前面说等待2MSL是为了足以让两个方向的数据包丢弃。先来说说为什么是两个方向。

客户端接收到服务器的FIN后进入TIME_WAIT状态且回复ACK报文,如果该ACK报文丢失,服务器就会超时重传,会重发FIN报文。这样第一次ACK去,和第二次FIN报文重发过来,一去一来刚好是2MSL时间。所以两方向指的是第一次客户端回复服务器的ACK,以及第二次服务器超时重传发向客户端的FIN(不一定是FIN报文哈!也可能是其他历史数据哈!)。反正理解“一去一来”指的是发送方发送数据去,以及接收方回复ACK来。

 TIME_WAIT等待2MSL时间合理解释是:网络中可能存在来自发送方的数据包,当这些数据包(卡着时间比如MSL快截至时)到达接收方,接收方接收后又要回ACK(ACK也卡着时间到发送方),所以这样一来一回久需要等待2MSL时间。

 思考:有人会问,等待2MSL时间不是只够服务器重传1次FIN报文啊,不是说重传FIN报文的次数由tcp_orphan_retries 内核参数控制吗,那该参数要是不为1咋办(一般也不为1)。

 注意:客户端收到服务器重传的FIN报文后,会重置TIME_WAIT的持续时间为2MSL。

 7、TIME_WAIT状态一定好吗?

 少量的TIME_WAIT状态当然对连接来说是好的。但是如果TIME_WAIT状态过多就会产生危害!

过多的 TIME-WAIT 状态主要的危害有两种:

  • 第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
  • 第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过 net.ipv4.ip_local_port_range参数指定范围。

 客户端和服务器端TIME_WAIT状态过多的危害是不一样的。

(1)客户端TIME_WAIT状态过多的危害

如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的(前面说过关于端口的问题)。

不过,即使是在这种场景下,只要连接的是不同的服务端,端口是可以重复使用的,所以客户端还是可以向其他服务端发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。

(2)服务器TIME_WAIT状态过多的危害

如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是TIME_WAIT 状态的 TCP 连接过多,这些连接又没有数传输,又会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等,有点浪费资源。

尽管TIME_WAIT看上去不是那么完美,但是我们不应该避免他,而是应该合理利用它。 

8、如何优化TIME_WAIT? 

 既然TIME_WAIT不是什么情况下都有好处,那么如何优化TIME_WAIT状态呢?有以下三个办法(各自有利有弊):

  • 打开 net.ipv4.tcp_tw_reuse(默认关闭) 和 net.ipv4.tcp_timestamps(默认开启) 选项
  • net.ipv4.tcp_max_tw_buckets
  • 程序中使用 SO_LINGER强制使用 RST 关闭

 (1)打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项

 开启了Linux内核参数net.ipv4.tcp_tw_reuse(TW状态复用) 和 net.ipv4.tcp_timestamps(时间戳) 选项后,就可以复用处于 TIME_WAIT 的 socket 为新的连接所用。注意tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。tcp_tw_reuse开启必须开启tcp_timestamps时间戳选项。

tcp_timestamps时间戳字段是在 TCP 头部的「选项」里,它由一共 8 个字节表示时间戳,其中第一个 4 字节字段用来保存发送该数据包的时间,第二个 4 字节字段用来保存最近一次接收对方发送到达数据的时间。由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。

 (2)net.ipv4.tcp_max_tw_buckets选项

 net.ipv4.tcp_max_tw_buckets值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置。

 (3)程序中使用 SO_LINGER 强制使用 RST 关闭

 通过设置 socket 选项,来设置调用 close 关闭连接行为。Linux 的套接字选项SO_LINGER 用来改变socket 执行 close () 函数时的默认行为。 linger 的英文释义有逗留、徘徊、继续存留、缓慢消失的意思。 这个释义与这个参数真正的含义很接近。

SO_LINGER 参数是一个 linger 结构体,代码如下:

struct linger {
    int l_onoff;    /* linger active */
    int l_linger;   /* how many seconds to linger for */
};

第一个字段 l_onoff 用来表示是否启用 linger 特性,非 0 为启用,0 为禁用 ,linux 内核默认为禁用。这种情况下 close 函数立即返回,操作系统负责把缓冲队列中的数据全部发送至对端

第二个参数 l_linger 在 l_onoff 为非 0 (即启用特性)时才会生效。

  • 如果 l_linger 的值为 0,那么调用 close,close 函数会立即返回,同时丢弃缓冲区内所有数据并立即发送 RST 包重置连接
  • 如果 l_linger 的值为非 0,那么此时 close 函数在阻塞直到 l_linger 时间超时或者数据发送完毕,发送队列在超时时间段内继续尝试发送,如果发送完成则皆大欢喜,超时则直接丢弃缓冲区内容 并 RST 掉连接。

SO_LINGER 启用时,操作系统开启一个定时器,在定时器期间内发送数据,定时时间到直接发送 RST 。该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。虽然这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

9、服务器出现大量TIME_WAIT状态的原因是什么?

 因为TIME_WAIT状态只会出现主动关闭方,所以服务器出现大量TIME_WAIT状态一定是服务器发生了大量主动关闭连接事件。可能有以下情况:

场景一:HTTP没有使用长连接

场景二:HTTP长连接超时

场景三:HTTP的长连接请求数量达到上限

(1)HTTP没有使用长连接

 HTTP1.1之后都是默认开启长连接Keep-Alive(感兴趣可以去看我的HTTP的博客哦,超全!!!这里不在说HTTP的知识了。)现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了,在长连接有效期间里,客户端发送HTTP请求都不用经过TCP三次握手。如果关闭 HTTP 长连接机制后,每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,会很麻烦。

在 RFC 文档中,并没有明确由谁来关闭连接,请求和响应的双方都可以主动关闭 TCP 连接。不过,根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。

(2)HTTP长连接超时

 开启长连接后,为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。如果客户端在60s内都没有发送请求,则nginx会触发回调函数来使服务器关闭该长连接,此时服务器就会出现TIM_WAIT状态。如果客户端有大量这样的连接就会导致服务器就会出现大量的TIM_WAIT状态。

(3)HTTP的长连接请求数量达到上限

 Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。

比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,如果这样的连接过多,那么此时服务端上就会出现大量的 TIME_WAIT 状态的连接。

keepalive_requests 参数的默认值是 100 ,意味着每个 HTTP 长连接最多只能跑 100 次请求,这个参数往往被大多数人忽略,因为当 QPS (每秒请求数) 不是很高时,默认值 100 凑合够用。特色使用场景可以调大keepalive_requests参数的值。

10、服务器出现大量的CLOSE_WAIT状态的原因是什么?

 CLOSE_WAIT状态是【被动关闭方】才有的状态,而且如果服务器【被动关闭方】没有调用关闭函数关闭连接,无法发送FIN报文,从而导致服务器【被动关闭方】不能从CLOSE_WAIT状态变为LAST_ACK状态。因为服务器是没有权利替应用程序关闭连接的,所以服务器出现大量CLOSE_WAIT状态时说明服务器应用层出现问题,应用程序没有调用关闭函数。

TCP服务器端应用程序的流程:

  1. 创建服务端 socket,bind 绑定端口、listen 监听端口
  2. 将服务端 socket 注册到 epoll
  3. epoll_wait 等待连接到来,连接到来时,调用 accept 获取已连接的 socket
  4. 将已连接的 socket 注册到 epoll(因为accept返回的连接描述符fd,与socket创建的不是同一个)
  5. epoll_wait 等待事件发生
  6. 对方连接关闭时,我方调用 close

可能导致服务端没有调用 close 函数的原因,如下 :

  • 【2】没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。
  • 【3】没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。
  • 【4】没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了
  • 【6】没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。 

以上情况可能是写的代码不健壮,没写或者写错了,程序中断导致没执行到那一步。

11、连接已经建立客户端/服务器分别出现故障 

(1)进程崩溃和宕机/断电的区别

 宕机和断电指的是操作系统或者电脑硬件因为一些原因不能正常工作。这种情况下服务器如果不采取措施,比如发送探测报文,服务器是感受不到的。进程崩溃指的是进程因为一些原因,比如内存溢出,等导致进程不能正常工作。这种情况操作系统会帮忙回收进程,在回收进程时如果和对端建立了TCP连接。操作系统是会发出FIN报文完成四次挥手的。

区别:

  • 进程崩溃:操作系统没死,进程死了,操作系统帮忙处理后事。
  • 宕机/断电:操作系统也死了

(2)连接已经建立,客户端出现故障会发生什么?

 客户端出现宕机或者断电时,若服务器不给客户端发数据,则服务器是无法感知这个事件的,服务器会一直处于ESTABLISHED状态,占用系统资源。为了防止这种情况TCP实现了一个【保活机制】。 

保活机制:

定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。 

Linux下由以下三个内核参数控制(均为默认值):

net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75  
net.ipv4.tcp_keepalive_probes=9
  • tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
  • tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
  • tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

注意:应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。(通信双方都可以开启) 

 TCP开启保活机制时,客户端异常可能有以下情况:

  • 1、客户端正常,只是单纯的长时间没有发送数据,当客户端收到服务器【保活机制】的探测报文时,会响应该报文,TCP的保活时长被重置。
  • 2、客户端重启,如果客户端主机宕机并重启。客户端是能收到探测报文的,但是客户端由于没有有效的连接,于是会回复给对端RTS报文,服务器接收到RTS报文后就能感知,然后很快和客户端重新建立连接。
  • 3、客户端宕机不重启,或者因为某些原因使得报文不可达,这样服务器发送的探测报文石沉大海,没有响应。达到探测报文最大探测次数后就关闭连接。

 我们可以在应用程序中自己去实现类似于【保活机制】机制。如:心跳机制。

(3)连接已建立,服务器崩溃会发生什么?

如果服务器刚开始就宕机或断电,很明显连接都建立不了。如果是建立了连接服务器突然宕机或断电TCP会如何处理?很明显发送方的数据一直得不到ACK回应,发送方就会重发数据,达到最大次数就发送RST报文断开连接。

但是如果是服务器的进程崩溃会怎么样?TCP的连接是由内核维护的,当服务器崩溃的时候内核会回收进程资源,就会发送FIN报文,后续的挥手过程也是在内核完成的(如果无异常,服务器这边是应用程序调用关闭函数然后内核去完成,应用程序参与),应用程序根本不参与,所以也是能完成四次挥手的。

TIP:遇到宕机/断电/崩溃,这类问题只需要记住宕机/断电和崩溃的区别,以及开没开启【保活机制】就可以了,不管面试官怎么问都能灵活回答。

(4)突然拔掉网线会怎么样?

突然拔掉网线,这种情况下,服务器和客户端双方主机都是没有问题的。除了网口用不了,网络通信的其他层不会受影响,都能正常工作。

  • 如果拔掉网线,插回去:传输层不会受影响,若此时有数据发送,到了网口这里会丢包,于是TCP会超时重传,如果这时快速把网线插回去,数据依然可以送达,根没事发生一样
  • 如果拔掉网线,不插回去:如果没有开启保活机制Keep-Alive,又没有数据交互,是感知不到这个事件的,就会一直处于ESTABLISHED状态,没有数据交互但开启保活机制,达到探测报文上限就会断开。有数据传输达到重传最大次数,也会断开连接。

八:TCP相关的其他问题 

除了前面的基础部分,后面遇到的各种“奇葩”面试问题 都会加在这部分的后面。本博客不定时加新内容,如果有的话。

1、既然IP层会分片,为什么TCP还要设置MSS呢? 

 首先了解一下什么是MTU和MSS:

  • MTU一个IP网络包的最大长度,以太网中一般为 1500 字节;
  • MSS除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;

思考: 如果把 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?

 如果TCP数据大于MSS时,选择在IP层分片,那么接收方也会在IP层对数据组装,组装成一个完整的TCP后再交给上层TCP处理,TCP再给应用程序。如果某一个IP分片丢失了,因为IP没有重传机制,所以丢了的分片不能被重传,接收方由于缺少该分片,也不能完成IP层的组装,就不能把数据交给上层处理,需要等到TCP发生超时重传,重传整个数据,明明只是丢了其中一个分片,却重传了整个数据,效率太低了。 

所以为了为了达到最佳的传输效能 ,TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了,所以到达接收方,接收方IP层不用对数据进行组装,直接给上层处理。经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。

2、什么情况下SYN会被丢弃 与 PAWS检测机制

 SYN报文被丢弃主要有以下两种情况:

  • 开启 tcp_tw_recycle 参数,并且在 NAT 环境下,造成 SYN 报文被丢弃

  • TCP 两个队列满了(半连接队列和全连接队列),造成 SYN 报文被丢弃 

 (1)tcp_tw_recycle 参数,在 NAT 环境下,造成 SYN 报文被丢弃

Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的(思考:为什么reuse也默认关闭,结尾给答案):

  • net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。所以该选项只适用于连接发起方
  • net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收。

要使得这两个选项生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1))。

但是tcp_tw_recycle 在使用了 NAT 的网络下是不安全的!

​ 什么是NAT(Network Address Translation,网络地址转换)网络?,在专用网连接到因特网的路由器上安装NAT软件。NAT网络至少有一个公网IP。NAT的作用简单来说就是,所有经过NAT路由器的主机IP都会变成一样的公网IP,这样当有多个不同主机通过它访问服务器时,服务器会以为他们是一台主机。

对于服务器来说,如果同时开启了TCP的recycle(默认关闭) 和 timestamps(默认开启) 选项,则服务器会开启一种称之为「 per-host 的 PAWS 机制」。

简单点:

  • timestamps开启:PAWS功能自动开启。
  • recycle+timestamps开启:per-host的PAWS自动开启。
a.什么是PAWS机制?

 每一个TCP包都有它的唯一seq标识,seq一般是32bit的若用光则又将从0开始。我们把这称为req回绕。所以当 seq 号出现溢出后单纯通过 seq 号无法标识数据包的唯一性,某个数据包延迟或因重发而延迟时可能导致连接传递的数据被破坏,比如:

 PAWS :就是为了避免这个问题而产生的,在开启 tcp_timestamps 选项情况下,一台机器发的所有 TCP 包都会带上发送时的时间戳,PAWS 要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。

b.什么是per-host的PAWS机制?

 开启了 recycle 和 timestamps 选项,就会开启一种叫 per-host 的 PAWS 机制。per-host 是对「对端 IP 做 PAWS 检查,而非对「IP + 端口」四元组做 PAWS 检查。Per-host PAWS 机制利用TCP option里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。

当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包

简单来说:就是本来A断开连接快速回收TIME-WAIT 状态的连接,但是还没有完成回收,B主机的请求连接报文就到了同一台服务器,又因为是NAT网络,服务器以为A,B是同一个主机,它会觉得TIME-WAIT都没有回收呢,你咋又和我建立连接,不可以。就造成了B主机连不上,其实A和B屁关系都没有。

 所以tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对「相同的 IP 做 PAWS 检查」,那么就不会存在这个问题了。好在tcp_tw_recycle 在 Linux 4.12 版本后,直接取消了这一参数。

思考: 我们知道虽然TIME_WAIT状态等待2MSL比浪费时间,也占用着端口资源,但是我们不应该想着取消或者减少它,有了它给我们能避免很多问题,包括历史数据等问题。所以我们不应该避免它。tcp_tw_reuse的作用是让客户端快速复用处于TIME_WAIT状态的端口,相当于跳过该状态,这可能会出现两个问题。

  • 1、历史RTS报文可能会终止后面相同的四元组连接,因为PAWS检查即使检查到RTS是过期的也不会丢弃。
  • 2、如果四次挥手的ACK报文丢失了,可能导致服务器不能正常关闭,以及历史数据可能会被错误接收。

(2)全/半连接队列满了导致SYN丢包 

看到这里你肯定看过前面的半连接队列以及全连接队列,所以这里不再说了,忘了翻前面看哦! 

3、 TIME_WAIT下收到SYN如何处理?

 这里探讨同一个【四元组】连接在处于TIME_WAIT状态时收到相同四元组的连接时的情况。不同四元组都属于不同的连接,不会相互影响没有探讨必要。注意以服务器主动断开为例。

 针对这个问题,关键是要看 SYN 的「序列号和时间戳」是否合法,合法与非法处理方式不同。

如何比较SYN是否合法:

开启时间戳时:

合法SYN:发送方SYN比接收方【期望下一个接收】序号大,时间戳比接收方最后一次接收数据时间戳大。

非法SYN:发送方SYN比接收方【期望下一个接收】序号小,时间戳比接收方最后一次接收数据时间戳小。

 不开启时间戳时:

合法SYN:发送方SYN比接收方【期望下一个接收】序号大。

非法SYN:发送方SYN比接收方【期望下一个接收】序号小

(1)对于合法SYN的处理

 对于主动关闭方(服务器),收到来自被动关闭方(客户端)的第三次握手FIN报文后进入ESTABLISHED状态且开启定时器(如果没有关闭时间戳的话),在该状态下如果又收到被动关闭方的相同四元组连接SYN那么,根据带有时间戳的判断SYN合法方法判断SYN是否合法,如果合法则重用该连接

 

(2)对于非法SYN的处理

如果检测到SYN非法,则主动关闭方(服务器)对于新的相同四元组SYN包的回复是,再回一次第四次挥手的ACK报文。当被动关闭方(客户端)收到该ACK后,发现不是新SYN连接的ACK回复,就会发送RST报文,断开连接。(思考:自动关闭方服务器,收到RST后是立即关闭还是等2MSL完。)

 

 思考:TIME_WAIT下收到了RST会不会立即关闭还是等2MSL结束,取决于

 net.ipv4.tcp_rfc1337 这个内核参数(默认情况是为 0):

  • 如果这个参数设置为 0, 收到 RST 报文会提前结束 TIME_WAIT 状态,释放连接。
  • 如果这个参数设置为 1, 就会丢掉 RST 报文。

TIME_WAIT 状态收到 RST 报文而释放连接,这样等于跳过 2MSL 时间,这么做还是有风险。

sysctl_tcp_rfc1337 这个参数是在 rfc 1337 文档提出来的,目的是避免因为 TIME_WAIT 状态收到 RST 报文而跳过 2MSL 的时间,文档里也给出跳过 2MSL 时间会有什么潜在问题。

疑问???:以上面(2)中图为例!服务器收到FIN报文后进入TIME_WAIT状态, 假如第一个ACK+SYN+第二个ACK一共耗时1.5个MSL时长,然后客户端回的RST因为网络原因,没有那么快到,到服务器耗时0.9MSL。所以服务器想收到RST要1.5+0.9 = 2.4MSL,但是服务器等2MSL就关闭了。客户端在2.2MSL时候重新发起连接,等到2.4MSLRST到了,就会对新连接产生影响。(因为即使检测出RST过期,也不会丢弃)那这种情况如何解决,是TIME_WAIT下服务器收到客户端的SYN会重置为2MSL吗?有谁知道可以打在评论区告诉我一下,目前我还没有找到答案。

4、使用TCP通信数据一定不会丢吗?

 数据的传输是靠多层协议相互协作完成的,虽然我们说TCP是可靠的传输协议,但是仅仅是保证TCP传输层自身的逻辑是可靠的,不能保证其他层,以及硬件,网络情况不会丢数据。下面我们看一下两个应用程序进行数据交互的过程。

 可能在上图中的6个地方发生丢包现象:

【1】建立连接时:前面说了,建立连接时内核会维护全连接队列和半连接队列,当这两个队列满时,就可以发生丢包。

【2】Qdisc:应用层能发网络数据包的软件有那么多,如果所有数据不加控制一股脑冲入到网卡,网卡会吃不消,那怎么办?让数据按一定的规则排个队依次处理,也就是所谓的qdisc(Queueing Disciplines,排队规则),这也是我们常说的流量控制机制。

排队,得先有个队列,而队列有个长度。我们可以通过下面的ifconfig命令查看到,里面涉及到的txqueuelen后面的数字1000,其实就是流控队列的长度。当发送数据过快,流控队列长度txqueuelen又不够大时,就容易出现丢包现象。

【3】网卡丢包:网卡和它的驱动导致丢包的场景也比较常见,原因很多,比如网线质量差,接触不良。网卡作为硬件,传输速度是有上限的。当网络传输速度过大,达到网卡上限时,就会发生丢包。

【4】网络中丢包:网络之间是要经过漫长的传输过程的,会经过很多的路由器,这期间发生丢包在所难免。

【5】Ring buffer过小丢包:在接收数据时,会将数据暂存到RingBuffer接收缓冲区中,然后等着内核触发软中断慢慢收走。如果这个缓冲区过小,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生丢包

 【6】内核态接收缓冲区过小:使用TCP socket进行网络编程的时候,内核都会分配一个发送缓冲区和一个接收缓冲区。当我们想要发一个数据包,会在代码里执行send(msg),这时候数据包并不是一把梭直接就走网卡飞出去的。而是将数据拷贝到内核发送缓冲区返回了,至于什么时候发数据,发多少数据,这个后续由内核自己做决定。接收缓冲区作用也类似,从外部网络收到的数据包就暂存在这个地方,然后坐等用户空间的应用程序将数据包取走。 如果发送缓冲区满了,就要看应用程序执行Send是如何实现的了,是阻塞等待啊,还是直接返回错误。如果接受缓冲区满了,一般情况下TCP会通告win = 0,但是如果这时还有数据包就会丢包。

5、四次挥手中数据比FIN先到怎么办?

 这个问题是想问服务器(被动关闭方)在CLOSE_WAIT状态下,发送数据后发FIN,但是FIN比数据先到客户端怎么办?

 TCP是有序的传输,对于乱序包是会处理的。当处于FIN_WAIT_2 状态的客户端收到包时,会根据序列号来判断是不是乱序的,如果是乱序的就放入【乱序队列】中,并不会进入TIME_WAIT状态,一直等到数据发来,也放入乱序队列中,然后判断【乱序队列】中有无可用数据,如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这时才会进入 TIME_WAIT 状态。

结尾:感谢阅读,写了很久,点个赞鼓励一下吧。

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值