nginx惊群问题

Nginx处于充分发挥多核CPU架构性能的考虑,使用了多个worker子进程监听相同端口的设计,这样多个子进程在accept建立新连接时会有争抢,这会带来著名的“惊群”问题,子进程数量越多越明显,这会造成系统性能的下



假设现在没有用户连入服务器,某一时刻恰好所有的子进程都休眠且等待新连接的系统调用(如epoll_wait),这时有一个用户向服务器发起了连接,内核在收到TCP的SYN包时,会激活所有的休眠worker子进程。最终只有最先开始执行accept的子进程可以成功建立新连接,而其他worker子进程都将accept失败。这些accept失败的子进程被内核唤醒是不必要的,他们被唤醒会的执行很可能是多余的,那么这一时刻他们占用了本不需要占用的资源,引发了不必要的进程切换,增加了系统开销。



如何解决惊群问题-post事件处理机制



很多操作系统的最新版本的内核已经在事件驱动机制中解决了惊群问题,但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类型的双链表



首先看worker进程中ngx_process_events_and_timers事件处理函数(src/event/ngx.event.c),它处于worker进程的ngx_worker_process_cycle方法中,循环处理时间,是事件驱动机制的核心,既会处理普通的网络事件,也会处理定时器事件。ngx_process_events_and_timers是Nginx实际处理web业务的方法,所有业务的执行都是由它开始的,它涉及Nginx完整的事件驱动机制



先处理ngx_posted_accept_events队列中的事件,处理完毕后立即释放ngx_accept_mutex锁,接着再处理ngx_posted_events队列中事件。这样大大减少了ngx_accept_mutex锁占用的时间



调用这个方法的结果是,要么唯一获取到锁且其epoll等事件驱动模块开始监控web端口上的新连接事件。这种情况下调用process_events方法时就会既处理已有连接上的事件,也处理新连接的事件。要么没有获取到锁,当前进程不会收到新连接事件。这种情况下process_events只处理已有连接上的事件
https://blog.csdn.net/xiajun07061225/article/details/9260535

单进程模式下服务端的socket编程一般有如下2种流程:



listen->阻塞accept->handle
listen->epoll->非阻塞accept->handle



多进程模式下服务端的socket编程一般有如下4种方式:



1,主进程首先调用listen创建监听TCP套接字,进行监听,接着调用阻塞accept等待连接请求,当有TCP连接请求到达时,阻塞accept会获得该连接请求并创建TCP连接,然后调用fork创建出多进程对该TCP连接进行handle。



这种方式需要不断地fork进程,会造成系统极大的性能损耗。



2,主进程首先调用listen创建监听TCP套接字,进行监听,接着调用fork创建多进程,子进程内部调用阻塞accept等待连接请求,当有TCP连接请求到达时,这些子进程全部被唤醒并抢占连接请求,抢占到的子进程会获得该连接请求并创建TCP连接,然后进行handle。抢占不到连接请求的进程会收到EAGAIN错误并在下次调用阻塞accept时再次挂起。



多个进程因为一个连接请求而被同时唤醒,称为惊群效应,在高并发情况下,大部分进程会无效地被唤醒然后因为抢占不到连接请求又重新进入睡眠,是会造成系统极大的性能损耗。



3,主进程首先调用listen创建监听TCP套接字,进行监听,接着调用epoll_create创建一个epfd和对应的红黑树,然后调用fork创建多进程,子进程内部调用epoll_wait等待同一个epfd的事件,当有TCP连接请求到达时,也就是这个epfd有事件发生时,这些子进程全部被唤醒并处理事件,只有一个子进程通过调用非阻塞accept抢占到该连接请求并创建TCP连接,然后进行handle。



这种方式有如下2个缺点:



同样会导致惊群效应
由于等待的是同一个epfd,假如A进程创建了TCP连接A,B进程创建了TCP连接B,当连接A发生读写事件时,可能B进程抢占了该事件,导致混乱。
4,主进程首先调用listen进行监听,接着调用fork创建多进程,子进程内部调用epoll_create创建各自的epfd和各自的红黑树,并调用epoll_wait等待同各自epfd的事件,这些子进程都将listen端口的监听事件放在自己的epfd中,当有TCP连接请求到达时,每个子进程的epfd的这个事件都会发生,这些子进程全部被唤醒并处理各自的这个事件,只有一个子进程通过调用非阻塞accept抢占到该连接请求并创建TCP连接,然后进行handle。



这种方式对比于第3种方式,不会出现进程与连接的不对应(创建TCP连接后的子进程会将该连接放在自己的epfd中而不是公共的epfd中),但在监听事件上仍然会出现惊群效应。
Linux与Nginx的解决方案
方式2的惊群效应称为accept惊群效应,方式3跟方式4则称为epoll惊群效应。



方式1需要不断地fork进程,基本上已经被抛弃使用了。



针对方式2的accept惊群效应,Linux 2.6版本给出了解决方案。



