Linux accept()/epoll_wait()惊群问题与解决方案

问题的来源:

参考《UNP 第三版》第30章“客户/服务器设计范式”中“30.6 TCP预先派生子进程服务器程序”

// 为便于说明问题,代码已简化
int main(int argc, char **argv)
{
    int listenfd = Tcp_Listen();

    for (int i = 0; i < nchildren; i++){
        if ((pid = fork()) == 0){
            child_main(i, listenfd, addrlen);
        }
    }

    for(;;){
        pause();
    }
}

pid_t child_main(int i, int listenfd, int addrlen)
{
    struct sockaddr clientAddr;
    socklen_t clientAddrLen;
    for(;;)
    {
        int connfd = accept(listenfd, &clientAddr, &clientAddrLen);
        web_child(connfd);
        close(connfd);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

在如上所示的代码中,主进程创建了用于监听的socket描述符listenfd,每个子进程阻塞调用accept(listenfd, ),等待获取客户端的新建连接connfd,并放入web_child(connfd)中执行。 
因为多个子进程被同时阻塞在同一个监听socket上,当有客户端的新建连接接入时,所有被阻塞的子进程都被唤醒,但是只有执行最快的那个子进程调用accept(listenfd, )能够得到客户连接connfd,其它子进程调用accept(listenfd, )只会等到EGAIN。这种多个子进程被唤醒后又无事可做的状态被称为“惊群”,因为每次进程调度的系统开销相对较大,所以对于高并发服务器,频繁的“惊群”必然导致的服务器性能低下。

针对30.6样例中存在的问题,30.7和30.8给出了两种方案分别使用“文件锁”和“线程互斥锁”保护accept()。简单的说,就是所有的子进程在调用accept()之前,先获取一个,只有得到的进程才能继续执行accept()函数,其它进程都被锁阻塞了。并且,通过验证,这种加锁的方式对于解决“惊群”问题是有效的。

for(;;) 
{    
    lock(); // 文件锁或互斥锁    
    int client = accept(...);    
    unlock();    

    if (client < 0) continue;    
    ...  
}  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

另外,30.9中还给出了一个“主进程aceept客户连接,再将客户连接描述符传递给子进程处理”的方案。不过,通过实验证明:和相比,进程间传递描述符的效率更低。这算是题外话吧。

其实,如果只考虑Linux系统,在Linux 2.6版本以后,内核内核已经解决了accept()函数的“惊群”问题,大概的处理方式就是,当内核接收到一个客户连接后,只会唤醒等待队列上的第一个进程或线程。所以,如果服务器采用accept阻塞调用方式,在最新的Linux系统上,已经没有“惊群”的问题了。

但是,对于实际工程中常见的服务器程序,大都使用select、poll或epoll机制,此时,服务器不是阻塞在accept,而是阻塞在select、poll或epoll_wait,这种情况下的“惊群”仍然需要考虑。接下来以epoll为例分析:

在早期的Linux版本中,内核对于阻塞在epoll_wait的进程,也是采用全部唤醒的机制,所以存在和accept相似的“惊群”问题。新版本的的解决方案也是只会唤醒等待队列上的第一个进程或线程,所以,新版本Linux 部分的解决了epoll的“惊群”问题。所谓部分的解决,意思就是:对于部分特殊场景,使用epoll机制,已经不存在“惊群”的问题了,但是对于大多数场景,epoll机制仍然存在“惊群”。

1、场景一:epoll_create()在fork子进程之前:

如果epoll_create()调用在fork子进程之前,那么epoll_create()创建的epfd 会被所有子进程继承。接下来,所有子进程阻塞调用epoll_wait(),等待被监控的描述符(包括用于监听客户连接的监听描述符)出现新事件。如果监听描述符发生可读事件,内核将阻塞队列上排在第一位的进程/线程唤醒,被唤醒的进程/线程继续执行accept()函数,得到新建立的客户连接描述符connfd。这种情况下,任何一个子进程被唤醒并执行accept()函数都是没有问题的。

但是,接下来,子进程的工作如果是调用epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);将新建连接的描述符connfd加入到epfd中统一监控的话,因为,当前的epfd是在fork之前创建的,此时系统中只有一个epoll监控文件,即所有子进程共享一个epoll监控文件。任何一个进程(父进程或子进程)向epoll监控文件添加、修改和删除文件描述符时,都会影响到其它进程的epoll_wait。 
后续,当connfd描述符上接收到客户端信息时,内核也无法保证每次都是唤醒同一个进程/线程,来处理这个连接描述符connfd上的读写信息(其它进程可能根本就不认识connfd;或者在不同进程中,相同的描述符对应不同的客户端连接),最终导致连接处理错误。(另外,不同的线程处理同一个连接描述符,也会导致发送的信息乱序)

所以,应该避免epoll_create()在fork子进程之前。关于这一点,据说libevent的文档中有专门的描述。

2、场景二:epoll_create()在fork子进程之后:

如果epoll_create()在fork子进程之后,则每个进程都有自己的epoll监控文件(当某个进程将新建连接的描述符connfd加入到本进程的epfd中统一监控,不会影响其它进程的epoll_wait),但是为了实现并发监听,所有的子进程都会调用 
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); 
监听描述符加入到监控文件中,也就是说所有子进程都在通过epoll机制轮询同一个监听描述符。如果有新的客户端请求接入,监听描述符出现POLLIN事件(表示描述符可读,有新连接接入),此时内核会唤醒所有的进程,所以“惊群”的问题依然存在。

对于这种情况下的“惊群”问题,Nginx的解决方案和《UNP 第三版》第30章中30.7和30.8给出的加锁方案类似,大概就是通过互斥锁对每个进程从epoll_wait到accept之间的处理通过互斥量保护。需要注意的是,对于这种加锁操作,每次只有一个子进程能执行epoll_wait和accept,具体哪个进程得到执行,要看内核调度。所以,为了解决负载均衡的问题,Nginx的解决方案中,每个进程有一个当前连接计数,如果当前连接计数超过最大连接的7/8,该进程就停止接收新的连接。

lock()    
epoll_wait(...);   
accept(...);    
unlock(...);   
  • 1
  • 2
  • 3
  • 4

另外,我在想,如果不考虑多进程,而是用多线程实现,因为,线程调度的开销比进程要小很多,那么在多线程下,是否就可以不用考虑惊群的问题,当然这个结论需要具体的测试数据,后面有空准备测试一下。

3、利用SO_REUSEPORT解决epoll的惊群问题

网上这方面的内容也非常多,大家可以自行搜索。因为这是利用内核新版本新特性的解决方案,应该算是终极解决方案吧。 
利用SO_REUSEPORT解决epoll的惊群

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值