计算机网络 TCP协议简介及连接管理

目录

简介

为什么要TCP,IP层实现控制不行么

既然IP层会分片,为什么TCP层还需要MSS 呢?

为什么UDP头部没有「首部长度」字段,而TCP头部有「首部长度」字段呢?

连接

如何唯一确定一个 TCP 连接呢?

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

TCP头部

连接管理(3次握手,4次挥手)

TCP客户端与服务端状态管理

三次握手

为什么要三次握手

TFO技术如何绕过三次握手?

三次握手如果失败会怎样。

三次握手优化参数

为什么客户端和服务端的初始序列号 ISN 是不相同的?

初始序列号ISN的取值

SYN超时了怎么处理?

第2次握手传回了ACK,为什么还要传回SYN?

SYN Flood攻击

如何避免SYN攻击?

为什么TCP客户端最后还要发送一次确认呢?

服务器没有收到ack怎么办?

accept 队列已满,只能丢弃连接吗?

如何调整 accept 队列的长度呢?

四次挥手具体

为什么要四次挥手

四次挥手状态一定是这样变迁的吗

挥手一定需要四次吗?

四次挥手优化参数

主动方的优化

主动方收不到ack怎么办

close 函数关闭的孤儿连接怎么收到FIN

为什么要有TIME_WAIT

为什么客户端最后还要等待2MSL?

为什么是 2 MSL 的时长呢?

等待2MSL会产生什么问题?

如何解决2MSL产生的问题?

被动方的优化


简介

UDP是一种没有复杂控制,提供面向无连接通信服务的一种协议。换句话说,它将部分控制转移给应用程序去处理,自己却只提供作为传输层协议的最基本功能。

与UDP不同,TCP则”人如其名“,可以说是对“传输、发送、通信”进行“控制”的"协议”。

TCP与UDP的区别相当大。它充分地实现了数据传输时各种控制功能,可以进行丢包时的重发控制,还可以对次序乱掉的分包进行顺序控制。而这些在UDP中都没有。此外,TCP作为一种面向有连接的协议,只有在确认通信对端存在时才会发送数据,从而可以控制通信流量的浪费。

TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。​​​​​​​

面向连接:一定是「一对一」才能连接,不能像 UDP 协议 可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的;

可靠的:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端;

字节流:消息是「没有边界」的,所以无论我们消息有多大都可以进行传输。并且消息是「有序的」,当「前一个」消息没有收到的时候,即使它先收到了后面的字节已经收到,那么也不能扔给应用层去处理,同时对「重复」的报文会自动丢弃。

为什么要TCP,IP层实现控制不行么

我们知道网络是分层实现的,网络协议的设计就是为了通信,从链路层到IP层其实就已经可以完成通信了。

你看链路层不可或缺毕竟咱们电脑都是通过链路相互连接的,然后IP充当了地址的功能,所以通过IP咱们找到了对方就可以进行通信了。

那加个TCP层干啥?IP层实现控制不就完事了嘛?

之所以要提取出一个TCP层来实现控制是因为IP层涉及到的设备更多,一条数据在网络上传输需要经过很多设备,而设备之间需要靠IP来寻址。

我举个例子,假如A要传输给F一个积木,但是无法直接传输到,需要经过B、C、D、E这几个中转站之手。这里有两种情况:

假设BCDE都需要关心这个积木搭错了没,都拆开包裹仔细的看看,没问题了再装回去,最终到了F的手中。

假设BCDE都不关心积木的情况,来啥包裹只管转发就完事了,由最终的F自己来检查这个积木答错了没。

你觉得哪种效率高?明显是第二种,转发的设备不需要关心这些事,只管转发就完事!

所以把控制的逻辑独立出来成TCP层,让真正的接收端来处理,这样网络整体的传输效率就高了。

既然IP层会分片,为什么TCP层还需要MSS 呢?

MTU:一个网络包的最大长度,以太网中一般为1500字节;

MSS:除去IP和TCP头部之后,一个网络包所能容纳的TCP数据的最大长度;

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

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

这看起来井然有序,但这存在隐患的,那么当如果一个IP分片丢失,整个IP报文的所有分片都得重传

因为IP层本身没有超时重传机制,它由传输层的TCP来负责超时和重传。

当接收方发现TCP报文(头部+数据)的某一片丢失后,则不会响应ACK给对方,那么发送方的TCP在超时后,就会重发「整个TCP报文(头部+数据)」。

因此,可以得知由IP层进行分片传输,是非常没有效率的。

所以,为了达到最佳的传输效能TCP协议在建立连接的时候通常要协商双方的MSS值,当TCP层发现数据超过MSS时,则就先会进行分片,当然由它形成的IP包的长度也就不会大于MTU,自然也就不用IP分片了。

经过TCP层分片后,如果一个TCP分片丢失后,进行重发时也是以MSS为单位,而不用重传所有的分片,大大增加了重传的效率。

为什么UDP头部没有「首部长度」字段,而TCP头部有「首部长度」字段呢?

