计算机网络之传输层(二)

一、TCP协议

1. TCP连接的建立与终止

1.1 TCP连接的概念

TCP 四元组可以唯一的确定一个TCP连接,四元组包括如下:

源地址和目的地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。

源端口和目的端口的字段(16 位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。

 问题1:有一个 IP 的服务端监听了一个端口,它的 TCP 的最大连接数是多少?

答:服务端通常固定在某个本地端口上监听,等待客户端的连接请求。因此,客户端 IP 和端口是可变的,其理论值计算公式如下:

对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。当然,服务端最大并发 TCP 连接数远不能达到理论上限,会受以下因素影响:

  • 文件描述符限制,每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 Too many open files。Linux 对可打开的文件描述符的数量分别作了三个方面的限制:
    • 系统级:当前系统可打开的最大数量,通过 cat /proc/sys/fs/file-max 查看;
    • 用户级:指定用户可打开的最大数量,通过 cat /etc/security/limits.conf 查看;
    • 进程级:单个进程可打开的最大数量,通过 cat /proc/sys/fs/nr_open 查看;
  • 内存限制,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM。

 问题2:TCP可以和UDP使用同一个端口吗?

答:可以的。在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。

传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突。

关于端口的知识点,还是挺多可以讲的,比如还可以牵扯到下面这几个问题(这个回头等TCP建立与终止讲解完后统一整理一下):

  • 多个 TCP 服务进程可以同时绑定同一个端口吗?
  • 重启 TCP 服务进程时,为什么会出现“Address in use”的报错信息?又该怎么避免?
  • 客户端的端口可以重复使用吗?
  • 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗?

1.2 TCP连接的建立(三次握手)

TCP通信过程有3个阶段,即:连接建立、数据传输和连接释放。这里先讲解连接的建立,也就是常说的三次握手。

三次握手过程:

最初两端的TCP进程都处于关闭(Closed)状态。注意:A客户端主动打开连接,B服务器被动打开连接。

1.  服务器初始化状态

主机B的TCP服务器进程先创建传输控制块(TCB),这时socket(),bind() 函数已经执行完毕,服务器进程准备接受客户进程的连接请求。然后服务器进程调用listen()函数,此时服务器进程处于监听(listen)状态,紧接着调用accept()函数,等待客户的连接请求到来。如有,即作出响应。

服务端进程调用函数顺序:socket—>bind—>listen—>accept。当执行到accept()函数时,服务器进程会一直处于阻塞状态,直到有客户连接请求到达才返回。

2. 客户端发起连接请求,发送SYN同步报文段,第一次握手

主机A的客户进程也是首先创建传输控制块(TCB),然后向主机B发出连接请求报文段,这时请求报文段的首部的同步位SYN=1,同时选择一个初始序号seq=x,这个初始序号x就是随机产生的整数ISN(至于为什么一定要用随机数而不是从0开始,后面会介绍)。TCP规定,SYN报文段(即SYN=1的TCP报文段)不能携带数据,但要消耗一个序号。这时,TCP客户进程进入 SYN-SENT(同步已发送) 状态。

客户进程调用函数顺序:socket——>connect。当客户进程调用connect()函数时,客户进程就会向服务器进程发出连接请求的SYN同步报文段。

前面我们已经讲过,携带SYN标志的TCP报文段称为同步报文段。此时,同步标志位 SYN = 1。

ISN(Initial Sequence Number) 初始序列号

3. 服务器同意建立连接,回复确认信息,第二次握手

主机B的服务器进程收到连接请求报文段后,如同意建立连接,则向主机A的客户进程发送确认报文段。在确认报文段的首部中,SYN=1,ACK=1,确认号=x+1(没有数据,所以长度为0,直接seq+1即可),同时也为自己选择一个随机的初始序号seq = y。请注意,这个确认报文段也不能携带数据,但同样要消耗一个序号。这时,TCP服务器进程进入 SYN-RCVD(同步收到) 状态。

注意:第二次握手时,服务器B发送给A的报文段,也可以拆成两个报文段分两次发送。即先发送对A的连接请求同步报文段的确认报文段(ACK=1, ack=x=1),接着再发送B自己的连接请求的同步报文段(SYN=1, seq=y)给A。A收到B的同步报文段后,再给B回复一个确认报文段。那么,这样的过程就变成了“四次握手”,但效果是一样的。

4. 客户端确认连接,发送确认连接信息,第三次握手

主机A的客户进程收到主机B的服务器进程的确认报文段后,还要向主机B的服务器进程的SYN报文段给出确认。在确认报文段的首部中,ACK=1,确认号ack=y+1,而自己的序号seq=x+1。TCP的标准规定,ACK报文段可以携带数据。但如果不携带数据,则不消耗序号。在这种情况下,客户进程的下一个数据报文段的序号仍是seq=x+1。这是,TCP连接已经建立,主机A的客户进程也进入 ESTABLISHED(已建立连接)状态。当主机B的服务器进程接收到主机A的客户进程发来的确认报文段后,也进入ESTABLISHED(已建立连接)状态。
 

常见的问题:

1.)TCP连接建立为什么要三次握手,不是两次、四次?

