9.8.2 如何解决“惊群”问题

只有打开了accept_mutex锁,才可以解决“惊群”问题。何谓“惊群”?就像上面说过的那样,master进程开始监听Web端口,fork出多个worker子进程,这些子进程开始同时监听同一个Web端口。一般情况下,有多少CPU核心,就会配置多少个worker子进程,这样所有的worker子进程都在承担着Web服务器的角色。在这种情况下,就可以利用每一个CPU核心可以并发工作的特性,充分发挥多核机器的“威力”。但下面假定这样一个场景:没有用户连入服务器,某一时刻恰好所有的worker子进程都休眠且等待新连接的系统调用(如epoll_wait),这时有一个用户向服务器发起了连接,内核在收到TCP的SYN包时,会激活所有的休眠worker子进程,当然,此时只有最先开始执行accept的子进程可以成功建立新连接,而其他worker子进程都会accept失败。这些accept失败的子进程被内核唤醒是不必要的,它们被唤醒后的执行很可能也是多余的,那么这一时刻它们占用了本不需要占用的系统资源,引发了不必要的进程上下文切换,增加了系统开销。

也许很多操作系统的最新版本的内核已经在事件驱动机制中解决了“惊群”问题,但Nginx作为可移植性极高的Web服务器,还是在自身的应用层面上较好地解决了这一问题。既然“惊群”是多个子进程在同一时刻监听同一个端口引起的,那么Nginx的解决方式也很简单,它规定了同一时刻只能有唯一一个worker子进程监听Web端口,这样就不会发生“惊群”了,此时新连接事件只能唤醒唯一正在监听端口的worker子进程。

可是如何限制在某一时刻仅能有一个子进程监听Web端口呢?下面看一下ngx_trylock_accept_mutex方法的实现。在打开accept_mutex锁的情况下,只有调用ngx_trylock_accept_mutex方法后,当前的worker进程才会去试着监听web端口,具体实现如下所示。


ngx_int_t ngx_trylock_accept_mutex(ngx_cycle_t*cycle)

{

/使用进程间的同步锁,试图获取accept_mutex锁。注意,ngx_shmtx_trylock返回1表示成功拿到锁,返回0表示获取锁失败。这个获取锁的过程是非阻塞的,此时一旦锁被其他worker子进程占用,ngx_shmtx_trylock方法会立刻返回(详见14.8节)/

if(ngx_shmtx_trylock(&ngx_accept_mutex)){

/如果获取到accept_mutex锁,但ngx_accept_mutex_held为1,则立刻返回。ngx_accept_mutex_held是一个标志位,当它为1时,表示当前进程已经获取到锁了/

if(ngx_accept_mutex_held

&&ngx_accept_events==0

&&!(ngx_event_flags&NGX_USE_RTSIG_EVENT))

{

//ngx_accept_mutex锁之前已经获取到了,立刻返回

return NGX_OK;

}

//将所有监听连接的读事件添加到当前的epoll等事件驱动模块中

if(ngx_enable_accept_events(cycle)==NGX_ERROR){

/既然将监听句柄添加到事件驱动模块失败,就必须释放ngx_accept_mutex锁/

ngx_shmtx_unlock(&ngx_accept_mutex);

return NGX_ERROR;

}

/经过ngx_enable_accept_events方法的调用,当前进程的事件驱动模块已经开始监听所有的端口,这时需要把ngx_accept_mutex_held标志位置为1,方便本进程的其他模块了解它目前已经获取到了锁/

ngx_accept_events=0;

ngx_accept_mutex_held=1;

return NGX_OK;

}

/如果ngx_shmtx_trylock返回0,则表明获取ngx_accept_mutex锁失败,这时如果ngx_accept_mutex_held标志位还为1,即当前进程还在获取到锁的状态,这当然是不正确的,需要处理/

if(ngx_accept_mutex_held){

/ngx_disable_accept_events会将所有监听连接的读事件从事件驱动模块中移除/

if(ngx_disable_accept_events(cycle)==NGX_ERROR){

return NGX_ERROR;

}

/在没有获取到ngx_accept_mutex锁时,必须把ngx_accept_mutex_held置为0/

ngx_accept_mutex_held=0;

}

return NGX_OK;

}


在上面关于ngx_trylock_accept_mutex方法的源代码中,ngx_accept_mutex实际上是Nginx进程间的同步锁。第14章我们会详细介绍进程间的同步方式,目前只需要清楚ngx_shmtx_trylock方法是一个非阻塞的获取锁的方法即可。如果成功获取到锁,则返回1,否则返回0。ngx_shmtx_unlock方法负责释放锁。ngx_accept_mutex_held是当前进程的一个全局变量,如果为1,则表示这个进程已经获取到了ngx_accept_mutex锁;如果为0,则表示没有获取到锁,这个标志位主要用于进程内各模块了解是否获取到了ngx_accept_mutex锁,具体定义如下所示。


ngx_shmtx_t

ngx_accept_mutex;

ngx_uint_t

ngx_accept_mutex_held;


因此,在调用ngx_trylock_accept_mutex方法后,要么是唯一获取到ngx_accept_mutex锁且其epoll等事件驱动模块开始监控Web端口上的新连接事件,要么是没有获取到锁,当前进程不会收到新连接事件。

如果ngx_trylock_accept_mutex方法没有获取到锁,接下来调用事件驱动模块的process_events方法时只能处理已有的连接上的事件;如果获取到了锁,调用process_events方法时就会既处理已有连接上的事件,也处理新连接的事件。这样的话,问题又来了,什么时候释放ngx_accept_mutex锁呢?等到这批事件全部执行完吗?这当然是不可取的,因为这个worker进程上可能有许多活跃的连接,处理这些连接上的事件会占用很长时间,也就是说,会有很长时间都没有释放ngx_accept_mutex锁,这样,其他worker进程就很难得到处理新连接的机会。

如何解决长时间占用ngx_accept_mutex锁的问题呢?这就要依靠ngx_posted_accept_events队列和ngx_posted_events队列了。首先看下面这段代码:


if(ngx_trylock_accept_mutex(cycle)==NGX_ERROR){

return;

}

if(ngx_accept_mutex_held){

flags|=NGX_POST_EVENTS;

}


调用ngx_trylock_accept_mutex试图处理监听端口的新连接事件,如果ngx_accept_mutex_held为1,就表示开始处理新连接事件了,这时将flags标志位加上NGX_POST_EVENTS。这里的flags是在9.6.3节中列举的ngx_epoll_process_events方法中的第3个参数flags。回顾一下这个方法中的代码,当flags标志位包含NGX_POST_EVENTS时是不会立刻调用事件的handler回调方法的,代码如下所示。


if((revents&EPOLLIN)&&rev->active){

if(flags&NGX_POST_EVENTS){

queue=(ngx_event_t**)(rev->accept?&ngx_posted_accept_events:&ngx_posted_events);

ngx_locked_post_event(rev,queue);

}else{

rev->handler(rev);

}

}


对于写事件,也可以采用同样的处理方法。实际上,ngx_posted_accept_events队列和ngx_posted_events队列把这批事件归类了,即新连接事件全部放到ngx_posted_accept_events队列中,普通事件则放到ngx_posted_events队列中。这样,接下来会先处理ngx_posted_accept_events队列中的事件,处理完后就要立刻释放ngx_accept_mutex锁,接着再处理ngx_posted_events队列中的事件(参见图9-7),这样就大大减少了ngx_accept_mutex锁占用的时间。