一:惊群的定义
惊群是多进程多线程编程中的一个常见问题,就是当多个进程和线程在同时阻塞等待同一个事件时,如果这个事件发生,会唤醒所有的进程,但最终只可能有一个进程/线程对该事件进行处理,其他进程/线程会在失败后重新休眠,这种性能浪费就是惊群。
二:accept惊群
在使用多进程处理客户端-服务器连接时,往往会出现惊群现象,即在主进程listen之后调用fork创建多个子进程之后,这些子进程由于等待客户端的connect链接而处于睡眠状态,而每次只有一个链接进入,内核会所有的子进程来处理,往往只有一个进程能够获得链接,而且他的子进程又要重新回到睡眠状态。
以前旧版本的Linux没有解决accept惊群,目前的Linux已经解决了accept惊群。下面展示实验部分内容。
server.c文件如下所示:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include <stdio.h>
#include <string.h>
#define PROCESS_NUM 3
int main()
{
int fd = socket(PF_INET, SOCK_STREAM, 0);
int connfd;
int pid;
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++)
{
int pid = fork();
if(pid == 0)
{
while(1)
{
connfd = accept(fd, (struct sockaddr*)NULL, NULL);
snprintf(sendbuff, sizeof(sendbuff), "accept PID is %d\n", getpid());
send(connfd, sendbuff, strlen(sendbuff) + 1, 0);
printf("process %d accept success!\n", getpid());
close(connfd);
}
}
}
int status;
wait(&status);
return 0;
}
为了简单起见,主进程只创建两三个子进程,共三个子进程来处理客户端链接。
client.c文件如下所示:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(int argc, char *argv[])
{
unsigned short port = 1234; // 服务器的端口号
char *server_ip = "192.168.188.139"; // 服务器ip地址
if( argc > 1 ) //函数传参,可以更改服务器的ip地址
{
server_ip = argv[1];
}
if( argc > 2 ) //函数传参,可以更改服务器的端口号
{
port = atoi(argv[2]);
}
int sockfd;
sockfd = socket(AF_INET, SOCK_STREAM, 0);// 创建通信端点:套接字
if(sockfd < 0)
{
perror("socket");
exit(-1);
}
// 设置服务器地址结构体
struct sockaddr_in server_addr;
bzero(&server_addr,sizeof(server_addr)); // 初始化服务器地址
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(port); // 端口
inet_pton(AF_INET, server_ip, &server_addr.sin_addr.s_addr); // ip
// 主动连接服务器
int err_log = connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if(err_log != 0)
{
perror("connect");
close(sockfd);
exit(-1);
}
char send_buf[512] = {0};
printf("send data to %s:%d\n",server_ip,port);
while(1)
{
printf("send:");
fgets(send_buf,sizeof(send_buf),stdin); // 输入内容
send_buf[strlen(send_buf)-1]='\0';
send(sockfd, send_buf, strlen(send_buf), 0); // 向服务器发送信息
}
close(sockfd);
return 0;
}
其中为了实验方便,将服务器socket设定为:192.168.188.139:1234,大家可以根据自己的ip重新设定。
接下来编译运行server,然后使用ps –ef|grep ./server观察
接下来观察 /proc/[pid]/status目录下的内容结果如下所示:
PID voluntary_ctxt_switchesnonvoluntary_ctxt_switches
4341 1 0
4342 1 0
4343 1 0
其中voluntary_ctxt_switches 代表主动放弃CPU的次数,nonvoluntary_ctxt_switches: 代表被动放弃CPU的次数,接着执行./client,在./server终端中出现process 4341 accept success!然后重新观察/proc/[pid]/status目录,结果如下:
PID voluntary_ctxt_switchesnonvoluntary_ctxt_switches
4341 2 0
4342 1 0
4343 1 0
结果表明只有4341号进程获得了一次CPU,并且处理了connect之后又主动放弃了CPU,其余子进程仍然处于睡眠状态,并未发生上下文切换。
目前内核解决了accept的惊群问题,并未解决epoll惊群,https://www.pureage.info/2015/12/22/thundering-herd.html,该博客网址有epoll惊群源码,和为何不解决epoll惊群的原因。大家可以按照上述方法去验证一下epoll惊群。
三 内核如何解决accept惊群
因为涉及到内核,水平有限,不能进行实验验证。只能从原理上大概分析一下。
通常情况下,我们首先能想到的就是进行加锁操作,这样一来,获得锁的进程阻塞与accept调用,而未获得锁的进程阻塞于锁的获取。这样可以有一定的性能提高,但是当unlock(),释放锁之后,同样会面临锁的争抢,也会出现惊群现象。
内核开发者增加了一个“互斥等待”选项。一个互斥等待的行为与睡眠基本类似,主要的不同点在于:
1)当一个等待队列入口有 WQ_FLAG_EXCLUSEVE 标志置位, 它被添加到等待队列的尾部. 没有这个标志的入口项, 相反, 添加到开始.
2)当 wake_up 被在一个等待队列上调用时, 它在唤醒第一个有 WQ_FLAG_EXCLUSIVE 标志的进程后停止。
也就是说,对于互斥等待的行为,比如如对一个listen后的socket描述符,多线程阻塞accept时,系统内核只会唤醒所有正在等待此时间的队列 的第一个,队列中的其他人则继续等待下一次事件的发生,这样就避免的多个线程同时监听同一个socket描述符时的惊群问题。
其实惊群的根本原因在于资源竞争,假如内核可以将资源竞争改为资源分配,这样内核就有了主动权,惊群问题就可以得到缓解。一般的设计可以在父进程中进行accept调用,然后将已连接套接字传递给某个子进程。
惊群是多进程多线程编程中的一个常见问题,就是当多个进程和线程在同时阻塞等待同一个事件时,如果这个事件发生,会唤醒所有的进程,但最终只可能有一个进程/线程对该事件进行处理,其他进程/线程会在失败后重新休眠,这种性能浪费就是惊群。
二:accept惊群
在使用多进程处理客户端-服务器连接时,往往会出现惊群现象,即在主进程listen之后调用fork创建多个子进程之后,这些子进程由于等待客户端的connect链接而处于睡眠状态,而每次只有一个链接进入,内核会所有的子进程来处理,往往只有一个进程能够获得链接,而且他的子进程又要重新回到睡眠状态。
以前旧版本的Linux没有解决accept惊群,目前的Linux已经解决了accept惊群。下面展示实验部分内容。
server.c文件如下所示:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include <stdio.h>
#include <string.h>
#define PROCESS_NUM 3
int main()
{
int fd = socket(PF_INET, SOCK_STREAM, 0);
int connfd;
int pid;
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++)
{
int pid = fork();
if(pid == 0)
{
while(1)
{
connfd = accept(fd, (struct sockaddr*)NULL, NULL);
snprintf(sendbuff, sizeof(sendbuff), "accept PID is %d\n", getpid());
send(connfd, sendbuff, strlen(sendbuff) + 1, 0);
printf("process %d accept success!\n", getpid());
close(connfd);
}
}
}
int status;
wait(&status);
return 0;
}
为了简单起见,主进程只创建两三个子进程,共三个子进程来处理客户端链接。
client.c文件如下所示:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(int argc, char *argv[])
{
unsigned short port = 1234; // 服务器的端口号
char *server_ip = "192.168.188.139"; // 服务器ip地址
if( argc > 1 ) //函数传参,可以更改服务器的ip地址
{
server_ip = argv[1];
}
if( argc > 2 ) //函数传参,可以更改服务器的端口号
{
port = atoi(argv[2]);
}
int sockfd;
sockfd = socket(AF_INET, SOCK_STREAM, 0);// 创建通信端点:套接字
if(sockfd < 0)
{
perror("socket");
exit(-1);
}
// 设置服务器地址结构体
struct sockaddr_in server_addr;
bzero(&server_addr,sizeof(server_addr)); // 初始化服务器地址
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(port); // 端口
inet_pton(AF_INET, server_ip, &server_addr.sin_addr.s_addr); // ip
// 主动连接服务器
int err_log = connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if(err_log != 0)
{
perror("connect");
close(sockfd);
exit(-1);
}
char send_buf[512] = {0};
printf("send data to %s:%d\n",server_ip,port);
while(1)
{
printf("send:");
fgets(send_buf,sizeof(send_buf),stdin); // 输入内容
send_buf[strlen(send_buf)-1]='\0';
send(sockfd, send_buf, strlen(send_buf), 0); // 向服务器发送信息
}
close(sockfd);
return 0;
}
其中为了实验方便,将服务器socket设定为:192.168.188.139:1234,大家可以根据自己的ip重新设定。
接下来编译运行server,然后使用ps –ef|grep ./server观察
jack 4341 4340 0 05:36 pts/6 00:00:00 ./server
jack 4342 4340 0 05:36 pts/6 00:00:00 ./server
jack 4343 4340 0 05:36 pts/6 00:00:00 ./server接下来观察 /proc/[pid]/status目录下的内容结果如下所示:
PID voluntary_ctxt_switchesnonvoluntary_ctxt_switches
4341 1 0
4342 1 0
4343 1 0
其中voluntary_ctxt_switches 代表主动放弃CPU的次数,nonvoluntary_ctxt_switches: 代表被动放弃CPU的次数,接着执行./client,在./server终端中出现process 4341 accept success!然后重新观察/proc/[pid]/status目录,结果如下:
PID voluntary_ctxt_switchesnonvoluntary_ctxt_switches
4341 2 0
4342 1 0
4343 1 0
结果表明只有4341号进程获得了一次CPU,并且处理了connect之后又主动放弃了CPU,其余子进程仍然处于睡眠状态,并未发生上下文切换。
目前内核解决了accept的惊群问题,并未解决epoll惊群,https://www.pureage.info/2015/12/22/thundering-herd.html,该博客网址有epoll惊群源码,和为何不解决epoll惊群的原因。大家可以按照上述方法去验证一下epoll惊群。
三 内核如何解决accept惊群
因为涉及到内核,水平有限,不能进行实验验证。只能从原理上大概分析一下。
通常情况下,我们首先能想到的就是进行加锁操作,这样一来,获得锁的进程阻塞与accept调用,而未获得锁的进程阻塞于锁的获取。这样可以有一定的性能提高,但是当unlock(),释放锁之后,同样会面临锁的争抢,也会出现惊群现象。
内核开发者增加了一个“互斥等待”选项。一个互斥等待的行为与睡眠基本类似,主要的不同点在于:
1)当一个等待队列入口有 WQ_FLAG_EXCLUSEVE 标志置位, 它被添加到等待队列的尾部. 没有这个标志的入口项, 相反, 添加到开始.
2)当 wake_up 被在一个等待队列上调用时, 它在唤醒第一个有 WQ_FLAG_EXCLUSIVE 标志的进程后停止。
也就是说,对于互斥等待的行为,比如如对一个listen后的socket描述符,多线程阻塞accept时,系统内核只会唤醒所有正在等待此时间的队列 的第一个,队列中的其他人则继续等待下一次事件的发生,这样就避免的多个线程同时监听同一个socket描述符时的惊群问题。
其实惊群的根本原因在于资源竞争,假如内核可以将资源竞争改为资源分配,这样内核就有了主动权,惊群问题就可以得到缓解。一般的设计可以在父进程中进行accept调用,然后将已连接套接字传递给某个子进程。