Nginx解决惊群效应

1. 惊群效应

1.1 简介

惊群问题又名惊群效应。简单来说就是多个进程或者线程在等待同一个事件,当事件发生时,所有线程和进程都会被内核唤醒。唤醒后通常只有一个进程获得了该事件并进行处理,其他进程发现获取事件失败后又继续进入了等待状态,在一定程度上降低了系统性能。

打个比方就是:当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉, 等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。

简单地说:就是扔一块食物,所有鸽子来抢,但最终只一个鸽子抢到了食物。

1.2 引发的问题

惊群效应会占用系统资源,降低系统性能。多进程/线程的唤醒,涉及到的一个问题是上下文切换问题。频繁的上下文切换带来的一个问题是数据将频繁的在寄存器与运行队列中流转。极端情况下,时间更多的消耗在进程/线程的调度上,而不是执行

食物只有一块,最终只有一个鸽子抢到,但是惊动了所有鸽子,每个鸽子都跑过来,消耗了每个鸽子的能量。

2. 常见的惊群效应

在 Linux 下,我们常见的惊群效应发生于我们使用 accept 以及我们 select 、poll 或 epoll 等系统提供的 API 来处理我们的网络链接。

2.1 accept 惊群

以多进程为例,在主进程创建监听描述符listenfd后,fork()多个子进程,多个进程共享listenfd,accept是在每个子进程中,当一个新连接来的时候,会发生惊群。

由上图所示:

  1. 主线程创建了监听描述符listenfd = 3
  2. 主线程fork 三个子进程共享listenfd=3
  3. 当有新连接进来时,内核进行处理

在内核2.6之前,所有进程accept都会惊醒,但只有一个可以accept成功,其他返回EGAIN。

在内核2.6及之后,解决了惊群,在内核中增加了一个互斥等待变量。一个互斥等待的行为与睡眠基本类似,主要的不同点在于:

  1.  当一个等待队列入口有 WQ_FLAG_EXCLUSEVE 标志置位, 它被添加到等待队列的尾部. 没有这个标志的入口项, 相反, 添加到开始.
  2. 当 wake_up 被在一个等待队列上调用时, 它在唤醒第一个有 WQ_FLAG_EXCLUSIVE 标志的进程后停止。
  3. 对于互斥等待的行为,比如如对一个listen后的socket描述符,多线程阻塞accept时,系统内核只会唤醒所有正在等待此时间的队列 的第一个,队列中的其他人则继续等待下一次事件的发生,这样就避免的多个线程同时监听同一个socket描述符时的惊群问题。

2.2 epoll惊群

epoll惊群分两种:

  • 在fork之前创建epollfd,所有进程共用一个epoll;
  • 在fork之后创建epollfd,每个进程独用一个epoll.

2.2.1 fork之前创建epollfd(内核2.6已解决)

  1. 主进程创建listenfd, 创建epollfd
  2. 主进程fork多个子进程
  3. 每个子进程把listenfd,加到epollfd中
  4. 当一个连接进来时,会触发epoll惊群,多个子进程的epoll同时会触发

分析:这里的epoll惊群跟accept惊群是类似的,共享一个epollfd, 加锁或标记解决。在新版本的epoll中已解决。但在内核2.6及之前是存在的。

2.2.2 fork之后创建epollfd(内核未解决)

  1. 主进程创建listendfd
  2. 主进程创建多个子进程
  3. 每个子进程创建自已的epollfd
  4. 每个子进程把listenfd加入到epollfd中
  5. 当一个连接进来时,会触发epoll惊群,多个子进程epoll同时会触发

分析:因为每个子进程的epoll是不同的epoll, 虽然listenfd是同一个,但新连接过来时, accept会触发惊群,但内核不知道该发给哪个监听进程,因为不是同一个epoll。所以这种惊群内核并没有处理。惊群还是会出现。

3. 内核解决惊群问题详解

首先如前面所说,Accept 的惊群问题在 Linux Kernel 2.6 之后就被从内核的层面上解决了。但是 EPOLL 怎么办?在 2016 年一月,Linux 之父 Linus 向内核提交了一个补丁

参见 epoll: add EPOLLEXCLUSIVE flag

其中的关键代码是

if (epi->event.events & EPOLLEXCLUSIVE)
            add_wait_queue_exclusive(whead, &pwq->wait);
        else
            add_wait_queue(whead, &pwq->wait);

简而言之,通过增加一个 EPOLLEXCLUSIVE 标志位作为辅助。如果用户开启了 EPOLLEXCLUSIVE ,那么在加入内核等待队列时,使用 add_wait_queue_exclusive 否则则使用 add_wait_queue

至于这两个函数的用法,可以参考这篇文章Handing wait queues

其中有这样一段描述

The add_wait_queue( ) function inserts a nonexclusive process in the first position of a wait queue list. The add_wait_queue_exclusive( ) function inserts an exclusive process in the last position of a wait queue list.

好了,我们现在来改一下我们的代码(内核版本要在 Linux Kernel 4.5)之后

#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#define SERVER_ADDRESS "0.0.0.0"
#define SERVER_PORT 10086
#define WORKER_COUNT 4
#define MAXEVENTS 64

