惊群效应

前言

上一篇博文 Epoll原理深入分析 在讲 accept 事件 的时候提到过 惊群效应,本文就分析一下惊群效应的原因以及解决方法。

惊群效应

什么是惊群

惊群效应就是多个进程(线程)阻塞等待同一件事情(资源)上,当事件发生(资源可用)时,操作系统可能会唤醒所有等待这个事件(资源)的进程(线程),但是最终却只有一个进程(线程)成功获取该事件(资源),而其他进程(线程)获取失败,只能重新阻塞等待事件(资源)可用,但是这就造成了额外的性能损失。这种现象就称为惊群效应。

如果细心的你可能会问,为什么操作系统要同时唤醒多个进程呢?只唤醒一个不行吗?这样不就没有这种性能损失了吗?

确实如此,操作系统也想只唤醒一个进程,但是它做不到啊,因为它也不知道该唤醒哪一个,只好把所有等待在这件事情(资源)的进程都一起唤醒了。

那有没有办法解决呢?当然有,我们后面再说。

惊群效应会造成多个进程白白唤醒而啥也做不了。那么唤醒进程损失了啥?这就涉及到进程上下文的概念。

惊群造成进程切换

进程上下文包括了进程的虚拟内存,栈,全局变量等用户空间的资源,还包括内核堆栈,寄存器等内核空间的状态。

所以进程上下文切换就首先需要保存用户态资源以及内核态资源,然后再去加载下一个进程,首先是加载了下一个进程的内核态,然后再去刷新进程的用户态空间

然而 CPU 保存进程的用户态以及内核态资源,再去加载下一个进程的内核态和用户态是有代价的,也是耗时的,每次可能在几十纳秒到数微妙的时间,如果频繁发生进程切换,那么 CPU 将有大量的时间浪费在不断保存资源,加载资源,刷新资源等事情上,造成性能的浪费。

所以惊群效应会造成多个进程切换,造成性能损失。

惊群测试

为了直观的了解惊群效应是什么,我们采用 mux 项目当中的 echo_server 为例说明:

https://github.com/smaugx/mux/tree/master/demo/echo

编译命令详见项目说明文档。编译之后得到:

1
echo_server  echo_client

我们在 echo_server 上开启 8 个 epoll 线程,观察当有新连接过来时是否这 8 个线程(epoll) 都被唤醒了。

首先,运行:

1
./echo_server

再运行:

1
./echo_client (或者直接用  nc 127.0.0.1 6666

我们观察的 echo_server 的 log 如下:

1
2
3
4
5
6
7
8
9
10
11
smaug@smaug-VirtualBox:~/workspace/mux/cbuild/bin/log$ tail -f echo_server.log  |grep accept


[basic_logger] [debug] eid:3 accept connection from 127.0.0.1:44251 time:2020.11.07 21:55:39.30
[basic_logger] [warning] eid:0 accept error, thundering herd happened
[basic_logger] [warning] eid:4 accept error, thundering herd happened
[basic_logger] [warning] eid:7 accept error, thundering herd happened
[basic_logger] [warning] eid:6 accept error, thundering herd happened
[basic_logger] [warning] eid:5 accept error, thundering herd happened
[basic_logger] [warning] eid:2 accept error, thundering herd happened
[basic_logger] [warning] eid:1 accept error, thundering herd happened

从上可以看到,我们总共有 8 个线程,其中只有 3号线程(epoll)被唤醒并且成功获取了 accept 事件,其他线程均 accept error

多次测试会有不同的线程获取 accept 事件,但是只有一个能够成功获取,其余的全部失败。

为了更加直观的感受惊群造成的性能损失,我们做一个并发压测:

https://github.com/smaugx/mux/tree/master/demo/bench

编译上面的代码得到:

1
bench_client_accept  bench_server

首先,启动:

1
./bench_server 127.0.0.1 10000 > /dev/null 2>&1 &

再启动:

1
./bench_client_accept 127.0.0.1 10000 30000 100 > /dev/null 2>&1 &

使用之前的博文 一键采集cpu生成火焰图 中的脚本采集火焰图如下:

可以看到途中 __libc_accept 占据了 31.7% 的 cpu,可以说是很高很高了。

由此可以看到惊群效应带来的性能损失有多少了吧。

惊群的类型

惊群的类型根据 socket 编程采用的不同方式有关。

传统 accept 惊群

传统的多进程 socket 编程,通常是 listen() 之后创建多个 worker 进程进行 accept,那么这里就会造成当有新连接过来时,多个 worker 同时去 accept 的情况,但最终只有一个 worker 进程成功 accept,其余的全部失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
int s = socket();

bind(s);

listen(s);

...

for (i=0;i < 4;++i) {
pid_t pid = fork();

if (pid == 0) {
# handle new connection
accept();
...
}
}

此种情况下的惊群称为 accept惊群效应,这在 linux 内核2.6以后就已经解决了,所以通常情况下讨论的惊群通常不是 accept惊群,而是 epoll惊群

epoll 惊群

epoll 的编程模型一般有两种,我们姑且先分别称为 版本1版本2 吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int listenfd = ::socket();


...
int epollfd = epoll_create(1); # create epoll instance

int p = fork() # 多进程 或者多线程创建

int r = epoll_ctl(..., listenfd, ...);

...

while(true) {
int num = epoll_wait(epollfd, alive_events, kMaxEvents, kEpollWaitTime);
...
}

或者 fork()epoll_create() 之前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

int listenfd = ::socket();


int p = fork() # 多进程 或者多线程创建

...
int epollfd = epoll_create(1); # create epoll instance


int r = epoll_ctl(..., listenfd, ...);

...

while(true) {
int num = epoll_wait(epollfd, alive_events, kMaxEvents, kEpollWaitTime);
...
}

虽然上述两个版本均能实现多进程下的 epoll 编程,且都存在惊群效应,但版本1,也就是 fork()epoll_create() 之后会造成事件混乱

因为多个进程等待的是同一个 epollfd,就有可能造成同一个连接,worker A 获取了 accept 事件,成功建立了连接,但是后续的读事件被 worker B 获取了,造成连接和读写事件不匹配的情况

所以通常,我们采用的是版本2,也就是 fork()epoll_create() 之前,那么多个子进程其实是拥有各自不同的 epollfd,只不过对于 listenfd 而言,都被添加到了各个子进程的 epoll instance 中。

当 listenfd 上有事件触发时(listenfd 上的事件自然是 accept 事件),由于有多个子进程的 epoll instance 上都有 listenfd。

根据之前的博文 Epoll原理深入分析,当某个 fd 上有事件后,内核会把这个 fd 拷贝到 epoll 的就绪链表中,并且唤醒进程,通知应用层使用 epoll_wait 来处理事件。

所以由于多个子进程都把 listenfd 插入到了自己的 epoll instance 中,那么当 listenfd 上有事件触发时,自然这些子进程都会被唤醒了。但是最终只有一个子进程成功获取 accept 事件,其余的均失败。这就是惊群效应,详见上面的惊群效应测试。

解决方案

传统 accept 惊群

上面提到,针对传统 accept 惊群,linux 在内核 2.6 以后就解决了,内核通过引入一个 WQ_FLAG_EXCLUSIVE 标志位,告诉内核排他性的唤醒,即当 socket 上有事件触发时,对于等待队列中的进程,如果这些进程没有 WQ_FLAG_EXCLUSIVE 这个标志位,那么就通通唤醒,如果有 WQ_FLAG_EXCLUSIVE 这个标志位,那么唤醒第一个有这个标志位的进程则结束。这样,就解决了传统 accept 惊群问题。

epoll 惊群

epoll 的惊群有两种解决办法。

SO_REUSEPORT

linux 在内核 3.9 版本引入了一个 socket 选项 SO_REUSEPORT 用来支持多个进程监听在同一个端口上,内核负责事件触发的负载均衡。

创建一个 listen socket,需要 {protocol, src_addr, src_port} 三元组,3.9 版本之前,内核不允许出现多个进程使用同样的三元组创建 socket,会出现 Address already in use 错误。

但是,通过引入 SO_REUSEPORT 以及 SO_REUSEADDR,内核允许多个进程使用同样的三元组创建 socket,内核负责负载均衡。

ok,明白了这个原理,对于解决 epoll 的惊群问题,还需要稍微修改一下编程的模型,我们姑且成为 版本3 吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

int p = fork() # 多进程 或者多线程创建

int listenfd = ::socket();

int ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT,(const void *)&reuse , sizeof(int));

