关闭TCP连接

关闭一个TCP连接有两个函数可调用:

  • close

    函数签名:int close(int sockfd)

  • shutdown()

    函数签名:int shutdown(int sockfd, int howto)

使用close函数


对已连接的套接字执行close操作,若成功则为0,若出错则为-1

这个函数会对套接字引用计数减1,当套接字引用计数到0,就会对套接字进行彻底释放,并且会关闭 TCP 两个方向的数据流

因为套接字可以被多个进程共享,所以就有了套接字引用计数,例如通过fork的方式产生子进程,套接字引用计数+1, 如果调用一次close函数,套接字引用计数就会-1。 这就是套接字引用计数的含义

当套接字引用计数为0时,连接的两个方向被关闭

  • 在输入方向

    系统内核会将该套接字设置为不可读,任何读操作都会返回异常

  • 在输出方向

    系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个FIN报文,接下来如果再对该套接字进行写操作会返回异常。如果对端没有检测到套接字已关闭, 还继续发送报文,就会收到一个RST报文,所以记得处理RST报文,RST报文产生的错误在Windows平台上对应的错误码为WSAECONNRESET,在Linux平台上对应的错误码为ECONNRESET,如果 未处理RST错误,在RST的套接字进行写操作,会直接触发 SIGPIPE 信号,现象就是程序退出

使用shutdown函数


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


验证思路:

  • 主动关闭方先向被动关闭方发送消息

  • 主动关闭方发起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命令,过程可以参考下面时序图:

Alt text

上图的流程可以正常关闭连接

现在让服务端不处理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方法

时序图如下:

Alt text

从客户端来看并没有收到服务器回复的消息,同时再向输入命令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


验证思路:

  • 主动关闭方先向被动关闭方发送消息

  • 主动关闭方发起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:

时序图如下:

Alt text

从主动关闭方和被动关闭方的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连接,这些CLOSE_WAIT连接保持了很长时间,说明服务端程序出现了套接字泄露,结合这篇文章提到的TCP四次挥手,服务端程序出现CLOSE_WAIT连接, 通常是客户端程序即主动关闭方发送了FIN包,即发起了第一次挥手,而服务端程序没有处理FIN包,即没有调用close,从而导致套接字泄露

如果出现大量的CLOSE_WAIT连接,常见的现象就是新连接无响应,大量的CLOSE_WAIT连接占用着大量系统资源,目前来看,应对策略就是分析服务端应用程序,从服务端应用程序端找到泄露点, 找到后重启服务端应用程序

还有一个点需要验证,这篇文章提到的挥手步骤3中:应用程序可以通过read调用来感知这个FIN包,这个EOF会被放在已排队等候的其他已接收的数据之后。验证思路:

  • 服务端程序感知新连接后,sleep一段时间

  • 在服务端程序sleep的这段时间,客户端程序发消息,随后kill掉客户端程序

  • 观察服务端对这个连接的状态变化,以及过了sleep时间后服务端程序对这个连接的处理

在过了sleep时间之前一定做完的几件事:

  1. 客户端程序发起连接

  2. 服务端观察连接状态

  3. 客户端程序发消息

  4. kill掉客户端程序

  5. 服务端再次观察连接状态

服务端程序:

./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状态持续时间

    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参数有几种可能:

      1. 如果l_onoff为 0

        那么关闭本选项。l_linger的值被忽略,这对应了默认行为,close 或 shutdown 立即返回。 如果在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去

      2. 如果l_onoff为非 0, 且l_linger值也为 0

        调用 close 后,会立该发送一个 RST 标志给对端,该TCP连接将跳过四次挥手,跳过了TIME_WAIT状态,直接关闭。 这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,造成数据丢失,始终会强制关闭而不是正常关闭,通常不建议使用此选项,被动关闭方也不知道对端已经彻底断开。 被动关闭方会收到RST报文,上面已经演示过了

      3. 如果l_onoff为非 0, 且l_linger的值也非 0

        调用close后,调用close的线程就将阻塞,直到数据被发送出去,或者设置的l_linger计时时间到

    • 复用TIME_WAIT套接字

      修改系统值net.ipv4.tcp_tw_reuse为1,默认为0,有几点需要注意:

      1. 只适用于连接发起方
      2. 对应的TIME_WAIT状态的连接创建时间超过 1 秒才可以被复用
      3. 需要打开对TCP时间戳的支持,即net.ipv4.tcp_timestamps=1

      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是另一种几乎安全的解决方案