目录
一:惊群效应
1.1惊群效应是什么?(以修勾为例)
1.2惊群问题(thundering herd)的产生
1.3惊群效应的影响
二:常见的惊群效应
2.1accept 惊群
2.2epoll惊群
2.2.1fork之前创建epollfd(内核2.6已解决)
2.2.2 fork之后创建epollfd(内核未解决)
三:如何解决惊群问题-post事件处理机制
3.1Nginx为什么高效?
3.2多进程的epoll惊群
3.3集群是fd惊群还是epoll惊群?
3.4如何解决nginx的惊群?
3.4.1负载均衡
3.4.2加锁
四:扩展
惊群问题又名惊群效应。简单来说就是多个进程或者线程在等待同一个事件,当事件发生时,所有线程和进程都会被内核唤醒。唤醒后通常只有一个进程获得了该事件并进行处理,其他进程发现获取事件失败后又继续进入了等待状态,在一定程度上造成了资源浪费,降低了系统性能。
打个比方就是:当你往一群修勾中间扔一块食物,虽然最终只有一个修勾抢到食物,但所有修勾都会被惊动来争夺,没有抢到食物的修勾只好悻悻而归回去继续睡觉, 等待下一块食物到来。这样,每扔一块食物,都会惊动所有的修勾,即为惊群。
简单地说:就是扔一块食物,所有修勾来抢,但最终只一个修勾抢到了食物。
在建立连接的时候,Nginx出于充分发挥多核CPU架构性能的考虑,使用了多个worker子进程监听相同端口的设计,这样多个子进程在accept建立新连接时会有争抢,这会带来著名的“惊群”问题,子进程数量越多越明显,这会造成系统性能的下降。
一般情况下,有多少CPU核心就有配置多少个worker子进程。假设现在没有用户连入服务器,某一时刻恰好所有的子进程都休眠且等待新连接的系统调用(如epoll_wait),这时有一个用户向服务器发起了连接,内核在收到TCP的SYN包时,会激活所有的休眠worker子进程。最终只有最先开始执行accept的子进程可以成功建立新连接,而其他worker子进程都将accept失败。这些accept失败的子进程被内核唤醒是不必要的,他们被唤醒会的执行很可能是多余的,那么这一时刻他们占用了本不需要占用的资源,引发了不必要的进程切换,增加了系统开销。
惊群效应会占用系统资源,降低系统性能。多进程/线程的唤醒,涉及到的一个问题是上下文切换问题。频繁的上下文切换带来的一个问题是数据将频繁的在寄存器与运行队列中流转。极端情况下,时间更多的消耗在进程/线程的调度上,而不是执行。
在 Linux 下,我们常见的惊群效应发生于我们使用 accept
以及我们 select
、poll
或 epoll
等系统提供的 API 来处理我们的网络连接。
以多进程为例,在主进程创建监听描述符 listenfd 后,fork()多个子进程,多个进程共享listenfd,accept是在每个子进程中,当一个新连接来的时候,会发生惊群。
由上图所示:
在内核2.6之前,所有进程accept都会惊醒,但只有一个可以accept成功,其他返回EGAIN。
在内核2.6及之后,解决了惊群,在内核中增加了一个互斥等待变量。一个互斥等待的行为与睡眠基本类似,主要的不同点在于:
epoll惊群分两种:
分析:这里的epoll惊群跟accept惊群是类似的,共享一个epollfd, 加锁或标记解决。在新版本的epoll中已解决。但在内核2.6及之前是存在的。
分析:因为每个子进程的epoll是不同的epoll, 虽然listenfd是同一个,但新连接过来时, accept会触发惊群,但内核不知道该发给哪个监听进程,因为不是同一个epoll。所以这种惊群内核并没有处理。惊群还是会出现。
很多操作系统的最新版本的内核已经在事件驱动机制中解决了惊群问题,但Nginx作为可移植性极高的web服务器,还是在自身的应用层面上较好的解决了这一问题。
Nginx规定了同一时刻只有唯一一个worker子进程监听web端口,这一就不会发生惊群了,此时新连接事件只能唤醒唯一的正在监听端口的worker子进程。
如何限制在某一时刻是有一个子进程监听web端口呢?在打开accept_mutex锁的情况下,只有调用ngx_trylock_accept_mutex方法后,当前的worker进程才会去试着监听web端口。
那么,什么时候释放ngx_accept_mutex锁呢?
显然不能等到这批事件全部执行完。因为这个worker进程上可能有许多活跃的连接,处理这些连接上的事件会占用很长时间,其他worker进程很难得到处理新连接的机会。
如何解决长时间占用ngx_accept_mutex的问题呢?这就要依靠post事件处理机制,Nginx设计了两个队列:ngx_posted_accept_events队列(存放新连接事件的队列)和ngx_posted_events队列(存放普通事件的队列)。这两个队列都是ngx_event_t类型的双链表。定义如下:
ngx_thread_volatile ngx_event_t *ngx_posted_accept_events;
ngx_thread_volatile ngx_event_t *ngx_posted_events;
总结有三点,网络,内存,进程的管理调度方式。
(1)网络,nginx采用的epoll网络模型,异步非阻塞。多进程处理请求。master进程先创建好需要的listen的socket后,然后在fork出多个worker进程,这样每个worker进程都可以去accept。当一个client连接到来时,所有sccept进程都会受到通知,但只有一个进程可以accept成功,其他的则会accept失败。这里会有一个惊群的问题,后面说。
(2)内存,这里nginx会用到内存池,nginx的内存池分为两个部分,一种是小块内存,一种是大块内存,小块内存指的是last所指向的位置,大块内存则需要重新开辟,有large指向。在需要开辟新的内存时,当传入内存大小大于max值时,在large中查找,反之,在last所指内存中查找,当小块内存不够时,会开辟新的内存块,有next指针指向。当需要销毁内存时,大块内存直接用ngx_free释放,小块则不作处理,只有在销毁整个内存池是才会处理。每个连接请求会匹配一个内存池,而且每个内存池只对单个连接有效,连接会向内存池请求内存资源,用完再返回给内存池
(3)进程的管理调度方式
master进程管理worker进程,主要功能是接受来自外界的信号;向各worker进程发送信息;监控worker进程的运行状态;当worker进程退出后,会自动重新启动worker进程。
woker进程主要用来处理网络事件,各个worker进程之间是对等且独立的,它们同等竞争来自客户端的请求,一个进程只能在一个worker进程中处理,worker进程个数一般设置为服务器CPU核心数。
这里说一下惊群的概念,惊群是指,当一个连接请求进来,多个进程多来等处理请求信息,就会消耗服务器资源。当一个client连接到来时,所有sccept进程都会受到通知,但只有一个进程可以accept成功,其他的则会accept失败。
master --> listen(sockfd,backlog)
worker --> 继承master的fd(五元组信息,sip,sport,dip,dport,proto)
所有的worker进程的fd都可以捕获的客户端的连接
当客户请求进来时,worker进程会将fd信息推送到epoll进行管理。客户端连接的时候,多个进程中的epoll_wait会返回,源码如下:
while(1){
epoll_wait()
}
所以说,nginx惊群是epoll环节的惊群
Nginx为了解决惊群问题从两个方面做了工作:负载均衡和互斥锁。
在Nginx中有两种负载均衡:
类别 | 作用 |
进程级负载均衡(前端负载均衡) | 主要用于接收客户端连接,即Accept事件。这个是为了解决惊群问题的一个优化点。 |
服务级负载均衡(后端负载均衡) | 主要用于访问后台服务,例如mysql,apache等。这个是我们通常所说的负载均衡。 |
Nginx解决惊群相关代码如下:
/* 解决惊群 */
if (ngx_use_accept_mutex)
{
if (ngx_accept_disabled > 0)
{//实现worker进程间负载均衡
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;
}
}
}
}
只有ngx_use_accept_mutex是1时表示开启负载均衡和惊群处理。 为什么说负载均衡能够减少惊群冲突呢?
Nginx内部实现,当一个worker进程已经服务连接数达到7/8*connetctions(最大连接数的八分之七)时,不在处理新的连接事件(Accept事件),也就是说不会去竞争锁,即不会把listening socket添加到自己的事件驱动中。也就能够减少惊群冲突。
全局变量ngx_accept_disabled初始值为负数,当处理一个新的Accept事件则变量就加1。具体代码如下:
void
ngx_event_accept(ngx_event_t *ev)
{
...
/* 负数 */
ngx_accept_disabled = ngx_cycle->connection_n / 8
- ngx_cycle->free_connection_n;
c = ngx_get_connection(s, ev->log);//获取新连接 并且free_connection_n减一
...
}
对于新的连接请求(Accept事件)处理函数是ngx_event_accept,当成功获取connection对象后free_connection_n就是减1,其中connection_n始终不变。
举例说明:在Nginx刚启动完毕时(没有处理一个新连接)最大处理连接数connection_n=1024,free_connection_n=1024,那么ngx_accept_disabled=-896(负数,八分之七)。当处理一个新的连接之后,free_connection_n变为1023,那么ngx_accept_disabled=-895。
加锁。我们只需要确定在某一时刻只有一个进程的fd被加入到epoll里,可以用多进程加共享锁的方法来做。epoll循环的时候,在fd加入epoll之前,判断共享锁是否被使用。这里又会有一个问题,fd使用水平触发还是边缘触发的方式(LT/ET)? listen的fd的时候采用水平触发,如果用边缘触发的话会漏掉一些fd。
其原理是:只有获取到锁的那个进程才能接受新的TCP连接事件(Accept事件),具体实现如下:
ngx_int_t
ngx_trylock_accept_mutex(ngx_cycle_t *cycle)
{
if (ngx_shmtx_trylock(&ngx_accept_mutex)) {//异步方式 尝试加锁 加锁成功返回1
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;
}
/* 只有获取到锁 才能将listen socket 添加到自己的事件驱动中 */
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);
/**
* 表示获取锁失败,这个时候有就有两种场景
* ngx_accept_mutex_held = 0 表示上一次没有获得锁(非本次) 也就是说该进程
* 连续两次获取锁失败
* ngx_accept_mutex_held = 1 表示上一次获得锁但是本次获得锁失败,这个时候需要
* 将listen socket 移除事件驱动本进程不得继续accept事件
*/
if (ngx_accept_mutex_held) {
if (ngx_disable_accept_events(cycle, 0)==NGX_ERROR) {//将listen socket移除时间循环
return NGX_ERROR;
}
ngx_accept_mutex_held = 0;//修改标志位
}
return NGX_OK;
}
举例说明:经过这个函数处理之后,进程B获得了锁,会把listen socket加入到自己的事件驱动中,以后新连接均由该进程B服务而原先获得锁的进程A要把listen socket从自己的事件驱动中删除。
epoll的两种工作方式:1.水平触发(LT)2.边缘触发(ET)
LT模式:若就绪的事件一次没有处理完要做的事件,就会一直去处理。即就会将没有处理完的事件继续放回到就绪队列之中(即那个内核中的链表),一直进行处理。
ET模式:就绪的事件只能处理一次,若没有处理完会在下次的其它事件就绪时再进行处理。而若以后再也没有就绪的事件,那么剩余的那部分数据也会随之而丢失。
由此可见:ET模式的效率比LT模式的效率要高很多。只是如果使用ET模式,就要保证每次进行数据处理时,要将其处理完,不能造成数据丢失,这样对编写代码的人要求就比较高。
注意:ET模式只支持非阻塞的读写:为了保证数据的完整性!!!