如何优雅的处理 Accept 出现 Emfile 的问题

本文转载自微信公众号「Linux开发那些事儿」,作者LinuxThings。转载本文请联系Linux开发那些事儿公众号。
通常情况下,服务端调用 accept 函数会返回一个新的文件描述符,用于和客户端之间的数据传输
在服务器的开发中,有时会遇到这种情况:当调用 accept 函数接受客户端连接,函数返回失败,对应的错误码是 EMFILE, 它表示当前进程打开的文件描述符已达上限,此时,服务器不能再接受客户端连接
当遇到上述问题,怎么合理的处理呢,下面就来分析一下
建立连接的流程
先简单回顾下客户端和服务器建立连接的流程,具体的如下图所示:

1. 客户端发起 SYN 请求
2. 服务器收到客户端的 SYN 请求后,内核把连接放入半连接队列,同时给客户端返回一个 SYN + ACK
3. 客户端向服务器返回一个确认的 ACK, 服务器收到本次 ACK 之后,三次握手完成,同时,内核把连接从半连接队列中移除,创建新完全连接,加入到全连接队列中
4. 应用层调用 accept 函数从全连接队列中取出连接
上面的第 1、第 2、第 3 步是 TCP 的三次握手,它是由内核中TCP协议完成的, 第 4 步是应用层调用 accept 接口
在 epoll 中的问题
epoll 是 Linux中IO多路复用模型,在服务器的开发中有广泛的应用,下面就以 epoll 为例来详细说明
服务器端创建侦听文件描述符 listenfd 之后, 向 epoll 注册读事件
当 epoll 检测到 listenfd 上有读事件发生,会立即通知应用层,应用层调用 accept 接受新连接,而此时进程打开的文件描述符数量已经达到上限了,所以每次 accept 都是失败的
这里会出现以下几个问题
- 由于 每次 accept 都失败了,相当于 listenfd 上的可读事件没有处理,epoll 会不停的触发 listenfd 上的可读事件,应用层也就会不停的调用 accept,然后又出现 accept 调用失败,如此这般不停的执行无效的循环,白白浪费了CPU的资源
- 上面提到服务器在不停的执行无效的循环, 将会引发另一个问题,如果此时有新客户端连接到来,建立连接的过程会很慢
前面说的 epoll 默认是使用了水平触发模式,如果使用垂直触发模式会出现什么问题呢?
垂直触发模式下,listenfd 从无读事件状态到有读事件状态时,才会通知到应用层,在应用层处理完 listenfd 上所有的读事件之前,epoll 不会再通知应用层
也就是说,应用层收到 listenfd 上读事件通知之后,需要把 listenfd 上所有的读事件全部处理完,下次listenfd 上再有读事件时,才会通知应用层
[SITESERVER_PAGE]回到 accept 的问题上,在垂直触发模式下,当 epoll 通知应用层 listenfd 上有可读事件时,应用层调用 accept, 由于此时进程打开的文件描述符数量已经达到上限了,所以 accept 调用失败
也即 listenfd 上的可读事件还没有处理,在应用层处理完 listenfd 上可读事件之前,epoll 不会再通知应用层 listenfd 上有可读事件
如果在应用层处理完 listenfd 上可读事件之前,有新的客户端连接到来,这个时候 epoll 是不会通知应用层 listenfd 上有可读事件,这会导致一个严重的问题:accept 只要出现了 EMFILE的错误码,就再也无法接受客户端的连接了
所以,当出现 EMFILE 时,不管使用 epoll 的水平触发模式还是垂直触发模式都会存在问题
如何解决
EMFILE 表示进程打开的文件描述符数量达到上限了,可以把这个值调大些,但这治标不治本
本来系统设置文件描述符数量上限是为了限制进程对系统资源的过度占用,况且,这个值调整到多大合适呢,总不能无限大吧,所以调整上限值的方式不是最合适的方式
accept 成功时会返回一个新的文件描述符,如果此时进程打开的文件描述符数量已经达到上限了,就会返回失败
假如此时能关闭一个空闲的文件描述符,让出一个名额,再调用 accept 就会创建成功,这种方式具体的处理步骤如下:
1、事先准备一个空闲的文件描述符 idlefd,相当于先占一个"坑"位
2、调用 close 关闭 idlefd,关闭之后,进程就会获得一个文件描述符名额
3、再次调用 accept 函数, 此时就会返回新的文件描述符 clientfd, 立刻调用 close 函数,关闭 clientfd
4、重新创建空闲文件描述符 idlefd,重新占领 "坑" 位,再出现这种情况的时候又可以使用
由于测试代码比较长,这里就不贴了,感兴趣可以通过文末的方式获取,下面是处理 EMFILE 的伪代码:
- int ret = accept( listenfd, (struct sockaddr*)&addr, sizeof(addr) );
- if (-1 == ret)
- {
- if ( errno == EMFILE )
- {
- //关闭空闲文件描述符,释放 "坑"位
- close(idlefd);
- //接受 clientfd
- clientfd = accept( listenfd, nullptr, nullptr);
- //关闭 clientfd,防止一直触发 listenfd 上的可读事件
- close(clientfd);
- //重新占领 "坑"位
- idlefd = ::open("/dev/null", O_RDONLY | O_CLOEXEC);
- }
- }