HeLei Blog

惊群效应

什么叫惊群现象

首先,我们看看维基百科对惊群的定义:

The thundering herd problem occurs when a large number of processes waiting for an event are awoken when that event occurs, but only one process is able to proceed at a time. After the processes wake up, they all demand the resource and a decision must be made as to which process can continue. After the decision is made, the remaining processes are put back to sleep, only to all wake up again to request access to the resource.

This occurs repeatedly, until there are no more processes to be woken up. Because all the processes use system resources upon waking, it is more efficient if only one process was woken up at a time.

This may render the computer unusable, but it can also be used as a technique if there is no other way to decide which process should continue (for example when programming with semaphores).

惊群简单来说就是多个进程或者线程在等待同一个事件,当事件发生时,所有线程和进程都会被内核唤醒。唤醒后通常只有一个进程获得了该事件并进行处理,其他进程发现获取事件失败后又继续进入了等待状态,在一定程度上降低了系统性能。

accept 惊群

具体来说惊群通常发生在服务器的监听等待调用上,服务器创建监听socket,后fork多个进程,在每个进程中调用accept或者epoll_wait等待终端的连接。

那么这个问题真的存在吗?

事实上,历史上,Linux 的 accpet 确实存在惊群问题,但现在的内核都解决该问题了。即,当多个进程/线程都阻塞在对同一个 socket 的 accept 调用上时,当有一个新的连接到来,内核只会唤醒一个进程,其他进程保持休眠,压根就不会被唤醒。

测试代码如下:

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
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <sys/wait.h>
#include <string.h>
#include <errno.h>
#define IP "127.0.0.1"
#define PORT 8888
#define WORKER 4
int worker(int listenfd, int i)
{
while (1) {
printf("I am worker %d, begin to accept connection.\n", i);
struct sockaddr_in client_addr;
socklen_t client_addrlen = sizeof( client_addr );
int connfd = accept( listenfd, ( struct sockaddr* )&client_addr, &client_addrlen );
if (connfd != -1) {
printf("worker %d accept a connection success.\t", i);
printf("ip :%s\t",inet_ntoa(client_addr.sin_addr));
printf("port: %d \n",client_addr.sin_port);
} else {
printf("worker %d accept a connection failed,error:%s", i, strerror(errno));
close(connfd);
}
}
return 0;
}
int main()
{
int i = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton( AF_INET, IP, &address.sin_addr);
address.sin_port = htons(PORT);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
int ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
for (i = 0; i < WORKER; i++) {
printf("Create worker %d\n", i+1);
pid_t pid = fork();
/*child process */
if (pid == 0) {
worker(listenfd, i);
}
if (pid < 0) {
printf("fork error");
}
}
/*wait child process*/
int status;
wait(&status);
return 0;
}

当我们对该服务器发起连接请求(用 telnet/curl 等模拟)时,会看到只有一个进程被唤醒。

epoll惊群

如上所述,accept 已经不存在惊群问题,但 epoll 上还是存在惊群问题。即,如果多个进程/线程阻塞在监听同一个 listening socket fd 的 epoll_wait 上,当有一个新的连接到来时,所有的进程都会被唤醒。

考虑如下场景:

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

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

很多博客中提到,测试表明虽然 epoll_wait 不会像 accept 那样只唤醒一个进程/线程,但也不会把所有的进程/线程都唤醒。