static int create_and_bind_socket() {
  int fd = socket(PF_INET, SOCK_STREAM, 0);
  struct sockaddr_in server_address;
  server_address.sin_family = AF_INET;
  inet_pton(AF_INET, SERVER_ADDRESS, &server_address.sin_addr);
  server_address.sin_port = htons(SERVER_PORT);
  bind(fd, (struct sockaddr *)&server_address, sizeof(server_address));
  return fd;
}

static int make_non_blocking_socket(int sfd) {
  int flags, s;
  flags = fcntl(sfd, F_GETFL, 0);
  if (flags == -1) {
    perror("fcntl error");
    return -1;
  }
  flags |= O_NONBLOCK;
  s = fcntl(sfd, F_SETFL, flags);
  if (s == -1) {
    perror("fcntl set error");
    return -1;
  }
  return 0;
}

int worker_process(int listenfd, int epoll_fd, struct epoll_event *events,
                   int k) {
  while (1) {
    int n;
    n = epoll_wait(epoll_fd, events, MAXEVENTS, -1);
    printf("Worker %d pid is %d get value from epoll_wait\n", k, getpid());
    sleep(0.2);
    for (int i = 0; i < n; i++) {
      if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) ||
          (!(events[i].events & EPOLLIN))) {
        printf("%d\n", i);
        fprintf(stderr, "epoll err\n");
        close(events[i].data.fd);
        continue;
      } else if (listenfd == events[i].data.fd) {
        struct sockaddr in_addr;
        socklen_t in_len;
        int in_fd;
        in_len = sizeof(in_addr);
        in_fd = accept(listenfd, &in_addr, &in_len);
        if (in_fd == -1) {
          printf("worker %d accept failed\n", k);
          break;
        }
        printf("worker %d accept success\n", k);
        close(in_fd);
      }
    }
  }

  return 0;
}

int main() {
  int listen_fd, s;
  int epoll_fd;
  struct epoll_event event;
  struct epoll_event *events;
  listen_fd = create_and_bind_socket();
  if (listen_fd == -1) {
    abort();
  }
  s = make_non_blocking_socket(listen_fd);
  if (s == -1) {
    abort();
  }
  s = listen(listen_fd, SOMAXCONN);
  if (s == -1) {
    abort();
  }
  epoll_fd = epoll_create(MAXEVENTS);
  if (epoll_fd == -1) {
    abort();
  }
  event.data.fd = listen_fd;
  // add EPOLLEXCLUSIVE support
  event.events = EPOLLIN | EPOLLEXCLUSIVE;
  s = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event);
  if (s == -1) {
    abort();
  }
  events = calloc(MAXEVENTS, sizeof(event));
  for (int i = 0; i < WORKER_COUNT; i++) {
    printf("create worker %d\n", i);
    int pid = fork();
    if (pid == 0) {
      worker_process(listen_fd, epoll_fd, events, i);
    }
  }
  int status;
  wait(&status);
  free(events);
  close(listen_fd);
  return EXIT_SUCCESS;
}

然后我们来看看效果

诶?为什么还是有两个进程被唤醒了?原因在于 EPOLLEXCLUSIVE 只保证唤醒的进程数小于等于我们开启的进程数,而不是直接唤醒所有进程,也不是只保证唤醒一个进程

我们来看看官方的描述

Sets an exclusive wakeup mode for the epoll file descriptor that is being attached to the target file descriptor, fd. When a wakeup event occurs and multiple epoll file descriptors are attached to the same target file using EPOLLEXCLUSIVE, one or more of the epoll file descriptors will receive an event with epoll_wait(2). The default in this scenario (when EPOLLEXCLUSIVE is not set) is for all epoll file descriptors to receive an event. EPOLLEXCLUSIVE is thus useful for avoid‐ ing thundering herd problems in certain scenarios.

恩,换句话说,就目前而言,系统并不能严格保证惊群问题的解决。很多时候我们还是要依靠应用层自身的设计来解决

4. Nginx解决惊群效应

目前而言,应用解决惊群有两种策略

  1. 这是可以接受的代价,那么我们暂时不管。这是我们大多数的时候的策略
  2. 通过加锁或其余的手段来解决这个问题,最典型的例子是 Nginx

我们来看看 Nginx 怎么解决这样的问题的:

Nginx通过控制争抢处理socket的进程数量抢占ngx_accept_mutex锁解决惊群现象。只有一个ngx_accept_mutex锁,谁拿到锁,谁处理该socket的请求。

同时:如果当前进程的连接数>最大连接数*7/8,则该进程不参与本轮竞争。

