LINUX----惊群效应

什么是惊群效应?

惊群现象(thundering herd就是当多个进程和线程在同时阻塞地等待同一个事件,如果这个事件发生,会唤醒所有的进程,但是最终只可能有一个进程/线程对该事件进行处理,其他进程/线程会在失败后重新休眠,这种性能浪费就是惊群。



惊群效应的危害?

(1)、系统对用户进程/线程频繁地做无效的调度,上下文切换系统性能大打折扣。

(2)、为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

        *1、上下文切换(context  switch)过高会导致cpu像个搬运工,频繁地在寄存器和运行队列之间奔波,更多的时间花在了进程(线程)切换,而不是在真正工作的进程(线程)上面。直接的消耗包括cpu寄存器要保存和加载(例如程序计数器)、系统调度器的代码需要执行。间接的消耗在于多核cache之间的共享数据。

        *2、通过锁机制解决惊群效应是一种方法,在任意时刻只让一个进程(线程)处理等待的事件。但是锁机制也会造成cpu等资源的消耗和性能损耗。目前一些常见的服务器软件有的是通过锁机制解决的,比如nginx(它的锁机制是默认开启的,可以关闭);还有些认为惊群对系统性能影响不大,没有去处理,比如lighttpd。


LINUX的惊群现象?

*1)accept()惊群

场景模拟:

         主进程创建了socket、bind、listen之后,fork()出来多个进程,每个子进程都开始循环处理(accept)这个listen_fd。每个进程都阻塞在accept上,当一个新的连接到来时候,所有的进程都会被唤醒,但是其中只有一个进程会接受成功,其余皆失败,重新休眠。

代码验证:

#include<stdio.h> 

#include<stdlib.h> 

#include<sys/types.h> 

#include<sys/socket.h> 

#include<sys/wait.h> 

#include<string.h> 

#include<netinet/in.h> 

#include<unistd.h> 

#include<errno.h>

#define PROCESS_NUM 10 

int main() 

{ 

   int fd = socket(PF_INET, SOCK_STREAM, 0); 

   int connfd; 

    intpid; 

 

   char sendbuff[1024]; 

   struct sockaddr_in serveraddr; 

   serveraddr.sin_family = AF_INET; 

   serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); 

   serveraddr.sin_port = htons(1234); 

   bind(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)); 

   listen(fd, 1024); 

   int i; 

   for(i = 0; i < PROCESS_NUM; ++i){ 

       pid = fork(); 

       if(pid == 0){ 

           while(1){ 

                connfd = accept(fd, (structsockaddr *)NULL, NULL); 

               snprintf(sendbuff,sizeof(sendbuff), "接收到accept事件的进程PID = %d\n", getpid()); 

 

                send(connfd, sendbuff,strlen(sendbuff)+1, 0); 

                printf("process %d acceptsuccess\n", getpid()); 

                close(connfd); 

           } 

       } 

   } 

   //int status; 

   wait(0); 

   return 0; 

} 

1运行该服务器程序:$./accept0


1接下来在另一个终端执行telnet 127.0.0.1 1234


2跟踪程序运行过程的系统调用$strace ./ accpet0

2接下来在另一个终端执行telnet 127.0.0.1 1234


很明显当telnet连接的时候只有一个进程accept成功,你会不会和我有同样的疑问,就是会不会内核中唤醒了所有的进程只是没有获取到资源失败了,就好像惊群被“隐藏”?


这个问题很好证明,我们修改一下代码:

#include<stdio.h> 

#include<stdlib.h> 

#include<sys/types.h> 

#include<sys/socket.h> 

#include<sys/wait.h> 

#include<string.h> 

#include<netinet/in.h> 

#include<unistd.h> 

#include<errno.h>

#define PROCESS_NUM 10 

int main()

{

         intfd = socket(PF_INET, SOCK_STREAM, 0);

         intconnfd;

         intpid;

 

         charsendbuff[1024];

         structsockaddr_in serveraddr;

         serveraddr.sin_family= AF_INET;

         serveraddr.sin_addr.s_addr= htonl(INADDR_ANY);

         serveraddr.sin_port= htons(1234);

         bind(fd,(struct sockaddr *)&serveraddr, sizeof(serveraddr));

         listen(fd,1024);

         inti;

         for(i = 0; i < PROCESS_NUM; ++i) {

                   pid= fork();

                   if(pid == 0) {

                            while(1) {

                                     connfd= accept(fd, (struct sockaddr *)NULL, NULL);

                                     if(connfd == 0) {

 

                                               snprintf(sendbuff,sizeof(sendbuff), "接收到accept事件的进程PID = %d\n", getpid());

 

                                               send(connfd,sendbuff, strlen(sendbuff) + 1, 0);

                                               printf("process%d accept success\n", getpid());

                                               close(connfd);

                                     }

                                     else{

                                               printf("process%d accept a connection failed: %s\n", getpid(), strerror(errno));

                                               close(connfd);

                                     }

                            }

                   }

                   //intstatus; 

                   wait(0);

                   return0;

         }

就是增加了一个accept失败的返回信息,按照上面的步骤运行,这里我就不截图了,我只告诉你运行结果与上面的运行结果无异,增加的失败信息并没有输出,也就说明了这里并没有发生惊群,所以注意阻塞和惊群的唤醒的区别。

 

Google了一下:其实在linux2.6版本以后,linux内核已经解决了accept()函数的“惊群”现象,大概的处理方式就是,当内核接收到一个客户连接后,只会唤醒等待队列上的第一个进程(线程),所以如果服务器采用accept阻塞调用方式,在最新的linux系统中已经没有“惊群效应”了

*2)epoll_wait()惊群:

场景模拟:

主进程创建socket,bind,listen后,将该socket加入到epoll中,然后fork出多个子进程,每个进程都阻塞在epoll_wait上,如果有事件到来,则判断该事件是否是该socket上的事件如果是,说明有新的连接到来了,则进行接受操作。为了简化处理,忽略后续的读写以及对接受返回的新的套接字的处理,直接断开连接。

那么,当新的连接到来时,是否每个阻塞在epoll_wait上的进程都会被唤醒呢?

代码验证:

#include<stdio.h> 

#include<sys/types.h> 

#include<sys/socket.h> 

#include<unistd.h> 

#include<sys/epoll.h> 

#include<netdb.h> 

#include<stdlib.h> 

#include<fcntl.h> 

#include<sys/wait.h> 

#include<errno.h> 

#define PROCESS_NUM 10 

#define MAXEVENTS 64 

//socket创建和绑定 

int sock_creat_bind(char * port){ 

   int sock_fd = socket(AF_INET, SOCK_STREAM, 0); 

   struct sockaddr_in serveraddr; 

   serveraddr.sin_family = AF_INET; 

   serveraddr.sin_port = htons(atoi(port)); 

   serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); 

 

   bind(sock_fd, (struct sockaddr *)&serveraddr,sizeof(serveraddr)); 

   return sock_fd; 

} 

//利用fcntl设置文件或者函数调用的状态标志 

int make_nonblocking(int fd){ 

   int val = fcntl(fd, F_GETFL); 

   val |= O_NONBLOCK; 

   if(fcntl(fd, F_SETFL, val) < 0){  

       perror("fcntl set"); 

       return -1; 

   } 

   return 0; 

} 

 

int main(int argc, char *argv[]) 

{ 

   int sock_fd, epoll_fd; 

   struct epoll_event event; 

   struct epoll_event *events; 

         

   if(argc < 2){ 

       printf("usage: [port]%s", argv[1]); 

       exit(1); 

   } 

    if((sock_fd = sock_creat_bind(argv[1])) < 0){ 

       perror("socket and bind"); 

       exit(1); 

   } 

   if(make_nonblocking(sock_fd) < 0){ 

       perror("make non blocking"); 

       exit(1); 

   } 

   if(listen(sock_fd, SOMAXCONN) < 0){ 

       perror("listen"); 

       exit(1); 

   } 

   if((epoll_fd = epoll_create(MAXEVENTS))< 0){ 

       perror("epoll_create"); 

       exit(1); 

   } 

   event.data.fd = sock_fd; 

   event.events = EPOLLIN; 

   if(epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock_fd, &event) < 0){ 

       perror("epoll_ctl"); 

       exit(1); 

   } 

   /*buffer where events are returned*/ 

   events = calloc(MAXEVENTS, sizeof(event)); 

   int i; 

   for(i = 0; i < PROCESS_NUM; ++i){ 

       int pid = fork(); 

       if(pid == 0){ 

           while(1){ 

                int num, j; 

                num = epoll_wait(epoll_fd,events, MAXEVENTS, -1); 

                printf("process %d returntfrom epoll_wait\n", getpid()); 

                //sleep(2); 

                for(i = 0; i < num;++i){ 

                    if((events[i].events &EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events &EPOLLIN))){ 

                        fprintf(stderr,"epoll error\n"); 

                       close(events[i].data.fd); 

                        continue; 

                    }else if(sock_fd ==events[i].data.fd){ 

                        //收到关于监听套接字的通知,意味着一盒或者多个传入连接 

                        struct sockaddrin_addr; 

                        socklen_t in_len =sizeof(in_addr); 

                       if(accept(sock_fd,&in_addr, &in_len) < 0){ 

                           printf("process %d accept failed!\n", getpid()); 

                        }else{ 

                           printf("process %d accept successful!\n", getpid()); 

                        } 

                    } 

                } 

           } 

       } 

   } 

   wait(0); 

   free(events); 

   close(sock_fd); 

   return 0; 

} 

一个终端运行代码 ./epoll 6666 另一个终端telnet 127.0.0.1 6666

运行结果显示了部分个进程被唤醒了,返回了“process accept failed”只是后面因为某些原因失败了。所以这里貌似存在部分“惊群”。

 

怎么判断发生了惊群呢?

 

我们根据strace的返回信息可以确定:

 

    1)系统只会让一个进程真正的接受这个连接,而剩余的进程会获得一个EAGAIN信号。图中有体现。

 

    2)通过返回结果和进程执行的系统调用判断。

 

