Nginx基础. 防止惊群与子进程之间的负载均衡

作为服务器子进程, 每个worker进程都需要处理大量网络事件. 而网络事件的处理来源于对监听端口新连接的建立.
当有多个worker进程同时监听同一个(或多个)端口时, 建立连接就没那么简单了.
Nginx出于充分发挥多核CPU性能的考虑, 则使用了多个worker子进程的设计. 这样多个子进程在accept建立连接时候就会有争抢, 产生"惊群"问题. 有的系统可能在内核就解决了这个问题, 但出于Nginx的跨平台, Nginx还是自己解决了这个问题.
所以, 这里我们将介绍accept锁.
另外, 既然采用了多进程模型, 那么多个进程之间就可能需要处理负载均衡的问题. 尽量使每个子进程处理连接的数量不会相差太大.
所以, 这里我们将介绍Nginx的post事件处理机制.


解决惊群问题
只有打开了accept_mutex锁, 才可解决惊群问题.
什么是惊群?
假设下面的场景, 某时刻恰好所有的worker子进程都休眠且等待新连接的系统调用epoll_wait, 这时有个用户向服务器发起连接, 内核收到TCP的SYN包后, 会激活所有的休眠的worker进程. 虽然只有最快的最先执行accept的worker子进程才能获得这个连接的处理, 其他子进程会accept失败, 但是其他子进程被内核唤醒是不必要的, 被唤醒后执行的内容也是不必要的. 那一刻他们引发了不必要的上下文切换资源, 增加了系统的开销.
如何解决?
Nginx的解决方式是, 规定同一时刻只能有唯一一个worker子进程监听Web端口, 这样就不会发生惊群了.此时新连接的到来只会唤醒一个进程.
如何实现?
在调用事件驱动模块的process_events方法前, 每个子进程会事先执行下面这段代码.
这段代码就是worker子进程之间争抢accept锁的部分:
     //以下代码截取自ngx_process_events_and_timers函数
    if (ngx_use_accept_mutex) {
          //如果当前worker进程的连接数过多, 那么选择不参与竞争
        if (ngx_accept_disabled > 0) {   
            ngx_accept_disabled--;

        } else {
               //争抢锁
            if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
                return;
            }
                //如果抢到了锁, 那么就需要采用Nginx的post事件处理机制(此机制下面会讲)
            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;
                }
            }
        }
从这部分可以看出, 如果在争抢到accept锁后, 该worker进程就会向process_events方法传入NGX_POST_EVENTS标志
这个标志用于各子进程之间的负载均衡, 放在下面会讲到
这里先看一下争抢锁的方法具体实现:
ngx_int_t
ngx_trylock_accept_mutex(ngx_cycle_t *cycle)
{
     //调用此方法试图获取进程间共享的accept锁.
     //此函数返回1表示成功获取, 0相反.
     //调用的是trylock, 所以从字面上就能看出是非阻塞的, 如果锁此时正被其他子进程占用, 那么立即返回失败
    if (ngx_shmtx_trylock(&ngx_accept_mutex)) {
          //以下就是成功抢到锁后的操作
          //抢到锁时发现自身本就持有着accept锁, 那么立即返回
        if (ngx_accept_mutex_held
            && ngx_accept_events == 0
            && !(ngx_event_flags & NGX_USE_RTSIG_EVENT))
        {
            return NGX_OK;
        }
          //将所有监听连接的读事件添加到当前的epoll中, 等待被触发
          //在 ngx_enable_accept_events中调用的方法是ngx_add_event
        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_accept_mutex_held关掉
    if (ngx_accept_mutex_held) {
        if (ngx_disable_accept_events(cycle) == NGX_ERROR) {
            return NGX_ERROR;
        }

        ngx_accept_mutex_held = 0;
    }

    return NGX_OK;
}
所以, 根据以上代码的含义, 就是说只有在获取到锁的情况下, 建立新连接的方法才会被注册到epoll中去. 即要么是唯一获取到accept_mutex锁且其epoll等事件驱动模块开始监控端口上的新连接, 要么是没有获取到锁, 当前进程不会收到新连接事件.
如果没有获取到锁, 接下来调用事件驱动模块的process_events方法时只能处理已有的连接上的事件;
如果获取到了锁, 调用process_events方法时就会既处理已有连接上的事件, 也处理新连接的事件.
何时释放持有的accept锁也是需要考虑的问题, 如果等到处理为完这批事件, 那么可能因为这个worker上本身就有的许多活跃连接而导致很长时间没有释放锁, 使得其他worker进程很难获得处理新连接的机会.
这时候我们考虑的就是各个worker子进程之间的负载均衡问题了.


何时释放accept锁?
这里就需要利用Nginx的post机制, 即将事件区分的ngx_posted_accept_events和ngx_posted_events队列
上面的截取自ngx_process_events_and_timers函数的部分代码可以看出, 如果某worker子进程获取了接收新连接的"权力", 就会有NGX_POST_EVENTS标志被设置.
再根据之前关于epoll事件驱动模块文章中的ngx_epoll_process_events函数代码解析部分代码来看:
        if (flags & NGX_POST_EVENTS) {
                    //对于EPOLLIN事件, 其有可能是普通的读事件, 也有可能是有新连接到来 
                queue = rev->accept ? &ngx_posted_accept_events
                                    : &ngx_posted_events;

                ngx_post_event(rev, queue);

            }
如果没有这个标志的话, 此事件会被立即处理. (即立刻调用该事件的回调函数)
可以看出, Nginx用ngx_posted_accept_events和ngx_posted_events队列将所有事件归类了
那依旧没有谈到何时释放了锁啊?看下面这段代码:
     //这一段代码也是截取自ngx_process_events_and_timers函数部分
     //上面提到, 会先获取锁, 无论是否获取成功, 之后就是调用事件驱动模块的process_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);
    }

    if (delta) {
        ngx_event_expire_timers();
    }

    ngx_event_process_posted(cycle, &ngx_posted_events);