a.)首先,我们要清楚一点,TCP是全双工通信的,只有通过三次握手才可以同步双方的初始序列号。三次握手的一个重要作用是客户端和服务端交换彼此的ISN,以便让对方知道接下来接收数据的时候如何按序列号重新组装TCP报文段。如果只有两次握手,至多只有连接发起方的起始序列号被确认,而另一方的起始序列号却得不到对方的确认因为只有TCP通信双方的SYN同步报文段的首部中有本端的起始序列号,所以每一方都要对对方的SYN同步报文段回复一个ACK确认报文段,用来表明我方已经确认收到你方发送的同步报文段了。

b.)另外,只有三次握手才可以避免出现多个无效的连接,避免资源浪费。比如在网络状况比较复杂或者网络状况比较差的情况下,发送方可能会连续发送多次建立连接的请求。如果 TCP 握手的次数只有两次,那么接收方只能选择接受请求或者拒绝接受请求,但它并不清楚这次的请求是正常的请求,还是由于网络环境问题而导致的过期请求,如果是过期请求的话就会造成错误的连接。所以如果 TCP 是三次握手的话,那么客户端在接收到服务器端 SEQ+1 的消息之后,就可以判断当前的连接是否为历史连接,如果判断为历史连接的话就会发送终止报文(RST)给服务器端终止连接;如果判断当前连接不是历史连接的话就会发送指令给服务器端来建立连接。

比如,考虑这样一种场景,如果客户端在发送完SYN报文(seq=90)后宕机了,而且这个 SYN 报文还被网络阻塞了,服务端并没有收到,接着客户端重启后,又重新向服务端建立连接,发送了 SYN(seq = 100)报文(注意!不是重传 SYN,重传的 SYN 的序列号是一样的)。假设不采用“三次握手”,只用“两次握手”,那么只要server发出确认,新的连接就建立了。由于现在client认为实际并没有建立seq=90的连接,因此不会理睬server的确认,也不会向server发送数据。但server却以为新的连接已经建立,并一直等待client发来数据。这样,server的很多资源就白白浪费掉了。而如果采用“三次握手”,当server端给client端回seq=90的确认时,由于此时client需要的是seq=100的确认,所以client就会给server发送一个RST报文,指示server端终止seq=90的连接。只有等到server给client回seq=100的确认时,client才会最终同意建立连接,并给server回确认。

注:有人问:客户端发送第三次握手(ack 报文)后就可以发送数据了,而被动方此时还是 syn_received 状态,如果 ack 丢了,那客户端发的数据是不是也白白浪费了?

不是的,即使服务端还是在 syn_received 状态,收到了客户端发送的数据,还是可以建立连接的,并且还可以正常收到这个数据包。这是因为数据报文中是有 ack 标识位,也有确认号,这个确认号就是确认收到了第二次握手。如下图:

所以,服务端收到这个数据报文,是可以正常建立连接的,然后就可以正常接收这个数据包了。 

以上两种原因就是 TCP 连接为什么需要三次握手的主要原因,当然 TCP 连接还可以四次握手,甚至是五次握手,也能实现 TCP 连接的稳定性,但三次握手是最节省资源的连接方式,因此 TCP 连接应该为三次握手。

2.) 为什么三次握手时,头两次不能带数据,但最后一次可以带数据?

因为前两次握手,连接还没正式建立,而第三次握手到达后,连接就已经建立好了,可以同时附带数据。

3.) 为什么初始序列号ISN要使用随机值而不是从0开始?

主要有两个原因:

a.)可以防止历史连接的报文被新的相同四元组的连接接收,从而避免新连接接收到的报文全部乱掉。假设client与server之间网络状况不好,不停地出现TCP连接建立与断开,并且上一个连接中client发送的报文段由于网络拥塞还没有成功送达到server端,并且此时旧的连接已经释放,建立了新的连接,且新建连接的序列号都是固定地从0开始,然后此时上一个连接发送的报文这时到达server端,假设报文seq号刚好也是0,由于server端旧的连接早已经释放掉了,那么这样就会极大的概率出现历史连接中传送的报文段的seq号出现在server端的接收窗口内,导致server端新的连接接收到早已不存在的连接的历史报文段数据,出现错乱。而如果每次建立新连接时,seq号都是随机产生的话,那么就有极大的概率使得新连接的接收窗口不包含历史数据的seq号,从而可以丢弃掉历史连接的数据。

下面是一个具体例子:

假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始:

过程如下:

  • 客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。
  • 紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;
  • 在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。

可以看到,如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题

如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,比如下图:

相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到历史报文的序列号刚「好在」对方的接收窗口内,从而导致历史报文被新连接成功接收。

所以,每次初始化序列号不一样很大程度上能够避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了(因为序列号会有回绕的问题,所以需要用时间戳的机制来判断历史报文。

b.) 如果不是随机分配起始序列号,那么黑客就会很容易获取到客户端与服务器之间TCP通信的初始序列号,然后通过伪造序列号让通信主机读取到携带病毒的TCP报文段,发起网络攻击。 

4.) 初始序列号 ISN 是如何随机产生的?

起始 ISN 是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。

RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。

  • M 是一个计时器,这个计时器每隔 4 微秒加 1。
  • F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。

可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。

5.)最大报文段长度MSS是什么?既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?

最大报文段长度(MSS)表示TCP传往另一端的最大块数据的长度。当一个连接建立时,连接的双方都要通告各自的 MSS(通过TCP报文的选项字段),也就是说MSS是在三次握手过程中,双方协商出来的。

MSS与MTU关系为如下:

 以太网中,MTU为1500字节,MSS为MTU减去IP头部与TCP头部之后,所允许的TCP数据的最大长度。