bind(listenfd);

...
int epollfd = epoll_create(1); # create epoll instance


int r = epoll_ctl(..., listenfd, ...);

...

while(true) {
int num = epoll_wait(epollfd, alive_events, kMaxEvents, kEpollWaitTime);
...
}

可以对比一下和上面的编程模型有何不同,其实区别在创建 listenfd 被移到了 fork() 之后,程序启动即创建多个进程,然后进程内部再创建 listenfd 以及 epollfd,等等后续一系列操作。

另外要注意在 socket() 之后,使用 setsockopt() 设置了 SO_REUSEPORT 选项

那么内核是如何做负载均衡的呢?

其实很简单,每一个新的连接都具有 socket 五元组 {protocol, src_addr, src_port, dst_addr, dst_port},那么用这个五元组哈希一下映射到不同的进程,那么就唤醒这个进程。

SO_REUSEPORT 由于采用的是哈希的方式,内核并不知道多个等待进程是否空闲,但哈希的方式依然可能还会分配到这个进程,此时这个新的 accept 就可能会超时不被处理。

EPOLLEXCLUSIVE

linux 在内核 4.5 引入了 EPOLLEXCLUSIVE 这个标志位用来解决 epoll 的惊群。当我们使用 epoll_ctl() 往进程的 epoll instance 中插入一个需要监听的 fd 时,如果显示的传入 EPOLLEXCLUSIVE,那么内核会排他性的进行唤醒。

当然这里通常只需要对多个子进程共同监听的 listenfd 设置 EPOLLEXCLUSIVE 标志位注意,这里的 epoll 编程模型要采用上面的版本2

和解决传统 accept惊群 类似的方式,但是区别是内核可能会唤醒不只一个进程(虽然解决了不全部唤醒的问题),详见:

https://man7.org/linux/man-pages/man2/epoll_ctl.2.html

1
2
3
4
5
6
7
When a wakeup event occurs and multiple epoll file descriptors
are attached to the same target file using EPOLLEXCLUSIVE, one
or more of the epoll file descriptors will receive an event
with epoll_wait(2). The default in this scenario (when
EPOLLEXCLUSIVE is not set) is for all epoll file descriptors
to receive an event. EPOLLEXCLUSIVE is thus useful for avoid‐
ing thundering herd problems in certain scenarios.

注意上面的 one or more

The END

OK,到这里基本上把惊群效应的原理以及带来的问题,以及解决方法都讲清楚了,本来还想做一个加上了 EPOLLEXCLUSIVE,再采集一下火焰图和之前的进行一下对比,但是不知道是我的方式不对还是什么原因,加上 EPOLLEXCLUSIVE 标志后,连接压力测试就池池上不去,一直出现 connection timeout 的问题。

算了,这个以后再研究下为啥。

如果上文发现有什么不对的地方,欢迎指正。

Blog:

2020-09-26 于杭州
By 史矛革

buy me a cola!

欢迎关注我的其它发布渠道