在Linux 2.6版本中,维护了一个等待队列,队列中的元素就是进程,非exclusive属性的元素会加在等待队列的前面,而exclusive属性的元素会加在等待队列的末尾,当子进程调用阻塞accept时,该进程会被打上WQ_FLAG_EXCLUSIVE标志位,从而成为exclusive属性的元素被加到等待队列中。当有TCP连接请求到达时,该等待队列会被遍历,非exclusive属性的进程会被不断地唤醒,直到出现第一个exclusive属性的进程,该进程会被唤醒,同时遍历结束。



只唤醒一个exclusive属性的进程,这也是exclusive的含义所在:互斥。



因为阻塞在accept上的进程都是互斥的(都是打上WQ_FLAG_EXCLUSIVE标志位),所以TCP连接请求到达时只会有一个进程被唤醒,从而解决了惊群效应。



虽然可以采用类似方式2的解决方案处理方式3的epoll惊群效应,但是该解决方案无法解决方式3中进程与连接不对应的问题。因为accept确实只需要任意一个进程就能够处理,通过互斥的方式就可以解决,而epoll处理的事件有连接请求事件,读写事件等,前者是任意一个进程就可以处理,但后者是需要在持有对应TCP连接的进程中才能处理。所以,一般也不会使用方式3。



目前多进程模式下socket编程使用比较广泛的是方式4,Nginx就是使用这种方式。



 } } 由于reuseport特征负载均衡在内核中的实现原理是按照套接字数量的hash,所以当Nginx进行reload,从reuseport升级为非reuseport,或者从多worker进程升级为少worker进程,都会有大幅度的性能下降。


EPOLLEXCLUSIVE标志位
在Linux 4.5版本引入EPOLLEXCLUSIVE标志位(Linux 4.5, glibc 2.24),子进程通过调用epoll_ctl将监听套接字与监听事件加入epfd时,会同时将EPOLLEXCLUSIVE标志位显式传入,这使得子进程带上了exclusive属性,也就是互斥属性,跟Linux 2.6版本解决accept惊群效应的解决方案类似,不同的地方在于,当有监听事件发生时,唤醒的可能不止一个进程(见如下对EPOLLEXCLUSIVE标志位的官方文档说明中的“one or more”),这一定程度上缓解了惊群效应。



Nginx在1.11.3版本时采用了该解决方案,所以从该版本开始,配置accept_mutex默认为off。



https://zhuanlan.zhihu.com/p/88181936



Linux 解决方案之 Epoll
在使用 select、poll、epoll、kqueue 等 IO 复用时,多进程(线程)处理链接更加复杂。
在讨论 epoll 的惊群效应时候,需要分为两种情况:



epoll_create 在 fork 之前创建
epoll_create 在 fork 之后创建



epoll_create 在 fork 之前创建



与 accept 惊群的原因类似,当有事件发生时,等待同一个文件描述符的所有进程(线程)都将被唤醒,而且解决思路和 accept 一致。



为什么需要全部唤醒?因为内核不知道,你是否在等待文件描述符来调用 accept() 函数,还是做其他事情(信号处理,定时事件)。



此种情况惊群效应已经被解决。



epoll_create 在 fork 之后创建



epoll_create 在 fork 之前创建的话,所有进程共享一个 epoll 红黑数。
如果我们只需要处理 accept 事件的话,貌似世界一片美好了。但是 epoll 并不是只处理 accept 事件,accept 后续的读写事件都需要处理,还有定时或者信号事件。



当连接到来时,我们需要选择一个进程来 accept,这个时候,任何一个 accept 都是可以的。当连接建立以后,后续的读写事件,却与进程有了关联。一个请求与 a 进程建立连接后,后续的读写也应该由 a 进程来做。



当读写事件发生时,应该通知哪个进程呢?Epoll 并不知道,因此,事件有可能错误通知另一个进程,这是不对的。所以一般在每个进程(线程)里面会再次创建一个 epoll 事件循环机制,每个进程的读写事件只注册在自己进程的 epoll 种。



我们知道 epoll 对惊群效应的修复,是建立在共享在同一个 epoll 结构上的。epoll_create 在 fork 之后执行,每个进程有单独的 epoll 红黑树,等待队列,ready 事件列表。因此,惊群效应再次出现了。有时候唤醒所有进程,有时候唤醒部分进程,可能是因为事件已经被某些进程处理掉了,因此不用在通知另外还未通知到的进程了。



Nginx 解决方案之锁的设计
首先我们要知道在用户空间进程间锁实现的原理,起始原理很简单,就是能弄一个让所有进程共享的东西,比如 mmap 的内存,比如文件,然后通过这个东西来控制进程的互斥。



Nginx 中使用的锁是自己来实现的,这里锁的实现分为两种情况,一种是支持原子操作的情况,也就是由 NGX_HAVE_ATOMIC_OPS 这个宏来进行控制的,一种是不支持原子操作,这是是使用文件锁来实现。



原子锁获取