MTU的作用是:当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上一层 TCP 传输层。

MSS作用是:当 TCP 层发现数据超过 MSS 时,则就先会在TCP层进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 在IP层 分片了。

那为什么要有MSS这个东西呢?主要就是因为,如果在IP层分片,那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传,因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。当某一个 IP 分片丢失后,接收方的 IP 层就无法组装成一个完整的 TCP 报文(头部 + 数据),也就无法将数据报文送到 TCP 层,所以接收方不会响应 ACK 给发送方,因为发送方迟迟收不到 ACK 确认报文,所以会触发超时重传,就会重发「整个 TCP 报文(头部 + 数据)」。经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。

6.)三次握手过程中,如果第一次握手丢失,会发生什么?

当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT 状态。

在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的

不同版本的操作系统可能超时时间不同,有的 1 秒的,也有 3 秒的,这个超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。

当客户端在 1 秒后没收到服务端的 SYN-ACK 报文后,客户端就会重发 SYN 报文,那到底重发几次呢?

在 Linux 里,客户端的 SYN 报文最大重传次数由 tcp_syn_retries内核参数控制,这个参数是可以自定义的,默认值一般是 5。

通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍。 

通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍

7.)第二次握手丢失了,会发生什么?

首先要明确第二次握手的目的有两个:a.) 第二次握手里的ACK,是对第一次握手的确认;b.) 第二次握手里的SYN,是服务端发起建立TCP连接的报文。所以如果第二次握手丢了,会发生:

  • 首先,因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以,如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文
  • 然后,因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。那么,如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边也会触发超时重传机制,重传 SYN-ACK 报文

在 Linux 下,SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定,默认值是 5:

因此,当第二次握手丢失了,客户端和服务端都会重传:

  • 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 tcp_syn_retries内核参数决定;
  • 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由 tcp_synack_retries 内核参数决定。

8.)第三次握手丢了,会发生什么?

客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 ESTABLISH 状态。

因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。

注意,ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文

9.)在TCP连接建立的过程中,可能会出现什么攻击?如何解决?

会出现SYN flood 攻击,又称为SYN泛洪攻击。下面介绍一下泛洪攻击是如何发生的。

(1)攻击者在短时间内伪造大量不存在的IP地址,向服务器不断地发送连接请求的SYN同步报文段,当服务端收到这些连接请求的报文段后,就会为该连接请求创建传输控制块来保存客户端的信息。当有大量的连接请求时,服务器会消耗掉大量的内存资源,直至内存资源被耗尽

(2)服务器同时需要为每条连接请求回复ACK确认报文段,并等待客户端的确认。但是客户端的IP地址是虚假的,也就不会向服务器回复确认报文段,那么服务器需要不断地重发第2次握手的报文段直至超时。同时,这些伪造的连接请求SYN同步报文段还将长时间占用半连接队列(Linux默认的限制一般是256个),导致正常客户端的连接请求SYN同步报文段被丢弃,目标系统运行缓慢,严重者引起网络阻塞甚至服务器系统瘫痪。

先跟大家说一下,什么是 TCP 半连接和全连接队列:

在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:半连接队列(也称SYN队列)和全连接队列(也称accept队列)。

我们先来看下 Linux 内核的 SYN 队列(半连接队列)与 Accpet 队列(全连接队列)是如何工作的?

正常流程:

  • 当服务端接收到客户端的 SYN 报文时,会创建一个半连接的对象,然后将其加入到内核的「 SYN 队列」;
  • 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
  • 服务端接收到 ACK 报文后,从「 SYN 队列」取出一个半连接对象,然后创建一个新的连接对象放入到「 Accept 队列」;
  • 应用通过调用 accpet() socket 接口,从「 Accept 队列」取出连接对象。

不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,默认情况都会丢弃报文。

SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。

避免SYN泛洪攻击的措施为:

  • 方法1:缩短SYN Timeout 时间。由于 SYN Flood 攻击的效果取决于服务器上保持的半连接数,这个值=SYN攻击频度 x SYN Timeout,所以通过缩短从接收到SYN报文段到确定这个报文段无效并丢弃该连接的时间。例如,设置为20秒以下(过低的SYN Timeout 设置可能会影响客户的正常访问),可以成倍地降低服务器的载荷。
  • 方法2:设置 SYN Cookie。就是给每一个连接请求的IP地址分配一个Cookie,如果短时间内连续收到某个IP地址的大量重复SYN报文段,就认定是收到了攻击。以后从这个IP地址来的报文段会被丢弃。
  • 方法3:使用防火墙。SYN Flood攻击很容易就能被防火墙拦截。

1.3 TCP连接的断开(四次挥手)

TCP连接的释放可以用“四次挥手”的过程来描述。数据传输结束后,通信的双方都可主动释放连接(但被关闭一方也需要调用close函数,否则发不出FIN报文,从而一直处在CLOSE_WAIT状态)。现在 客户端A和服务器B都处于 ESTABLISHED 状态。释放连接的过程如下图所示:

  描述整个过程

1. A客户端主动断开连接,发送释放连接的FIN报文段,第一次挥手