原因是TCP有可变长的「选项」字段,而UDP头部长度则是不会变化的,无需多一个字段去记录UDP的首部长度。

先说说TCP是如何计算负载数据长度:

其中IP总长度和IP首部长度,在IP首部格式是已知的。TCP首部长度,则是在TCP首部格式已知的,所以就可以求得TCP数据的长度。

大家这时就奇怪了问:“UDP也是基于IP层的呀,那UDP的数据长度也可以通过这个公式计算呀?为何还要有「包长度」呢?”

这么一问,确实感觉UDP「包长度」是冗余的。

因为为了网络设备硬件设计和处理方便,首部长度需要是4字节的整数倍。

如果去掉UDP「包长度」字段,那UDP首部长度就不是4字节的整数倍了,所以觉得这可能是为了补全UDP首部长度是4字节的整数倍,才补充了「包长度」字段。

连接

连接是指各种设备、线路,或网络中进行通信的两个应用程序为了相互传递消息而专有的、虚拟的通信线路,也叫做虚拟电路。

一旦建立了连接,进行通信的应用程序只使用这个虚拟的通信线路发送和接收数据,就可以保障信息的传输。应用程序可以不用顾虑提供尽职服务的IP网络上可能发生的各种问题,依然可以转发数据。TCP则负责控制连接的建立、断开、保持等管理工作。

所谓的连接其实只是双方都维护了一个状态,通过每一次通信来维护状态的变更,使得看起来好像有一条线关联了对方。

简单来说就是,用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。​​​​​​​

所以我们可以知道,建立一个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识。

Socket:由 IP 地址和端口号组成

序列号:用来解决乱序问题等

窗口大小:用来做流量控制

如何唯一确定一个 TCP 连接呢?

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

源地址

源端口

目的地址

目的端口

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

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

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

服务器通常固定在某个本地端口上监听,等待客户端的连接请求。

因此,客户端IP和端口是可变的,其理论值计算公式如下:

最大tcp连接数=客户端ip数*客户端端口数

对IPv4,客户端的IP数最多为232次方,客户端的端口数最多为216次方,也就是服务端单机最大TCP连接数,约为248次方。

当然,服务端最大并发TCP连接数远不能达到理论上限。

首先主要是文件描述符限制,Socket都是文件,所以首先要通过ulimit配置文件描述符的数目;

另一个是内存限制,每个TCP连接都要占用一定内存,操作系统是有限的。

TCP头部

对于TCP三次握手和四次挥手,我们最主要的就是关注TCP头部的序列号、确认号以及几个标记位(SYN/FIN/ACK/RST)

序列号:在初次建立连接的时候,客户端和服务端都会为「本次的连接」随机初始化一个序列号。(纵观整个TCP流程中,序列号可以用来解决网络包乱序的问题)

确认号:该字段表示「接收端」告诉「发送端」对上一个数据包已经成功接收(确认号可以⽤来解决网络包丢失的问题)

而标记位就很好理解啦。SYN为1时,表示希望创建连接。ACK为1时,确认号字段有效。FIN为1时,表示希望断开连接。RST为1时,表示TCP连接出现异常,需要断开。

 

 

连接管理(3次握手,4次挥手)

TCP提供面向有连接的通信传输。面向有连接是指在数据通信开始之前先做好通信两端之间的准备工作。

UDP是一种面向无连接的通信协议,因此不检查对端是否可以通信,直接将UDP包发送出去。TCP与此相反,它会在数据通信之前,通过TCP首部发送一个SYN包作为建立连接的请求等待确认应答。如果对端发来确认应答,则认为可以进行数据通信。如果对端的确认应答未能到达,就不会进行数据通信。此外,在通信结束时会进行断开连接的处理(FIN包)。

可以使用TCP首部用于控制的字段来管理TCP连接。

一个连接的建立与断开,正常过程至少需要来回发送7个包才能完成。

注意:主机B握手的ACK和SYN在一个包里。主机B挥手的ACK和FIN不在一个包里,因为B当时可能还有东西要发送,发送完毕,才会发送FIN。

TCP客户端与服务端状态管理

理解状态转换是使用netstat命令来诊断网络问题的基础,也是理解比如调用connect,accept和close函数的过程的关键所在。

下面是TCP中的一种连接过程:客户端发起连接请求-->连接确立-->信息交互-->客户端发起关闭连接请求-->完成关闭连接.

(1)客户机的TCP状态序列

(2)服务器的TCP状态序列

三次握手

TCP服务器进程先创建传输控制块TCB,时刻准备接受客户进程的连接请求,此时服务器就进入了LISTEN(监听)状态;

TCP客户进程也是先创建传输控制块TCB,然后向服务器发出连接请求报文,这是报文首部中的同部位SYN=1,同时选择一个初始序列号seq=x,此时,TCP客户端进程进入了SYN-SENT(同步已发送状态)状态。TCP规定,SYN报文段(SYN=1的报文段)不能携带数据,但需要消耗掉一个序号。

