关闭一个TCP连接有两个函数可调用:
close
函数签名:int close(int sockfd)
shutdown()
函数签名:int shutdown(int sockfd, int howto)
对已连接的套接字执行close操作,若成功则为0,若出错则为-1
这个函数会对套接字引用计数减1,当套接字引用计数到0,就会对套接字进行彻底释放,并且会关闭 TCP 两个方向的数据流
因为套接字可以被多个进程共享,所以就有了套接字引用计数,例如通过fork的方式产生子进程,套接字引用计数+1, 如果调用一次close函数,套接字引用计数就会-1。 这就是套接字引用计数的含义
当套接字引用计数为0时,连接的两个方向被关闭
在输入方向
系统内核会将该套接字设置为不可读,任何读操作都会返回异常
在输出方向
系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个FIN报文,接下来如果再对该套接字进行写操作会返回异常。如果对端没有检测到套接字已关闭, 还继续发送报文,就会收到一个RST报文,所以记得处理RST报文,RST报文产生的错误在Windows平台上对应的错误码为WSAECONNRESET,在Linux平台上对应的错误码为ECONNRESET,如果 未处理RST错误,在RST的套接字进行写操作,会直接触发 SIGPIPE 信号,现象就是程序退出
close函数并不能直接的关闭连接的一个方向,但shutdown可以,从上面shutdown函数签名看出,shutdown函数有两个参数,下面是对howto参数的解释:
SHUT_RD(0)
关闭连接的“读”这个方向,对该套接字进行读操作直接返回EOF。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达, 会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下发送端根本不知道数据已经被丢弃了
SHUT_WR(1)
关闭连接的“写”这个方向,这就是常被称为”半关闭“的连接。此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去, 并发送一个FIN报文给对端。应用程序如果对该套接字进行写操作会触发SIGPIPE
SHUT_RDWR(2)
相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向
close会关闭连接,并释放所有连接对应的资源,而shutdown并不会释放掉套接字和所有的资源
close存在引用计数的概念,并不一定导致该套接字不可用;shutdown则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响
由于有引用计数概念的存在,close不一定会发出FIN结束报文,除非引用计数为0,而shutdown则总是会发出FIN结束报文
验证思路:
主动关闭方先向被动关闭方发送消息
主动关闭方发起close
观察被动关闭方的处理
套接字引用计数为1
启动服务器:
// 收到客户端消息后sleep 8000ms再处理
./test0306 --handle_sleep=8000
启动客户端:
./bin/client -p 5701 --dest_host=*.*.*.* --dest_port=5700
...
enter command:
连接成功后在客户端依次输入命令:1、close3,1表示向服务器发送一次消息,close3表示对id为3的socket调用close方法,注意:应在服务器端wake前发送close3命令,过程可以参考下面时序图:
上图的流程可以正常关闭连接
现在让服务端不处理FIN包,则服务端内存中的消息数据被调用发送,
启动服务器:
// 收到客户端消息后sleep 8000ms再处理,这里多加了一个参数--skip_EOF
./test0306 --handle_sleep=8000 --skip_EOF
启动客户端:
./bin/client -p 5701 --dest_host=*.*.*.* --dest_port=5700
...
enter command:
连接成功后在客户端依次输入命令:1、close3,1表示向服务器发送一次消息,close3表示对id为3的socket调用close方法
时序图如下:
从客户端来看并没有收到服务器回复的消息,同时再向输入命令1,服务端没有收到消息,此时跟踪客户端发现,select模型并没有针对这个socket抛出可读、可写的事件,所以使用close函数会 释放连接对应的资源
而上面提到:如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个RST报文。从服务端log来看并没有收到RST报文:
log:debug, function: ProcessMsg, line_num: 42, msg: send data fd:4,sent len:1
出现这条log说明发送返回了,所以决定使用linger试一下,对linger的使用如下:
struct linger l = { 0 };
l.l_onoff = 1;
l.l_linger = 0;
setsockopt(socket_, SOL_SOCKET, SO_LINGER, (const char *)&l, sizeof(l));
这种使用方式表示:如果l_onoff为非 0, 且l_linger值为 0,那么调用close后,会立该发送一个 RST报文给对端
测试方案:
客户端对socket设置linger
服务端忽略RST,继续回复client消息,观察服务端
启动服务器:
// 收到客户端消息后sleep 8000ms再处理,
// 这里多加了一个参数--ignore_RST
./test0306 --handle_sleep=8000 --ignore_RST
启动客户端:
./bin/client -p 5701 --dest_host=*.*.*.* --dest_port=5700 --linger=0
...
enter command:
从服务端log来看:
log:debug, function: CatchSockError, line_num: 122, msg: TcpPacketInputHandler catch socket error fd:4,err_number:104
catch a sig:13
验证了这一点:对RST的套接字进行写操作,会直接触发 SIGPIPE 信号,现象就是程序退出。如果没有处理这个sig,则程序直接退出
从这一小节总结下来:
一个健壮的程序应该增加对SIGPIPE信号的捕获
对RST连接的处理,否则触发SIGPIPE 信号
对正常关闭的连接也要处理,否则造成主动关闭方TIME_WAIT,被动关闭方CLOSE_WAIT(套接字泄露)
从上面log也看出,这是一个典型的 write 操作造成异常,再通过 read 操作来感知异常的样例,TcpPacketInputHandler
表示通过read得到的错误
验证思路:
主动关闭方先向被动关闭方发送消息
主动关闭方发起shutdown,关闭写方向
被动关闭方不处理FIN包,保证被动关闭方的回复消息发出
由于先处理接收消息,并且对FIN包的处理就是释放连接相关资源,所以回复给主动关闭方的消息被释放,所以被动关闭方不处理FIN包,保证被动关闭方的回复消息发出
观察主动关闭方是否收到被动关闭方回复的消息
在主动关闭方继续输入发送消息命令:1,观察主动关闭方的反应
启动服务器:
// 收到客户端消息后sleep 8000ms再处理
// 这里多加了一个参数--skip_EOF
./test0306 --handle_sleep=8000 --skip_EOF
启动客户端:
./bin/client -p 5701 --dest_host=*.*.*.* --dest_port=5700
...
enter command:
时序图如下:
从主动关闭方和被动关闭方的log看出,主动关闭方收到了被动关闭方的回复消息:
// 被动关闭方的log
log:debug, function: ProcessMsg, line_num: 42, msg: send data fd:4,sent len:16
// 主动关闭方的log
log:debug, function: MsgS2C0407Handler, line_num: 37, msg: MsgS2C0407,msg_id:3,msg_len:10,data:512
收到被动关闭方的回复消息后继续在主动关闭方输入发送消息命令:1,主动关闭方触发了SIGPIPE:
catch a sig:13
从这一小节总结出:
对于一个关闭写方向的socket,进行写操作触发SIGPIPE
使用shutdown关闭socket,并不会释放掉套接字和所有的资源,应用程序依然能感知到可读、可写事件
对于服务端程序如果实际生产过程中出现大量CLOSE_WAIT连接,这些CLOSE_WAIT连接保持了很长时间,说明服务端程序出现了套接字泄露,结合这篇文章提到的TCP四次挥手,服务端程序出现CLOSE_WAIT连接, 通常是客户端程序即主动关闭方发送了FIN包,即发起了第一次挥手,而服务端程序没有处理FIN包,即没有调用close,从而导致套接字泄露
如果出现大量的CLOSE_WAIT连接,常见的现象就是新连接无响应,大量的CLOSE_WAIT连接占用着大量系统资源,目前来看,应对策略就是分析服务端应用程序,从服务端应用程序端找到泄露点, 找到后重启服务端应用程序
还有一个点需要验证,这篇文章提到的挥手步骤3中:应用程序可以通过read调用来感知这个FIN包,这个EOF会被放在已排队等候的其他已接收的数据之后。验证思路:
服务端程序感知新连接后,sleep一段时间
在服务端程序sleep的这段时间,客户端程序发消息,随后kill掉客户端程序
观察服务端对这个连接的状态变化,以及过了sleep时间后服务端程序对这个连接的处理
在过了sleep时间之前一定做完的几件事:
客户端程序发起连接
服务端观察连接状态
客户端程序发消息
kill掉客户端程序
服务端再次观察连接状态
服务端程序:
./test0306 --listen_backlog=3 --accept_sleep=7000
客户端程序:
./client -p 5701 --dest_host=IP.IP.IP.IP --dest_port=5700
服务端对于这个连接的变化如下:
netstat -alepn|grep 5700
tcp 1 0 0.0.0.0:5700 0.0.0.0:* LISTEN 0 1604060 20684/./test0306
tcp 16 0 *.*.*.*:5700 *.*.*.*:18186 ESTABLISHED 0 0
客户端程序发消息完毕后,kill掉客户端程序:
netstat -alepn|grep 5700
tcp 0 0 0.0.0.0:5700 0.0.0.0:* LISTEN 0 1604060 20684/./test0306
tcp 17 0 *.*.*.*:5700 *.*.*.*:18186 CLOSE_WAIT 0 1604096 20684/./test0306
过了sleep时间后,服务端程序log输出:
log:info, function: HandleInput, line_num: 57, msg: a connect accept,remote socket address,ip:*.*.*.*,port:18186
log:debug, function: Msg2Handler, line_num: 25, msg: msg2,msg_id:2,msg_len:10,data:213
log:debug, function: OnGetError, line_num: 147, msg: TcpPacketInputHandler get socket error fd:4
log:debug, function: ~Session, line_num: 17, msg: Session have released
...
从服务端程序log来看,服务端程序先处理消息,随后处理了FIN包,证明了验证的点是正确的
在这篇文章提到TIME_WAIT状态只能出现在主动关闭方,而对于一个频繁关闭连接的服务来说,如果被动关闭方没有收到最后一次挥手,例如被动关闭方宕机等原因,造成TIME_WAIT,从以下几点 了解一下TIME_WAIT:
TIME_WAIT危害
如果服务存在大量的TIME_WAIT状态的连接,对于内存的影响可以忽略不计,关于对内存的分析可以参考这篇文章,但是对于服务端的端口占用不可忽略,导致的后果就是无法创建新连接
TIME_WAIT状态持续时间
TIME_WAIT停留持续时间是固定的,是最长分节生命期 MSL(maximum segment lifetime)的两倍,一般称之为 2MSL。 Linux 系统里有一个硬编码的字段——TCP_TIMEWAIT_LEN,其值为60秒。即Linux 系统停留在TIME_WAIT的时间为固定的60秒
2MSL的时间是从主动关闭方接收到被动关闭方的FIN报文后发送ACK开始计时的;如果在TIME_WAIT时间内,因为主动关闭方的ACK没有传输到被动关闭方, 主动关闭方又接收到被动关闭方重发的FIN报文,则2MSL时间将重新计时。因为2MSL的时间,目的是为了让旧连接的所有报文都能自然消亡, 现在主动关闭方重新发送了ACK报文,自然需要重新计时,以便防止这个ACK报文对新可能的连接造成干扰
TIME_WAIT作用
TIME_WAIT的作用有两点:
确保被动关闭方接收到ACK,从而被动关闭方状态由LAST_ACK切换到CLOSED状态
上面提到的对linger的使用就是使连接跳过了TIME_WAIT状态,直接进入CLOSED状态,从上面演示也看出,对这个状态的连接进行写操作会收到RST报文,如果被动关闭方没有处理RST 报文,被动关闭方会触发SIGPIPE
防止这个连接的报文,对新连接造成干扰
在一种特殊的情况下,可能出现原连接和新连接的四元组完全一样,这样原连接的报文有可能对新连接造成影响,所以在这个状态上设计了2MSL的计时器,以确保原连接的报文不对新连接造成影响, 经过了2MSL后原连接的报文消失,状态切换到CLOSED状态
保证数据不丢失
对于TCP套接字,在将数据添加到发送缓冲区和让TCP实现真正发送数据之间可能会有相对较长的延迟。结果,当关闭TCP套接字时, 发送缓冲区中可能仍然有待处理的数据,这些数据尚未发送,但是应用程序可能将其视为已发送,如果TCP实现是立即关闭套接字,那么所有这些数据都将丢失, 应用程序甚至不知道
TCP是可靠的协议,丢失数据不是很可靠。这就是为什么当调用close后仍要发送数据,并且将这个的套接字的状态置为TIME_WAIT。在这种状态下,它将等待, 直到所有未发送数据已成功发送或直到发生超时为止,在这种情况下,将强制关闭套接字
优化TIME_WAIT
优化TIME_WAIT有几种方法:
缩短TIME_WAIT时间
调低TCP_TIMEWAIT_LEN,重新编译系统,需要重新编译内核
修改TIME_WAIT数量
通过调低系统值net.ipv4.tcp_max_tw_buckets,当系统中处于TIME_WAIT的连接一旦超过这个值时,系统就会将所有的TIME_WAIT连接状态重置,并且只打印出警告信息
设置SO_LINGER
设置linger参数有几种可能:
如果l_onoff为 0
那么关闭本选项。l_linger的值被忽略,这对应了默认行为,close 或 shutdown 立即返回。 如果在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去
如果l_onoff为非 0, 且l_linger值也为 0
调用 close 后,会立该发送一个 RST 标志给对端,该TCP连接将跳过四次挥手,跳过了TIME_WAIT状态,直接关闭。 这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,造成数据丢失,始终会强制关闭而不是正常关闭,通常不建议使用此选项,被动关闭方也不知道对端已经彻底断开。 被动关闭方会收到RST报文,上面已经演示过了
如果l_onoff为非 0, 且l_linger的值也非 0
调用close后,调用close的线程就将阻塞,直到数据被发送出去,或者设置的l_linger计时时间到
复用TIME_WAIT套接字
修改系统值net.ipv4.tcp_tw_reuse为1,默认为0,有几点需要注意:
RFC1323中实现了TCP拓展规范,以便保证 TCP 的高可用,并引入了新的 TCP 选项,两个 4 字节的时间戳字段,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。 由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃
适用于两端
一种适用于连接发起方、被连接方的方案就是打开系统选项:net.ipv4.tcp_tw_recycle,默认为0,但是从Linux4.2开始已经完全被移除了, 最好禁用此选项,因为它导致难以检测和难以诊断的问题
在服务器端,请勿启用net.ipv4.tcp_tw_recycle,启用net.ipv4.tcp_tw_reuse对于传入连接无效
从上面列出的几种优化方案,复用TIME_WAIT套接字和设置SO_LINGER的第二种方案是可取的
使用设置SO_LINGER的第二种方案时要注意对端的处理,处理不当有可能造成对端进程退出
在客户端,启用net.ipv4.tcp_tw_reuse是另一种几乎安全的解决方案