A客户端进程先向B服务器进程发送释放连接的FIN结束报文段,并停止再发送数据,主动关闭TCP连接。在结束报文段的首部中,终止控制位FIN=1,其序号字段seq=u,它等于前面已发送过的数据的最后一个字节的序号加1此时,A客户端进程进入 FIN-WAIT-1(终止等待1)状态,等待B服务器进程的确认。请注意TCP规定,FIN报文段即使不携带数据,它也要消耗掉一个序号。这点和SYN报文段是一样的。

2. B服务器收到A客户端的结束报文段,发出确认报文段,第二次挥手

B服务器进程收到A客户端进程发来的释放连接的FIN结束报文段后,立即发出确认报文段,确认号ack=u+1,而这个报文段自己的序号seq=v,等于B服务器前面已发送过的数据的最后一个字节的序号加1。然后B服务器进程进入 CLOSE-WAIT(关闭等待)状态。TCP服务器进程这时通知高层的应用进程,那么从 A 到 B 这个方向的连接就释放了,此时TCP连接处于半关闭(half-close)状态,即A客户端已经没有数据要发送了,但B服务器若发送数据,A客户端仍要接收。也就是说,从 B 到 A 这个方向的连接并未关闭,这个状态可能会持续一段时间。

A客户端收到B服务器的确认报文段后,就进入 FIN-WAIT-2(终止等待2)状态,等待B服务器发出的FIN结束报文段。

3. B服务器释放连接,发出连接释放的结束报文段,第三次挥手

当B服务器已经没有要向A客户端发送的数据时,其应用进程就通知TCP释放连接,向A客户端发送释放连接的结束报文段。在这个结束报文段的首部中,终止控制位FIN置1,假定其序号字段为w(在半关闭状态中,B服务器可能又发送了一些数据)同时还必须重复上次已发送过的确认号ack=u+1。这时,B服务器进程就进入 LAST-ACK(最后确认)状态,等待A客户端的确认。

4. A客户端收到B服务器释放连接的结束报文段,发出确认报文段,第四次挥手

A客户端在收到B服务器的释放连接的结束报文段后,必须对此发出确认,即向B服务端发送一个确认报文段。在确认报文段的首部中,控制位ACK=1,确认号字段ack=w+1,而自己的序号字段seq=u+1(根据TCP标准,前面发送过的FIN报文段是要消耗一个序号的)。然后A客户端进入到TIME-WAIT(时间等待)状态。

请注意,此时客户端的TCP连接还没有释放掉,必须经过时间等待计时器(TIME-WAIT timer)设置的时间2MSL后,A才进入到 CLOSED 状态。时间MSL(Maximum Segment Lifetime,最长报文段寿命) 即一个TCP报文段存活的最长时间。RFC793建议设为2分钟,现在可以根据情况使用更小的MSL值。因此从A客户端进入到 TIME-WAIT 状态后,要经过4分钟才能进入到CLOSED状态,才可以建立下一个新的连接,当A客户端撤销相应的传输控制块TCB后,就结束了这次的TCP连接。

而B服务器只要收到了A客户端发出的确认报文段,就进入 CLOSED状态。同样,B服务器在撤销相应的传输控制块TCB后,就结束了此次的TCP连接。

可以发现,B服务器结束TCP连接的时间要比A客户端早一些。

上述内容就是TCP连接释放的过程,俗称“四次挥手”过程,其实质上是四报文挥手。

这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态

常见的问题:

1.)为什么建立连接是三次握手,而关闭连接却是四次挥手?

在建立TCP连接时,当服务器收到客户端发来的连接请求的SYN同步报文段后,可以直接发送一个ACK+SYN的报文段给客户端,其中ACK控制位是用来确认的,SYN控制位是用来同步的。但是在关闭连接时,当服务器收到客户端发来的FIN结束报文段时,自己这边可能还有数据没有发送完,因此只能先回复一个ACK确认报文,告诉客户端,“你发来的FIN报文我收到了”。只有等到服务器所有的数据都发送完了,服务器才会向客户端发送一个FIN结束报文段,最后客户端回复一个确认报文,总共就是四次挥手过程。也就是说,在关闭连接的第二次挥手阶段,服务器不能将控制位ACK+FIN 同时放在一个报文段中回复给客户端。

注意:发送了FIN结束报文段,只是表示本端不能再继续发送数据了,但是还可以接受数据。TCP通信它是全双工的,收到一个FIN报文段,只是关闭了一个方向上的连接,而另一个方向仍能发送数据,此时TCP处于半关闭状态。

当然,在一些特殊情况下,也是可以将四次挥手变成三次挥手的,参考:4.22 TCP 四次挥手,可以变成三次吗? | 小林coding

2.)FIN 报文一定得调用关闭连接的函数(比如close或shutdown),才会发送吗?

不一定,如果进程退出了,不管是不是正常退出,还是异常退出(如进程崩溃),内核都会发送 FIN 报文,与对方完成四次挥手。

3.)为什么需要TIME_WAIT状态?

首先,需要明确,只有主动发起关闭连接的一方,才会有 TIME-WAIT 状态。

a.) 第一个作用和TCP连接建立时使用随机的初始序列号一样,可以避免历史连接中的报文被新的具有相同四元组的连接所接收,从而出现错乱。如果旧的连接中有报文段因为网络拥塞的原因没有到达server端,而后该连接释放了并且又新建了相同的四元组的连接,而在此时历史连接中的报文段又刚好到达server端,且出现在其接收窗口内,那么这个历史报文段就会被新的连接所接收从而出现错乱。而通过引入TIME_WAIT状态,让其持续2MSL的时间后再关闭连接,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。