TCP服务器收到请求报文后,如果同意连接,则发出确认报文。确认报文中应该ACK=1,SYN=1,确认号是ack=x+1,同时也要为自己初始化一个序列号seq=y,此时,TCP服务器进程进入了SYN-RCVD(同步收到)状态。这个报文也不能携带数据,但是同样要消耗一个序号。

TCP客户进程收到确认后,还要向服务器给出确认。确认报文的ACK=1,ack=y+1,自己的序列号seq=x+1,此时,TCP连接建立,客户端进入ESTABLISHED(已建立连接)状态。TCP规定,ACK报文段可以携带数据,但是如果不携带数据则不消耗序号。

当服务器收到客户端的确认后也进入ESTABLISHED状态,此后双方就可以开始通信了。

为什么要三次握手

1 三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。

第一次握手:Client什么都不能确认;Server确认了对方发送正常,自己接收正常

第二次握手:Client确认了:自己发送、接收正常,对方发送、接收正常;Server确认了:对方发送正常,自己接收正常

第三次握手:Client确认了:自己发送、接收正常,对方发送、接收正常;Server确认了:自己发送、接收正常,对方发送、接收正常

所以三次握手就能确认双发收发功能都正常,缺一不可。

2 为了初始化Seq Numer,SYN的全称是Synchronize Sequence Numbers,这个序号是用来保证之后传输数据的顺序性。

3 为了防止旧的重复连接初始化造成混乱。

客户端连续发送多次SYN建立连接的报文,在网络拥堵等情况下:

  • 一个「旧SYN报文」比「最新的SYN」 报文早到达了服务端;

  • 那么此时服务端就会回一个SYN+ACK报文给客户端;

  • 客户端收到后可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),那么客户端就会发送RST报文给服务端,表示中止这一次连接。

如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端因有足够的上下文来判断当前连接是否是历史连接:

如果是历史连接(序列号过期或超时),则第三次握手发送的报文是RST报文,以此中止历史连接;

如果不是历史连接,则第三次发送的报文是ACK报文,通信双方就会成功建立连接;

4 避免资源浪费

如果只有「两次握手」,当客户端的SYN请求连接在网络中阻塞,客户端没有接收到ACK报文,就会重新发送SYN由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的ACK确认信号,所以每收到一个SYN就只能先主动建立一个连接,这会造成什么情况呢?

如果客户端的SYN阻塞了,重复发送多次SYN报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。

TFO技术如何绕过三次握手?

三次握手建立连接造成的后果就是,HTTP请求必须在一次RTT(Round Trip Time,从客户端到服务器一个往返的时间)后才能发送,Google对此做的统计显示,三次握手消耗的时间,在HTTP请求完成的时间占比在10%到30%之间。

因此,Google提出了TCP fast open 方案(简称TFO),客户端可以在首个SYN报文中就携带请求,这节省了1个RTT的时间。

接下来我们就来看看,TFO具体是怎么实现的。

为了让客户端在SYN报文中携带请求数据,必须解决服务器的信任问题。因为此时服务器的 SYN 报文还没有发给客户端,客户端是否能够正常建立连接还未可知,但此时服务器需要假定连接已经建立成功,并把请求交付给进程去处理,所以服务器必须能够信任这个客户端。

TFO到底怎样达成这一目的呢?它把通讯分为两个阶段,

在客户端首次建立连接时的过程:

客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie;

支持 TCP Fast Open 的服务器生成 Cookie,Cookie是服务器会把客户端IP地址用只有自己知道的密钥加密(比如AES加密算法),并将其置于 SYN-ACK 数据包中的 Fast Open 选项以发回客户端;

客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。

所以,第一次发起 HTTP GET 请求的时候,还是需要正常的三次握手流程。

之后,如果客户端再次向服务器建立连接时的过程:

客户端发送 SYN 报文,该报文包含数据对于非 TFO 的普通 TCP 握手过程,SYN 报文中不包含数据以及此前记录的 Cookie;

支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:

如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和数据进行确认,服务器随后将「数据」递送至相应的应用程序

如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的数据,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号;

如果服务器接受了 SYN 报文中的数据,服务器可在握手完成之前发送数据,这就减少了握手带来的 1 个 RTT 的时间消耗;

客户端将发送 ACK 确认服务器发回的 SYN 以及数据,但如果客户端在初始的 SYN 报文中发送的数据没有被确认,则客户端将重新发送数据;

此后的 TCP 连接的数据传输过程和非 TFO 的正常情况一致。

服务器收到后,会用自己的密钥验证Cookie是否合法,验证通过后连接才算建立成功,再把请求交给进程处理,同时给客户端返回SYN+ACK。虽然客户端收到后还会返回ACK,但服务器不等收到ACK就可以发送HTTP响应了,这就减少了握手带来的1个RTT的时间消耗。

在 Linux 系统中,可以通过设置 tcp_fastopn 内核参数,来打开 Fast Open 功能

net.ipv4.tcp_fastopen各个值的意义:

0 关闭

1 作为客户端使用 Fast Open 功能

2 作为服务端使用 Fast Open 功能

3 无论作为客户端还是服务器,都可以使用 Fast Open 功能

