I/O复用典型使用在下列网络应用场合:
- 当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用。
- 如果一个TCP服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用。
- 如果一个服务器既要处理TCP,又要处理UDP,一般要使用I/O复用。
- 如果一个服务器要处理多个服务或者多个协议(如inetd守护进程),一般要使用I/O复用。
I/O模型
Unix下可用的5种I/O模型基本区别:
- 阻塞式I/O
- 非阻塞式I/O
- I/O复用(select和poll)
- 信号驱动式I/O(SIGIO)
- 异步I/O(POSIX的aio_系列函数)
阻塞式I/O
非阻塞式I/O模型
I/O复用模型
信号驱动式I/O模型
首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。
异步I/O模型
一般的,该模型夏啊的函数工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与信号驱动模型的主要区别紫玉:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。
select函数
该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。
#include<sys/select.h>
#include<sys/time.h>
//返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
int select(int mxfdpl,fd_set *readset,fd_set *writeset,fd_set *exceptset,\
const struct timevla *timeout)
参数解析:
1、timeout:告知内核等待所指定描述符中的任何一个就绪可花多长时间,其timeval结构用于指定这段时间的秒数和微妙数。
struct timeval{
long tv_sec; //seconds
long tv_usec; //microseconds
}
这个参数有以下三种可能:
- 永远等待下去:仅在有一个描述符准备好I/O时才返回,为此,可设置为空指针。
- 等待一段固定的时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
- 根本不等待:检查描述符后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值(由timeval结构指定)必须为0。
中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述符。目前支持的异常条件只有两个:
- 某个套接字的带外数据的到达。
- 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息。
这3个参数中的每一个参数指定一个或多个描述符值是一个设计上的问题。通常,select使用描述符集,通常是一个整数数组。例如,使用32位整数,则该数组的第一个元素对应于描述符0~31,第二个元素对应于描述符32~63,并依次类推。它们隐藏在fd_set的数据类型和以下四个宏中:
void FD_ZERO(fd_set *fdset); //clear all bits in fdset
void FD_SET(int fd,fd_set *fdset); //turn on the bit for fd in fdset
void FD_CLR(int fd,fd_set *fdset); //turn off the bit for fd in fdset
int FD_ISSET(int fd,fd_set *fdset); //is the bit for fd on in fdset ?
我们分配一个fd_set数据类型的描述符集,并用这些宏设置或测试该集合中的每一位,也可以用c语言中的赋值语句把它赋值成另外一个描述符集。例如,以下代码用于定义一个fd_set类型的变量,然后打开描述符1、4和5对应位:
fd_set rset;
FD_ZERO(&rset); //initialize the set:all bits off
FD_SET(1,&rset); //turn on bit for fd 1
FD_SET(4,&rset); //turn on bit for fd 4
FD_SET(5,&rset); //turn on bit for fd 5
【注】select函数中的三个参数readset、writeset和exceptset中,如果对某一个条件不感兴趣,可以置为空指针。并且,如果这三个指针均为空,我们就有了一个比unix的sleep更精准的定时器,其睡眠单位以微秒为单位。
maxfdpl参数指定待测试的描述符个数,它的值是待测试的最大描述符加1,例如上面的例子1、4和5,则maxfdpl则指定为6(即5+1),描述符0,1,2....maxfdpl-1均被测试。
头文件<sys/select.h>中定义的FD_SETSIZE常值是数据类型fd_set中的描述符总数,其值通常为1024,不过很少程序能用到这么多的描述符。maxfdpl参数迫使我们计算出所关心的最大描述符并告知内核该值。
shutdown函数
终止网络连接的通常方法是调用close函数。不过close函数有两个限制:
- close把描述符的引用计数减1,仅在该计数变为0时才关闭套接字。
- close终止读和写两个方向的数据传送。
而shutdown可以很好解决这两个限制:
- shutdown可以不管引用计数就激发TCP的正常连接终止序列。
- 可以告知对端我们已经完成了数据发送,此时对端仍有数据要发送给我们。
#include <sys/socket.h>
//返回:若成功则为0,若出错则为-1
int shutdown(int sockfd,int howto);
howto的参数设置如下:
SHUT_RD:关闭连接的读这一半,即套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调用shutdown函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。
SHUT_WR:关闭连接的写这一半,即半关闭。当前留在套接字发送缓冲区中的数据将被发送掉,后跟TCP的正常连接终止序列。
SHUT_RDWR:连接的读半部和写半部都关闭,即调用一次SHUT_RD,调用一个SHUT_WR。
示例:调用select的TCP回显服务器
26~27行:select等待某个事件的发生,或者是新客户连接的建立,或是数据、FIN或RST的到达。
28~45行:如果监听套接字变为可读,那么就建立了一个新的连接,再调用accept并相应地更新数据结构,使用client数组中的第一个未用项纪录这个已连接描述符。就绪描述符减1,若值为0,就可以避免进入下一个for循环。这样做让我们可以使用select的返回值来避免检查未就绪的描述符。
46~60行:对于每个现有的客户连接,我们要测试其描述符是否在select返回的描述符集中。如果是就从该客户读入一行文本并回射给它。如果该客户关闭了连接,那么read就返回0,相应地要更新数据结构。
【缺点】
若有一个恶意的客户连接该服务器,然后发送一个字节数据后进入了睡眠,此时服务器将调用read,它从客户读入这个单字节的数据,然后阻塞于下一个read调用,以等待来自该客户的其余数据,从而不再为任何客户提供服务(即接受新的客户连接或者读取现有客户的数据),直到这个恶意的客户发出一个换行符或者是终止为止。
【解决办法】
- 使用非阻塞时I/O
- 让每个客户由单独的控制线程提供服务(例如创建一个子进程或者线程来服务每个客户)
- 对I/O操作设置一个超时
pselect函数
#include <sys/select.h>
#include <signal.h>
#include <time.h>
int pselect(int maxfdpl, fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timespec *timeout, const sigset_t *sigmask);
pselect和select的区别:
- pselect使用timespec结构,而不使用timeval结构
struct timespec{ time_t tv_sec; //seconds long tv_nsec; //nanoseconds };
- pselect函数增加了第六个参数:一个指向信号掩码的指针。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止信号的信号处理函数设置的全局变量,然后调用pselect,告诉它重新设置信号掩码。
poll函数
#include <poll.h>
//返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
int poll(struct pollfd* fdarray, unsigned long nfds, int timeout);
【参数解析】
第一个参数指向一个结构数组第一个元素的指针。每个数组元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件
struct pollfd{
int fd;
short events;
short revents;
};
要测试的条件由events成员指定,函数在相应的revents成员中返回该描述符的状态(每个描述符都有两个变量,一个为调用值,另外一个为返回结果)。这两个成员中的每一个都由某个特定条件的一位或多位构成。常用的events标志及测试revents标志的值如下:
poll识别三类数据:普通、优先级带和高优先级。这些术语均出自于基于流的实现。
就TCP和UDP套接字而言,以下条件引起poll返回特定的revent。不行的是,POSIX在其poll的定义中留了许多空洞(也就是说有多种方法可返回相同的条件):
- 所有正规TCP数据和所有UDP数据都被认为是普通数据;
- TCP的带外数据被认为是优先级带数据;
- 当TCP连接的读半部关闭时(譬如收到了一个来自对端的FIN),也被认为是普通数据,随后的读操作将返回0;
- TCP连接存在错误既可认为是普通数据,也可认为是错误(POLLERR)。无论哪种情况,随后的读操作将返回-1,并把errno设置成合适的值。这可用于处理诸如接收到RST或发生超时等条件;
- 在监听套接字上有新的连接可用既可认为是普通数据,也可认为是优先级数据。大多数视之为普通数据;
- 非阻塞式connect的完成被认为是使相应套接字可写;
结构数组中的元素的个数是由nfds参数指定
timeout参数指定poll函数返回前等待多长时间。它是一个指定应等待毫秒数的正值,取值如下:
INFTIM常值被定义为一个负值。如果系统不能提供毫秒级精度的定时器,该值就向上舍入到最接近的支持值。同时,INFTIM值常常在<poll.h>中被定义,但也有部分系统在<sys/stropts.h>中定义。
poll的函数返回值
- 若发生错误,返回-1;
- 若定时器到时之前没有任何描述符就绪,返回0,否则返回就绪描述符的个数,即revents成员值非0的描述符个数;
如果我们不再关心某个特定描述符,那么可以把与它对应的pollfd结构的fd成员设置成一个负值。poll函数将忽略这样的pollfd结构的events成员,返回时将它的revents成员的值置为0.
示例:TCP回射服务器程序(再修订版)
使用poll替代select重新改程序,在select版本中,我们必须分配一个client数组以及一个名为rset的描述符集。改用poll后,我们只需分配一个pollfd结构的数组来维护客户信息,而不必分配另外一个数组。
使用poll函数的TCP服务器程序的前半部分
#include "unp.h" //书上的代码,大部分用到的头文件
#include <limits.h> //for OPEN_MAX
int main(int argc, char **argv){
int i,maxi,listenfd,connfd,sockfd;
int nready;
ssize_t n;
char buf[MAXLINE];
socklen_t clilen;
struct pollfd client[OPEN_MAX];
struct sockaddr_in cliaddr,servaddr;
listenfd = socket(AF_INET,SOCK_STREAM,0);
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //监控本地地址
servaddr.sin_port = htons(SERV_PORT);
bind(listenfd, (struct sockaddr*) &servaddr,sizeof(servaddr));
listen(listenfd,LISTENQ);
client[0].fd = listenfd;
client[0].events = POLLRDNORM;
for(i = 1,i<OPEN_MAX;i++)
client[i].fd = -1;
maxi = 0;
}
使用poll函数的TCP服务器程序的后半部分
for(;;){ //26行
nready = poll(client,maxi+1,INFTIM);
if(client[].revents & POLLRDNORM) { //new client connection
clilen = sizeof(cliaddr);
connfd = accept(listenfd,(struct sockaddr*)&cliaddr,&clilen);
for(i=1;i<OPEN_MAX;i++){
if(client[i].fd<0){
client[i].fd = connfd; //save descriptor
break;
}
}
if(i==OPEN_MAX)
err_quit("too many clients");
client[i].events = POLLRDNORM;
if(i<maxi)
maxi = i; //max index in client[] array
if(--nready <= 0)
continue; //no more readable descriptors
} //42行
//43行
for(i=1;i<=maxi;i++){ //check all clients for data
if((sockfd = client[i].fd)<0)
continue;
if(client[i].revents & (POLLRDNORM | POLLERR)){
if((n = read(sockfd,buf,MAXLINE))<0){
if(errno == ECONNRESET){ //connection reset by client
close(sockfd);
client[i].fd = -1;
}else
err_sys("read error");
}else if(n==0){ //connection closed by client
close(sockfd);
client[i].fd = -1;
}else
writen(sockfd,buf,n);
if(--nready <= 0)
break; //no more readable descriptors
} //63行
}
}
调用poll,检查新的连接
26~42行表示:调用poll等待新的连接或者现有连接上有数据可读。当一个连接被接受后,我们在client数组中查找第一个描述符成员为负的可用想。注意,我们从下标1开始搜索,因为下标0用于监听套接字。找到一个可用项后,我们把新连接的描述符保存到其中,并设置POLLRDNORM事件。
检查某个现有连接上的数据
43~63行表示:检查两个返回事件POLLRDNORM和POLLERR。其中并没有在event成员中设置第二个事件,因为它在条件成立时总是返回。检查POLLERR的原因在于:有些实现在一个连接上接受到RST时返回的是POLLERR时间,而其他实现返回的知识POLLRDNORM时间。不论哪种情形,我们都调用read,当有错误发生时,read将返回这个错误。当一个现有连接由它的客户终止时,我们就把塔的fd成员置为1。