什么是惊群效应
惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。
惊群效应场景
在早期的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事件(表示描述符可读,有新连接接入),此时内核会唤醒所有的进程,所以“惊群”的问题依然存在。
源码实现
int ngx_cdecl
main(int argc, char *const *argv)
{
...
cycle = ngx_init_cycle(&init_cycle);
...
ngx_master_process_cycle(cycle); // 创建工作进程
}
ngx_cycle_t *
ngx_init_cycle(ngx_cycle_t *old_cycle)
{
...
if (ngx_open_listening_sockets(cycle) != NGX_OK) {
goto failed;
}
}
ngx_int_t
ngx_open_listening_sockets(ngx_cycle_t *cycle)
{
...
//ngx_socket定义
//#define ngx_socket socket
//#define ngx_socket_n "socket()"
s = ngx_socket(ls[i].sockaddr->sa_family, ls[i].type, 0);
if (bind(s, ls[i].sockaddr, ls[i].socklen) == -1) {
err = ngx_socket_errno;
}
if (listen(s, ls[i].backlog) == -1) {
...
}
...
}
nginx解决方式
-
锁
对于这种情况下的“惊群”问题,Nginx的解决方案和《UNP 第三版》第30章中30.7和30.8给出的加锁方案类似,大概就是通过互斥锁对每个进程从epoll_wait到accept之间的处理通过互斥量保护。需要注意的是,对于这种加锁操作,每次只有一个子进程能执行epoll_wait和accept,具体哪个进程得到执行,要看内核调度。所以,为了解决负载均衡的问题,Nginx的解决方案中,每个进程有一个当前连接计数,如果当前连接计数超过大连接的7/8,该进程就停止接收新的连接。
源码实现如下:
void
ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
...
//对监听的sockets事件进行处理
if (ngx_use_accept_mutex) {
if (ngx_accept_disabled > 0) {
ngx_accept_disabled--;
} else {
// 加锁成功进行后续处理
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
//设置网络读写事件延迟处理标志,即在释放锁后处理
if (ngx_accept_mutex_held) {
flags |= NGX_POST_EVENTS;
} else {
if (timer == NGX_TIMER_INFINITE
|| timer > ngx_accept_mutex_delay)
{
timer = ngx_accept_mutex_delay;
}
}
}
}
...
//这里面epollwait等待网络事件
//网络连接事件,放入ngx_posted_accept_events队列
//网络读写事件,放入ngx_posted_events队列
(void) ngx_process_events(cycle, timer, flags);
...
//先处理网络连接事件,只有获取到锁,这里才会有连接事件
ngx_event_process_posted(cycle, &ngx_posted_accept_events);
//释放锁,让其他进程也能够拿到
if (ngx_accept_mutex_held) {
ngx_shmtx_unlock(&ngx_accept_mutex);
}
ngx_event_expire_timers();
//处理网络读写事件
ngx_event_process_posted(cycle, &ngx_posted_events);
}
-
SO_REUSEPORT
nginx 在 1.9.1 版本加入了这个功能,其本质是利用了 Linux 的 reuseport 的特性, 内核允许多个进程 listening socket 到同一个端口上,从内核层面做了负载均衡,每次只唤醒其中一个进程。
修改参数如下,只需要在listen 端口后面加上 reuseport即可 :
server {
listen 80 reuseport;
listen 443 ssl http2 reuseport;
server_name xxx.xxx.com;
charset utf-8;
官方测试情况如下:
网上这方面的内容也非常多,大家可以自行搜索。详细参考 The SO_REUSEPORT socket option [LWN.net] https://blog.n0p.me/2018/02/2018-02-20-portsharding/
注意:启用REUSEPORT也可能导致一些意想不到的问题,比如单个请求处理的最大延迟可能会增加,可以参考 https://blog.cloudflare.com/the-sad-state-of-linux-socket-balancing/