<>TCP的连接与断开

这一篇来具体分析一下TCP是如何连接并断开的。三次握手过程后建立一个TCP会话,四次挥手后断开一个TCP会话。所有TCP通信必须在TCP会话中进行。

学习TCP连接与断开时,不仅要知道建立连接时通信双方发送的报文和过程,还需要掌握断开和连接时双方TCP协议的状态变迁。

<>一、 TCP三次握手详解

<>1. 过程详解

图1 TCP连接的建立过程

*
双方都处于CLOSE状态,服务端一般会先调用listen函数、accept函数以主动监听某个端口,调用listen、accept成功后,服务端处于LISTEN
状态,默认阻塞等待客户端的SYN报文。

*
客户端调用connect函数以发送一个SYN报文,开始建立连接。

SYN报文
的SYN字段为1,同时客户端将随机初始化一个初始序号“client_isn”,将此序号置于序列号字段,作为以后通信时客户端报文的初始序列号。该报文不含应用层数据。发出SYN报文后,客户端处于
SYN_SENT状态。
​ 图2 SYN报文

*
服务端成功收到 SYN报文 后,将发送 SYN+ACK报文 ,同时服务端处于SYN_RCVD状态。

SYN+ACK报文可以看成服务端发送的SYN报文和对客户端的应答ACK报文的组合:一方面起到SYN报文的作用(向客户端发起连接请求);一方面起到应答客户端SYN报文的作用。

同时,和客户端发送的SYN报文一样,服务端也会随机产生一个服务端初始序号“server_isn”置于序列号字段,作为以后通信时服务端报文的初始序列号。确认应答号字段置为“client_isn+1”此报文也不能包含应用层数据。

图3 SYN+ACK报文

*
客户端收到服务端的 SYN+ACK报文 后,将处于ESTABLISHED状态,客户端连接建立完成。同时向服务端发送ACK报文。此ACK报文
的确认应答号为“server_isn+1”。最后的ACK报文是可以携带应用层数据的。

图4 ACK报文

*
最后,服务端收到ACK报文后也进入ESTABLISHED状态,此时双向连接建立完成。在应用层的表现就是进程从accept的阻塞中解除。

<>2. 为什么使用三次握手建立连接

为什么握手次数非要是3次呢?两次不行吗?主要有两方面原因:

*
阻止过时的连接请求:

设想有一个过时的连接请求(SYN报文),如果TCP协议只有两次握手,服务端收到过时的SYN立即建立连接、分配资源。这样没有及时感知这个SYN报文是不是过时的就已经分配了资源,这样明显不好。注意,对于过时的SYN报文,只有客户端才能通过SYN+ACK报文感知出来,服务端是懵逼的!

而三次握手中,如果服务端收到的是过时的SYN,其序列号是过时,**那么服务端向客户端发送的SYN+ACK的应答序列号就是过时的,**客户端就可以根据这一点判断出此时不该接受连接,于是向服务端发送RST复位报文即可阻止该连接。

*
为双方连接维护一组序列号:

双方向对端发送的SYN(SYN+ACK)报文中包含序列号字段
,初始化了双方通信使用的序列号,以后的通信中,客户端和服务端都使用自己产生的初始随机序列号来通信。作为可靠地通信的基础。

<>3. 连接队列

连接队列是客户端与服务端并发地建立TCP连接过程中,由服务端维护的,记录已建立连接/未完全建立连接的TCP会话的队列(其实这种数据结构并不是真正的队列)。

连接队列有两种,全连接队列(SYN队列)、半连接队列(Accept队列)。随着三次握手过程的进行,两种队列也在入队出队。

图5 TCP三次握手过程中的连接队列机制

想象有大量的客户端向服务端请求建立连接,服务端会收到来自这些客户端的SYN报文,服务端收到这些SYN报文后,会将这些连接信息存入SYN队列中(意味着这些TCP会话正处于
SYN_RCVD状态),并向对端发送SYN+ACK报文;开始等待客户端最后的ACK报文。

当收到客户端的ACK后,服务端将对应的信息从SYN队列中取出,放入Accept队列中,等待应用层取出处理。