TryLock,它是非阻塞的,也就是说它会尝试的获得锁,如果没有获得的话,它会直接返回错误。
Lock,它也会尝试获得锁,而当没有获得他不会立即返回,而是开始进入循环然后不停的去获得锁,知道获得。不过 Nginx 这里还有用到一个技巧,就是每次都会让当前的进程放到 CPU 的运行队列的最后一位,也就是自动放弃 CPU。



原子锁实现



如果系统库支持的情况,此时直接调用OSAtomicCompareAndSwap32Barrier,即 CAS



https://zhuanlan.zhihu.com/p/51251700



尝试获取accept锁
  if 获取成功:
  在epoll中注册accept事件
  else:
  在epoll中注销accept事件
  处理所有事件
  释放accept锁



如果服务器非常忙,有非常多事件要处理,那么“处理所有事件这一步”就会消耗非常长的时间,也就是说,某一个进程长时间占用accept锁,而又无暇处理新连接;其他进程又没有占用accept锁,同样无法处理新连接——至此,新连接就处于无人处理的状态,这对服务的实时性无疑是很要命的。



Nginx采用了将事件处理延后的方式。即在ngx_process_events的处理中,仅仅将事件放入两个队列中:
  ngx_thread_volatile ngx_event_t *ngx_posted_accept_events;
  ngx_thread_volatile ngx_event_t *ngx_posted_events;
  返回后先处理ngx_posted_accept_events后立刻释放accept锁,然后再慢慢处理其他事件。



那么具体是怎么实现的呢?其实就是在static ngx_int_t ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)的flags参数中传入一个NGX_POST_EVENTS的标志位,处理事件时检查这个标志位即可。
  这里只是避免了事件的消费对于accept锁的长期占用,那么万一epoll_wait本身占用的时间很长呢?这种事情也不是不可能发生。这方面的处理也很简单,epoll_wait本身是有超时时间的,限制住它的值就可以了,这个参数保存在ngx_accept_mutex_delay这个全局变量中。



https://blog.csdn.net/weixin_44400506/article/details/86231334



在执行ngx_epoll_process_events()的时候,如果有新连接事件出现,那么就会调用ngx_event_accept()方法进行建立连接。
什么是post事件处理机制?
所谓的post事件处理机制就是允许事件延后执行。Nginx设计了两个队列,一个是由被触发的监听连接的读事件构成的ngx_posted_accept_events队列,另一个是由普通读/写事件构成的ngx_posted_events队列。



将epoll_wait()产生的事件,分别存放到这两个队列中。让存放新连接事件的ngx_posted_accept_events队列优先执行,存放普通事件的ngx_posted_events队列最后执行,这样就可以保证建立连接的及时性,同时这也是解决惊群和负载均衡的关键所在。
https://www.cnblogs.com/zhengerduosi/p/10207816.html



Nginx 启动的时候 ngx_accept_disabled 的值为零,这个时候所有 worker 都会参与ngx_trylock_accept_mutex(cycle),但只有一个会成功。



胜出的 worker 会把监听 socket 添加到自己的事件循环。这个时候事件循环会进入NGX_POST_EVENTS模式。Nginx 在执行ngx_process_events()的时候只会记录事件,但对应的回调函数会延迟到ngx_event_process_posted()执行。执行完后会主动释放监听锁。如此,所有 worker 进入下一轮争抢循环。



因为同一时间只有一个 worker 能把监听 socket 放到自己的事件循环,worker 之间只会争抢锁,不会同时监听,也就不会有惊群问题。但是保不齐有 worker 运气特别好,一直能抢到锁,就会出现负载不均衡的问题。Nginx 同时使用ngx_accept_disabled跟踪当前 worker 处理的连接总数,在有新连接传入的时候会更新ngx_accept_disabled。



当这个 worker 处理的连接数太多,ngx_accept_disabled就会小于零,这个 worker 就会暂时放弃去争抢监听锁,把机会让给其他 worker。可是,如果所有 worker 都高风亮节把机会让给别人,岂不是新连接没有管了吗?为了防止出现这种情况,不参与锁争抢的 worker 会主动执行ngx_accept_disabled–,当ngx_accept_disabled小于零的时候重新加入争抢。最后,这种使用锁进行负载均衡的方式已经不推荐使用了,Nginx 在 1.11.3 之后也默认不开启 accept_mutex 。Linux 3.9 引入了 SO_REUSEPORT 支持,Linux 4.5 引入了 EPOLLEXCLUSIVE 支持。只要开启 reuseport或者升级到 Linux 4.5+,就不用开启accept_mutex。



https://www.zhihu.com/question/344123909
https://baijiahao.baidu.com/s?id=1704272189216726540&wfr=spider&for=pc
https://www.cnblogs.com/HadesBlog/p/14573062.html
总结:1,所有的进程抢锁
2,抢到锁的进程把监听到的事件放入posted队列
3,当队列达到7/8的时候,释放锁,其他进程抢锁
4,为例保证accept连接事件优先处理,它被放入了优先队列
5,处理完优先队列的数据后,处理非优先队列的任务
6,处理完所有的任务后重新加入抢锁大军



  


Category linux