TCP Fast Open 功能需要客户端和服务端同时支持,才有效果。

当然,为了防止SYN攻击,服务器的TFO实现必须能够自动化地定时更新密钥。

三次握手如果失败会怎样。

第一次握手A发送SYN传输失败,A,B都不会申请资源,连接失败。如果一段时间内发出多个SYN连接请求,那么A只会接受它最后发送的那个SYN的SYN+ACK回应,忽略其他回应全部回应,B中多申请的资源也会释放

第二次握手B发送SYN+ACK传输失败,A不会申请资源,B申请了资源,但收不到A的ACK,过一段时间释放资源。如果是收到了多个A的SYN请求,B都会回复SYN+ACK,但A只会承认其中它最早发送的那个SYN的回应,并回复最后一次握手的ACK

第三次握手ACK传输失败,B没有收到ACK,释放资源,对于后序的A的传输数据返回RST。实际上B会因为没有收到A的ACK会多次发送SYN+ACK,次数是可以设置的,如果最后还是没有收到A的ACK,则释放资源,对A的数据传输返回RST

三次握手优化参数

 客户端的优化

当客户端发起 SYN 包时,可以通过 tcp_syn_retries 控制其重传的次数。

服务端的优化

当服务端 SYN 半连接队列溢出后,会导致后续连接被丢弃,可以通过 netstat -s 观察半连接队列溢出的情况,如果 SYN 半连接队列溢出情况比较严重,可以通过 tcp_max_syn_backlog、somaxconn、backlog 参数来调整 SYN 半连接队列的大小。

服务端回复 SYN+ACK 的重传次数由 tcp_synack_retries 参数控制。如果遭受 SYN 攻击,应把 tcp_syncookies 参数设置为 1,表示仅在 SYN 队列满后开启 syncookie 功能,可以保证正常的连接成功建立。

服务端收到客户端返回的 ACK,会把连接移入 accpet 队列,等待进行调用 accpet() 函数取出连接。

可以通过 ss -lnt 查看服务端进程的 accept 队列长度,如果 accept 队列溢出,系统默认丢弃 ACK,如果可以把 tcp_abort_on_overflow 设置为 1 ,表示用 RST 通知客户端连接建立失败。

如果 accpet 队列溢出严重,可以通过 listen 函数的 backlog 参数和 somaxconn 系统参数提高队列大小,accept 队列长度取决于 min(backlog, somaxconn)。

绕过三次握手

TCP Fast Open 功能可以绕过三次握手,使得 HTTP 请求减少了 1 个 RTT 的时间,Linux 下可以通过 tcp_fastopen 开启该功能,同时必须保证服务端和客户端同时支持。

为什么客户端和服务端的初始序列号 ISN 是不相同的?

因为网络中的报文会延迟、会复制重发、也有可能丢失,这样会造成的不同连接之间产生互相影响,所以为了避免互相影响,客户端和服务端的初始序列号是随机且不同的。

初始序列号ISN的取值

不知道大家有没有想过ISN的值要设成什么?代码写死从零开始?

想象一下如果写死一个值,比如0,那么假设已经建立好连接了,client也发了很多包比如已经第20个包了,然后网络断了之后client重新,端口号还是之前那个,然后序列号又从0开始,此时服务端返回第20个包的ack,客户端是不是傻了?

所以RFC793中认为ISN要和一个假的时钟绑定在一起ISN每四微秒加一,当超过2的32次方之后又从0开始,要四个半小时左右发生ISN回绕。 

所以ISN变成一个递增值,真实的实现还需要加一些随机值在里面,防止被不法份子猜到ISN。

RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。

ISN = M + F (localhost, localport, remotehost, remoteport)

M 是一个计时器,这个计时器每隔 4 毫秒加 1。

F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。

SYN超时了怎么处理?

当tcp进行三次握手的时候,第一步是客户端发送syn请求,服务端返回syn+sck ,客户端响应sck

当syn请求超时的时候,tcp会进行超时重传

在Linux中就是默认重试5次,并且就是阶梯性的重试,间隔就是1s、2s、4s、8s、16s,再第五次发出之后还得等32s才能知道这次重试的结果,所以说总共等63s才能断开连接。

重发的次数由 tcp_syn_retries 参数控制,默认是 5 次:

可以根据网络的稳定性和目标服务器的繁忙程度修改 SYN 的重传次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

第2次握手传回了ACK,为什么还要传回SYN?

接收端传回发送端所发送的ACK是为了告诉客户端,我接收到的信息确实就是你所发送的信号了,这表明从客户端到服务端的通信是正常的。而回传SYN则是为了建立并确认从服务端到客户端的通信。

SYN同步序列编号(Synchronize Sequence Numbers)是TCP/IP建立连接时使用的握手信号。在客户机和服务器之间建立正常的TCP网络连接时,客户机首先发出一个SYN消息,服务器使用SYN-ACK应答表示接收到了这个消息,最后客户机再以ACK(Acknowledgement)消息响应。这样在客户机和服务器之间才能建立起可靠的TCP连接,数据才可以在客户机和服务器之间传递。

