《深入理解计算机系统》Tiny服务器2——多进程版和selectIO复用版Tiny
在上个博客中,我们根据csapp里的源码实现了一个小型的web服务器Tiny,通过在浏览器地址栏输入本机地址和所设置的端口号,我们可以访问到静态网页和动态的CGI程序。但我们之前的那个Tiny存在一个很大的问题,那就是每次只能接受一个请求,这在实际的服务器里肯定是不存在的。所以,这次我们就来把Tiny改为支持多个访问的并发服务器。
实现并发服务器程序一般有三种方法:
1)
利用多进程。
当监听套接字收到来自浏览器的连接请求之后,通过调用fork()函数,产生一个子进程。在子进程中,进行与浏览器的交互。这种方式的优点就是实现起来很简单,缺点也很明显,由于fork()子进程和进程上下文切换需要移动相当多的资源,造成多进程的程序效率不高。
2)
利用IO复用。
IO复用就是由服务器维护一个客户端池,其中存放着服务器的监听套接字和已连接的客户端文件描述符。服务器通过调用select(),poll()或者epoll()来观察哪些套接字或文件描述符发生了响应。举例来说就是,有外来客户端发来connect()连接请求时,监听套接字就进行响应;当已连接的客户端发来请求报文时,相对应的客户端文件描述符会进行响应。之后就是对满足响应条件的套接字或描述符进行相应的处理。如果有新的客户端发送了连接请求,就调用accept()函数,与之建立连接,同时将新建立的与客户端相连的连接文件描述符放入客户端池;如果是已连接的客户端发来请求报文,请求访问静态或动态数据,就进行数据的处理和发送。IO复用的缺点也很明显,在三种并发模型里是最复杂的一种。其优点是效率最高,在现在的主流商业服务器中,都是采用的IO多路复用技术。
3) 利用多线程。
通过调用子线程来进行与客户端的数据传递,多线程技术像是上述两种方案的综合。由于线程比进程的资源占用小,上下文切换快,使得多线程技术无论在复杂度上还是效率上都是中等的水平。
这篇博客就从这三种并发模型来进行Tiny的改进。
(1) 多进程版Tiny:
首先我们先来最简单的多进程服务器模型。由于和源代码差别不大,这里我们只把需要改变的几句代码贴出来。
1 while (1) { 2 clientlen = sizeof(clientaddr); 3 connfd = accept(listenfd, (SA*)&clientaddr, &clientlen); 4 getnameinfo((SA*) &clientaddr, clientlen, hostname, MAXLINE, 5 port, MAXLINE, 0); 6 if (fork() == 0) 7 { 8 close(listenfd); 9 doit((void *)&connfd); 10 close(connfd); 11 exit(0); 12 } 13 close(connfd); 14 printf("Accepted connection from (%s, %s)\n", hostname, port); 15 }
这里需要注意,在我们这个web服务器中,子进程先于父进程结束,这样子进程就形成了“僵尸进程”。设置这种状态的目的是维护子进程的信息,以便父进程在以后的某个时候获取。但这里,我们不需要获取子进程的信息,为了避免僵尸子进程消耗资源,我们可以通过信号来及时回收它们。如下代码所示:
1 void sig_chld(int signo) 2 { 3 pid_t pid; 4 int stat; 5 6 while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0) 7 printf("child %d terminated\n", pid); 8 return; 9 }
注意这里使用的是waitpid()函数,而不是wait()函数。因为信号是不排队的,可能有多个同时到达,但只会提示一次,所以这里使用while循环,确保将全部的信号处理完。只是多了这么几行代码,原来的Tiny服务器就变成了支持并发访问的进阶版服务器了。
(2) IO多路复用版Tiny
接下来,我们进行IO多路复用的服务器实现。连续几个小时用gdb调试这个服务器,我只能说真的很酸爽。。这里我们采用select()函数,因为我个人习惯使用select()函数,其他两个poll()和epoll()在实现上有些差别,但在思想上和select()是一样的。由于IO复用改变的地方稍多一些,所以我把main()函数和doit()函数都贴出来。其中前边带红色+的代码段为改进的部分。
1 int main(int argc, char *argv[]) 2 { 3 int listenfd, connfd; 4 char hostname[MAXLINE], port[MAXLINE]; 5 socklen_t clientlen; 6 struct sockaddr_storage clientaddr; 7 int err, maxfd, i, num; 8 fd_set read_set, ready_set; 9 10 if (argc != 2) { 11 fprintf(stderr, "usage: %s <port>\n", argv[0]); 12 exit(1); 13 } 14 15 listenfd = open_listenfd(argv[1]); 16 + FD_ZERO(&read_set); //初始化read_set 17 + FD_SET(listenfd, &read_set); //将listenfd添加到read_set 18 + maxfd = listenfd; 19 20 printf("listenfd = %d\n", listenfd); 21 22 while (1) { 23 + ready_set = read_set; //因为select()会改变其中set集合,所以这里用一个read_set的拷贝 24 + num = select(maxfd+1, &ready_set, NULL, NULL, NULL); 25 + if (num == -1) 26 + { 27 + fprintf(stderr, "select error\n"); 28 + exit(1); 29 + } 30 + else if (num == 0) //没有已准备好读取的文件描述符 31 + continue; 32 33 + for (i = 0 ; i <= maxfd; i++) 34 + { 35 + if (FD_ISSET(i, &ready_set)) 36 + { 37 + if (i == listenfd) //有新的客户端发来连接请求 38 + { 39 + clientlen = sizeof(clientaddr); 40 + connfd = accept(listenfd, (SA*)&clientaddr, &clientlen); 41 + getnameinfo((SA*) &clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0); 42 + printf("Accepted connection from (%s, %s)\n", hostname, port); 43 + printf("connfd = %d\n", connfd); 44 + FD_SET(connfd, &read_set); 45 + if (connfd > maxfd) 46 + maxfd = connfd; 47 + } 48 else //访问数据请求 49 { 50 printf("message from connfd\n"); 51 doit((void *)&connfd, &read_set); 52 } 53 } 54 } 55 } 56 }
因为第一次忘记了将已关闭的文件描述符从read_set清除掉,害得我从头到尾调试了好几遍,在这里也提醒一个大家。
在csapp第12章并发编程里介绍了以IO多路复用为基础的基于事件驱动的一个连接池框架。主要代码如下,其中check_clients()函数是与业务处理相联系的,这里我们把它稍加改动,使其可以应用于我们这个并发服务器的业务场景。
1 typedef struct { //Represents a pool of connected descriptors 2 int maxfd; //Largest descriptor in read_set 3 fd_set read_set; //Set of all active descriptors 4 fd_set ready_set; //Subset of descriptors ready for reading 5 int nready; //Number of ready descriptors from select 6 int maxi; //High water index into client array 7 int clientfd[FD_SETSIZE]; //Set of active descriptors 8 rio_t clientrio[FD_SETSIZE]; // Set of active read buffers 9 } pool; 10 11 void init_pool(int listenfd, pool *p) 12 {//初始化客户端池 13 int i; 14 p->maxi = -1; 15 for (i = 0; i < FD_SETSIZE; i++) 16 p->clientfd[i] = -1; 17 //Initially, listenfd is only member of select read set 18 p->maxfd = listenfd; 19 p->nready = 0; 20 FD_ZERO(&p->read_set); 21 FD_SET(listenfd, &p->read_set); 22 } 23 24 void add_client(int connfd, pool *p) 25 {//添加一个新的客户端到活动客户端池 26 int i; 27 p->nready--; 28 for (i = 0; i < FD_SETSIZE; i++) //Find an acailable slot 29 if (p->clientfd[i] < 0) { 30 //Add connected descriptor to the pool 31 p->clientfd[i] = connfd; 32 rio_readinitb(&p->clientrio[i], connfd); 33 34 //Add the descriptor to descriptor set 35 FD_SET(connfd, &p->read_set); 36 37 //Update max descriptor and pool high water mark 38 if (connfd > p->maxfd) 39 p->maxfd = connfd; 40 if (i > p->maxi) 41 p->maxi = i; 42 break; 43 } 44 if (i == FD_SETSIZE) //Couldnot find an empty slot 45 fprintf(stderr, "add_client error: Too many clients\n"); 46 } 47 48 void check_clients(pool *p) 49 { 50 int i, connfd, n; 51 rio_t rio; 52 53 for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) { 54 connfd = p->clientfd[i]; 55 rio = p->clientrio[i]; 56 57 //If the descriptor is ready, doit 58 if ((connfd > 0) && (FD_ISSET(connfd, &p->ready_set))) { 59 p->nready--; 60 doit((void *)&connfd, p); 61 }//请求处理完毕之后的清理工作 62 close(connfd); 63 FD_CLR(connfd, &p->read_set); 64 p->clientfd[i] = -1; 65 } 66 }
接下来,我们准备实现基于poll()函数的连接池框架和多线程版本的Tiny服务器。