b.) 第二个作用是,保证被动关闭的一方能够正确的关闭连接。因为如果出现网络拥塞,客户端回复的第四次挥手的ACK报文段是有可能丢失的,因而使处于LAST-ACK状态的服务器收不到客户端对自己已发送过的FIN+ACK报文段的确认。那么,服务器会超时重传这个FIN+ACK报文段,而客户端就能在2MSL时间内收到这个重传的FIN+ACK报文段。接着客户端就会再次重传一次确认,重新启动2MSL计时器。最后,客户端和服务器都正常进入到CLOSED状态。如果客户端在 TIME-WAIT 状态时不等待一个2MSL时间,而是在发送完ACK确认报文段后立即释放连接,进入到CLOSED状态,那么客户端在收到服务端重传的 FIN+ACK 报文后,就会回 RST 报文,服务端收到这个 RST 会将其解释为一个错误,这对于一个可靠的协议来说不是一个优雅的终止方式。

4.)为什么TIME_WAIT的等待的时间是2MSL?这个时间是如何得来的?

MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。

MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。

TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了

TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间

比如,如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。

可以看到 2MSL时长 这其实是相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。

为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。

2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时

在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒

其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN:

如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。 

5.) TIME_WAIT状态过多有什么危害?

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

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

客户端和服务端 TIME_WAIT 过多,造成的影响是不同的。

如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。具体可以这篇文章:客户端的端口可以重复使用吗?(opens new window)

因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT 」都一样的服务端建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务端建立连接了。

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

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

6.) TIME-WAIT 状态何时出现?TIME-WAIT会带来哪些问题?(跟上面一个问题大同小异)

TIME-WAIT状态是主动关闭连接的一方收到了对方发来的FIN结束报文段并且本端发送ACK确认报文段后的状态。

TIME-WAIT的引入是为了让TCP报文段得以自然消失,同时为了让被动关闭的一方能够正常关闭连接。

  • 服务器主动关闭连接,短时间内关闭了大量的客户端连接,会造成服务器上出现大量的 TIME-WAIT状态的连接,占据大量的tuple(源IP地址、目的IP地址、协议号、源端口、目的端口),严重消耗着服务器的资源。
  • 客户端主动关闭连接,短时间内大量的短连接,会大量消耗客户端主机的端口号,毕竟端口号只有65535个,断开耗尽了,后续就无法启用新的TCP连接了。

7.)如何优化TIME_WAIT?

这里给出优化 TIME-WAIT 的几个方式,都是有利有弊:

  • 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;
  • net.ipv4.tcp_max_tw_buckets
  • 程序中使用 SO_LINGER ,应用强制使用 RST 关闭。

方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps

如下的 Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用

有一点需要注意的是,tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用

 使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即

这个时间戳的字段是在 TCP 头部的「选项」里,它由一共 8 个字节表示时间戳,其中第一个 4 字节字段用来保存发送该数据包的时间,第二个 4 字节字段用来保存最近一次接收对方发送到达数据的时间。

由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。

方式二:net.ipv4.tcp_max_tw_buckets

这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置,这个方法比较暴力。

方式三:程序中使用 SO_LINGER

 我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。

如果l_onoff为非 0, 且l_linger值为 0,那么调用close后,会立该发送一个RST标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。

但这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

前面介绍的方法都是试图越过 TIME_WAIT状态的,这样其实不太好。虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。

《UNIX网络编程》一书中却说道:TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它

如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT

8.)解决 TIME-WAIT 状态引起的bind()函数执行失败的问题?

问题场景我在编写一个基于TCP连接的socket服务器程序并反复调试的时候,发现了一个让人无比心烦的情况:每次kill掉该服务器进程并重新启动的时候,都会出现bind错误:error:98,Address already in use。然而再kill掉该进程,再次重新启动的时候,就bind成功了。

问题原因:当我kill掉服务器进程的时候,系统并没有马上完全释放掉socket的TCP连接资源,此时socket处于TIME-WAIT状态,当我使用 netstat 命令查看该进程的端口号时,发现该进程处于TIME-WAIT状态,需要等待2MSL时间后,整个TCP连接才算真正结束。这就是我的服务器进程被杀死后,不能马上重新启动的原因(错误提示为:“Address already in use”)。Linux系统中,一个端口释放后需要等待两分钟才能再次被使用。

问题解决:我们可以使用setsockopt()函数设置socket描述符的SO_REUSEADDR选项,该socket选项可以让端口被释放后立即就能被再次使用,表示允许创建端口号相同但是IP地址不同的多个socket描述符。

代码描述如下:
 

int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

9.)服务器出现大量 TIME_WAIT 状态的原因有哪些?

首先要知道 TIME_WAIT 状态是主动关闭连接方才会出现的状态,所以如果服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接。

问题来了,什么场景下服务端会主动断开连接呢?

  • 第一个场景:HTTP 没有使用长连接
  • 第二个场景:HTTP 长连接超时
  • 第三个场景:HTTP 长连接的请求数量达到上限

接下来,分别介绍下。

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

我们先来看看 HTTP 长连接(Keep-Alive)机制是怎么开启的。

在 HTTP/1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的 header 中添加:

然后当服务器收到请求,作出回应的时候,它也被添加到响应中 header 里:

这样做,TCP 连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个 TCP 连接。这一直继续到客户端或服务器端提出断开连接。

