其实我自己本身也不太懂,大家相互学习学习,这些也是我看过很多东西之后写的。
惊群:
在多线程或者多进程的情况下,多个worker等待同一个socket事件,当事件发生时这些线程同时被内核调度唤醒,但是只是
会有一个worker来处理事件,其他的worker在accept()返回失败后重新休眠,这种性能浪费现象就是惊群,可以想象:这种
现象效率很低。
我们以服务器和客户端的通信为例《基于tcp》
应该说惊群现象它发生在服务器端,主线程(父进程)负责 绑定端口号监听套接字,然后就创建线程(创建子进程),这些
线程会同时循环处理accept连接的套接字,每当有客户端发起一个TCP链接的时候,这些子线程就会同时被唤醒,然后选择其
中一个子线程进行accept的连接,其他的线程都会连接失败,重新进入休眠期。
那么就有人说啦,为什么不直接只是让主线程去accept进行连接,然后用一些同步的实现方法让子进程处理新的链接,这样的
话就是可以避免惊群现象啦,但是,如果是这样的话,它就只能用来进行accept连接,但是这样的话,这对cpu其实是一种资源
浪费,所以呢,惊群现象是必须要解决的。
在linux中,我们通常用epoll解决非阻塞socket,所以accept的调用就不会有惊群现象了,但是我们如果调用epoll_wait,当有新的
连接,多个线程都会在epoll_wait后被唤醒。(epoll_wait 返回就绪fd的个数)
nginx
eg:主进程(或者线程)绑定端口号和ip,所有的nginx worker进程调用epoll_wait来处理事件,如果不加任何保护,当一个新的连接
来临时,很多进程会在epoll_wait后被唤醒,然后发现自己accept失败。。。。。。。
那我们来看一看nginx怎么解决惊群
每一个worker进程在ngx_process_events_and_timers里处理事件,
ngx_process_events(cycle,timer,flags)封装了不同事件的处理机制,
void ngx_process_events_and_timers(ngx_ctcle_t *cycle)
{
/*
表示是否可以通过对accept加锁解决惊群如果
nginx worker进程数>1时并且配置文件中打开
accept_mutex锁的时候
*/
if(ngx_use_accept_mutex)
{
/*
判断ngx_accept_disabled是否大于0
ngx_accept_disabled == 单进程所有的连接数的1/8-剩余空间链接数
nginx.conf中配置每一个nginx worker进程能够处理的最大连接数,
当达到最大数的7/8时,nginx_accept_disabled就会为正,表示不会
处理这个新连接,(类似一个负载均衡)
通过disabled控制是否去竞争accept_mutex;值越大,让出就会越大
*/
if(ngx_accept_disabled > 0)
{
ngx_accept_disabled--;
}
else
{
/*
获取锁accept,监听句柄(accept事件)放入当前worker的epoll中去
*/
if(NGX_ERRNO == ngx_trylock_accept_mutex(cycle))
{
return ;
}
if(ngx_accept_mutex_held)
{
/*
拿到锁之后,将flag置为NGX_POXT_EVENTS,把accept事件放入
ngx_posted_accept_events链表当中去。EPOLLIN还有EPOLLOUT
也放入ngx_posted_events当中去,读写事件消耗的事件较长,会在释放锁之后
延迟处理。
*/
flags |= NGX_POSTED_EVENTS;
}
else
{
/*
如果没有拿到锁,就不会去处理监听事件,将timer修改成
ngx_accept_mutex_delay,将以更短的超时时间返回,使得
没有获得锁的worker去拿到锁的频率更高。
*/
if(timer == NGX_TIMER_INFINITE ||timer >ngx_accept_mutex_delay)
{
timer = ngx_accept_mutex_delay
}
}
}
}
//
/*
然后调用ngx_epoll_process_events函数开始处理
*/
ngx_process_events(cycle,timer,flags);
/*
如果ngx_posted_accept_events链表中是有数据的
就开始进行accept建立连接。
*/
if(ngx_posted_accept_events)
{
ngx_event_process_posted(cycle,&ngx_posted_accept_events);
}
/*
然后释放锁之后再处理EPOLLIN/EPOLLOUT事件
*/
if(ngx_accept_mutex_held)
{
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链表中,延迟到锁释放了再处理。
*/
if(ngx_postet_events)
{
if(ngx_threaded)
ngx_wakeup_worker_thread(cycle);
else
ngx_event_process_posted(cycle,&ngx_posted_events);
}
}
大家可能心里还是有一点混乱,我把上面代码先做一个总结:
1》首先判断flags也就是ngx_use_accept_mutex是否大于一:因为只有ngix worker的进程数大于1并且
配置文件中打开accept_mutex锁的时候,才会考虑要不要加锁。
2》然后再判断ngx_accept_disabled是否大于0:在nginx.conf中配置每一个进程可以连接事件的最大数
当达到最大数的7/8时,ngx_accept_disabled就会为正(表示满负荷),当这个数字越大的时候,让出
的机会就越大,通过disabled来控制是否去竞争accept_mutex,这个数字小于0的时候就去竞争accpet_mutex
(这里有负载均衡的意思)
3》获取锁:ngx_trylock_accept_mutex();然后把监听句柄放到当前worker的epoll当中去,
4》拿到锁之后,我们将flags置为NGX_POSITED_EVENTS,我们把accept事件放入ngx_posted_accept_events
链表中,将EPOLLIN|EPOLLOUT事件放到ngx_posted_events中去(读写事件耗时长,将在锁释放后延迟处理)
5》没有拿到锁的worker,timer修改为ngx_accept_mutex_delay,将以更短的超时时间返回,使得没有获得锁的
worker去拿锁的频率更高
6》然后开始处理,当ngx_posted_accept_events中有数据时,就进行新的连接,然后在释放锁之后在进行处理
读写操作。(这里有一个延迟过程)
从上面的代码注释可以得知:
不论有多少的nginx worker进程,同一个时刻只能有一个worker进程在自己的epoll中加入监听句柄,这个处理accept
的nginx worker进程将flags设置为NGX_POST_EVENTS,这样的话,他在接下来ngx_process_events函数(linux中
是ngx_epoll_process_events函数)中不会立刻处理事件,会延后,先处理完所有的accept之后,释放锁之后,然后再
处理正常的读写事件;
7》我们就开始看看ngx_epoll_process_events是怎么处理accept的:::::::::::::::::
static ngx_int_t ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
/*
epoll_wait返回就绪fd的个数。
*/
events = epoll_wait(ep, event_list, (int) nevents, timer);
/*
给事件加锁(accept事件)
*/
ngx_mutex_lock(ngx_posted_events_mutex);
for (i = 0; i < events; i++)
{
c = event_list[i].data.ptr;
rev = c->read; //(读事件)
if ((revents & EPOLLIN) && rev->active)
{
/*
有NGX_POST_EVENTS标志的话,就把accept事件放到ngx_posted_accept_events队列中,
把正常的事件放到ngx_posted_events队列中延迟处理
*/
if (flags & NGX_POST_EVENTS)
{
queue = (ngx_event_t **) (rev->accept ? &ngx_posted_accept_events : &ngx_posted_events);
ngx_locked_post_event(rev, queue);
}
else
{
rev->handler(rev);
}
}
wev = c->write;
if ((revents & EPOLLOUT) && wev->active)
{
/*
同理,有NGX_POST_EVENTS标志的话,写事件延迟处理,
放到ngx_posted_events队列中
*/
if (flags & NGX_POST_EVENTS)
{
ngx_locked_post_event(wev, &ngx_posted_events);
}
else
{
wev->handler(wev);
}
}
}
ngx_mutex_unlock(ngx_posted_events_mutex);
return NGX_OK;
}
ngx_use_accept_mutex在什么情况下被打开:
当nginx worker的数量大于1时,也就是多个进程可能同时accept一个连接,这事如果配置文件的accept_mutex开关打开了
就将ngx_use_accept_mutex置为1;
还有我刚才在上面写的类似于一个负载均衡:ngx_accept_disabled是怎么控制的呢???
ngx_accept_disabled = ngx_cycle->connection_n /8-ngx_cycle->free_connect_n;
表示:当已使用的连接数占到总是的7/8以上的时候,ngx_accept_disabled为正,这时
worker将ngx_accept_disabled减1,而且本次不会处理这个新连接。
下面是我写的一个普通的epoll实现的一个代码(没有多进程,没有锁),大家可以看一下
捋捋思路
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <assert.h>
#include <unistd.h>
#include <sys/epool.h>
#define MAX 10
void main()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
assert(sockfd != -1);
struct sockaddr_in ser,cli;
memset(&ser,0,sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = (6500);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&ser,sizeof(ser));
assert(res != -1);
listen(sockfd,5);
int epfd = epoll_create(10);
struct epoll_event events[MAX];
events.event = EPOLLIN|EPOLLET;
event.data.fd = sockfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&events);
while(1)
{
int n = epoll_wait(epfd,events,MAX,-1);
if(n<=0)
{
continue;
}
int i = 0;
for(;i<n;i++)
{
int fd = events[i].data.fd;
if(fd == sockfd)
{
int len = sizeof(cli);
int c = accept(sockfd,(struct sockaddr*)&cli,&len);
events.event = EPOLLIN|EPOLLET;
events.data.fd = c;
epoll_ctl(epfd,EPOLL_CTL_ADD,c,&events);
}
else
{
while(1)
{
char buff[1024] = {0};
int res = recv(fd,buff,1023,0);
if(res <= 0)
}
}
}
}
}
1.先处理新用户的连接事件,再释放处理新连接的锁,为什这么设计?
如果刚释放锁,就有新连接,刚获得锁的进程要给等待队列中添加sockfd时,此时原获得锁的进程也要从等待队列中删除sockfd,
TCP的三次握手的连接是非线程安全的。为了避免产生错误,使得将sockfd从等待队列中删除后,再让新的进程抢占锁,处理新连接。
2.拿到锁,将任务放在任务队列中,不是立刻去处理,为什这么设计?
每个进程要处理新连接事件,必须拿到锁,当前进程将新连接事件的sokect添加到任务队列中,立即释放锁,让其他进程尽快获得锁,
处理用户的连接。
你可能有个疑问,如果没有加锁,有新事件连接时,所有的进程都会被唤醒执行accept,有且仅有一个进程会accept返回成功,
其他进程都重新进入睡眠状态。现在有了锁,在发生accept之前,进程们要去抢占锁,也是有且仅有一个进程会抢到锁,
其他进程也是重新进入睡眠状态。
即:不论是否有accept锁,都会有很多进程被唤醒在重新进入睡眠状态,那惊群现象是不能解决了??????
其实,锁不能解决惊群现象,惊群现象是没办法解决的,很多进程被同时唤醒是一个必然的过程。
Nginx中通过检查当前进程的连接数是否>最大连接数*7/8来判断当前进程是否能处理新连接,减少被唤醒的进程数量,
也实现了简单的负载均衡。锁只能保证不让所有的进程去调用accept函数,解决了很多进程调用accept返回错误,
锁解决的是惊群现象的错误,并不是解决了惊群现象!