I/O多路复用的引入
我们知道在C/S模型中,通常都是一个服务器和多个客户端的,那么这样就无可避免的产生了这样一个问题,就是服务器如何管理多个客户端。这里包括处理客户端的链接,处理客户机的数据传输等。每个客户端的链接和数据什么时候来是根本无法确定的,那该怎么办那?
这时就产生了三种方法:
(1)将read、accept等阻塞函数修改为非阻塞函数,即轮询模型。
(2)使用多进程或多线程,将每路I/O通过一个进程或线程处理,即并发模型。
(3)单个线程,通过记录跟踪每个I/O流(sock)的状态,来同时管理多个I/O流 ,即多路I/O复用模型。
由于轮询模型的效率比较低,所以这里就在进行讨论了。在上一篇文章中介绍了并发服务器(多进程和多线程),并发模型有很多的好处,但也存在一定的缺点,在一般的C/S模型中,客户端和服务器的交互并不是很多,比如QQ,经常就是挂上也不聊天,尽管聊天数据量也是非常小的。但在并发服务器中,处理的方式为,给每个服务器建立一个进程或线程来处理一个客户端,这样就会导致一个可能我们的服务器上开了很多的进程或线程,但他们中的绝大多数是处于阻塞状态的即不活跃,这样就会导致很大的浪费。而且一旦有很多的用户的同时发起通信,就会导致系统在各个进程或线程之间不停的切换,导致大部分的时间浪费在现场保存和进程切换的上,而导致服务器的服务性能下降。所以I/O复用循环模型服务器是为了降低系统的不必要开支,将主要的系统处理能力集中在核心业务上,需要降低并发服务器处理单元数量,从而提高服务器的吞吐量。
什么是I/O多路复用?
“I/O多路”其实就是“多路I/O”的意思,那么什么是I/O那,在这里我们可以简单将每一个客户单看成是一路I/O,那么多个客户端就多路I/O。
“复用”其实就是“共享”,“复用”是使用在通信的技术中的,像“时分复用、频分复用”,就是如何将一条信道能同时(宏观)很多的用户使用。放到我们这里就是如何将一个线程给很多客户端使用。
简单理解I/O多路复用的意思就是:多个客户端(网络I/O)同时使用一个(极少)线程。I/O 复用这里面的“复用”指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流。这里就会让人产生疑惑,一个线程如何能管理多个I/O流状态,这里其实就是类似电路中开关一样(类似):
I/O复用的实现方式有三种:(1)select、(2)poll、(3)epoll。
I/O多路复用 --- select
select函数作用:在一段时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。
#include <sys/select.h>
int select(int nfds,fd_set* readfds,fd_set* writefds,fd_set* exceptfds,struct timval* timeout);
参数说明:
1、nfds:指定被监听的文件描述符的总数。它通常被设置位select的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。
2、readfds、writefds、exceptfds:可读、可写、异常等事件对应的文件描述符集合。
这三个参数为传入传出参数:将用户设置的文件描述符传入,select调用返回时,内核传出那些文件描述符已经就绪。
这三个参数的类型为:fd_set的指针。fd_set的结构体内部就是一个整形数组,该数组的每个元素的每一位(bit)标记一个文件描述符。由于位操作过于麻烦,所以linux提供了一组宏函数,来方便用户使用。
#include <sys/select.h>
FD_ZERO(fd_set *fdset); //清除fdset所有位
FD_SET(int fd,fd_set *fdset); //设置fdset的位fd
FD_CLR(int fd,fd_set *fdset); //清除fdset的位fd
int FD_ISSET(int fd,fd_set *fdset); //测试fdset的位fd是否被设置
3、timeout:指定select函数的阻塞时间。传入传出参数:传入用户指定的时间,内核传出select的等待时间。timeval结构体的定义:
struct timeval
{
long tv_sec; //秒数
long tv_usec; //微秒数
};
两个特殊取值:
(1)NULL:select将一直阻塞,直到某个文件描述符就绪。
(2)tv_sec和tv_usec的值都为0,则select将立即返回。
返回值:成功返回就绪文件描述符的总数,如果在指定时间内没有任何文件描述符就绪,select将返回0.
失败返回-1,并设置errno。如果select在等待期间接收到信号,则select立即返回-1,并设置errno为EINTR。
文件描述符就绪条件
socket可读:
(1)socket内核接收缓冲区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时就可以无阻塞的读该socket,并且读操作返回字节数大于0。
(2)socket通信的对方关闭连接。此时对该socket的读操作将返回0。
(3)监听socket上有新的连接请求。
(4)socket上有未处理的错误。此时可以使用getsockopt来读取和清除该错误。
socket可写:
(1)socket内核发送缓冲区中可用字节数大于等于其低水位标记SO_SNOLOWAT。此时可无阻塞的写该socket,并且写操作的返回值大于0。
(2)socket的写操作被关闭。对写操作被关闭的socket执行写操作将出发SIGPIPE信号。
(3)socket使用非阻塞connent连接成功或者失败(超时)之后。
(4)socket上有未处理的错误。此时可以使用getsockopt来读取和清除该错误。
socket异常:
select可以处理的异常只有一种:socket接收到带外数据。带外数据也称为加速数据,就是优先级比较高的数据(紧急数据)。
程序1、简单select时间请求服务器
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/select.h>
#include "sockErrHand.h"
#define PORT 5500
#define MAXLISTEN 32
#define MAXSIZE 512
//没有处理客户端断开连接
int main()
{
int serSock=Socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in serAddr;
bzero(&serAddr,sizeof(serAddr));
serAddr.sin_family=AF_INET;
serAddr.sin_port=htons(PORT);
serAddr.sin_addr.s_addr=htonl(INADDR_ANY);
Bind(serSock,(struct sockaddr*)(&serAddr),sizeof(serAddr));
Listen(serSock,MAXLISTEN);
printf("SERVER LISTENING\n");
int linkNum=0;//已连接的客户端数量
int cliSockList[FD_SETSIZE]; //FD_SETSIZE默认值为1024
int i=0;
for(i=0;i<FD_SETSIZE;++i)
{
//初始化客户端列表socket
cliSockList[i]=-1;
}
fd_set readfds,rfds;
FD_ZERO(&rfds); //清空读
FD_SET(serSock,&rfds);//设置serSock的读
int maxfd=serSock;
while(1)
{
readfds=rfds;
int nReady=select(maxfd+1,&readfds,NULL,NULL,NULL);
if(nReady<0)
{
Exit("select");
}
//检测serScok的读位,检测是否由新的客户端的连接
if(FD_ISSET(serSock,&readfds))
{
struct sockaddr_in cliAddr;
bzero(&cliAddr,sizeof(cliAddr));
socklen_t cliAddrLen=sizeof(cliAddr);
int cliSock=Accept(serSock,(struct sockaddr*)(&cliAddr),&cliAddrLen);
cliSockList[linkNum]=cliSock;
//将新的客户端加入,函数会修改readfds的值
FD_SET(cliSock,&rfds);
if(cliSock>maxfd)
{
maxfd=cliSock;
}
++linkNum;
--nReady;
}
//剩余就客户端的请求
int i=0;
char dataBuf[MAXSIZE];
for(i=0;nReady>0&&i<linkNum;++i)
{
if(FD_ISSET(cliSockList[i],&readfds))
{
--nReady;
memset((void*)dataBuf,0,MAXSIZE);
Read(cliSockList[i],(void*)dataBuf,MAXSIZE);
if(strcmp(dataBuf,"Time\n")==0)
{
memset((void*)dataBuf,0,MAXSIZE);
time_t t;
time(&t);
sprintf(dataBuf,"进程号:%d\n当前服务器时间为:%s\n",getpid(),ctime(&t));
}
else
{
memset((void*)dataBuf,0,MAXSIZE);
sprintf(dataBuf,"%s\n","无效命令");
}
Write(cliSockList[i],(void*)dataBuf,strlen(dataBuf));
}
}
}
Close(serSock);
return 0;
}
运行结果:
select函数是第一个实现的I/O复用的函数,所有它不可避免的存在很多问题:
(1)select函数不友好。当使用select函数就会发现,select函数会修改传入的参数,但这个参数下次还需要继续的使用,所有需要我们额外再保存一份数据。
(2)select使用比较麻烦。这个从上面的列子中很容易看出来,当select函数返回后,如果有数据到达,select函数并不能直接告诉我们具体是那一路I/O上有数据到达,而是需要用户自己判断。一旦当链接的客户过多,那么判断就会浪费大量的时间。
(3)select只能监视1024个链接,FD_SETSIZE的值就是1024。(毕竟在1983年1024个链接已经很多了)
(4)select不是线程安全的。如果将一个sock加入到select,然后在另外一个线程中发现该sock不需要了,需要收回,这个select是不支持的。如果直接关闭sock,那么就会导致select产生不可预测行为。
(5)select会在用户态和内核态之间切换,会产生不小的开销。
(6)可以处理的事件类型较少。
但select也是有优点的:
(1)select函数的可移植性好,在一些unix下不支持poll。
(2)select函数对于超时提供了更好的精度:微秒,而poll是毫秒。
I/O多路复用 --- poll
poll和select调用类似,也是在指定时间内轮询一定数据的文件描述符,已测试其中是否有就绪者。
#include <poll.h>
int poll(struct pollfd* fds,nfds_t nfds,int timeout);
参数说明:
(1)fds:是一个pollfd结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写、异常等事件。pollfd结构体的定义定义如下:
struct polled
{
int fd; //文件描述符
short events; //注册的事件,监听的事件
short revents; //实际发生的事件,由内核填充通知用户
};
poll支持的事件类型(监听多个事件需要将每一个数据按位“或”):
事件 | 描述 | 是否可作为输入 | 是否可作为输出 |
POLLIN | 数据(包括普通数据和优先数据)可读 | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Liunx不支持) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如TCP的带外数据 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先数据)可写 | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起。比如管道写端关闭后,读端描述符上将收到POLLHUP事件 | 否 | 是 |
POLLNVAL | 文件描述符没有打开 | 否 | 是 |
需要注意的是:
POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBAND是由XOPEN规范定义的,linux并不支持。
POLLRDHUP:在socket上接收对方关闭连接请求之后触发的。使用时需要定义_GNU_SOURCE
(2)nfds:指定被监听事件集合的fds大小。其类型nfds_t的定义如下:
typedef unsigned long int nfds_t; //无符号长整型
(3)timeout:指定poll的等待值。当timeout为-1时,poll调用将永远阻塞直到事件发生,当timeout为0时,poll将立即返回。
返回值:成功返回就绪文件描述符的总数,如果在指定时间内没有任何文件描述符就绪,poll将返回0.
失败返回-1,并设置errno。如果poll在等待期间接收到信号,则poll立即返回-1,并设置errno为EINTR。
程序2、简单poll时间请求服务器
#include <poll.h>
#include <time.h>
#include <stdio.h>
#include <string.h>
#include "sockErrHand.h"
#define PORT 5500
#define MAXLISTEN 32
//linux系统中没有定义这个宏
#define POLLTIME -1
#define MAXSIZE 512
int main()
{
int serScok=Socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in serAddr;
bzero(&serAddr,sizeof(serAddr));
serAddr.sin_family=AF_INET;
serAddr.sin_port=htons(PORT);
serAddr.sin_addr.s_addr=htonl(INADDR_ANY);
Bind(serScok,(struct sockaddr*)(&serAddr),sizeof(serAddr));
Listen(serScok,MAXLISTEN);
printf("SERVER LISTENING\n");
int linkCli=1;
struct pollfd cliPollfd[MAXLISTEN];
cliPollfd[0].fd=serScok;
cliPollfd[0].events=POLLIN;//读取普通数据
while(1)
{
int nReady=poll(cliPollfd,linkCli,POLLTIME);
if(nReady<0)
{
Exit("poll");
}
//处理客户端连接
if(cliPollfd[0].revents&POLLIN)
{
struct sockaddr_in cliAddr;
bzero(&cliAddr,sizeof(cliAddr));
socklen_t cliAddrLen=sizeof(cliAddr);
int cliSock=Accept(serScok,(struct sockaddr*)(&cliAddr),&cliAddrLen);
//将客户端加入poll
cliPollfd[linkCli].fd=cliSock;
cliPollfd[linkCli].events=POLLIN;
++linkCli;
--nReady;
}
int i=0;
char dataBuf[MAXSIZE];
//轮询操作
for(i=1;nReady>0&&i<linkCli;++i)
{
if(cliPollfd[i].revents&POLLIN)
{
--nReady;
memset((void*)dataBuf,0,MAXSIZE);
Read(cliPollfd[i].fd,(void*)dataBuf,MAXSIZE);
if(strcmp(dataBuf,"Time\n")==0)
{
memset((void*)dataBuf,0,MAXSIZE);
time_t t;
time(&t);
sprintf(dataBuf,"进程号:%d\n当前服务器时间为:%s\n",getpid(),ctime(&t));
}
else
{
sprintf(dataBuf,"%s无效命令\n",dataBuf);
}
Write(cliPollfd[i].fd,(void*)dataBuf,strlen(dataBuf));
}
}
}
Close(serScok);
return 0;
}
由于select的存在不少的问题,所以在1977年,也就是select出现的14年后,又实现了poll函数,poll修复了select的很多问题。
poll的优点:
(1)poll去掉了select连接的1024个的限制。
(2)poll不要求开发者计算最大文件描述符加一,即select中的nfds。
(3)poll在处理大数目的文件描述符的时候比select的速度更快。
poll的缺点:
(1)大量的fds的数组被整体复制于用户态和内核态地址空间之间(fds数组必须提前创建出来,即使没有使用也必须传递给poll函数)。
(2)poll函数任然没有解决,select函数最麻烦的问题,就是返回后需要应用程序轮询处理,依此判断,而没有指出具体是那路I/O上有数据。
(3)poll任然是线程不安全的。
(4)一些平台对poll的支持并不好。
文章参考:知乎 罗志宁对多路I/O复用的回答