一、简单的服务器I/O模型
最简单的的TCP服务器,有三种模式:
1、单执行流,一个server端连接一个client端
2、多进程,一个server端通过多进程的方式,每个进程连接一个client端
3、多线程,一个server端通过多进程的方式,每个线程连接一个client端
( 这里实现过
要提升服务器性能,其实就是想要让一个server端能在负载允许的情况下,连接尽可能多的client端。
因此,以上三种模式中:
第一种模式,一个服务器连接一个客户端 ~!@#%…… 这是低性能服务器 是没人会考虑的
第二种模式,用多进程,能同时服务多个client端,不过线程开销较大,因此这也不是最好的方式
第三种模式,用多线程,线程的开销比进程要小,因此这种方式是 这三种方式中,最优的方式。
二、所谓高性能
我们仔细分析第三种模式下,server端每个线程和对应连接的client在进行通信的过程中,其实都是阻塞方式的。
也就是,对于server端的每个线程,在对面客户端端有消息的时候(连接、接受、断开时)进行处理,而当对面没有消息过来的时候,则一直等待。
因此,在这种阻塞模式下,这些等待的时间被白白浪费掉了。归根到底,每个线程也只能服务一个客户端,因此还不够高性能。要想提高性能,就是要尽可能减少等待时间,也就是说,服务器最高效的工作状态是: 在能力范围内一直在进行数据搬迁。
要想进一步提升服务器性能,达到上述的工作状态,条件允许时(大部分情况都是如此)自然需要换一种效率更高的I/O模型,这里有以下模型:
//1、非阻塞I/O; //2、I/O复用 //3、信号驱动I/O //4、异步I/O
以上在这些模型当中,效率最高的当属第二种:I/O复用模型。
关于I/O复用,客官且听我慢慢道来
三、所谓I/O复用
I/O复用,是针对一个单线程而言的,采用I/O复用的server端,一个线程就可以处理多个client端。
它的实现,就好像有一个"管家",这个"管家"被托管了许多个套接字。当有新的client端要连接的时候,把这个client端(的文件描述符)托管给这个“管家”,所以这个“管家“上很有可能被托管了许多个client端。
管家的任务就是:管理他托管的这些套接字,如果这些套接字有消息传来,就通知server。
而server则平时一直等待它的"管家",直到"管家"告诉它有消息,才做相应的处理。
这样,server端的一个线程,就能同时服务许多client端,相比多线程模型,线程用来等待时间的比例明显低了很多,效率也高了许多。
要实现I/O复用,有三种模式:分别是: select、poll、epoll,以下分别介绍。
三、I/O复用——select 和 poll
别看poll 和 epoll 名字就差一个e,但其实 poll 和 select 更像,所以这里就把这两个模型放在一起介绍了。
(1)、select模式
还记得前面说到的那个"管家"吗?现在这里管家就是select,管家有一个(或多个)名单fd,fd上记录着所有被托管着的文件描述符。
//文件描述符被保存在fd_set类型的变量中。fd_set中存放的文件描述符,都是通过位运算的方式存放的。
select模式,用到的函数有这些:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,const struct timeval *timeout);void FD_CLR(int fd, fd_set *set); //删除指定文件描述符int FD_ISSET(int fd, fd_set *set); //判断指定文件描述符是否在fd中void FD_SET(int fd, fd_set *set); //向select表中添加指定元素void FD_ZERO(fd_set *set); //清空select表
后面4个FD开头的函数作用在注释中;
select函数需要接收5个参数:
第1个参数为select表中所记录的元素的数量之和 + 1;
第2~4个参数为3个fd_set类型的地址,也就是三张表的地址,简单的说,select执行的时候,关
心的也正是这3张表中文件描述符对应的目标的读信号、写信号、异常信号。
最后一个参数是一个struct timeval类型的结构体指针,表示等待时间。传NULL表示阻塞,当有信号时才返回,传0表示非阻塞,传具体的经过初始化的对象则表示等待指定的时间后返回.
另外,在使用select函数的时候要特别注意,一旦使用这3个结构体指针在传参后,select在收到信号的时候,相应的文件描述符的值就会发生变化,而该文件描述符所对应的结构体会发生改变。
也就是说,使用完select之后,这3个结构体实际上已经不是之前的了,因此需要保存和还原。
由于我们通常要向select中保存多个文件描述符,所以不妨利用一个辅助数组fd_arr,用来保存和恢复这些文件描述符,每次使用FD_SET添加的时候,也把这个文件描述符存到数组中,并初始化为-1.
当超时或收到信号时select会返回它所保存的元素中有信号的元素的个数。当出错时返回-1,如果返回0则说明没有信号产生。
如果返回值大于0,则说明有信号产生,这时候遍历一遍辅助数组fd_arr,如果遇到改变的文件描述符,则说明该文件描述符对应的一端有信号传过来,这时候用这个描述符就可以进行 添加、接收信号、删除等操作。
总之,在使用时需要注意:
1、select的第一个参数是所有信号的数目 + 1
2、第2 ~ 5个 fd_set指针类型的参数,每次使用前都要保存,使用后要恢复
3、select通常支持的文件描述符数目为1024
select模式的server端实现代码如下:
/************************************************************************* > File Name: TCP_select.c > Author: HonestFox > Mail: zhweizhi@foxmail.com > Created Time: Thu 28 Jul 2016 04:49:50 PM CST ************************************************************************/#include#include #include #include #include #include #include #include #include #define _MAX_SIZE 100int fd_arr[_MAX_SIZE]; int max_fd = 0;void usage(char *str){ printf("Usage: %s [IP]:[port]\n]", str); exit(1);}static void init_fd_arr(){ int i = 0; for(i = 0; i < _MAX_SIZE; ++i) { fd_arr[i] = -1; }}static int add_fd_arr(int fd){ int i = 0; for(; i < _MAX_SIZE; ++i) { if(fd_arr[i] == -1) { fd_arr[i] = fd; return 0; } } return -1;}static int remove_fd_arr(int fd){ printf("want to remove : %d\n", fd); int i = 0; for(; i < _MAX_SIZE; ++i) { if(fd_arr[i] == fd) { printf("remove : %d\n", fd); fd_arr[i] = -1; break; } } return 0;}static int reload_fd_set(fd_set *fd_set){ int i = 0; for(; i < _MAX_SIZE; ++i) { if(fd_arr[i] != -1) { FD_SET(fd_arr[i], fd_set); if(fd_arr[i] > max_fd) { max_fd = fd_arr[i]; } } } return 0;}static void print_msg(int i, char buf[]){ printf("fd : %d, msg : %s\n", i, buf);}int select_server(char *_ip, char *_port){ struct sockaddr_in ser; struct sockaddr_in cli; fd_set fds; int fd = socket(AF_INET, SOCK_STREAM, 0); if(fd == 0) { perror("create socket error"); exit(2); } int tmp_val = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &tmp_val, sizeof(int)); memset(&ser, '\0', sizeof(ser)); ser.sin_family = AF_INET; ser.sin_port = htons(atoi(_port)); ser.sin_addr.s_addr = inet_addr(_ip); if(bind(fd, (struct sockaddr*)&ser, sizeof(ser)) != 0) { perror("bind error"); exit(3); } init_fd_arr(); add_fd_arr(fd); FD_ZERO(&fds); if(listen(fd, 5) != 0) { perror("listen error"); exit(4); } int done = 0; while(!done) { max_fd = 0; reload_fd_set(&fds); printf("max_fd : %d\n", max_fd); struct timeval timeout = {1, 1}; switch(select(max_fd + 1, &fds, NULL, NULL, &timeout)) { reload_fd_set(&fds); case -1: { perror("select error"); exit(5); } case 0: { printf("timeout .. .. ..\n"); break; } default: { int index = 0; for(index = 0; index < _MAX_SIZE; ++index) { if(index == 0 && fd_arr[index] != -1 && FD_ISSET(fd_arr[index], &fds)) { socklen_t len = sizeof(cli); memset(&cli, '\0', sizeof(cli)); int new_fd = accept(fd, (struct sockaddr*)&cli, &len); printf("new : %d\n", new_fd); // if(new_fd != -1) { printf("get a new client!\n"); if(add_fd_arr(new_fd) == -1) { perror("fd arr is full, close new fd:\n"); } } continue; } if(fd_arr[index] != -1 && FD_ISSET(fd_arr[index], &fds )) { char buf[1042]; memset(buf, '\0', sizeof(buf)); printf("flag3\n"); ssize_t _size = recv(fd_arr[index], buf, sizeof(buf)-1, 0); //read if(_size == 0 || _size == -1) { printf("client close\n"); remove_fd_arr(fd_arr[index]); close(fd_arr[index]); FD_CLR(fd_arr[index], &fds); } else { print_msg(index, buf); } FD_ZERO(&fds); } } printf("out for()\n"); } break; } }}int main(int argc, char *argv[]){ if(argc != 3) { usage(argv[0]); exit(1); } select_server(argv[1], argv[2]); return 0;}
(2)、poll模式
刚刚介绍的select是通过 fd_set 作为“管家的名单”的,而 fd_set则是一个“位图”,它用过位运算的方式存取各个被托管的端口的文件描述符。也就是说,把所有要监听接收、发送、错误信号的文件描述符分别放进三个不同的fd_set中。
而poll 是用一个 pollfd 结构体实现的,这个pollfd结构体包括3个变量:长度、类型、返回值。然后再用一个数组将所有要托管的poll存起来就可以了。
所以poll函数就不需要传那么多参数了,只需要传递 存放pollfd的数组、 nfds 、等待时间即可。
poll模式和select模式很像,基本上就是poll模式下,对文件描述符进行了封装,然后还是将这些封装后的结构体存在数组中,然后遍历数组中的元素,看其中哪些收到了信号。不过,由于这个数组是用户自己开辟、维护的,因此不像select用的位图那样有数量限制。 理论上poll模式是没有数量限制的。
三、I/O复用—— epoll
epoll模式不同于 select/poll 的地方在于,我们需要先创建一个 epoll的文件描述符 然后将需要托管的端口的文件描述符通过 epoll_ctl 函数注册到epoll的文件描述符中,然后等待的时候调用 epoll_wait函数,内核会在epoll中那些注册过的端口中等待信号,直到收到信号的时候返回。
要注意epoll_create得到的的是一个fd,所以使用完后要记得关闭
这里应该特别注意以下 第四个参数,又是一个结构体,通过设置它,可以选择epoll的工作模式,其中
events可以设置两种触发模式:ET模式和LT模式
其中,LT模式是缺省的模式。
ET与LT的 区别在于,当一个新的事件到来时,ET模式下当然可以从epoll_wait调用中获取到这个事件, 可是如果这次没有把这个事件对应的套接字缓冲区处理完,在这个套接字中没有新的事件 再次到来时,在ET模式下是无法再次从epoll_wait调用中获取这个事件的。而LT模式正好相 反,只要一个事件对应的套接字缓冲区还有数据,就总能从epoll_wait中获取这个事件。 因此,LT模式下开发基于epoll的应用要简单些,不太容易出错。
举个例子,如果读到信息 处理完后,设置为ET模式不做其他的处理,那么该client端再发消息的时候,server端是收不到的。因为server对本次通信的处理仅仅是接收,并没有完整的处理套接字缓冲区。
其中,epoll_wait的第二个参数是一个结构体数组,epoll会把发生的事件放进这个数组中;而第三个参数就是这个数组的大小
正是因为这样,在轮循等待信号的时候,就不需要向前两种模型那样把整个数组都遍历一遍,而是只需要遍历已经注册的信号就行了,因此epoll模型也是这三种模型中,被公认是最高效的。
实现代码如下:
/*************************************************************************> File Name: epoll_tcp.c> Author: HonestFox> Mail: zhweizhi@foxmail.com> Created Time: Fri 29 Jul 2016 03:14:27 PM CST************************************************************************/#include#include #include #include #include #include #include #include #include static void usage(){ printf("usage: ip: port\n");}static int set_nonblock(int sock){ int fl = fcntl(sock, F_GETFL); return fcntl(sock, F_SETFL, fl | O_NONBLOCK);}static int startup(const char *_ip, const int _port){ //Create Socket int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { perror("socket"); exit(2); } //Bind struct sockaddr_in local; local.sin_family = AF_INET; local.sin_port = htons(_port); local.sin_addr.s_addr = inet_addr(_ip); if (bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0) { perror("bind"); exit(3); } //Set Listen if (listen(sock, 5) < 0) { perror("listen"); exit(5); } return sock;}int main(int argc, char *argv[]){ if (argc != 3) { usage(); } int listen_sock = startup(argv[1], atoi(argv[2])); int epfd = epoll_create(256); if (epfd < 0) { perror("epoll_create"); exit(5); } struct epoll_event _ev; _ev.events = EPOLLIN; _ev.data.fd = listen_sock; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &_ev); struct epoll_event _ready_ev[128]; int _ready_evs = 128; int _timeout = 1000; int done = 0; int nums = 0; while (!done) { nums = epoll_wait(epfd, _ready_ev, _ready_evs, _timeout); switch (nums) { case -1: perror("epoll_wait"); exit(6); case 0: printf("time out\n"); break; default: { int i = 0; for (; i < nums; ++i) { int _fd = _ready_ev[i].data.fd; if (_fd == listen_sock && (_ready_ev[i].events & EPOLLIN)) { printf("get a new client\n"); struct sockaddr_in peer; socklen_t len = sizeof(peer); int new_sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //Get a New Link if (new_sock > 0) { printf("new sock > 0\n"); printf("client info %s : %d\n", inet_ntoa(peer.sin_addr), ntohs(peer.sin_port)); _ev.events = EPOLLIN | EPOLLET; //ET _ev.data.fd = new_sock; set_nonblock(new_sock); epoll_ctl(epfd, EPOLL_CTL_ADD, new_sock, &_ev); } } else { if (_ready_ev[i].events & EPOLLIN) { char buf[1024]; memset(buf, '\0', sizeof(buf)); ssize_t _s = recv(_fd, buf, sizeof(buf) - 1, 0); if (_s > 0) { printf("client : %s\n", buf); //_ev.events = EPOLLOUT | EPOLLET; //ET 如果缺省是LT //_ev.data.fd = _fd; epoll_ctl(epfd, EPOLL_CTL_MOD, _fd, &_ev); //Mod } else if (_s == 0) //Client Close { printf("client close...\n"); close(_fd); epoll_ctl(epfd, EPOLL_CTL_DEL, _fd, NULL); } else { perror("recv"); exit(5); } } else if (_ready_ev[i].events & EPOLLOUT) { //写信号,做的事情 } } } } break; } } return 0;}
四、总结
这次介绍了3种I/O多路转接的模型,采用I/O复用模型的服务器,性能上要比多线程服务器高得多,因此也叫 高性能服务器。
三种模型分别是 select模型、 poll模型、epoll模型。
select模型和poll模型都只提供了一个等待函数,每次调用相应等待函数的时候,都需要把client端的文件描述符集合遍历一遍,如果集合空间很大但实际存放的文件描述符并不多,那就会浪费很多额外的时间,此外,这两种模型需要多次发生用户态和内核态之间的拷贝,开销比较大。
epoll相比select/poll的优点:
1、相比select,没有 fd数目的限制 2、每次注册的时候,就将新的文件描述符存入内核态,因此开销较小。
3、epoll的具体实现采用了mmap,加速了内核态和用户态之间传递的效率。