*
与全连接队列和半连接队列相关的内核参数

*
全连接队列(Accept队列):

全连接队列的大小在Linux内核中是这样计算的:min(somaxconn,backlog)

somaxconn可以通过 /proc/sys/net/core/somaxconn来设置其值。
//Linux5.4.72内核的默认值为4096 LAPTOP-OHBI7I8S% cat /proc/sys/net/core/somaxconn 4096
backlog则是listen函数的第二个参数,由用户指定;listen函数原型:
listen(int sockfd, int backlog);//将sockfd设为主动监听状态,并将全连接队列参数设为backlog
​ 全连接队列情况的查看:
//-l 显示正在监听的socket //-n 不解析服务器名称 //-t 只显示TCP socket LAPTOP-OHBI7I8S% ss -lnt
State Recv-Q Send-Q... ... ... LISTEN 0 511 LAPTOP-OHBI7I8S% ss -nt State
Recv-Q Send-Q... ... ... ESTAB 0 50
当查看处于Listen状态的套接字时:

* Recv-Q为当前全连接队列中的存在的元素个数
* Send-Q为全连接队列的容量
当查看处于非Listen状态的套接字时:

* Recv-Q为已收到但未被上层协议处理的字节数
* Send-Q为已发送但未收到对端ACK的字节数
*
半连接队列(SYN队列):

半连接队列中的TCP会话都是处于TCP_RCVD状态的,所以可以利用这一点使用netstat工具来查看半连接队列中有多少元素:
LAPTOP-OHBI7I8S% netstat | grep SYN_RCVD
<>二、TCP四次挥手详解

TCP连接的断开需要双方进行四次挥手。建立连接一般需要客户端发起,服务端监听并接受;而断开连接时,双方都可以主动发起断开请求,同时释放主机占用的连接资源。

图6 TCP会话断开时的四次挥手

可以看到,断开和连接的过程类似,只不过连接时将服务端的SYN和ACK两个报文合成为一个了(SYN+ACK);而断开连接时并没有将FIN和ACK合为一个。为什么这样处理稍后会看到。

<>1. 断开连接的过程

上图可以很清楚地反映断开连接时双方发送的报文和双方状态的变迁过程。我认为在练习基础的网络编程时有几个事情需要注意:

*
关闭连接的API——close和shutdown的区别

close调用
将关闭本设备TCP连接的读端和写端,当仍在内核缓冲区的数据完全发送完毕后,向对端发送一个FIN报文;此后,本端将彻底失去对端的感知,即不能从对端读入数据、也不能向对端发数据了。
int close(int sockfd);
shutdown调用
允许用户关闭指定连接的“任一半”,可以是读端或写端。比如,关闭读端后,不能再从对端读数据,但仍可以向对端发送数据。shutdown调用时也会向对端发送FIN报文,但本端并不会完全失去对端的感知。
int shutdown(int sockfd, int how); //SHUT_RD:关闭读端 //SHUT_WR:关闭写端
//SHUT_REWR:关闭读端和写端(相当于先调用SHUT_RD,再调用SHUT_WR)
当你觉得我现在不需要再向对端发送或读取任何数据了,那么你就可以使用close彻底关闭本端的描述符;

而当你觉得我不需要再向对端发送数据了,但是我还需要读取对端此后发送的数据,这个时候就应该用shutdown。

*
那么,你可能觉得shutdown(sockfd, SHUT_REWR);与close(sockfd);等效?

其实还是有不一样的,当有多个进程共享文件(套接字)时,close的行为只是会关闭当前进程的描述符,而不会真的断掉物理上的TCP会话,其他进程仍可以自由读写。当引用计数为0后,再彻底断掉物理上的TCP会话;而shutdown则不管引用计数是否为0,直接对物理上的TCP连接关闭相应的读端或写端。

这一点上的处理方式和Unix对文件的管理方式是一致的,我之前的笔记有详细分析。

*
发送FIN报文的时机

内核向对端发送FIN报文的时机有两种:

*
用户调用close或shutdown时并且引用计数为0,调用后也不会立即发送FIN,如果数据已经被压入缓冲区,但还没来得及发走,那么会等所有数据发走后、并收到ACK后,才会发送FIN。因为本端要确保所有数据对方可以成功接受,才能发送FIN,否则没来得及发送的数据就会丢失。
* 套接字描述符所在的所有进程进程都结束时,那么内核会自动管理处于打开状态的描述符,将其关闭。此时也会向对端发送FIN报文。
另外,对端对FIN报文的感知是一个文件结束符(EOF)。当读到一个EOF时,说明对端已经关闭了连接,发送了FIN报文。用Unix 的文件IO
API来感知FIN报文的方法:
if(read(connfd,read_buff,size)!=0) {//对端尚未发送FIN报文} else {//收到对端的FIN报文}
read调用的返回值意义:返回0,读到EOF;读到-1,读异常;正数,成功读到的字节数。

*
TIMEWAIT状态

最后,还需要分析一下TIMEWAIT状态。

首先,主动关闭连接的一方,会在收到对端FIN报文后,立即进入TIMEWAIT状态。

TIMEWAIT状态将持续2个MSL(最长报文生存时间,Maximum Segment
Lifetime),它是任何报文在网络上生存的最长时间,超过这个时间的报文会被网络设备丢弃。

另外,与MSL类似的网络名词是TTL(Time To
Live),它是网络层IP协议头的一个字段,数据报每过一个路由器此字段就会减一,到0后此数据报会被路由器丢弃,同时路由器发送一个ICMP报文以通知源主机。

TTL与MSL的区别在于:TTL的单位是路由器跳数;MSL的单位是时间。一般来说MSL需要设置得大于等于TTL跳数消耗为0的时间。

了解以上之后,似乎可以理解为什么内核要设置TIMEWAIT状态,并将其设置为2个MSL时间了:

如果因为网络延迟原因造成服务端收到FIN报文后,又马上与对端建立了一个相同的TCP会话,而此时恰好收到了旧连接的数据。。此数据理应被接受,此时就会造成令人迷惑的bug。所以必须服务端必须要等待2个MSL时间,确保旧连接的数据彻底消失在网际中了,才能建立新连接。下面这幅图描述了这个bug:

图7 TIMEWAIT时间过短(或没有)的隐含BUG

我们知道了为什么TCP协议需要一个TIMEWAIT
状态:**就是为了避免因网络延迟等原因造成对用户的服务出现不可修复的偶然错误。**那,这样做就完全没有坏处了吗?显然不是。。

这意味着,主动断开连接的一方,需要花费2个MSL才能重新利用此端口,在2个MSL内,端口是不可用的!

所以,如果服务器含有大量TIMEWAIT状态的连接,端口被全部占满,会导致服务器无法继续接受服务了,而且会占用大量内存。

最后,要是问我说怎么才能避免服务器产生过多的TIMEWAIT状态连接呢??

我觉得最好的方法就是服务器不主动断开连接哈哈哈。。。

除此以外,还有一些方法是治标不治本:

法1:**客户端复用TIMEWAIT状态的端口+开启时间戳功能。**由于时间戳开启了,所以过时报文的担忧就不存在了,但是这种”复用“只能用于客户端的connect调用,并不能用于服务端的accept。。。

法2:**重置TIMEWAIT端口。**可以设置内核参数使处于TIMEWAIT的TCP连接超过一个值重置以后的TIMEWAIT
端口。这种做法显然很危险,后面的TCP连接可能会遇到过期数据包的困扰!

法3:**改变close调用的行为。**使用socket选项(SO_LINGER
)甚至可以改变close函数的行为,使其不向对端发送FIN报文,而是发送RST复位报文,让对端强制断开,绕过4次挥手。。这就更扯了,还是会很危险。

除此以外,《UNP》中还提到一种端口复用的方法,并建议所有服务端套接字都应该使用之:

技术
下载桌面版
GitHub
Gitee
SourceForge
百度网盘(提取码:draw)
云服务器优惠
华为云优惠券
腾讯云优惠券
阿里云优惠券
Vultr优惠券
站点信息
问题反馈
邮箱:[email protected]
吐槽一下
QQ群:766591547
关注微信