关于上面这段代码, 我们不明白的应该就是ngx_event_process_posted方法了. 此方法的用途就是 执行某个post队列中的事件回调函数.
这里的关注点在于该段代码表明, 在执行完接收连接的post队列后, 立即释放accept锁, 之后才处理已有连接的事件.

如何实现负载均衡?
根据上面的内容, 我们已经有所了解, 想要实现负载均衡, accept锁必不可少.
当监听端口有新连接到来时, 连接事件会被放到ngx_posted_accept_events队列中, Nginx会调用ngx_event_accept来试图建立新的连接
在ngx_event_accept这个建立连接的方法中, 控制负载均衡的关键部分如下:
ngx_accept_disabled = ngx_cycle->connection_n / 8
                              - ngx_cycle->free_connection_n;
在Nginx启动时, ngx_accept_disable的值就是一个负数, 其值为连接总数的 7/8. 虽然是一个整型数据, 但它是负载均衡的实现的关键阀值
当此值为负数时, 不会触发负载均衡操作; 而当此值为正时, 就会触发负载均衡的操作了, 即当前进程不再处理新连接事件, 而是简单的ngx_accept_disable-1操作.
这时候我们再次回到文章开头的截取自ngx_process_events_and_timers函数的部分代码:
        if (ngx_accept_disabled > 0) {   
            ngx_accept_disabled--;

        } else {
             //争抢锁
             if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
                  return;
                  ...
              }
所以, 我们可以看出, 在当前使用的连接到达总连接数的7/8时, 就不会再处理新的连接了, 同时, 在每次调用process_evnets_and_timers函数时都会将ngx_accept_disabled减一, 知道ngx_accept_disabled降到总连接数的7/8以下, 才会再次参与accept锁的竞争尝试接收新连接.
因此, Nginx各worker子进程间的负载均衡仅在某个worker进程处理的连接数到达最大处理数的7/8时才会触发. 这时该worker子进程将减少处理新连接的机会. 这样其他空闲的worker进程就有机会去处理更多的连接. 在Nginx中, accept锁默认是打开的.


流程

上面讨论了Nginx使用accept锁解决惊群问题, 以及多个worker子进程之间是如何解决负载均衡问题的.
分析过程中, 多次涉及一个方法ngx_process_events_and_timers, 事实上, 循环调用此方法正是事件驱动机制的核心.此方法不仅处理网络事件, 也处理定时器事件.
代码这里就不再分析了, 因为上面已经断断续续的分析过了. 下面简述一下其执行流程(摘自<深入理解Nginx>):
第一步:
        如果配置文件中声明使用timer_resolution配置项, 即令全局变量ngx_timer_resolution大于0, 则说明用户希望服务器的时间精度为timer_resolution秒. 这时候会将传给epoll_wait的timer参数置为-1, 即令epoll_wait一直等待直到有事件发生. 可以这样做是因为timer_resolution的存在. 是否还记得之前在分析事件模块ngx_event_core_module时, 其模块接口中声明的函数ngx_event_process_init, 此函数中就因为配置项timer_resolution的存在而设置了这样一段代码:
    if (ngx_timer_resolution && !(ngx_event_flags & NGX_USE_TIMER_EVENT)) {
        struct sigaction  sa;
        struct itimerval  itv;

        ngx_memzero(&sa, sizeof(struct sigaction));
        sa.sa_handler = ngx_timer_signal_handler;
        sigemptyset(&sa.sa_mask);
          //可以发现, 这里设置了信号处理函数, 针对的信号是SIGALRM, 处理函数是ngx_timer_signal_handler
          //跳转到ngx_timer_signal_handler函数, 发现其作用就是使ngx_event_timer_alarm置1, 置1有什么用呢?
          //该变量置1表示需要更新时间. 在事件驱动机制实现的事件模块接口ngx_event_module_t中的ngx_event_actions_t成员中的process_events中,
          //当ngx_event_timer_alarm为1时, 都会调用ngx_time_update方法更新系统时间. 下面对定时器还会有更详细的分析
        if (sigaction(SIGALRM, &sa, NULL) == -1) {
            ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
                          "sigaction(SIGALRM) failed");
            return NGX_ERROR;
        }

        itv.it_interval.tv_sec = ngx_timer_resolution / 1000;
        itv.it_interval.tv_usec = (ngx_timer_resolution % 1000) * 1000;
        itv.it_value.tv_sec = ngx_timer_resolution / 1000;
        itv.it_value.tv_usec = (ngx_timer_resolution % 1000 ) * 1000;
          //引起SIGALRM的函数是setitimer函数. 此函数用于间隔性的引起SIGALRM信号
        if (setitimer(ITIMER_REAL, &itv, NULL) == -1) {
            ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
                          "setitimer() failed");
        }
    }
