初步总结我们基于Nginx+uWSGI+python的服务最近经常遇到高峰期高负载,导致部分请求报错。当单机qps只有2000-3000左右时,内核的CPU占用高达20%以上,内核上下文切换每秒超过200万次。经过分析发现,nginx+uwsgi造成了令人震惊的羊群效应,导致性能急剧下降。通过加锁解决了惊群问题后,恢复了服务。基于这个排查过程,再加上我之前写的epoll的分析,也一下子提到了惊群效应。当时没写完整,这次就来说说这个话题吧。我先详细分析震撼群体效应。这样的原因,然后把nginx和uwsgi拿出来讨论一下他们各自是怎么处理这个问题的,他们的解决方案有什么优缺点。本文源码分析基于:linux2.6和4.5内核,nginx1.8和1.16,uWSGI2.201。多进程不使用epoll/select如何共享端口监听?当不使用多路复用时,进程必须调用accept并被阻塞以接受tcp连接,直到连接到达。在此之前,它不能做其他事情,也就是说,单个进程一次只能处理一个连接,业务处理完成后,调用close关闭连接,然后继续等待accept,如此循环往复。这种情况下无法实现高并发,所以一般采用多进程同时处理更多的连接。一般多进程中有两个进程。第一种模式是一个主进程进行accept监听,accept一个connection,然后fork一个子进程,把connection丢给子进程进行业务处理,然后主进程继续监听。这是最简单的模式,因为只有一个进程在使用accept监听,不涉及多进程竞争。当tcp连接事件到来时,只会唤醒监听进程,自然不会有惊群效应。第二种形式是主进程的一个分支。批量子进程,子进程继承父进程的监听端口,大家共享,然后一起监听。这涉及到内核在多个进程处于阻塞状态等待同一个端口事件时的行为。接下来重点分析这个场景//当进程调用accept时,会进入inet_csk_accept,这是accept的核心structsock*inet_csk_accept(structsock*sk,intflags,int*err){structinet_connection_sock*icsk=inet_csk(sk);结构袜子*newsk;整数错误;锁袜子(sk);/*我们需要确保这个套接字正在侦听,*并且它有一些未决的事情。*/错误=-EINVAL;//确认socket处于监听状态if(sk->sk_state!=TCP_LISTEN)gotoout_err;/*Findalreadyestablishedconnection*//*接下来,找到一个Establishedconnection*/if(reqsk_queue_empty(&icsk->icsk_accept_queue)){//如果sock的连接队列为空longtimeo=sock_rcvtimeo(sk,flags&O_NONBLOCK);/*如果这是一个非阻塞套接字,不要休眠*/error=-EAGAIN;if(!timeo)//如果设置了非阻塞模式,直接返回,欢迎err-EAGAINgotoout_err;//如果是阻塞模式,进入inet_csk_wait_for_connect,进程会被阻塞,直接给新到达的连接唤醒error=inet_csk_wait_for_connect(sk,timeo);如果(错误)转到out_err;}//这里,连接队列至少会有一个可用连接返回newsk=reqsk_queue_get_child(&icsk->icsk_accept_queue,sk);WARN_ON(newsk->sk_state==TCP_SYN_RECV);输出:release_sock(sk);返回newsk;out_err:newsk=NULL;*错误=错误;gotoout;}EXPORT_SYMBOL(inet_csk_accept);//inet_csk_wait_for_connect会挂起进程直到被新连接唤醒DEFINE_WAIT(等待);//定义一个挂在socket监听队列中的等待节点interr;for(;;){//使用prepare_to_wait_exclusive确认互斥等待。事件到来后,内核只会唤醒等待队列中的一个进程prepare_to_wait_exclusive(sk_sleep(sk),&wait,TASK_INTERRUPTIBLE);释放袜子(sk);if(reqsk_queue_empty(&icsk->icsk_accept_queue))//再次判断队列是否为空,如果为空则进入调度,此时会挂起当前进程timeo=schedule_timeout(timeo);锁袜子(sk);错误=0;如果(!reqsk_queue_empty(&icsk->icsk_accept_queue))中断;错误=-EINVAL;如果(sk->sk_state!=TCP_LISTEN)中断;错误=sock_intr_errno(timeo);如果(signal_pending(当前))中断;错误=-EAGAIN;如果(!timeo)中断;}finish_wait(sk_sleep(sk),&wait);returnerr;}我提取了linux内核中accept部分的核心代码。linux提供了accept4系统调用,accept4最终会调用上面的inet_csk_accept,inet_csk_accept最终会调用inet_csk_wait_for_connect。如果此时没有连接可用,内核会挂起当前进程。重点是挂起的进程使用了??prepare_to_wait_exclusive函数,没有多进程唤醒。PS:如果没有内核原理某某基础可能不知道什么是prepare_to_wait_exclusive。简单的说,Linux内核提供了两种进程唤醒的模式,一种是prepare_to_wait,一种是prepare_to_wait_exclusive,exclusive就是互斥。如果调用prepare_to_wait_exclusive,那么在唤醒一个等待队列进程时,只会唤醒一个进程,prepare_to_wait不设置互斥位,会唤醒所有挂在等待队列上的进程。综上所述,在常见的多进程共享监听端口的情况下,内核只会在新的连接事件到来时唤醒其中一个进程。可以直接看上面的流程图。父进程创建的监控套接字fd1由fork出来的两个子进程共享。此时子进程的两个fd在内核中属于同一个文件,记录在openfiles表中。下一个第4步和第5步,两个子进程同时调用accept进行阻塞监听,两个进程都会被挂起,内核会将这两个PID记录在socket的waitqueuelist中,以唤醒它们;在第8步,当连接事件到来时,内核会取出对应socket下的等待队列。对于tcp连接事件,内核只会为一个连接事件唤醒一个进程,取出等待队列链表的第一个节点,唤醒对应的进程。此时PID1进程的accept成功获取连接并返回用户态,PID2没有被唤醒。事实上,在linux2.6之前的版本中,accept也会唤醒等待队列中的所有进程,这也造成了惊群效应。在2.6中添加了互斥标志来解决这个问题。2、epoll下共享监听端口的行为接下来我们看下使用epoll时多个进程一起监听端口的情况。最经典的例子就是nginx,多个worker会一起监听同一个端口,但是在它的1.11版本之前,使用的是上面第一节提到的方法。master进程创建监听端口后,woker进程通过fork继承这个端口,然后放到epoll中去监听,现在我们重点说下内核在这种场景下(epoll+accept)的行为与直接的有何不同接受。epoll需要调用epoll_create在内核中创建一个epoll文件。如下图,epoll会创建一个匿名的inode节点,它指向一个epoll主结构,这个结构中有两个核心字段,一个是红黑树,一个是user中需要监控的文件mode会挂在这棵红黑树下,实现lgn的查找和插入,更新的复杂度;另一个是rdlist,是文件事件就绪队列,指向一个链表。当一个事件发生时,epoll会将对应的epitem(即红黑树上的节点)插入到这个链表中。回到用户态时,只需要遍历就绪列表,而不用像select一样遍历所有文件。不过本文的重点是分析epoll的阻塞和唤醒过程。epoll本身的主要结构就简单拿下了。上面的结构比较复杂,可以看我之前写的文章([https://medium.com/@heshaobo2...)接下来我们看看如何把要监听的socketfd挂在epoll上,这个过程叫它就是epoll_ctl,把fd传给内核,内核实际上会做两件事把fd挂在红黑树上调用文件设备驱动的poll回调指针(这是重点),epoll/select等模型实现多路复用,其实主要是靠:把进程挂在对应fd的等待队列上,这样当fd有事情发生时,设备驱动程序会唤醒这个队列上的进程。如果进程不依赖epoll,毫无疑问他不能同时挂在多个fd队列上。这件事情的一个核心步骤就是调用对应的fd驱动设备提供的poll方法。在Linux中,进行了设备模型的标准化。例如,设备分为字符设备、块设备、网络设备等。对于开发人员来说,要实现一个设备的驱动程序,就必须按照linux提供的规范来实现。对于与用户层的交互,内核要求开发者实现一个名为file_operations的结构体,该结构体定义了一系列操作的回调指针,如Read、write等用户熟悉的操作。当用户调用read、write等方法时,内核最终会回调到这个设备的file_operations.read和file_operations.write方法。该方法的具体逻辑需要驱动开发者自行实现,比如这篇文章中的accept调用实际上是调用了socket下的file_operations.accept方法。综上所述,如果一个设备要支持epoll/select调用,就必须实现file_operations.poll方法,epoll就是处理从用户层传入的fd。其实这个方法最终还是被调用了,这个方法linux也做了一系列的规范。它需要开发者实现如下逻辑:要求poll方法返回用户感兴趣的事物的flags,比如当前fd是否可读,是否可写等。如果poll传入一个poll特定的等待queue结构,那么他就会调用这个结构。在这个结构体中会有一个叫做poll_table的东西,它有一个回调函数,poll方法最终会调用这个回调,这个回调是epoll设置的。该方法中epoll实现的逻辑是:将当前进程挂在这个fd的等待队列上。简单的说,如果进程自己调用accept,协议栈驱动会亲自把accept这个进程挂在等待队列上。如果被epoll调用,poll方法会被回调。最后epoll自己把进程挂在等待队列上。记住这个结论,这是accept震撼人心的人群效应最根本的原因。看看epoll是如何跟随file_opr的eations->poll方法进行交互,我画了一个简单的时序图如上图,当用户调用epoll_ctl的add事件时,在第6步,epoll会把当前进程挂在fd的等待队列下,但是默认情况下,这个kindofmount不会设置互斥标志,意思是当设备有什么事要唤醒等待队列,如果当前队列有多个进程在等待,则全部被唤醒。可以想象,在下面的epoll_wait调用中,如果多个进程将同一个fd添加到epoll中进行监听,当事件到来时,这些进程会一起被唤醒但是唤醒不一定会回到用户态,因为epoll唤醒后会遍历一次就绪列表,确认至少有一个事件发生就会返回用户态,我们可以想象epoll是如何引起accept冲击群的:当多个进程共享同一个监听端口,使用epoll进行多路复用监听时,epoll将这些进程挂在同一个等待队列中。当事件发生时,socket设备驱动会尝试唤醒等待队列,但是由于挂载队列时使用了epoll挂载方式,所以没有设置互斥标志(代替accept自己挂载队列的方式,如在第一节中描述),所以这个队列下的所有进程都会被唤醒。这些进程醒来后,此时仍处于内核态,它们会立即查看事件就绪列表,确认是否有事件。对于accept,accept->poll方法会检查当前socket的tcp全连接列表中是否有可用连接,如果有则在所有进程都被唤醒时返回可用事件标志,但在真正做accept动作之前,所有的事件检查都认为接受事件可用,所以所有这些检查都返回到用户模式。当用户态检查到有可用的accept事件时,它们就会真正调用accept函数,此时只有一个进程能够真正获得连接,其他进程都会返回EAGAIN错误。您可以使用strace-pPID命令来跟踪此类错误。并非所有进程都会返回到用户态。关键是这些都被唤醒了。在检查事件的过程中,如果已经有成功接受连接的进程,其他的东西此时不会检查这件事,所以会继续休眠,不会回到用户态,虽然可能不会必然会返回到用户态,但同时也造成了内核上下文切换的发生,这其实是雷霆万钧效应的一种体现。3、内核是否解决了雷霆万钧的羊群效应?所有进程唤醒后续内核版本主要提供两种解决方案。由于默认不设置互斥,所以最好添加一个互斥功能:-)。linux4.5内核之后,epoll增加了一个EPOLLEXCLUSIVE标志。如果设置了这个标志位,当epoll将进程挂到等待队列时,会设置互斥标志位。这时候就会实现和内核nativeaccept一样的特性,只会唤醒队列中的一个进程。第二种方法:linux3.9内核之后给socket提供SO_REUSEPORT标志,是比较彻底的解决方法。它允许不同进程的socket绑定到同一个端口,取代了之前需要子进程共享socket监听的方式。这时候各个进程的监听socket会指向open_file_tables不同节点下的不同节点,也就是说不同进程挂在各自的设备等待队列下,不存在共享fd的问题,也不存在可能同时被唤醒,内核会在驱动中设置SO_REUSEPORT,将绑定在同一个端口的这些套接字分到同一组。当一个tcp连接事件到来时,内核会对源IP+源端口进行hash,并指定组内的其中一个进程接受连接,相当于基于以上两种方式在内核层面实现了一个负载均衡.事实上,目前epoll生态中并不存在所谓的令人震惊的羊群效应,除非:你过度使用epoll,比如多个进程之间共享同一个epfd(父进程创建epoll被多个子进程调用),那么不能怪epoll,因为此时这个epoll下挂了多个进程,这样的话,就不仅仅是雷霆万钧效应的问题了,比如进程A在epoll中挂掉了socket1的连接事件,进程B调用epoll_wait。由于属于同一个epfd,当socket1产生事件时,进程B也会被唤醒,更严重的是不在B的空间,socket1的fd不存在,这就造成了问题非常复杂总结:不要在多线程/多进程之间共享epfd4。Nginx如何解决雷霆万钧的羊群效应?在nginx1.11及以上版本,已经默认开启SO_REUSEPORT选项来解决这个问题。应用层不需要做特别的事情,而在此之前,nginx解决惊群的方法是加锁。多个进程共享一个文件锁。只有抢到这个锁,进程才会把要监听的端口放到epoll中。当epoll_wait返回后,nginx会调用accept取出连接,然后释放文件锁,让其他进程监听。这是一种折衷的方法,并不完美。首先,进程间竞争锁会消耗性能(即使是非阻塞锁)。可能有一小段时间没有进程获取锁。比如进程A获得了锁,其他进程会在短时间内尝试获得锁,如果在这段短时间内有大量的请求,而A只接受少量的请求,然后释放锁,中间进程会挂一些连接事件。总而言之,升级nginx版本,不要再依赖这种模式了!5.uwsgi如何解决thunderingherdeffecthttps://uwsgi-docs.readthedocs.io/en/latest/articles/SerializingAccept.html,AKAThunderingHerd,AKAtheZeegProblem-uWSGI2.0documentation)以上是官方的uwsgi说明uwsgi应用一般不追求并发。其实雷团的问题不需要特别注意。同时提供了一个-thunder-lock选项,实现进程间竞争accept的锁。当然,新版本也支持SO_REUSEPORT,默认开启。但是在实际运行中发现,如果不开启锁,uwsgi的震撼效果是:所有进程都在监听时CPU倾斜,谁从内核层回到用户态,谁先接受,就能成功拿到socket,拿到socket之后,通常会继续把这个socket添加到epoll中,监听接收数据的事件。如果一个进程在epoll中加入的套接字越多,它被唤醒的概率就越大。醒来后会勾选accept,所以成功accept的概率也比较高。随着时间的推移,你会看到总是有几个worker在处理请求。还有其他工人都被饿死了,但是这个情况我还没有仔细想过。以后有机会的话,我会单独写一篇uwsgi的文章。
