由一个EPOLLET模式accept()问题引起对TCP连接的回顾

时间:2024-04-08 17:57:16
  • 问题起因

在生产环境出现一个TCP网络连接的异常,socket accept()时返回错误,错误码errno=24,strerror=”Too many open files”,线程占用CPU接近100%,即死循环。查明直接原因是,进程的open files数量太小,当连接数超时该数目时即会返回上述错误。(这要提醒一句:要注意父子进程的limits参数,也许系统配置参数已修改,但父进程是在修改前启动的,再去fork子进程时,子进程的limits参数也许就不如本意。通过cat /proc/<pid>/limits 查看进程的limits参数。)

上述问题提供给用户连接服务的是子进程,而其父进程的open files却是1024,即便系统设置了open files为1,000,000,也阻止不了其共享父进程的参数,所以触发了上述的问题。为修复该问题,当然第一步要重启父进程,让其参数与系统配置的一致,再让其fork子进程。

然而还要问,为何进程占用CPU会接近100%?这就是底层网络实现涉及的问题。熟知IO多路复用模型epoll的人知道,其有两种触发模式,水平触发(EPOLLLT)和边缘触发(EPOLLET),默认为EPOLLLT模式。水平触发模式相对容易操作,只要内核socket读写缓冲区仍有数据就会触发epoll。对边缘触发模式,man手册推荐的正确用法:

(i) with non-blocking file descriptors; and

(ii) by waiting for an event only after read(2) or write(2) return EAGAIN.

非阻塞socket一直读/写,直到返回错误码EAGAIN(linux下EWOULDBOCK 宏定义为 EAGAIN)。

正是由于生产环境底层网络实现中利用了epoll的EPOLLET模式,而返回的错误码并非EAGAIN,所以导致线程一直没有退出循环。如此可能就导致了整个网络读写瘫痪,无法正常服务了。如何避免在出现open files达到上限后就不能读写的困境呢?接下来就去回顾下TCP连接的基础。

  • TCP连接的三次握手、四次挥手的回顾及理解

由一个EPOLLET模式accept()问题引起对TCP连接的回顾由一个EPOLLET模式accept()问题引起对TCP连接的回顾

       图1是大家熟知的TCP连接的状态图。图2是TCP连接三次握手状态图与应用层的关系。从图2可见,在应用层调用accept()函数前,在内核TCP连接可以正常握手成功,被放入一个accept队列内。这涉及到一个参数backlog,listen(int sockfd, int backlog),linux 内核2.2版本之后,backlog的设置只与accept queue的大小有关。syns queue = /proc/sys/net/ipv4/tcp_max_syn_backlog。accept queue = min(backlog, /proc/sys/net/core/comaxconn)。可通过ss -l命令查看监听端口的SendQ、RecvQ。

当accept队列满了之后,内核会怎么处理呢?通过命令查看cat /proc/sys/net/ipv4/tcp_abort_on_overflow。 如果是1,则内核将直接忽略最后一个ACK,而给对端发reset,关闭该连接;如果是0,内核会忽略最后一个ACK,而启动SYN+ACK定时重传机制,此时的状态仍是SYN_RECV,由/proc/sys/net/ipv4/tcp_synack_retries指定重传次数,在重传次数满之前accept queue出现空位时,内核会处理最后一次ACK将连接放进accept队列。

回到生产环境的那个问题,EPOLLET模式下,accept queue相当于accpet()的读缓冲区,一个epoll触发事件accept()函数会一直循环读取accept queue里的连接数据,所以accept queue一般情况并不会出现满的情况。而当达到进程的open files上限后,accept()函数将一直处于循环之中,后续的连接必将慢慢填满accept queue,而一旦队列满了,EPOLLET模式还能被触发accept()吗?!这时会提一个问题,如果旧的连接断开后文件描述符被释放,accept()就能成功,将accept queue的连接数据读取出来了?接下来看TCP断连的四次挥手。

TCP/IP详解(卷1)有说明,发送FIN通常是应用层进行关闭的结果。也就是说,旧的连接断开对端发送了FIN包,本地应用层去读取后才能回应FIN包,这样才能释放旧的fd,否则一直处于CLOSE_WAIT状态。如果有空闲线程去读取回应FIN包,释放旧的fd,accept死循环将被终结,服务可短时间恢复连接。所以无论单线程还是多线程,如果所有线程都出现errno=24导致accept()死循环的情况,基本服务不可恢复了,所有读写都会被阻塞住了。

 

  • 尝试解决方案

刚开始简单地想尝试对调用accept()固定在最多循环有限次就退出,这样就不会死循环而可以释放断开连接发来的FIN包,但这样就违背了man手册推荐的用法,读到返回EAGAIN为止。同时也会产生另外一个问题,设有限次数为10,假如并发达到20次,一次循环只能accept 10个连接,剩下的10次连接将一直放在accept queue内直到有新的连接请求触发epoll,这样就可能会导致有些连接长时间没有被accept,当然在客户端本地会设定超时重连。其实,这对生产环境的那个问题,也不完全能解决。假若达到open files上限,即便在循环有限次后能退出去处理其他epoll事件,但若没有旧连接断开,每次accept事件仍将返回错误,最后也会使accept queue填满,这时一旦退出accept的循环,将再也不可能触发accept了,即便能读取回应FIN包,释放fd数量。

其他方案?!暂时没想到。可能用EPOLLLT模式会更直接方便,不用一直循环,由缓冲区数据去驱动循环事件,这样是不是就可以处理accept/read/write事件了呢,而不至于由于accept死循环影响了read/write的事件驱动。

(注:文中图片来自网络其他文章,具体路径已无法查明,特说明表致意。)