目录

高并发中的惊群效应

原文:https://blog.csdn.net/second60/article/details/81252106

惊群效应简介

当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉, 等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。

简单地说:就是扔一块食物,所有鸽子来抢,但最终只一个鸽子抢到了食物。

语义分析:食物只有一块,最终只有一个鸽子抢到,但是惊动了所有鸽子,每个鸽子都跑过来,消耗了每个鸽子的能量。(这个很符合达尔文的进化论,物种之间的竞争,适者生存。)

操作系统的惊群

在多进程/多线程等待同一资源时,也会出现惊群。即当某一资源可用时,多个进程/线程会惊醒,竞争资源。这就是操作系统中的惊群。

惊群的坏处

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

在某些情况:惊群次数少/进(线)程负载不高,惊群可以忽略不计

惊群的几种情况

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

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

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

以多进程为例,在主进程创建监听描述符listenfd后,fork()多个子进程,多个进程共享listenfd,accept是在每个子进程中,当一个新连接来的时候,会发生惊群。

https://gitee.com/lienhui68/picStore/raw/master/null/20200914110654.png

由上图所示:

  1. 主线程创建了监听描述符listenfd = 3
  2. 主线程fork 三个子进程共享listenfd=3
  3. 当有新连接进来时,内核进行处理

在内核2.6之前,所有进程accept都会惊醒,但只有一个可以accept成功,其他返回EGAIN。

在内核2.6及之后,解决了惊群,在内核中增加了一个互斥等待变量。一个互斥等待的行为与睡眠基本类似,主要的不同点在于:

  1. 当一个等待队列入口有 WQ_FLAG_EXCLUSEVE 标志置位, 它被添加到等待队列的尾部. 没有这个标志的入口项, 相反, 添加到开始.
  2. 当 wake_up 被在一个等待队列上调用时, 它在唤醒第一个有 WQ_FLAG_EXCLUSIVE 标志的进程后停止。

对于互斥等待的行为,比如如对一个listen后的socket描述符,多线程阻塞accept时,系统内核只会唤醒所有正在等待此时间的队列 的第一个,队列中的其他人则继续等待下一次事件的发生,这样就避免的多个线程同时监听同一个socket描述符时的惊群问题。

epoll惊群

epoll惊群分两种:

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

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

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

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

  2. 主进程fork多个子进程

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

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

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

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

  1. 主进程创建listendfd

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

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

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

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

分析:因为每个子进程的epoll是不同的epoll, 虽然listenfd是同一个,但新连接过来时, accept会触发惊群,但内核不知道该发给哪个监听进程,因为不是同一个epoll。所以这种惊群内核并没有处理。惊群还是会出现。

nginx惊群的解决

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

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

在nginx中流程如下:

1 主线程创建listenfd
2 主线程fork多个子进程(根据配置)
3 子进程创建epollfd
4 获到accept锁,只有一个子进程把listenfd加到epollfd中 同一时间只有一个进程会把监听描述符加到epoll中
5 循环监听

在nginx中,解决惊群的方法,使用了互斥锁还解决。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
void ngx_process_events_and_timers(ngx_cycle_t *cycle)
 
{
 
// 忽略....
 
//ngx_use_accept_mutex表示是否需要通过对accept加锁来解决惊群问题。
 
//当nginx worker进程数>1时且配置文件中打开accept_mutex时,这个标志置为1
 
    if (ngx_use_accept_mutex) {
 
     //ngx_accept_disabled表示此时满负荷,没必要再处理新连接了,
 
//我们在nginx.conf曾经配置了每一个nginx worker进程能够处理的最大连接数,
 
//当达到最大数的7/8时,ngx_accept_disabled为正,说明本nginx worker进程非常繁忙,
 
//将不再去处理新连接,这也是个简单的负载均衡
 
        if (ngx_accept_disabled > 0) {
 
            ngx_accept_disabled--;
 
        } else {
 
         //获得accept锁,多个worker仅有一个可以得到这把锁。
 
//获得锁不是阻塞过程,都是立刻返回,获取成功的话ngx_accept_mutex_held被置为1。
 
//拿到锁,意味着监听句柄被放到本进程的epoll中了,
 
//如果没有拿到锁,则监听句柄会被从epoll中取出。
 
            if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
 
                return;
 
            }
 
 
 
//拿到锁的话,置flag为NGX_POST_EVENTS,这意味着ngx_process_events函数中,
 
//任何事件都将延后处理,会把accept事件都放到ngx_posted_accept_events链表中,
 
// epollin|epollout事件都放到ngx_posted_events链表中
 
            if (ngx_accept_mutex_held) {
 
                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;
 
                }
 
            }
 
        }
 
    }
 
