[TOC]
概念
惊群通常发生在server 上,当父进程绑定一个端口监听socket,然后fork出多个子进程,子进程们开始循环处理(比如accept)这个socket。每当用户发起一个TCP连接时,多个子进程同时被唤醒,然后其中一个子进程accept新连接成功,余者皆失败,重新休眠。
惊群现象(thundering herd)就是当多个进程和线程在同时阻塞等待同一个事件时,如果这个事件发生,会唤醒所有的进程,但最终只可能有一个进程/线程对该事件进行处理,其他进程/线程会在失败后重新休眠,这种性能浪费就是惊群。
那么,我们不能只用一个进程去accept新连接么?然后通过消息队列等同步方式使其他子进程处理这些新建的连接,这样惊群不就避免了?没错,惊群是避免了,但是效率低下,因为这个进程只能用来accept连接。对多核机器来说,仅有一个进程去accept,这也是程序员在自己创造accept瓶颈。所以,我仍然坚持需要多进程处理accept事件。
其实,在linux2.6内核上,accept系统调用已经不存在惊群了(至少我在2.6.18内核版本上已经不存在)。大家可以写个简单的程序试下,在父进程中bind,listen,然后fork出子进程,所有的子进程都accept这个监听句柄。这样,当新连接过来时,大家会发现,仅有一个子进程返回新建的连接,其他子进程继续休眠在accept调用上,没有被唤醒。
但是很不幸,通常我们的程序没那么简单,不会愿意阻塞在accept调用上,我们还有许多其他网络读写事件要处理,linux下我们爱用epoll解决非阻塞socket。所以,即使accept调用没有惊群了,我们也还得处理惊群这事,因为epoll有这问题。上面说的测试程序,如果我们在子进程内不是阻塞调用accept,而是用epoll_wait,就会发现,新连接过来时,多个子进程都会在epoll_wait后被唤醒!
nginx就是这样,master进程监听端口号(例如80),所有的nginx worker进程开始用epoll_wait来处理新事件(linux下),如果不加任何保护,一个新连接来临时,会有多个worker进程在epoll_wait后被唤醒,然后发现自己accept失败。现在,我们可以看看nginx是怎么处理这个惊群问题了。
Nginx如何处理惊群问题
ngx_process_events_and_timers
nginx的每个worker进程在函数ngx_process_events_and_timers中处理事件,(void) ngx_process_events(cycle, timer, flags);封装了不同的事件处理机制,在linux上默认就封装了epoll_wait调用。我们来看看ngx_process_events_and_timers为解决惊群做了什么:
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
| void ngx_process_events_and_timers(ngx_cycle_t *cycle) { ngx_uint_t flags; ngx_msec_t timer, delta;
if (ngx_timer_resolution) { timer = NGX_TIMER_INFINITE; flags = 0;
} else { timer = ngx_event_find_timer(); flags = NGX_UPDATE_TIME;
#if (NGX_WIN32)
if (timer == NGX_TIMER_INFINITE || timer > 500) { timer = 500; }
#endif }
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; } } } }
if (!ngx_queue_empty(&ngx_posted_next_events)) { ngx_event_move_posted_next(cycle); timer = 0; }
delta = ngx_current_msec;
(void) ngx_process_events(cycle, timer, flags);
delta = ngx_current_msec - delta;
ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "timer delta: %M", delta);
ngx_event_process_posted(cycle, &ngx_posted_accept_events);
if (ngx_accept_mutex_held) { ngx_shmtx_unlock(&ngx_accept_mutex); }
if (delta) { ngx_event_expire_timers(); }
ngx_event_process_posted(cycle, &ngx_posted_events); }
|
从上面的注释可以看到,无论有多少个nginx worker进程,同一时刻只能有一个worker进程在自己的epoll中加入监听的句柄。这个处理accept的nginx worker进程置flag为NGX_POST_EVENTS,这样它在接下来的ngx_process_events 函数(在linux中就是ngx_epoll_process_events函数)中不会立刻处理事件,延后,先处理完所有的accept事件后,释放锁,然后再处理正常的读写socket事件。我们来看下 **ngx_epoll_process_events **是怎么做的
ngx_epoll_process_events-获得锁后的事件处理
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| static ngx_int_t ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags){ ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "epoll timer: %M", timer);
events = epoll_wait(ep, event_list, (int) nevents, timer); rev->ready = 1; rev->available = -1;
if (flags & NGX_POST_EVENTS) { queue = rev->accept ? &ngx_posted_accept_events : &ngx_posted_events;
ngx_post_event(rev, queue);
} else { rev->handler(rev); } }
wev = c->write;
if ((revents & EPOLLOUT) && wev->active) {
if (c->fd == -1 || wev->instance != instance) {
ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "epoll: stale event %p", c); continue; }
wev->ready = 1; #if (NGX_THREADS) wev->complete = 1; #endif
if (flags & NGX_POST_EVENTS) { ngx_post_event(wev, &ngx_posted_events);
} else { wev->handler(wev); } } }
return NGX_OK; }
|
加锁的逻辑-ngx_trylock_accept_mutex
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| ngx_int_t ngx_trylock_accept_mutex(ngx_cycle_t *cycle) { if (ngx_shmtx_trylock(&ngx_accept_mutex)) {
ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "accept mutex locked");
if (ngx_accept_mutex_held && ngx_accept_events == 0) { return NGX_OK; }
if (ngx_enable_accept_events(cycle) == NGX_ERROR) { ngx_shmtx_unlock(&ngx_accept_mutex); return NGX_ERROR; }
ngx_accept_events = 0; ngx_accept_mutex_held = 1;
return NGX_OK; }
ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "accept mutex lock failed: %ui", ngx_accept_mutex_held);
if (ngx_accept_mutex_held) { if (ngx_disable_accept_events(cycle, 0) == NGX_ERROR) { return NGX_ERROR; }
ngx_accept_mutex_held = 0; }
return NGX_OK; }
|
总结
OK,关于锁的细节是如何实现的,这篇限于篇幅就不说了,下篇帖子再来讲。现在大家清楚nginx是怎么处理惊群了吧?简单了说,就是同一时刻只允许一个nginx worker在自己的epoll中处理监听句柄 (listenfd)。它的负载均衡也很简单,当达到最大connection的7/8时,本worker不会去试图拿accept锁,也不会去处理新连接,这样其他nginx worker进程就更有机会去处理监听句柄,建立新连接了。而且,由于timeout的设定,使得没有拿到锁的worker进程,去拿锁的频繁更高。
同一时间,只有一个 worker 能拿到 listenfd(监听句柄), 拿到 listenfd 的 worker 将这个文件描述符通过 epoll_ctl, 将这个句柄加入自己的监听列表中,其他没有拿到 listenfd 的 worker 就只处理 connfd (已连接的句柄)。这当然还涉及到多个worker的负载均衡的问题。单总体来说,同一时间,只有一个 worker 持有了 listenfd.
加锁的方式是对 listenFd 文件上锁
参考
原文:“惊群”,看看nginx是怎么解决它的