从 HTTP/1.1 开始, 就默认是开启了 Keep-Alive,现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。 

如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection:close 信息,也就是说,只要客户端和服务端任意一方的 HTTP header 中有 Connection:close 信息,那么就无法使用 HTTP 长连接的机制

关闭 HTTP 长连接机制后,每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,那么此方式就是 HTTP 短连接,如下图:

在前面我们知道,只要任意一方的 HTTP header 中有 Connection:close 信息,就无法使用 HTTP 长连接机制,这样在完成一次 HTTP 请求/处理后,就会关闭连接。

问题来了,这时候是客户端还是服务端主动关闭连接呢?

在 RFC 文档中,并没有明确由谁来关闭连接,请求和响应的双方都可以主动关闭 TCP 连接。

不过,根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接那么此时服务端上就会出现 TIME_WAIT 状态的连接。

问题1:如果是客户端禁用了 HTTP Keep-Alive,服务端开启 HTTP Keep-Alive,谁是主动关闭方?

答:刚刚已经说了,都是由服务端主动关闭连接。当客户端禁用了 HTTP Keep-Alive,这时候 HTTP 请求的 header 就会有 Connection:close 信息,这时服务端在发完 HTTP 响应后,就会主动关闭连接。为什么要这么设计呢?HTTP 是请求-响应模型,发起方一直是客户端,HTTP Keep-Alive 的初衷是为客户端后续的请求重用连接,如果我们在某次 HTTP 请求-响应模型中,请求的 header 定义了 connection:close 信息,那不再重用这个连接的时机就只有在服务端了,所以我们在 HTTP 请求-响应这个周期的「末端」关闭连接是合理的。

问题2:客户端开启了 HTTP Keep-Alive,服务端禁用了 HTTP Keep-Alive,谁是主动关闭方?

答:前面也说了,都是由服务端主动关闭连接。当客户端开启了 HTTP Keep-Alive,而服务端禁用了 HTTP Keep-Alive,这时服务端在发完 HTTP 响应后,服务端也会主动关闭连接。为什么要这么设计呢?在服务端主动关闭连接的情况下,只要调用一次 close() 就可以释放连接,剩下的工作由内核 TCP 栈直接进行了处理,整个过程只有一次 syscall;如果是要求 客户端关闭,则服务端在写完最后一个 response 之后需要把这个 socket 放入 readable 队列,调用 select / epoll 去等待事件;然后调用一次 read() 才能知道连接已经被关闭,这其中是两次 syscall,多一次用户态程序被激活执行,而且 socket 保持时间也会更长。

因此,当服务端出现大量的 TIME_WAIT 状态连接的时候,可以排查下是否客户端和服务端都开启了 HTTP Keep-Alive,因为任意一方没有开启 HTTP Keep-Alive,都会导致服务端在处理完一个 HTTP 请求后,就主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接。

针对这个场景下,解决的方式也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制。

  • 第二个场景:HTTP 长连接超时

HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。

HTTP 长连接可以在同一个 TCP 连接上接收和发送多个 HTTP 请求/应答,避免了连接建立和释放的开销。

可能有的同学会问,如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗?

对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。

假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接

当服务端出现大量 TIME_WAIT 状态的连接时,如果现象是有大量的客户端建立完 TCP 连接后,很长一段时间没有发送数据,那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于 TIME_WAIT 状态的连接。

可以往网络问题的方向排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收到,以至于 HTTP 长连接超时。

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

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

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

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

但是,对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000 , 50000 甚至更高,如果 keepalive_requests 参数值是 100,这时候就 nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态

针对这个场景下,解决的方式也很简单,调大 nginx 的 keepalive_requests 参数就行。

10.)TIME-WAIT 和 CLOSE-WAIT 的区别?

  • TIME-WAIT 状态是主动关闭TCP连接的一方在本端已经关闭的前提下,收到对端的关闭请求并将ACK确认报文段发送过去后所处的状态。

这种状态表示:通信双方都已完成工作,只是为了保证本次TCP连接能够顺利正常的关闭,即可靠地终止TCP连接。

  • CLOSE-WAIT 状态是被动关闭TCP连接的一方在接收到对端的关闭请求(FIN结束报文段)并且将ACK确认报文段发送出去后所处的状态。

这种状态表示:收到了对端关闭连接的请求,但是本端还没有完成工作,还未关闭本端的TCP连接。

11.)服务器出现大量 CLOSE_WAIT 状态的原因有哪些?

CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。

所以,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接

那什么情况会导致服务端的程序没有调用 close 函数关闭连接?这时候通常需要排查代码。

我们先来分析一个普通的 TCP 服务端的流程:

  1. 创建服务端 socket,bind 绑定端口、listen 监听端口
  2. 将服务端 socket 注册到 epoll
  3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
  4. 将已连接的 socket 注册到 epoll
  5. epoll_wait 等待事件发生
  6. 对方连接关闭时,我方调用 close

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

第一个原因:第 2 步没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。

不过这种原因发生的概率比较小,这种属于明显的代码逻辑 bug,在前期 read view 阶段就能发现的了。

第二个原因: 第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。

发生这种情况可能是因为服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。

第三个原因:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。

发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。之前看到过别人解决 close_wait 问题的实践文章,感兴趣的可以看看:一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析(opens new window)

第四个原因:第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。

