高并发中的惊群效应简介

一.惊群效应简介

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

具体来说,惊群通常发生在服务器的监听等待调用上。服务器创建监听socket,然后fork多个进程,在每个进程中调用accept或者epoll_wait等待终端的连接。

二.惊群的坏处

惊醒所有进程/线程,导致n-1个进程/线程做了无效的调度、上下文切换,cpu瞬时增高。
多个进程/线程争抢资源,所以涉及到同步问题,需对资源进行加锁保护,加解锁加大系统CPU开销。

三.惊群的几种情况

在高并发(多线程/多进程/多连接)中,会产生惊群的情况有:

  • accept惊群
  • epoll惊群
  • nginx惊群
  • 线程池惊群

3.1 accept惊群(新版内核已解决)

以多进程为例,在主进程创建监听描述符listenfd后,fork多个子进程,多个进程共享listenfd,accept是在每个子进程中,当一个新连接来的时候,会发生惊群。
在内核2.6之前,所有进程accept都会惊醒,但只有一个可以accept成功,其他返回EGAIN。
在内核2.6及之后,解决了惊群问题,在内核中增加了一个互斥等待变量。一个互斥等待行为与睡眠基本类似,主要的不同点在于:
1)当一个进程有WQ_FLAG_EXCLUSEVE标志置位,它被添加到等待队列的尾部。若没有这个标志置位,则添加到队列首部。
2)当wake_up被在一个等待队列上调用时,它在唤醒第一个有WQ_FLAG_EXCLUSIVE标志的进程后停止。
对于互斥等待的行为,比如对一个listen后的socket描述符,多线程阻塞accept时,系统内核只会唤醒所有正在等待此时间的队列中的第一个进程/线程,队列中的其他进程/线程则继续等待下一次事件的发生。这样,就避免了多个进程/线程同时监听同一个socket描述符时的惊群问题。

3.2 epoll惊群

epoll惊群分两种:

一是在fork之前创建epollfd,所有进程共用一个epoll。

二是在fork之后创建epollfd,每个进程独用一个epoll。

3.2.1 fork之前创建epollfd(新版内核已解决)

1. 主进程创建listenfd,创建epollfd。

2. 主进程fork多个子进程。

3. 每个子进程把listenfd加到epollfd中。

4. 当一个新连接进来时,会触发epoll惊群,多个子进程的epoll同时会触发。

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

epoll_wait的惊群问题在这个patch epoll: add EPOLLEXCLUSIVE flag 21 Jan 2016已经解决了。通过添加EPOLLEXCLUSIVE标志标识,在唤醒时只唤醒一个等待进程。相比而言,内核在解决accept的惊群时是作为一个问题进行了修复,即无需设置标志;而对于epoll_wait,则作为添加一个功能选项。这主要是因为accept等待的是一个socket,并且这个socket的连接只能被一个进程处理,内核可以很明确的进行这个预设,因此,accept只唤醒一个进程才是更优的选择。而对于epoll_wait,等待的是多个socket上的事件,有连接事件、读写事件等,这些事件可以同时被一个进程处理,也可以同时被多个进程分别处理,内核不能进行唯一进程处理的假定,因此,提供一个设置标志让用户决定。

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

1. 主进程创建listendfd。

2. 主进程创建多个子进程。

3. 每个子进程创建自已的epollfd。

4. 每个子进程把listenfd加入到epollfd中。

5. 当一个连接进来时,会触发epoll惊群,多个子进程epoll同时会触发。

分析:虽然listenfd是同一个,但每个子进程的epollfd是不同的epollfd,当新连接过来时,但内核不知道该发给哪个监听进程,accept会触发惊群。所以,对于这种情况,惊群还是会出现。

3.3 nginx惊群的解决

这里说的nginx惊群,其实就是上面的问题(fork之后创建epollfd),下面看看nginx是怎么处理惊群的。

在nginx中使用的epoll,是在创建子进程后创建的epollfd。因此,会出现上面的惊群问题,即每个子进程worker都会惊醒。