// 忽略....
 
//linux下,调用ngx_epoll_process_events函数开始处理
 
    (void) 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_posted_events) {
 
        if (ngx_threaded) {
 
            ngx_wakeup_worker_thread(cycle);
 
        } else {
 
            ngx_event_process_posted(cycle, &ngx_posted_events);
 
        }
 
    }
 

步骤 **nginx主动解决惊群流程****
1 子进程loop
2 判断是否使用accept加锁
3 判断是否满负荷最大连接数的7/8(是不处理)
4 *多个worker**竞争**accept_mutex锁**(主动精髓)*
5 获得锁成功 获得锁失败
6 (****监听句柄****加到本进程的epoll) ****监听句柄****会被从epoll中取出
7 事件加入到链表中(accept事件放到ngx_posted_accept_events链表epollin|out事件放到ngx_posted_events链表) 修改epoll_wait的超时时间(为了下次更早抢锁)
8 如果有accept_event就处理新连接
9 释放锁accept_mutex
10 处理正常的数据读写请求
11 子进程继续loop

分析

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

小结: nginx采用互斥锁和主动的方法,避免了惊群,使得nginx中并无惊群

线程池惊群

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

  • pthread_mutex_lock/pthread_mutex_unlock:线程互斥锁的加锁及解锁函数。
  • pthread_cond_wait:线程池中的消费者线程等待线程条件变量被通知;
  • pthread_cond_signal/pthread_cond_broadcast:生产者线程通知线程池中的某个或一些消费者线程池,接收处理任务;

这里的惊群现象出现在3里,pthread_cond_signal,语义上看,是通知一个线程。调用此函数后,系统会唤醒在相同条件变量上等待的一个或多个线程(可参看手册)。如果通知了多个线程,则发生了惊群。

正常的用法:

  1. 所有线程共用一个锁,共用一个条件变量
  2. 当pthread_cond_signal通知时,就可能会出现惊群

解决惊群的方法:

  1. 所有线程共用一个锁,每个线程有自已的条件变量
  2. pthread_cond_signal通知时,定向通知某个线程的条件变量,不会出现惊群

高并发设计

以多线程为例,进程同理

主线程 子线程epoll 是否有惊群 参考
1 listenfd/epollfd 共用listenfd/epollfd子线程accept epoll惊群 被动
2 listenfd 共用listenfd,每个线程创建epollfdlistenfd加入epoll epoll惊群 被动
3 listenfd主线程accept并分发connfd 每个线程创建epollfd接收主线程分发的connfd 无惊群accept瓶颈 被动
4 listenfd 共用listenfd,每个线程创建epollfd互斥锁决定加入/移出epoll 无惊群 nginx

例1

分析

主线程创建listenfd和epollfd, 子线程共享并把listenfd加入到epoll中,旧版中会出现惊群,新版中已解决了惊群。

缺点:

  1. 应用层并不知道内核会把新连接分给哪个线程,可能平均,也可能不平均
  2. 如果某个线程已经最大负载了,还分过来,会增加此线程压力甚至崩溃

总结:因为例1并不是最好的方法,因为没有解决负载和分配问题

例2

分析

主线程创建listenfd, 子线程创建epollfd, 把listenfd加入到epoll中, 这种方法是无法避免惊群的问题。每次有新连接时,都会唤醒所有的accept线程,但只有一个accept成功,其他的线程accept失败EAGAIN。

总结:例2 解决不了惊群的问题,如果线程超多,惊群越明显,如果真正开发中,可忽略惊群,或者需要用惊群,那么使用此种设计也是可行的。

例3

分析:

主线程创建listenfd, 每个子线程创建epollfd,主线程负责accept,并发分新connfd给负载最低的一个线程,然后线程再把connfd加入到epoll中。无惊群现象。