可以发现,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,通常都是代码的问题,这时候我们需要针对具体的代码一步一步的进行排查和定位,主要分析的方向就是服务端为什么没有调用 close

12.)如果已经建立了连接,但是客户端突然出现故障了怎么办?

客户端出现故障指的是客户端的主机发生了宕机,或者断电的场景。发生这种情况的时候,如果服务端一直不会发送数据给客户端,那么服务端是永远无法感知到客户端宕机这个事件的,也就是服务端的 TCP 连接将一直处于 ESTABLISH 状态,占用着系统资源。

为了避免这种情况,TCP 搞了个保活机制。这个机制的原理是这样的:

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

在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:

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

也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。

注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。

如果开启了 TCP 保活,需要考虑以下几种情况:

  • 第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。

  • 第二种,对端主机宕机并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。

  • 第三种,是对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡

TCP 保活的这个机制检测的时间是有点长,我们可以自己在应用层实现一个心跳机制

比如,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完成一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接

13.)如果已经建立了连接,但是服务端的进程崩溃会发生什么?

TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。

我自己做了个实验,使用 kill -9 来模拟进程崩溃的情况,发现在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手

关于进程崩溃和主机宕机的区别,可以参考这篇:TCP 连接,一端断电和进程崩溃有什么区别?

还有一个类似的问题:「拔掉网线后, 原本的 TCP 连接还存在吗?」,具体可以看这篇:拔掉网线后, 原本的 TCP 连接还存在吗?

14.)半打开与半关闭的区别?

  • 半打开:如果TCP通信一方异常关闭(如断网、断电、进程被kill掉),而通信对端并不知情,此时TCP连接处于半打开状态,如果双方不进行数据通信,是无法发现问题的。解决的办法是引入心跳机制,设置一个保活计时器(keepalive timer),以检测半打开状态,检测到了就发送RST复位报文段,重新建立连接。
  • 半关闭:主动发起连接关闭请求的一方A发送了FIN结束报文段,对端B回复了ACK确认报文段后,B并没有立即发送本端的FIN结束报文段给A。此时A端处于FIN-WAIT-2(结束等待2)状态,A仍然可以接收B发送过来的数据,但是A已经不能再向B发送数据了。这时的TCP连接为半关闭状态。

半关闭的一个应用:rsh命令。没有半关闭,需要其他的一些技术让客户通知服务器, 客户端已经完成了它的数据传送,但仍要接收来自服务器的数据。使用两个T C P连接也可作为一个选择,但使用半关闭的单连接更好。

  • 半打开和半关闭的区别半打开是指TCP通信的一端由于异常关闭,通信双方已经无法进行正常的数据传输了;半关闭是指TCP通信的其中一个方向上的连接已经关闭了,而另一个方向的连接还是正常的,仍然可以进行数据传输。

对于半关闭,学习一下close与shutdown两个系统调用的区别:【网络】close与shutdown_bdview的博客-CSDN博客

总结一下区别:

  • 调用shutdown关闭输出流与调用close关闭套接字时都会向对方发送FIN包,此时TCP连接都会进入半连接状态,只有等到对方也调用close或shutdown后收到对方发来的FIN包后才可以真正的关闭连接;
  • shutdown() 仅用来关闭连接,而不是关闭套接字,不管调用多少次 shutdown(),套接字依然存在(只有等到调用close后套接字才可以关闭);而调用 close() / closesocket() 会将socket fd的引用 减1,减到0时,不仅连接会释放,套接字也会被释放。
  • 因为调用close后直接就将套接字关闭了,所以此时既无法调用read接收数据,也无法调用send发送数据,相当于和服务端完全断开了(但注意,此时TCP连接仍然是半关闭状态,如果服务端一直没有发数据给客户端,则服务端必须等到调用close后给客户端发FIN报文才可以完全将TCP连接断开;当然了,如果服务端要给客户端发数据,由于客户端套接字都关闭了,所以即使服务端发送数据给客户端,客户端还是会回RST报文关闭连接,但此时对于服务端,仍然必须调用close来关闭套接字资源的,否则占着浪费);而调用shutdown则可以选择终止哪个方向的数据传送,比如如下三个选项:

  • 如果有多个进程共享一个套接字,close每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了close,套接字才会被释放。

  • 在多进程中如果一个进程中shutdown(sfd, SHUT_RDWR)后其它的进程将无法进行通信. 如果一个进程close(sfd)将不会影响到其它进程. 得自己理解引用计数的用法了. 有Kernel编程知识的更好理解了。

  • 当有多个socket描述符指向同一socket对象时,调用close时首先会递减该对象的引用计数,计数为0时才会发送FIN包结束TCP连接。shutdown不同,只要以SHUT_WR/SHUT_RDWR方式调用即发送FIN包。

15.)四次挥手中,第一次挥手丢失会发生什么?

当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1 状态。

正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2状态。

如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。

当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么直接进入到 close 状态。

16.)第二次挥手丢失了,会发生什么?

当服务端收到客户端的第一次挥手后,就会先回一个 ACK 确认报文,此时服务端的连接进入到 CLOSE_WAIT 状态。

在前面我们也提了,ACK 报文是不会重传的,所以如果服务端的第二次挥手丢失了,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。

这里提一下,当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会处于 FIN_WAIT2 状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。

对于 close 函数关闭的连接,由于无法再发送和接收数据,所以FIN_WAIT2 状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒。

这意味着对于调用 close 关闭的连接,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭。