SYN Flood攻击

当服务端收到 SYN 包后,服务端会立马回复 SYN+ACK 包,表明确认收到了客户端的序列号,同时也把自己的序列号发给对方。

此时,服务端出现了新连接,状态是 SYN_RCV。在这个状态下,Linux 内核就会建立一个「半连接队列」来维护「未完成」的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。

SYN超时需要耗费服务端63s的时间断开连接,也就说63s内服务端需要保持这个资源,所以不法分子就可以构造出大量的client向server发SYN但就是不回server。

使得server的SYN队列耗尽,无法处理正常的建连请求。

如何避免SYN攻击?

方式1:

其中一种解决方式是通过修改Linux内核参数,控制队列大小和当队列满时应做什么处理。

当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。

控制该队列的最大值如下参数:

net.core.netdev_max_backlog参数:

表示当每个网络接口接收数据包的速率比内核处理这些包的速率快时,允许发送到队列的数据包的最大数目,一般默认值为128(可能不同的linux系统该数值也不同)。nginx服务器中定义的NGX_LISTEN_BACKLOG默认为511。

net.core.somaxconn参数:

该参数用于调节同一个客户端同时发起的TCP连接数,一般默认值为128.在客户端存在高并发请求的情况下,该默认值较小,肯那个导致连接超时或重传问题,我们可以根据实际需要结合并发请求数来调节此值。

SYN_RCVD状态连接的最大个数:

net.ipv4.tcp_max_syn_backlog

注意:调整队列大小要上面3个参数一块改!

超出处理能时,对新的SYN直接回RST,丢弃连接:

net.ipv4.tcp_abort_on_overflow

方式2:

tcp_syncookies的方式可以应对SYN攻击的方法

net.ipv4.tcp_syncookies 参数主要有以下三个值:

0 值,表示关闭该功能;

1 值,表示仅当 SYN 半连接队列放不下时,再启用它;

2 值,表示无条件开启功能;

那么在应对 SYN 攻击时,只需要设置为 1 即可:

当「SYN队列」满之后,后续服务器收到SYN包,不进入SYN队列;

计算出一个 cookie 值,再以SYN+ACK中的序列号返回客户端,

服务端接收到客户端的应答报文时,服务器会检查这个ACK包的合法性。如果合法,直接放入到「Accept队列」。

最后应用通过调用 accpet() socket接口,从Accept队列取出的连接。

为什么TCP客户端最后还要发送一次确认呢?

一句话,主要防止已经失效的连接请求报文突然又传送到了服务器,从而产生错误。

如果使用的是两次握手建立连接,假设有这样一种场景,客户端发送了第一个请求连接并且没有丢失,只是因为在网络结点中滞留的时间太长了,由于TCP的客户端迟迟没有收到确认报文,以为服务器没有收到,此时重新向服务器发送这条报文,此后客户端和服务器经过两次握手完成连接,传输数据,然后关闭连接。此时此前滞留的那一次请求连接,网络通畅了到达了服务器,这个报文本该是失效的,但是,两次握手的机制将会让客户端和服务器再次建立连接,这将导致不必要的错误和资源的浪费。

如果采用的是三次握手,就算是那一次失效的报文传送过来了,服务端接受到了那条失效报文并且回复了确认报文,但是客户端不会再次发出确认。由于服务器收不到确认,就知道客户端并没有请求连接。

服务器没有收到ack怎么办?

假设第三个包丢了(ACK包),客户端发送完第三个包后单方面进入了 ESTABLISHED 状态,而服务端也认为此时连接是正常的,但第三个包没到达服务端

一、如果此时客户端与服务端都还没数据发送,那服务端会认为自己发送的SYN+ACK的包没发送至客户端,所以会超时重传自己的SYN+ACK包,同时一直处于 SYN_RCV 状态。

当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整 tcp_synack_retries 参数:

tcp_synack_retries 的默认重试次数是 5 次,与客户端重传 SYN 类似,它的重传会经历 1、2、4、8、16 秒,最后一次重传后会继续等待 32 秒,如果服务端仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒。

二、如果这时候客户端已经要发送数据了,服务端接收到了ACK + Data数据包,那自然就切换到 ESTABLISHED 状态下,并且接收客户端的Data数据包

三、如果此时服务端要发送数据了,但发送不了,会一直周期性超时重传SYN + ACK,直到接收到客户端的ACK包

accept 队列已满,只能丢弃连接吗?

服务器收到 ACK 后连接建立成功,此时,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。

如果进程不能及时地调用 accept 函数,就会造成 accept 队列(也称全连接队列)溢出,最终导致建立好的 TCP 连接被丢弃。

丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1。

tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:

0 :如果 accept 队列满了,那么 server 扔掉 client  发过来的 ack ;

1 :如果 accept 队列满了,server 发送一个 RST 包给 client,表示废掉这个握手过程和这个连接;

如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。

通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。

举个例子,当 accept 队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,客户端进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,客户端的请求就会被多次「重发」

如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 accept 队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。

所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。 

如何调整 accept 队列的长度呢?