利用setitimer设置了一个定时器, 此定时器总是会在一定间隔时间后引发SIGALRM信号.
如果我们的epoll_wait因为没有事件被触发而陷入睡眠, 在一定间隔时间后到来的SIGALRM信号必然会将其打断.
在平时处理可能休眠的系统调用时, 我们可能会判断该调用是否因为突然到来的信号而返回EINTR错误, 从而忽视信号继续调用该函数.
然而在Nginx中, 可能因为需要信号来控制时间精度, 所以在epoll_wait陷入睡眠期间如果被打断,会立即根据返回的错误判断错误是否是SIGALRM信号引发的, 即要求我们更新时间.
在epoll事件驱动模块中, 可以看到下面这段代码:
    events = epoll_wait(ep, event_list, (int) nevents, timer);

    err = (events == -1) ? ngx_errno : 0;

    if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
        ngx_time_update();
    }

    if (err) {
        if (err == NGX_EINTR) {

            if (ngx_event_timer_alarm) {
                ngx_event_timer_alarm = 0;
                return NGX_OK;
            }

        ...
    }
SIGALRM的信号处理函数很简单, 就是将ngx_event_timer_alarm全局变量置1. 根据该变量判断是否是SIGALRM导致.
如果是别的信号, 则另做处理
第二步:
        如果没有在配置文件中声明timer_resolution配置项, 那么将调用ngx_event_find_timer方法, 获取最近一个将要触发的事件距离现在有多少毫秒. 然后把这个值赋予timer参数. 令epoll_wait方法如果没有任何事件发生, 最多等待timer事件就需要返回, 且将flags参数设置NGX_UPDATE_TIME参数. 让process_events方法更新时间.
第三步:
        如果在配置文件中关闭了accept锁, 那么直接执行process_events(跳到第七步). 否则检查负载均衡阀值变量ngx_accept_disabled. 若此变量为正, 那么将其值减一, 直接执行process_events(跳到第七步)
第四步:
        如果负载均衡阀值变量ngx_accept_disabled为负数, 则调用trylock尝试获取锁
    events = epoll_wait(ep, event_list, (int) nevents, timer);

    err = (events == -1) ? ngx_errno : 0;

    if (flags & NGX_UPDATE_TIME || ngx_event_timer_alarm) {
        ngx_time_update();
    }

    if (err) {
        if (err == NGX_EINTR) {

            if (ngx_event_timer_alarm) {
                ngx_event_timer_alarm = 0;
                return NGX_OK;
            }

           ...
    }
第五步:
        如果获取到了锁, flags将设置NGX_POST_EVENTS标志. 让之后收集到的事件不要立即执行而是放在post队列等待执行
第六步:
        没有获取到锁的话, 意味着有进程已经持有锁了. 此进程不能频繁的去尝试获取锁, 需要延后一段时间才再尝试获取锁. 但也不能让其等太长时间, 所以有了
ngx_accept_mutex_delay变量:
       if (timer == NGX_TIMER_INFINITE
                    || timer > ngx_accept_mutex_delay)
                {
                    timer = ngx_accept_mutex_delay;
                }
可以看出来的是, 如果我们设置了timer_resolution来控制时间精度, 即使它大于ngx_accept_mutex_delay的值, 进程也会在ngx_accept_mutex_delay之后尝试获取锁
第七步:
        调用ngx_process_evnets方法, 并记录其消耗的时间, 如果消耗时间为0, 那么接下来将不会处理定时器中的事件.(没有事件流逝, 自然也没有定时器超时的可能)
第八步:
       如果ngx_posted_accept_events队列不空, 那么将处理ngx_posted_accept_events队列中需要建立新连接的事件
第九步:
       如果当前持有锁, 那么在这个地方将会释放锁. 文章前面内容有涉及了.
第十步:
       如果ngx_process_evnets耗时了, 那么可能有定时器事件超时了, 需要处理
第十一步:
       如果ngx_posted_events不空, 处理其中的读写事件

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值