在nginx中,整个流程如下:

1 主线程创建listenfd。

2 主线程fork多个子进程(根据配置)。

3 子进程创建epollfd。

4 获得accept锁,只有一个子进程把listenfd加到epollfd中,同一时间只有一个进程会把监听描述符加到epoll中。

5 循环监听。

在nginx中,解决惊群的方法,使用了互斥锁还解决。nginx的每个worker进程,都会在函数ngx_process_events_and_timers()中处理不同的事件,然后通过ngx_process_events()封装了不同的事件处理机制,在Linux上默认采用epoll_wait()。
nginx主要在ngx_process_events_and_timers()函数中解决惊群现象。

 void ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
    ... ...
    // 是否通过对accept加锁来解决惊群问题,需要工作线程数>1且配置文件打开accetp_mutex
    if (ngx_use_accept_mutex) {
        // 超过配置文件中最大连接数的7/8时,该值大于0,此时满负荷不会再处理新连接,简单负载均衡
        if (ngx_accept_disabled > 0) {
            ngx_accept_disabled--;
        } else {
            // 多个worker仅有一个可以得到这把锁。获取锁不会阻塞过程,而是立刻返回,获取成功的话,
            // ngx_accept_mutex_held被置为1。拿到锁意味着监听句柄被放到本进程的epoll中了,如果
            // 没有拿到锁,则监听句柄会被从epoll中取出。
            if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
                return;
            }
            if (ngx_accept_mutex_held) {
                // 此时意味着ngx_process_events()函数中,任何事件都将延后处理,会把accept事件放到
                // ngx_posted_accept_events链表中,epollin|epollout事件都放到ngx_posted_events链表中
                flags |= NGX_POST_EVENTS;
            } else {
                // 拿不到锁,也就不会处理监听的句柄,这个timer实际是传给epoll_wait的超时时间,修改
                // 为最大ngx_accept_mutex_delay意味着epoll_wait更短的超时返回,以免新连接长时间没有得到处理
                if (timer == NGX_TIMER_INFINITE || timer > ngx_accept_mutex_delay) {
                    timer = ngx_accept_mutex_delay;
                }
            }
        }
    }
    ... ...
    (void) ngx_process_events(cycle, timer, flags);   // 实际调用ngx_epoll_process_events函数开始处理
    ... ...
    if (ngx_posted_accept_events) { //如果ngx_posted_accept_events链表有数据,就开始accept建立新连接
        ngx_event_process_posted(cycle, &ngx_posted_accept_events);
    }
 
    if (ngx_accept_mutex_held) { //释放锁后,再处理下面的EPOLLIN EPOLLOUT请求
        ngx_shmtx_unlock(&ngx_accept_mutex);
    }
 
    if (delta) {
        ngx_event_expire_timers();
    }
 
    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "posted events %p", ngx_posted_events);
	// 然后再处理正常的数据读写请求。因为这些请求耗时久,所以在ngx_process_events里NGX_POST_EVENTS标
    // 志将事件都放入ngx_posted_events链表中,延迟到锁释放了再处理。
}

分析:nginx里采用了主动轮询的方法,去把监听描述符放到epoll中或从epoll移出(这个是nginx的精髓所在,因为大部分的并发架构都是被动的)。nginx中采用互斥锁去解决谁来accept问题,保证了同一时刻只有一个worker接收新连接(所以nginx并没有惊群问题)。nginx根据自已的载负(最大连接的7/8)情况,决定去不去抢锁,简单方便地解决负载,防止进程因业务太多而导致所有业务都不及时处理。

总结: nginx采用互斥锁和主动轮询的方法,使得nginx中并无惊群。

3.4 线程池惊群

在多线程设计中,经常会用到互斥和条件变量的问题。当一个线程解锁,并通知其他线程的时候,就会出现惊群的现象。