accept 队列的长度取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog),其中:

somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 net.core.somaxconn 来设置其值;

backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小;

Tomcat、Nginx、Apache 常见的 Web 服务的 backlog 默认值都是 511。

四次挥手具体

数据传输完毕后,双方都可释放连接。

客户端和服务端双方都可以主动断开连接,通常先关闭连接的一方称为主动方,后关闭连接的一方称为被动方。

图例是客户端为主动方。

最开始的时候,客户端和服务器都是处于ESTABLISHED状态,然后客户端主动关闭,服务器被动关闭。

四次挥手过程只涉及了两种报文,分别是 FIN 和 ACK

FIN 就是结束连接的意思,谁发出 FIN 报文,就表示它将不会再发送任何数据,关闭这一方向上的传输通道;

ACK 就是确认的意思,用来通知对方:你方的发送通道已经关闭;

客户端进程发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为seq=u(等于前面已经传送过来的数据的最后一个字节的序号加1),此时,客户端进入FIN-WAIT-1(终止等待1)状态。TCP规定,FIN报文段即使不携带数据,也要消耗一个序号。

服务器收到连接释放报文,发出确认报文,ACK=1,ack=u+1,并且带上自己的序列号seq=v,此时,服务端就进入了CLOSE-WAIT(关闭等待)状态。TCP服务器通知高层的应用进程,客户端向服务器的方向就释放了,这时候处于半关闭状态,即客户端已经没有数据要发送了,但是服务器若发送数据,客户端依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。

客户端收到服务器的确认请求后,此时,客户端就进入FIN-WAIT-2(终止等待2)状态,等待服务器发送连接释放报文(在这之前还需要接受服务器发送的最后的数据)。

服务器将最后的数据发送完毕后,就向客户端发送连接释放报文,FIN=1,ack=u+1,由于在半关闭状态,服务器很可能又发送了一些数据,假定此时的序列号为seq=w,此时,服务器就进入了LAST-ACK(最后确认)状态,等待客户端的确认。

客户端收到服务器的连接释放报文后,必须发出确认,ACK=1,ack=w+1,而自己的序列号是seq=u+1,此时,客户端就进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2*MSL(最长报文段寿命)的时间后,当客户端撤销相应的TCB后,才进入CLOSED状态。

服务器只要收到了客户端发出的确认,立即进入CLOSED状态。同样,撤销TCB后,就结束了这次的TCP连接。可以看到,服务器结束TCP连接的时间要比客户端早一些。

为什么要四次挥手

因为TCP是全双工协议,也就是说双方都要关闭,每一方都向对方发送FIN和回应ACK。

客户端-发送一个FIN,用来关闭客户端到服务器的数据传送

服务器-收到这个FIN,它发回一个ACK,确认序号为收到的序号加1。和SYN一样,一个FIN将占用一个序号

服务器-关闭与客户端的连接,发送一个FIN给客户端

客户端-发回ACK报文确认,并将确认序号设置为收到序号加1

建立连接的时候,服务器在LISTEN状态下,收到建立连接请求的SYN报文后,把ACK和SYN放在一个报文里发送给客户端。

而关闭连接时,服务器收到对方的FIN报文时,仅仅表示对方不再发送数据了但是还能接收数据,而自己也未必全部数据都发送给对方了,所以己方可以立即关闭,也可以发送一些数据给对方后,再发送FIN报文给对方来表示同意现在关闭连接,因此,己方ACK和FIN一般都会分开发送,从而导致多了一次。

举个例子:A和B打电话,通话即将结束后,A说“我没啥要说的了”,B回答“我知道了”,但是B可能还会有要说的话,A不能要求B跟着自己的节奏结束通话,于是B可能又巴拉巴拉说了一通,最后B说“我说完了”,A回答“知道了”,这样通话才算结束。

四次挥手状态一定是这样变迁的吗

状态一定是这样变迁的吗?让我们再来看个图。

由于 TCP 是双全工的协议,所以是会出现两方同时关闭连接的现象,也就是同时发送了 FIN 报文。

此时,优化策略仍然适用。两方发送 FIN 报文时,都认为自己是主动方,所以都进入了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。

接下来,双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是一种新情况,所以连接会进入一种叫做 CLOSING 的新状态,它替代了 FIN_WAIT2 状态接着,双方内核回复 ACK 确认对方发送通道的关闭后,进入 TIME_WAIT 状态,等待 2MSL 的时间后,连接自动关闭。

挥手一定需要四次吗?

假设client已经没有数据发送给server了,所以它发送FIN给server表明自己数据发完了,不再发了,如果这时候server还是有数据要发送给client那么它就是先回复ack,然后继续发送数据。

等server数据发送完了之后再向client发送FIN表明它也发完了,然后等client的ACK这种情况下就会有四次挥手。

那么假设client发送FIN给server的时候server也没数据给client,那么server就可以将ACK和它的FIN一起发给client,然后等待client的ACK,这样不就三次挥手了?

四次挥手优化参数

 主动方的优化

