前一篇文章提到了,多线程/多进程虽然能够在一定程度上解决并发,但是由于进程/线程的切换消耗系统资源,多余并发量比较大的服务无法支持,因此为了解决大并发场景,需要一种能够支持更多并发的方法----IO复用, 与多线程/进程相比,IO复用最大的优点是避免了线程进程切换时产生的系统开销,内核不用创建线程也不用再维护这些线程了,这样就避免了很多不必要的开销。
本文介绍一种IO复用模型,select模型,其基本流程如下:
select模型的允许进程指示内核等待多个事件的任何一个发生,并在只有一个或者多个事件发生或者经历一段事件后select函数才返回,函数原型如下:
#include<sys/select.h>
#include <sys/time.h>
int select(int maxfdp1,fd_set* readset, fd_set *writeset, fd_set* exceptset, const struct timeval * timeout);
maxfdp1是待制定的最大描述符加1,因此被定义为maxfd plus 1: maxfdp1,描述符从0,1,....maxfdp1-1都将被测试。
readset/writeset/exceptset指定我们要让内核测试的描述字,如果对某一个不感兴趣可以置空。这三个参数都是指向fd_set的指针,Linux提供了一套操作fd_set的接口:
FD_ZERO(fd_set * fdset) //清空集合
FD_SET(int fd, fd_set * fdset) //将制定的描述符加入集合
FD_ISSET(int fd, fd_set *fdset) // 判断制定描述符是否可读可写
FD_CLR(int fd, fd_set* fdset) //将制定描述符从集合中删除
timeout指定select函数等待的最大时间,若设置为空指针,则select一直等待下去直到有描述符可读可写,若设置了timeval的非零值,则等待设定的时间,若等待的时间内还没有描述符可读可写,则函数超时返回,若指定了一个timeval的结构,且timeval的成员值都为0,则select函数不等待,检查完描述符后立即返回。
示例代码:
#include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <time.h> #include <stdlib.h> #include <stdio.h> #include <arpa/inet.h> #include <string.h> #include <fcntl.h> int doaccept(int fd) { struct sockaddr_in cliAddr; socklen_t len = sizeof(cliAddr); bzero(&cliAddr, sizeof(cliAddr)); int connfd = accept(fd, (struct sockaddr*)&cliAddr, &len); if(connfd < 0 || connfd > FD_SETSIZE) return -1; char ipaddr[32] = {0}; inet_ntop(AF_INET,&(cliAddr.sin_addr),ipaddr,32); printf("accept client connect %s:%d.\n", ipaddr,ntohs(cliAddr.sin_port)); return connfd; } void doRead(int fd) { int n; char buf[1024] = {0}; while((n = recv(fd,buf, sizeof(buf),0))> 0) { printf("msg recv is %s\n", buf); char* temp = buf; while(*temp != '\r' && *temp != '\n') temp++; *temp = '\0'; send(fd,buf,sizeof(buf),0); if(strcmp(buf,"quit") == 0) exit(0); } } int main() { int listenfd; struct sockaddr_in serAddr; fd_set rdSet, allset; int fdArray[FD_SETSIZE] = {0}; int maxfd; int flag; FD_ZERO(&rdSet); serAddr.sin_family = AF_INET; serAddr.sin_addr.s_addr = htonl(INADDR_ANY); serAddr.sin_port = htons(12345); for(int i =0; i< FD_SETSIZE; i++) fdArray[i] = -1; listenfd = socket(AF_INET, SOCK_STREAM, 0); if(listenfd < 0) { perror("create socket failed.\n"); exit(-1); } if(flag = fcntl(listenfd,F_GETFL,0) < 0) { perror("FCNTL ERROR"); return -1; } flag |= O_NONBLOCK; if(fcntl(listenfd, F_SETFL, flag) < 0 ) { perror("fcntl set error"); return -1; } /*一个端口释放后会等待两分钟之后才能再被使用,SO_REUSEADDR是让端口释放后立即就可以被再次使用。*/ int reuse = 1; if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) exit(-1); if(bind(listenfd, (struct sockaddr*)&serAddr, sizeof(serAddr)) < 0) { perror("bind error"); exit(-1); } if(listen(listenfd, 64) < 0) { perror("listen failed.\n"); exit(-1); } FD_SET(listenfd, &rdSet); fdArray[0] = listenfd; maxfd = listenfd; while(1) { allset = rdSet; //select每次检查前都必须重置描述符集 int n = select(maxfd+1, &allset, NULL, NULL, NULL); if(n < 0) { perror("select failed.\n"); exit(-1); } if(FD_ISSET(listenfd, &allset)) { int connfd = doaccept(listenfd); if(connfd < 0) exit(-1); if(flag = fcntl(connfd,F_GETFL,0) < 0) { perror("FCNTL ERROR"); return -1; } flag |= O_NONBLOCK; //如果不将客户端套接字设置为非阻塞服务器将阻塞在某一个客户端套接字 //的recv调用上,其他客户端的请求得不到响应 if(fcntl(connfd, F_SETFL, flag) < 0 ) { perror("fcntl set error"); return -1; } FD_SET(connfd, &rdSet); for(int i=0; i< FD_SETSIZE; i++) { if(fdArray[i] == -1) { fdArray[i] = connfd; break; } } //重置当前最大描述符字 maxfd = (maxfd > connfd ? maxfd:connfd); } for(int i = 1; i< maxfd; i++) { if(FD_ISSET(fdArray[i], &allset)) doRead(fdArray[i]); } } return 0; }
关于FD_SETSIZE
由于select 模型的可检查的描述符最大个数是系统定义的FD_SETSIZE,因此客观限制了select模型支持的最大并发个数,而FD_SETSIZE是由内核决定的,在我的环境的FD_SETSIZE是1024,因此限制了最大并发的数量,虽然可以通过修改内核FD_SETSIZE然后重新编译内核来解决这个问题,但这样的代码不好移植。
综上所述,select模型虽然解决了多线程/进程场景下的线程创建,保持和切换的开销问题,但多线程/进程无法解决并发数量多的场景的问题select模型同样没有解决,首先与系统设定的FD_SETSIZE的值,因此需要另外一种能够解决高并发的方案。