这究竟是什么原因导致的呢?

 

看我们的代码,看似部分进程被唤醒了,而事实上其余进程没有被唤醒的原因是因为某个进程已经处理完这个事件,无需唤醒其他进程,你可以在epoll获知这个事件的时候sleep(2);

保证在2秒没该事件仍未被处理完。这样2秒内所有的进程都会被唤起。看下面改正后的代码结果更加清晰:

 

代码修改:

num = epoll_wait(epoll_fd, events, MAXEVENTS, -1); 

printf("process %d returnt from epoll_wait\n",getpid()); 

sleep(2);  

测试结果:

如图所示:所有的进程都被唤醒了。所以epoll_wait的惊群确实存在。

 

为什么内核处理了accept的惊群,却不处理epoll_wait的惊群呢?

我想,应该是这样的:

accept确实应该只能被一个进程调用成功,内核很清楚这一点。但epoll不一样,他监听的文件描述符,除了可能后续被accept调用外,还有可能是其他网络IO事件的,而其他IO事件是否只能由一个进程处理,是不一定的,内核不能保证这一点,这是一个由用户决定的事情,例如可能一个文件会由多个进程来读写。所以,对epoll的惊群,内核则不予处理。

*3)线程惊群:

printf("初始的红包情况:<个数:%d  金额:%d.%02d>\n",item.number,item.total/100, item.total%100); 

pthread_cond_broadcast(&temp.cond);//红包包好后唤醒所有线程抢红包 

pthread_mutex_unlock(&temp.mutex);//解锁 

sleep(1); 

没错你可能已经注意到了,pthread_cond_broadcast()在资源准备好以后,或者你再编写程序的时候设置的某个事件满足时它会唤醒队列上的所有线程去处理这个事件,但是只有一个线程会真正的获得事件的“控制权”。

解决方法之一就是加锁。下面我们来看一看解决或者避免惊群都有哪些方法?

怎么解决惊群现象?

(1)、Nginx的解决方案:

nginx的解决思路:避免惊群:

 Nginx中使用mutex互斥锁解决这个问题,具体措施有使用全局互斥锁,每个子进程在epoll_wait()之前先去申请锁,申请到则继续处理,获取不到则等待,并设置了一个负载均衡的算法(当某一个子进程的任务量达到总设置量的7/8时,则不会再尝试去申请锁)来均衡各个进程的任务量。这样其他nginx worker进程就更有机会去处理监听句柄,建立新连接了。

 

详情参考:https://blog.csdn.net/russell_tao/article/details/7204260

(2)、Lighttpd的解决方案:

lighttpd的解决思路:无视惊群

采用Watcher/Workers模式,具体措施有优化fork()与epoll_create()的位置(让每个子进程自己去epoll_create()和epoll_wait()),捕获accept()抛出来的错误并忽视等。这样子一来,当有新accept时仍将有多个lighttpd子进程被唤醒。


(3)、SO_REUSEPORT选项:

Linux内核的3.9版本带来了SO_REUSEPORT特性,该特性支持多个进程或者线程绑定到同一端口,提高服务器程序的性能,允许多个套接字bind()以及listen()同一个TCP或UDP端口,并且在内核层面实现负载均衡。


在未开启SO_REUSEPORT的时候,由一个监听socket将新接收的连接请求交给各个工作者处理,如下图:



在使用SO_REUSEPORT后,多个进程可以同时监听同一个IP:端口,然后由内核决定将新链接发送给哪个进程,显然会降低每个工人接收新链接时锁竞争。如下图:



 

下面让我们比较一下多进程(线程)服务器编程传统方法和使用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和端口的多个sock之间均衡选择。

 

多个套接字绑定一个端口时,当一个连接到来的时候,系统到底是怎么决定那个套接字来处理它?

 

对于不同内核,存在两种模式,这两种模式并不共存,一种叫做热备份模式,另一种叫做负载均衡模式,3.9内核以后,全部改为负载均衡模式。

 

热备份模式:一般而言,会将所有的reuseport同一个IP地址/端口的套接字挂在一个链表上,取第一个即可,工作的只有一个,其他的作为备份存在,如果该套接字挂了,它会被从链表删除,然后第二个便会成为第一个。

负载均衡模式:和热备份模式一样,所有reuseport同一个IP地址/端口的套接字会挂在一个链表上,你也可以认为是一个数组,这样会更加方便,当有连接到来时,用数据包的源IP/源端口作为一个HASH函数的输入,将结果对reuseport套接字数量取模,得到一个索引,该索引指示的数组位置对应的套接字便是工作套接字。这样就可以达到负载均衡的目的,从而降低某个服务的压力。

 

想比较SO_REUERADDSO_REUERPORT不同可以参考这篇博文:

https://blog.csdn.net/yaokai_assultmaster/article/details/68951150

参考博文:

http://blog.163.com/pandalove@126/blog/static/9800324520122633515612

https://www.cnblogs.com/Anker/p/7071849.html

https://blog.csdn.net/lyztyycode/article/details/78648798?locationNum=6&fps=1

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值