但是注意,如果主动关闭方使用 shutdown 函数关闭连接,指定了只关闭发送方向,而接收方向并没有关闭,那么意味着主动关闭方还是可以接收数据的。

此时,如果主动关闭方一直没收到第三次挥手,那么主动关闭方的连接将会一直处于 FIN_WAIT2 状态(tcp_fin_timeout 无法控制 shutdown 关闭的连接)。

17.)第三次挥手丢失了,会发生什么?

当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。

此时,内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。

服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。

如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。

18.)第四次挥手丢失了,会发生什么?

当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入 TIME_WAIT 状态。

在 Linux 系统,TIME_WAIT 状态会持续 2MSL 后才会进入关闭状态。

然后,服务端(被动关闭方)没有收到 ACK 报文前,还是处于 LAST_ACK 状态。

如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。


2. TCP状态转换

为了更清晰地看出TCP连接的各种状态之间的关系,下图给出了TCP的状态转换示意图:


3. RST(复位)报文的作用 

需要注意的是 RST报文段不会导致另一端产生任何响应,另一端根本不进行确认。收到RST的一方将终止该连接,并通知应用层连接复位。

  1. 产生复位的一种常见情况是当连接请求到达时,目的端口没有进程正在听。对于 UDP,我们在前面看到这种情况,当一个数据报到达目的端口时,该端口没在使用,它将产生一个ICMP端口不可达的信息。而 TCP则使用复位。
  2. 异常终止一个连接。终止一个连接的正常方式是一方发送 FIN。有时这也称为有序释放(orderly release),因为在所有排队数据都已发送之后才发送 FIN,正常情况下没有任何数据丢失。但也有可能发送一个复位报文段而不是 FIN来中途释放一个连接。有时称这为异常释放(abortive release)。异常终止一个连接对应用程序来说有两个优点: (1)丢弃任何待发数据并立即发送复位报文段;(2)RST的接收方会区分另一端执行的是异常关闭还是正常关闭。
  3. 检测半打开连接。如果一方已经关闭或异常终止连接而另一方却还不知道,我们将这样的 TCP连接称为半打开的。任何一端的主机异常都可能导致发生这种情况。只要不打算在半打开连接上传输数据,仍处于连接状态的一方就不会检测另一方已经出现异常。半打开连接的另一个常见原因是当客户主机突然掉电而不是正常的结束客户应用程序后再关机。能很容易地建立半打开连接。在客户端主机上运行Telnet客户程序,通过它和服务器建立连接。我们键入一行字符,然后通过 tcpdump进行观察,接着断开服务器主机与以太网的电缆,并重启服务器主机。这可以模拟服务器主机出现异常(在重启服务器之前断开以太网电缆是为了防止它向打开的连接发送 FIN,某些TCP在关机时会这么做)。服务器主机重启后,我们重新接上电缆,并从客户向服务器发送另一行字符。由于服务器的 TCP已经重新启动,它将丢失复位前连接的所有信息,因此它不知道数据报文段中提到的连接。 TCP的处理原则是接收方以复位作为应答。

4. TCP的Socket编程

  • 服务端和客户端初始化 socket,得到文件描述符;
  • 服务端调用 bind,将 socket 绑定在指定的 IP 地址和端口;
  • 服务端调用 listen,进行监听;
  • 服务端调用 accept,等待客户端连接;
  • 客户端调用 connect,向服务端的地址和端口发起连接请求;
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write 写入数据;服务端调用 read 读取数据;
  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。

这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。

所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket

成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

相关问题:

1.)listen 时候参数 backlog 的意义?

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

  • 半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
  • 全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;

  • 参数一 socketfd 为 socketfd 文件描述符
  • 参数二 backlog,这参数在历史版本有一定的变化

在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。

在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。

但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。

想详细了解 TCP 半连接队列和全连接队列,可以看这篇:TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?

2.)accept 发生在三次握手的哪一步?

我们先看看客户端连接服务端时,发送了什么?

  • 客户端的协议栈向服务端发送了 SYN 包,并告诉服务端当前发送序列号 client_isn,客户端进入 SYN_SENT 状态;
  • 服务端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包 client_isn 的确认,同时服务端也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务端进入 SYN_RCVD 状态;
  • 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务端的 SYN 包进行应答,应答数据为 server_isn+1;
  • ACK 应答包到达服务端后,服务端的 TCP 连接进入 ESTABLISHED 状态,同时服务端协议栈使得 accept 阻塞调用返回,这个时候服务端到客户端的单向连接也建立成功。至此,客户端与服务端两个方向的连接都建立成功。

从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。

3.)客户端调用 close 了,连接是断开的流程是什么?

我们看看客户端主动调用了 close,会发生什么?

  • 客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态;
  • 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态;
  • 接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态;
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
  • 客户端经过 2MSL 时间之后,也进入 CLOSE 状态;

4.)没有 accept,能建立 TCP 连接吗?

答案:可以的

accpet 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket,用户层通过 accpet 系统调用拿到了已经建立连接的 socket,就可以对该 socket 进行读写操作了。

更想了解这个问题,可以参考这篇文章:没有 accept,能建立 TCP 连接吗? 

5.)没有 listen,能建立 TCP 连接吗?

答案:可以的

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

更想了解这个问题,可以参考这篇文章:服务端没有 listen,客户端发起连接建立,会发生什么?

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值