总结:

  1. 主线程只用accept用,可能会主线程没干,或连接太多处理不过来,accept瓶颈(一般情况不会产生)
  2. 主线程可以很好地根据子线程的连接来分配新连接,有比较好的负载
  3. 并发量也比较大,自测(单进程十万并发连接QPS十万,四核四G内存,很稳定)

例4

这是nginx的设计,无疑是目前最优的一种高并发设计,无惊群。

nginx本质:

同一时刻只允许一个nginx worker在自己的epoll中处理监听句柄。它的负载均衡也很简单,当达到最大connection的7/8时,本worker不会去试图拿accept锁,也不会去处理新连接,这样其他nginx worker进程就更有机会去处理监听句柄,建立新连接了。而且,由于timeout的设定,使得没有拿到锁的worker进程,去拿锁的频繁更高。

总结:

nginx的设计非常巧妙,很好的解决了惊群的产生,所以没有惊群,同时也根据各进程的负载主动去决定要不要接受新连接,负载比较优。

总结

高并发设计,仁者见仁,智者见智,如果要求不高,随便拿个常用的开源库,就可能支撑。如果对业务有特殊要求,那么根据业务去选择,如网关(可用高并发连接的开源库libevent/libev),消息队列(zmq/RabbitMQ/ActiveMQ/Kafka),数据缓存(redis/memcached),分布式等。

研究高并发有一段时间了,总结下我自已的理解,怎么样才算是高并发呢?单进程百万连接,单进程百万QPS?

先说说基本概念

高并发连接:指的是连接的数量,对服务端来说,一个套接字对就是一个连接,连接和本地 文件描述符无关,不受本地文件描述符限制,只跟内存有关,假设一个套接字对占用服 务器8k内存,那么1G内存=1024*1024/8 = 131072。因此连接数跟内存有关。

1G = 10万左右连接,当然这是理论,实际要去除内核占用,其他进程占用,和本进程其他占用。

假哪一个机器32G内存,那个撑个100万个连接是没有问题的。

如果是单个进程100万连,那就更牛B了,但一般都不会这么做,因为如果此进程宕了,那么,所有业务都影响了。所以一般都会分布到不同进程,不同机器,一个进程出问题了,不会影响其他进程的处理。(这也是nginx原理)

PV : 每天的总访问量pave view, PV = QPS * (24*0.2) * 3600 (二八原则)

QPS: 每秒请求量。假如每秒请求量10万,假如机器为16核,那么启16个线程同时工作, 那么每个线程同时的请求量= 10万/ 16核 = 6250QPS。

按照二八原则,一天24小时,忙时=24*0.2 = 4.8小时。

则平均一天总请求量=4.8 * 3600 *10万QPS = 172亿8千万。

那么每秒请求10万并发量,每天就能达到172亿的PV。这算高并发吗?

丢包率: 如果客端端发10万请求,服务端只处理了8万,那么就丢了2万。丢包率=2/10 = 20%。丢包率是越小越好,最好是没有。去除,网络丢包,那么就要考虑内核里的丢包 问题,因此要考虑网卡的吞吐量,同一时间发大多请求过来,内核会不会处理不过来, 导致丢包。

稳定性:一个高并发服务,除了高并发外,最重要的就是稳定了,这是所有服务都必须的。 一千QPS能处理,一万QPS也能处理,十万QPS也能处理,当然越多越好。不要因为 业务骤增导致业务瘫痪,那失败是不可估量的。因为,要有个度,当业务增加到一定程 度,为了保证现有业务的处理,不处理新请求业务,延时处理等。同时保证代码的可靠。

因此,说到高并发,其实跟机器,内存,网卡,CPU核数等有关,一个强大的服务器,比如:32核,64G内存,网卡吞吐很大,那么单个进程,开32个线程,做一个百万连接,百万QPS的服务,是可行的。

本身 按例3去做了个高并发的设计,做到了四核4G内存的虚拟机里,十万连接,十万QPS,很稳定,没加业务,每核CPU %sys 15左右 %usr 5%左右。如果加了业务,应该也是比较稳定的。有待测试。当然例3是有自已的缺点的。