pthread_mutex_lock/pthread_mutex_unlock:线程互斥锁的加锁及解锁函数。
pthread_cond_wait:线程池中的消费者线程等待线程条件变量被通知;
pthread_cond_signal/pthread_cond_broadcast:生产者线程通知线程池中的某个或一些消费者线程池,接收处理任务;
这里的惊群现象出现在3里,pthread_cond_signal,语义上看,是通知一个线程。调用此函数后,系统会唤醒在相同条件变量上等待的一个或多个线程(可参看手册)。如果通知了多个线程,则发生了惊群。

传统用法:所有线程共用一个锁,共用一个条件变量。当pthread_cond_signal通知时,就可能会出现惊群。
解决惊群的方法:所有线程共用一个锁,每个线程有自已的条件变量。当pthread_cond_signal通知时,定向通知某个线程的条件变量,不会出现惊群。
 

四. linux Os最新的避免和解决惊群的办法

Linux内核的3.9版本带来了SO_REUSEPORT特性,该特性支持多个进程或者线程绑定到同一端口,提高服务器程序的性能,允许多个套接字bind()以及listen()同一个TCP或UDP端口,并且在内核层面实现负载均衡。
在未开启SO_REUSEPORT的时候,由一个监听socket将新接收的连接请求交给各个工作者处理。
在使用SO_REUSEPORT后,多个进程可以同时监听同一个IP和端口,然后由内核决定将新连接发送给哪个进程,显然会降低每个worker接收新连接时的锁竞争。
下面,让我们好好比较一下多进程(线程)服务器编程传统方法和使用SO_REUSEPORT的区别。
运行在Linux系统上的网络应用程序,为了利用多核的优势,一般使用以下典型的多进程(多线程)服务器模型:
1.单线程listener/accept,多个工作线程接受任务分发,虽然CPU工作负载不再成为问题,但是仍然存在问题:
(1)单线程listener,在处理高速率海量连接的时候,一样会成为瓶颈。
(2)cpu缓存行丢失套接字结构现象严重。
2.所有工作线程都accept()在同一个服务器套接字上呢?一样存在问题:
(1)多线程访问server socket锁竞争严重。
(2)高负载情况下,线程之间的处理不均衡,有时高达3:1。
(3)导致cpu缓存行跳跃(cache line bouncing)。
(4)在繁忙cpu上存在较大延迟。
上面两种方法共同点就是:很难做到cpu之间的负载均衡,随着核数的提升,性能并没有提升。甚至服务器的吞吐量CPS(Connection Per Second)会随着核数的增加呈下降趋势。
下面,我们就来看看SO_REUSEPORT解决了什么问题:
(1)允许多个套接字bind()/listen()同一个tcp/udp端口。每一个线程拥有自己的服务器套接字,在服务器套接字上没有锁的竞争。
(2)内核层面实现负载均衡。
(3)安全层面,监听同一个端口的套接字只能位于同一个用户下面。
(4)处理新建连接时,查找listener的时候,能够支持在监听相同IP和端口的多个socket之间均衡选择。
当一个连接到来的时候,系统到底是怎么决定那个套接字来处理它?
对于不同内核,存在两种模式,这两种模式并不共存,一种叫做热备份模式,另一种叫做负载均衡模式。3.9内核以后,全部改为负载均衡模式。
热备份模式:一般而言,会将所有的reuseport同一个IP地址/端口的套接字挂在一个链表上,取第一个即可,工作的只有一个,其他的作为备份存在。如果该套接字挂了,它会被从链表删除,然后第二个便会成为第一个。
负载均衡模式:和热备份模式一样,所有reuseport同一个IP地址/端口的套接字会挂在一个链表上,你也可以认为是一个数组,这样会更加方便。当有连接到来时,用数据包的源IP/源端口作为一个HASH函数的输入,将结果对reuseport套接字数量取模,得到一个索引,该索引指示的数组位置对应的套接字便是工作套接字。这样就可以达到负载均衡的目的,从而降低某个服务的压力。

猜你喜欢

转载自blog.csdn.net/chinawangfei/article/details/102800478
今日推荐