为了验证这个问题,我自己写了一个测试程序:

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
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netdb.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/wait.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define IP "127.0.0.1"
#define PORT 8888
#define PROCESS_NUM 4
#define MAXEVENTS 64
static int create_and_bind ()
{
int fd = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton( AF_INET, IP, &serveraddr.sin_addr);
serveraddr.sin_port = htons(PORT);
bind(fd, (struct sockaddr*)&serveraddr, sizeof(serveraddr));
return fd;
}
static int make_socket_non_blocking (int sfd)
{
int flags, s;
flags = fcntl (sfd, F_GETFL, 0);
if (flags == -1) {
perror ("fcntl");
return -1;
}
flags |= O_NONBLOCK;
s = fcntl (sfd, F_SETFL, flags);
if (s == -1) {
perror ("fcntl");
return -1;
}
return 0;
}
void worker(int sfd, int efd, struct epoll_event *events, int k) {
/* The event loop */
while (1) {
int n, i;
n = epoll_wait(efd, events, MAXEVENTS, -1);
//sleep(1);
printf("worker %d return from epoll_wait!\n", k);
for (i = 0; i < n; i++) {
if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events &EPOLLIN))) {
/* An error has occured on this fd, or the socket is not ready for reading (why were we notified then?) */
fprintf (stderr, "epoll error\n");
close (events[i].data.fd);
continue;
} else if (sfd == events[i].data.fd) {
/* We have a notification on the listening socket, which means one or more incoming connections. */
struct sockaddr in_addr;
socklen_t in_len;
int infd;
char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
in_len = sizeof in_addr;
infd = accept(sfd, &in_addr, &in_len);
if (infd == -1) {
printf("worker %d accept failed!\n", k);
break;
}
printf("worker %d accept successed!\n", k);
/* Make the incoming socket non-blocking and add it to the list of fds to monitor. */
close(infd);
}
}
}
}
int main (int argc, char *argv[])
{
int sfd, s;
int efd;
struct epoll_event event;
struct epoll_event *events;
sfd = create_and_bind();
if (sfd == -1) {
abort ();
}
s = make_socket_non_blocking (sfd);
if (s == -1) {
abort ();
}
s = listen(sfd, SOMAXCONN);
if (s == -1) {
perror ("listen");
abort ();
}
efd = epoll_create(MAXEVENTS);
if (efd == -1) {
perror("epoll_create");
abort();
}
event.data.fd = sfd;
event.events = EPOLLIN | EPOLLEXCLUSIVE;
s = epoll_ctl(efd, EPOLL_CTL_ADD, sfd, &event);
if (s == -1) {
perror("epoll_ctl");
abort();
}
/* Buffer where events are returned */
events = (struct epoll_event*)calloc(MAXEVENTS, sizeof event);
int k;
for(k = 0; k < PROCESS_NUM; k++) {
printf("Create worker %d\n", k+1);
int pid = fork();
if(pid == 0) {
worker(sfd, efd, events, k);
}
}
int status;
wait(&status);
free (events);
close (sfd);
return EXIT_SUCCESS;
}

运行server端后用telnet连,出现了2个进程被唤醒的情况,并不是每次都出现。

1
2
3
4
worker 3 return from epoll_wait!
worker 3 accept successed!
worker 2 return from epoll_wait!
worker 2 accept failed!

也就是说,到目前为止,还没有得到一个确定的答案。但后来,在下面这篇博客中看到这样一个评论:http://blog.csdn.net/spch2008/article/details/18301357

这个总结,需要进一步阐述,你的实验,看上去是只有4个进程唤醒了,而事实上,其余进程没有被唤醒的原因是你的某个进程已经处理完这个 accept,内核队列上已经没有这个事件,无需唤醒其他进程。你可以在 epoll 获知这个 accept 事件的时候,不要立即去处理,而是 sleep 下,这样所有的进程都会被唤起

看到这个评论后,我顿时如醍醐灌顶,重新修改了上面的测试程序,即在 epoll_wait 返回后,加了个 sleep 语句,这时再测试,果然发现所有的进程都被唤醒了。

1
2
3
4
5
6
7
8
worker 1 return from epoll_wait!
worker 3 return from epoll_wait!
worker 1 accept successed!
worker 3 accept failed!
worker 2 return from epoll_wait!
worker 2 accept failed!
worker 0 return from epoll_wait!
worker 0 accept failed!

所以,epoll_wait上的惊群确实是存在的。

为什么内核不处理 epoll 惊群

看到这里,我们可能有疑惑了,为什么内核对 accept 的惊群做了处理,而现在仍然存在 epoll 的惊群现象呢?

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

Nginx 是如何处理惊群问题的

使用EPOLLEXCLUSIVE

Linux 4.5解决了这一问题,使用EPOLLEXCLUSIVE标记,但我自己的系统还没这么新,留待之后验证。
http://www.man7.org/linux/man-pages/man2/epoll_ctl.2.html

小结

现在我们对惊群及 Nginx 的处理总结如下:

  • accept 不会有惊群(since linux2.6),epoll_wait 才会。
  • Nginx 的 accept_mutex,并不是解决 accept 惊群问题,而是解决 epoll_wait 惊群问题。
  • 说Nginx 解决了 epoll_wait 惊群问题,也是不对的,它只是控制是否将监听套接字加入到epoll 中。监听套接字只在一个子进程的 epoll 中,当新的连接来到时,其他子进程当然不会惊醒了。
坚持原创技术分享,您的支持将鼓励我继续创作!