//nginx的每个worker进程在函数ngx_process_events_and_timers中处理事件。下面代码是ngx_process_events_and_timers()函数的核心部分。
void ngx_process_events_and_timers(ngx_cycle_t *cycle)  
{  
    //ngx_use_accept_mutex表示是否需要通过对accept加锁来解决惊群问题。当nginx worker进程数>1时且配置文件中打开accept_mutex时,这个标志置为1         
    if (ngx_use_accept_mutex) 
    {
    //ngx_accept_disabled表示此时满负荷,没必要再处理新连接了,nginx.conf配置了每一个nginx worker进程能够处理的最大连接数,当达到最大数的7/8时,ngx_accept_disabled为正,说明本nginx worker进程非常繁忙,将不再去处理新连接,这也是个简单的负载均衡  
        if (ngx_accept_disabled > 0) 
        {      
            ngx_accept_disabled--;  
        } 
        else 
        {  
            //工作进程抢占锁,抢占成功的进程将ngx_accept_mutex_held变量置为1。拿到锁,意味着socket被放到本进程的epoll中了,如果没有拿到锁,则socket会被从epoll中取出。  
            //此处trylock是非阻塞锁,如果没有抢占到锁,进程会立刻返回,处理自己监听的描述符上的读写事件。
            if(pthread_mutex_trylock(&ngx_accept_mutex))
            {
                ngx_accept_mutex_held = 1;
            }
            else
            {
                //设置time时间,500ms后就去争抢锁,使得没有拿到锁的worker进程,去拿锁的频繁更高,确保每个进程可以处理几乎相同数量的fd的读写。
                timer = 500;
                ngx_accept_mutex_held = 0;
            }

             //拿到锁的话,置flag为NGX_POST_EVENTS,这意味着ngx_process_events函数中,任何事件都将延后处理,会把accept事件都放到ngx_posted_accept_events链表中,epollin|epollout事件都放到ngx_posted_events链表中  
            if (ngx_accept_mutex_held) 
            {   
                flags |= NGX_POST_EVENTS;
            }
        }
        //继续epoll_wait等待处理事件
        int num = epoll_wait(epollfd, events, length, timer);
        for(int i=0; i<num; ++i)
        {
            ......
            //如果是读事件
            if (revents & EPOLLIN)
            {
                //有NGX_POST_EVENTS标志的话,就把accept事件放到ngx_posted_accept_events队列中,把正常的事件放到ngx_posted_events队列中延迟处理
                //新连接事件队列ngx_posted_accept_events
                //用户读写事件队列ngx_posted_events
                if (flags & NGX_POST_EVENTS)
                {
                    queue = rev->accept ? 
                        &ngx_posted_accept_events:
                        &ngx_posted_events;

                    ngx_post_event(rev, queue);
                }
                else//处理
                {
                    rev->handler(rev);
                }
            }

            //如果是写事件
            if (revents & EPOLLOUT)
            {
                //同理,有NGX_POST_EVENTS标志的话,写事件延迟处理,放到ngx_posted_events队列中 
            if (flags & NGX_POST_EVENTS) 
            {
                ngx_post_event(rev, &ngx_posted_events);
            }
            else//处理
            {
                rev->handler(rev);
            }
        }
    }

    //先处理新用户的连接事件
        ngx_event_process_posted(cycle, &ngx_posted_accept_events);

    //释放处理新连接的锁
    if(ngx_accept_mutex_held)
    {
        pthread_mutex_unlock(&ngx_accept_mutex);
    }

      //再处理已建立连接的用户读写事件
      ngx_event_process_posted(cycle, &ngx_posted_events);
}

nginx从抢锁、释放锁到处理事件的整个过程,我已经结合代码做了注释,相信大家对整个过程应该已经不陌生了。至于pthread_mutex_trylock()中进程是如何抢占锁的,这就有赖于实现抢占的算法了,此处只是解释处理过程,并不关心抢占实现原理。感兴趣的同学可以自己搜索相关资料。

  1. 先处理新用户的连接事件,再释放处理新连接的锁:如果刚释放锁,就有新连接,刚获得锁的进程要给等待队列中添加sockfd时,此时原获得锁的进程也要从等待队列中删除sockfd,TCP的三次握手的连接是非线程安全的。为了避免产生错误,使得将sockfd从等待队列中删除后,再让新的进程抢占锁,处理新连接。
  2. 拿到锁,将任务放在任务队列中,不是立刻去处理:每个进程要处理新连接事件,必须拿到锁,当前进程将新连接事件的sokect添加到任务队列中,立即释放锁,让其他进程尽快获得锁,处理用户的连接。

你可能有个疑问,如果没有加锁,有新事件连接时,所有的进程都会被唤醒执行accept,有且仅有一个进程会accept返回成功,其他进程都重新进入睡眠状态。现在有了锁,在发生accept之前,进程们要去抢占锁,也是有且仅有一个进程会抢到锁,其他进程也是重新进入睡眠状态。即:不论是否有accept锁,都会有很多进程被唤醒再重新进入睡眠状态的过程,那惊群现象如何解释

其实,锁不能解决惊群现象,惊群现象是没办法解决的,很多进程被同时唤醒是一个必然的过程。Nginx中通过检查当前进程的连接数是否>最大连接数*7/8来判断当前进程是否能处理新连接,减少被唤醒的进程数量,也实现了简单的负载均衡。锁只能保证不让所有的进程去调用accept函数,解决了很多进程调用accept返回错误,锁解决的是惊群现象的错误,并不是解决了惊群现象!

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值