主动发起 FIN 报文断开连接的一方,如果迟迟没收到对方的 ACK 回复,则会重传 FIN 报文,重传的次数由 tcp_orphan_retries 参数决定。

当主动方收到 ACK 报文后,连接就进入 FIN_WAIT2 状态,根据关闭的方式不同,优化的方式也不同:

如果这是 close 函数关闭的连接,那么它就是孤儿连接。如果 tcp_fin_timeout 秒内没有收到对方的 FIN 报文,连接就直接关闭。同时,为了应对孤儿连接占用太多的资源,tcp_max_orphans 定义了最大孤儿连接的数量,超过时连接就会直接释放。

反之是 shutdown 函数关闭的连接,则不受此参数限制;

当主动方接收到 FIN 报文,并返回 ACK 后,主动方的连接进入 TIME_WAIT 状态。这一状态会持续 1 分钟,为了防止 TIME_WAIT 状态占用太多的资源,tcp_max_tw_buckets 定义了最大数量,超过时连接也会直接释放。

当 TIME_WAIT 状态过多时,还可以通过设置 tcp_tw_reuse 和 tcp_timestamps 为 1 ,将 TIME_WAIT 状态的端口复用于作为客户端的新连接,注意该参数只适用于客户端。

被动方的优化

被动关闭的连接方应对非常简单,它在回复 ACK 后就进入了 CLOSE_WAIT 状态,等待进程调用 close 函数关闭连接。因此,出现大量 CLOSE_WAIT 状态的连接时,应当从应用程序中找问题。

当被动方发送 FIN 报文后,连接就进入 LAST_ACK 状态,在未等到 ACK 时,会在 tcp_orphan_retries 参数的控制下重发 FIN 报文。

主动方的优化

关闭的连接的方式通常有两种,分别是 RST 报文关闭和 FIN 报文关闭。

如果进程异常退出了,内核就会发送 RST 报文来关闭,它可以不走四次挥手流程,是一个暴力关闭连接的方式。

