一、简单的服务器I/O模型
最简单的的TCP服务器,有三种模式:
1、单执行流,一个server端连接一个client端
2、多进程,一个server端通过多进程的方式,每个进程连接一个client端
3、多线程,一个server端通过多进程的方式,每个线程连接一个client端
(http://zhweizhi.blog.51cto.com/10800691/1830267) 这里实现过
要提升服务器性能,其实就是想要让一个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<stdio.h>
#include<unistd.h>
#include<sys/select.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
#include<netinet/in.h>
#define _MAX_SIZE 100
int 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<stdio.h>
#include<sys/epoll.h>
#include<sys/socket.h>
#include<stdlib.h>
#include<netinet/in.h>
#include<string.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<fcntl.h>
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,加速了内核态和用户态之间传递的效率。
转载于:https://blog.51cto.com/zhweizhi/1832644