安全关闭连接的方式必须通过四次挥手,它由进程调用 close 或 shutdown 函数发起 FIN 报文(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。

调用 close 函数 和 shutdown 函数有什么区别?

调用了 close 函数意味着完全断开连接,完全断开不仅指无法传输数据,而且也不能发送数据。此时,调用了 close 函数的一方的连接叫做孤儿连接,如果你用 netstat -p 命令,会发现连接对应的进程名为空。

使用 close 函数关闭连接是不优雅的。于是,就出现了一种优雅关闭连接的 shutdown 函数,它可以控制只关闭一个方向的连接:

int shutdown(int sock,int howto)

第二个参数决定断开连接的方式,主要有以下三种方式:

SHUT_RD(0):关闭连接的读这个方向,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。

SHUT_WR(1):关闭连接的写这个方向,这就是常被称为半关闭的连接。如果发送缓冲区还有未发送的数据,将被立即发送出去,并发送一个 FIN 报文给对端。

SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。

close 和 shutdown 函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的 Linux 参数也不相同。

主动方收不到ack怎么办

主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态,正常情况下,如果能及时收到被动方的 ACK,则会很快变为 FIN_WAIT2 状态。

但是当迟迟收不到对方返回的 ACK 时,连接就会一直处于 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0。

你可能会好奇,这 0 表示几次?实际上当为 0 时,特指 8 次,从下面的内核源码可知:

如果 FIN_WAIT1 状态连接很多,我们就需要考虑降低 tcp_orphan_retries 的值,当重传次数超过 tcp_orphan_retries 时,连接就会直接关闭掉。

对于普遍正常情况时,调低 tcp_orphan_retries 就已经可以了。

如果遇到恶意攻击,FIN 报文根本无法发送出去,这由 TCP 两个特性导致的:

首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。

其次,TCP 有流量控制功能,当接收方接收窗口为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过接收窗口设为 0 ,这就会使得 FIN 报文都无法发送出去,那么连接会一直处于 FIN_WAIT1 状态。

解决这种问题的方法,是调整 tcp_max_orphans 参数,它定义了孤儿连接的最大数量:

当进程调用了 close 函数关闭连接,此时连接就会是孤儿连接,因为它无法在发送和接收数据。Linux 系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 tcp_max_orphans 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。

close 函数关闭的孤儿连接怎么收到FIN

当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。

这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法在发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒

它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。

这个 60 秒不是随便决定的,它与 TIME_WAIT 状态持续的时间是相同的

为什么要有TIME_WAIT

值得注意的是,在客户端TCP的状态转换图中,客户机会经历一个TIME_WAIT的状态。为什么在关闭连接过程中还需要这样的一个状态呢?

主动发起关闭连接的一方,才会有TIME-WAIT状态。

先看一下下面这幅图:

断开连接发起方在接受到接受方的FIN并回复ACK之后并没有直接进入CLOSED状态,而是进行了一波等待,等待时间为2MSL。

MSL是Maximum Segment Lifetime,即报文最长生存时间,RFC 793定义的MSL时间是2分钟,Linux实际实现是30s,那么2MSL是一分钟。

为什么客户端最后还要等待2MSL?

第一,保证客户端发送的最后一个ACK报文能够到达服务器,因为这个ACK报文可能丢失,站在服务器的角度看来,我已经发送了FIN+ACK报文请求断开了,客户端还没有给我回应,应该是我发送的请求断开报文它没有收到,于是服务器又会重新发送一次,而客户端就能在这个2MSL时间段内收到这个重传的报文,接着给出回应报文,并且会重启2MSL计时器。

第二,防止类似与“三次握手”中提到了的“已经失效的连接请求报文段”出现在本连接中。客户端发送完最后一个确认报文后,在这个2MSL时间中,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样新的连接中不会出现旧连接的请求报文。

为什么是 2 MSL 的时长呢?

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

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

因此,TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL,由于在 Linux 系统中,MSL 的值固定为 30 秒,所以它们都是 60 秒。

等待2MSL会产生什么问题?

如果服务器主动关闭大量的连接,那么会出现大量的资源占用,需要等到2MSL才会释放资源。

第一是内存资源占用;

第二是对端口资源的占用,一个TCP连接至少消耗一个本地端口;

第二个危害是会造成严重的后果的,要知道,端口资源也是有限的,一般可以开启的端口为32768~61000,也可以通过如下参数设置指定

net.ipv4.ip_local_port_range

如果是客户端主动关闭大量的连接,那么在2MSL里面那些端口都是被占用的,端口只有65535个,如果端口耗尽了就无法发起送的连接了,不过我觉得这个概率很低,这么多端口你这是要建立多少个连接?

如何解决2MSL产生的问题?

方式一:net.ipv4.tcp_tw_reuse和tcp_timestamps

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

net.ipv4.tcp_tw_reuse=1
使用这个选项,还有一个前提,需要打开对TCP时间戳的支持

tcp_tw_reuse 从协议角度理解是安全可控的,可以复用处于 TIME_WAIT 的端口为新的连接所用。

什么是协议角度理解的安全可控呢?主要有两点:

只适用于连接发起方,也就是 C/S 模型中的客户端;

对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。

net.ipv4.tcp_timestamps=1(默认即为1)

这个时间戳的字段是在TCP头部的「选项」里,用于记录TCP发送方的当前时间戳和从对端接收到的最新时间戳。

由于引入了时间戳,它能带来了些好处:

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

同时,它还可以防止序列号绕回,也是因为重复的数据包会由于时间戳过期被自然丢弃;

老版本的 Linux 还提供了 tcp_tw_recycle 参数,但是当开启了它,就有两个坑:

Linux 会加快客户端和服务端 TIME_WAIT 状态的时间,也就是它会使得 TIME_WAIT 状态会小于 60 秒,很容易导致数据错乱;

另外,Linux 会丢弃所有来自远端时间戳小于上次记录的时间戳(由同一个远端发送的)的任何数据包。就是说要使用该选项,则必须保证数据包的时间戳是单调递增的。那么,问题在于,此处的时间戳并不是我们通常意义上面的绝对时间,而是一个相对时间。很多情况下,我们是没法保证时间戳单调递增的,比如使用了 NAT,LVS 等情况;

所以,不建议设置为 1 ,建议关闭它:

在 Linux 4.12 版本后,Linux 内核直接取消了这一参数。

温馨提醒:net.ipv4.tcp_tw_reuse要慎用,因为使用了它就必然要打开时间戳的支持 net.ipv4.tcp_timestamps,当客户端与服务端主机时间不同步时,客户端的发送的消息会被直接拒绝掉。

方式二:net.ipv4.tcp_max_tw_buckets

这个值默认为18000,当系统中处于TIME_WAIT的连接一旦超过这个值时,系统就会将所有的TIME_WAIT连接状态重置。

这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。

方式三:程序中使用SO_LINGER

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

structlingerso_linger;
so_linger.l_onoff=1;
so_linger.l_linger=0;
setsockopt(s,SOL_SOCKET,SO_LINGER,&so_linger,sizeof(so_linger));

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

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

所以我给出的建议是服务端不要主动关闭,把主动关闭方放到客户端。毕竟咱们服务器是一对很多很多服务,我们的资源比较宝贵。

被动方的优化

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

内核没有权利替代进程去关闭连接,因为如果主动方是通过 shutdown 关闭连接,那么它就是想在半关闭连接上接收数据或发送数据。因此,Linux 并没有限制 CLOSE_WAIT 状态的持续时间。

当然,大多数应用程序并不使用 shutdown 函数关闭连接。所以,当你用 netstat 命令发现大量 CLOSE_WAIT 状态。就需要排查你的应用程序,因为可能因为应用程序出现了 Bug,read 函数返回 0 时,没有调用 close 函数。

处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文关闭发送通道,同时连接进入 LAST_ACK 状态,等待主动方返回 ACK 来确认连接关闭。

如果迟迟收不到这个 ACK,内核就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与主动方重发 FIN 报文的优化策略一致。

还有一点我们需要注意的,如果被动方迅速调用 close 函数,那么被动方的 ACK 和 FIN 有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值