1.每个文件描述符都由一个单独的进程来监视
2.select
3.poll
4.带有轮询的无阻塞型I/O
5.POSIX异步I/O
6.每个文件描述符都由一个独立的线程来监视
监视多个文件描述符的一种方法是为每个描述符分别使用一个独立的进程。
使用独立的进程来监视文件描述符时,初始进程会创建出一个子进程来处理每个文件描述符。因为一旦被创建出来之后,子进程就不再共享任何变量了,所以这种方法适用于代表独立I/O流的描述符。如果对描述符的处理不是独立的,子进程可以用共享内存或消息传递机制来交换信息。
方法1:用进程来监控文件描述符
/* 为了流程清晰,省略了错误检查 */
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[]){
int childpid;
int flag;
int fd,fd1,fd2;
char *file1;
char *file2;
char buf;
file1 = "file1";
file2 = "file2";
if((fd1 = open(file1, O_RDWR | O_CREAT)) == -1)
return -1;
if((fd2 = open(file2, O_RDWR | O_CREAT)) == -1)
return -1;
if((childpid = fork()) == -1)
return 1;
if(childpid > 0)
fd = fd1;
else
fd = fd2;
for(;;){
if((flag = read(fd,&buf,1)) == -1)
return 1;
else if(flag > 0){
fprintf(stdout,"file discriptor %d is ready\n",fd);
break;
}
}
}
我们把这个程序编译成a.out之后,在终端1前台执行:
终端1上,由于进程(其实是前台进程组,因为fork了)是前台执行,控制权并没有返回给终端控制进程shell(也就是bash),最直观的就是我们平没有看到终端提示符:
我们另外打开一个终端2,ls一下看看结果:
可以看到file1和file2两个空文件被创建起来了。文件是基于操作系统的文件系统的,不论哪个进程去创建,一般情况下所有进程都可以在系统中“看到”这些文件。
读程序1可以得知,这个程序用自己run起来的进程和它fork出的一个子进程分别监控file1和file2;一旦文件就绪,进程就提示后退出。
那么我们在终端2上给file1和file2这两个文件随意添加几个字节,看看终端1上会有什反应:
在终端2上通过vim给file2添加一些内容后,终端1上弹出了提示:
继续在终端2上通过vim给file1添加一些内容后,终端1上继续弹出了提示:
此时程序已经完全结束了,将控制权返回给了终端(出现了终端提示符)。
如此实现了“通过进程对文件描述符的监控”。
但有一个比较有意思的现象,如果我们先给file1添加内容,在给file2添加内容,情况还会一样吗?
在终端2上通过vim给file1添加一些内容后,终端1上弹出了提示,但同时也出现了终端提示符!这就说明前台进程组已经将终端控制权返回给了终端控制进程(shell进程);此时a.out中的子进程还在做对file2的监控(还没死掉),该子进程就变成了一个孤儿进程,若此时用
ps -o comm,pid,ppid
命令可以看到该子进程的父进程为1,被init进程统一收养了。
用独立的进程来监视文件描述符可能很有用,但是这些进程都有自己独立的地址空间,因此他们之间的交互很困难。
select 函数用来够监视我们需要监视的文件描述符(读或写的文件集中的文件描述符)的状态变化情况。
并能通过返回的值告知我们。
select调用提供了一种在单个进程中监视多个文件描述符的方法。
它可以对三种可能的状况进行监视:
(1)可以无阻塞地进行的读操作
(2)可以无阻塞地进行的写操作
(3)有挂起的错误情况的文件描述符
老版本的UNIX在sys/time.h中定义了select函数,但POSIX标准现在使用的是sys/select.h。
1.函数原型:
#include
int select( int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout
);
在对参数进行讲解之前先介绍一个重要的结构 —— fd_set:
fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄。
fd_set集合可以通过下面的宏来进行人为来操作。(1) FD_ZERO 用法:FD_ZERO(fd_set*); 用来清空fd_set集合,即让fd_set集合不再包含任何文件句柄。 (2) FD_SET 用法:FD_SET(int ,fd_set *); 用来将一个给定的文件描述符加入集合之中 (3) FD_CLR 用法:FD_CLR(int ,fd_set*); 用来将一个给定的文件描述符从集合中删除 (4) FD_ISSET 用法:FD_ISSET(int ,fd_set*); 检测fd在fdset集合中的状态是否变化,当检测到fd状态发生变化时返回真,否则,返回假(也可以认为集合中指定的文件描述符是否可以读写)。
2.参数讲解
#include
int select( int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout
);
(1)int nfds:集合中所有文件描述符的范围,其值要比“所有文件描述符的最大值”还要大1。
(2)fd_set *readfds:要进行监视的读文件集。
(3)fd_set *writefds :要进行监视的写文件集。
(4)fd_set *errorfds:用于监视异常数据,指定了为错误情况监视的文件描述符集。
/*
* 这些fd_set类型的参数中的任何一个都能为NULL,在这种情况下,select不为相应的事件监视描述符。
*/
(5)struct timeval* timeout:select的超时时间,它可以使select处于三种状态:
/*
* timeout这个参数可能的常用情况:
*第一,若将NULL以形参传入,即不传入时间结构,就是 将select置于阻塞状态,
一定等到监视文件描述符集合中某个文件描述符发生变化为止;
*第二,若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,
不管文件描述符是否有变化,都立刻返回继续执行,
文件无变化返回0,有变化返回一个正值;
*第三,timeout的值大于0,这就是等待的超时时间,
即 select在timeout时间内阻塞,超时时间之内有事件到来就返回了,
否则在超时后不管怎样一定返回。
*/
3.返回值
成功返回时:
(1)除了那些已经准备就绪的描述符之外,select清空每个readfas、 writefds和errorfas中所有描述符。
(2)select函数返网已准备就绪的文件描述符的数目。
如果不成功:
select返回-1并设置errno,下表列出了select的实现必须检测的错误及相应的错误码。
errno | 原因 |
---|---|
EBADF | 个或多个文件描述符集指定了无效的文件描述符 |
EINTR | 在超时或被选中的事作发生之前被select信号中断 |
EINVAL | 指定了-个无效的超时间隔,或者nids小于0或大于FD_SETSTZE |
下面是一个一直阻塞到两个文件描述符中的一个准备就绪为止的函数。
//代码2
#include
#include
#include
int monitor(int fd1,int ed2){
int maxfd;
int nfds;
fa_set readset;
if ((fd1 < 0) || (fd1 >= FD_SETSIZE) || \
(fa2 < 0) || (fd2 >= FD_SETSIZE)){
errno 4 FINVAL;
return -1;
}
maxfd =(fa1 > fd2) ? fd1 : fd2;
FD_ZERO(&readset);
FD_SET(fd1,&readset);
FD_SET(fd2,Greadset);
nfds = select(maxfd+1, &readset, NULL, NULL, NULL);
if (nfds == -1)
return -1;
if(FD_ISSET(fd1,&readset))
return fd1;
if(FD_ISSET(fd2,&readset))
return fd2;
return -1;
}
/*
该函数一直保持阻塞状态,直到作为参数传递的两个文件描述符中至少有 一个准备好作读操作为止,
然后返网那个文件描述符。
如果两个都准备好了,就返回第一个文件描述符。
如果不成功,就返回-1。一直阻塞到两个文件描述符中的一个准备就绪为止的函数。
*/
功能:监视并等待多个文件描述符的属性变化
poll()和select() 系统调用的本质一样,poll() 的机制与 select() 在本质上没有多大差别:
都是通过轮询管理多个描述符并根据描述符的状态进行处理。
但是 poll() 没有最大文件描述符数量的限制(不过数量过大后性能仍会下降)。
poll() 和 select() 同样存在一个缺点 —— 包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间(不论这些文件描述符是否就绪),它的开销随着文件描述符数量的增加而线性增大。
1. 函数原型
int poll ( struct pollfd *fds, nfds_t nfds, int timeout );
在对poll的参数进行讲解之前先介绍一个重要的结构 —— struct pollfd:
fds结构体参数说明:
struct pollfd{ int fd; //文件描述符 short events; //等待的事件(请求的事件) short revents; //实际发生的事件 (返回的事件) };
(1)fd:每一个 pollfd 结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示 poll() 监视多个文件描述符。
(2)events:指定监测fd的事件(输入、输出、错误),每一个事件有多个取值。
(3)revents:revents 域是文件描述符的操作结果事件,内核在调用返回时设置这个域。events 域中请求的任何事件都可能在 revents 域中返回。
注意:每个结构体的 events 域是由用户来设置,告诉内核我们关注的是什么,而 revents 域是返回时内核设置的,以说明对该描述符发生了什么事件。
fd是文件描述符值,events和revents是通过对代表各种事件的标志符进行逻辑或运算构建而成的。设置events来包含所要监视的事件;poll用已经发生的事件来填写revents。下表列出了这些事件:
事件标识符 | 含义 |
---|---|
POLLTN | 无阻塞地读除了具有高优先级的数据之外的数据 |
POLLRONORM | 无阻塞地读常规数据 |
POLLKIBAND | 无阻塞地读具有优先级的数据 |
POLLPRI | 无阻塞地读具有高优先级的数据 |
POLLCUT | 无阻塞地写常规数据 |
POLLWRNORM | 与POLLOUT相同 |
POLLERR | 描述符中出现错误 |
POLLHUP | 设备已经被断开 |
POLLNVAL | 文件描述符无效 |
poll函数通过在revents中设置标志符POLLHUP、POLLERR和 POLLNVAL来反映相关条件的存在。不需要在events中对与这些标志符相关的比特位进行设置。
如果fd小于零,那么events字段被忽略,而revents被设置为零。
标准中没有说明应该如何处理文件结束。文件结束可以通过revents的标志符POLLHUP或者返回0字节的常规读操作来传达。即使POLLIN或POLLRDNORM指出还有数据要读,POLLHUP地可能会被设置,因此,应该在错误检验之前处理正常的读操作。
2.参数解析
struct pollfd{
int fd; //文件描述符
short events; //等待的事件
short revents; //实际发生的事件
}
(1)fds:指向一个结构体数组的第0个元素的指针,
每个数组元素都是一个struct pollfd结构,用于指定测试某个给定的fd的条件。
(2)nfds:用来指定第一个参数数组元素个数。
(3)timeout:指定等待的毫秒数,无论 I/O 是否准备好,poll() 都会返回。
3. 返回值
成功时:
poll() 返回结构体中 revents 域不为 0 的文件描述符个数;
如果在超时前没有任何事件发生,poll()返回 0;
失败时:
poll() 返回 -1,并设置 errno 为下列值之一:
EBADF:一个或多个结构体中指定的文件描述符无效。
EFAULT:fds 指针指向的地址超出进程的地址空间。
EINTR:请求的事件之前产生一个信号,调用可以重新发起。
EINVAL:nfds 参数超出 PLIMIT_NOFILE 值。
ENOMEM:可用内存不足,无法完成请求。
用poll来监视一个文件描述符数组的函数。
#include
#include
#include
#include
#include
#define BUFSIZE 1024
void monitor_poll(int fd[], int numfds)
char buf[BUFSIZE];
int bytesread;
int i;
int numnow = 0;
int numready;
struct pollfd *pollfd;
for (i = 0; i < numfds; i++) /* initialize the polling structure */
if (fd[i] >= 0)
numnow++;
if ((pollfd = (void *)calloc(numfds, sizeof(struct pollfd))) == NULL)
return;
for(i = 0; i < numfds; i++){
(pollfd + i)->fd = *(fd + i); //将参数中的fd数组装入poll数组中的fd成员项
(pollfd + i)->events = POLLRDNORM; //装入要监控的文件描述符
}
while (numnow > 0)( /* Continue monitoring until descriptors done */
nunready = poll(pollfd,numfds,-1);
if((numready ==-1 && (errno == EINTR)) /*poll interrupted by a signal, try again */
continue;
else if (numready ==-1) /* real poll error, can't continue */
break;
for (i = 0; i < numfds && numready > 0; i++){
if ((pollfd + i)->revents){
if ((pollfd + i)->revents &(POLLRDNORM | POLLIN) ){
bytesread = read(fd[i],buf,BIPSIZE);
numready--;
if(bytesread > 0)
fprintf(stdout,"file descriptor %d is ready, readout %d Bytes\n", fd[i],bytesread);
else
bytesread = -1; /* end of file */
}else if ((pollfd + i)->revents &(POLLERR | POLLHUP))
bytesread =-1;
else /* descriptor not involved in this round */
bytesread = 0;
if (bytesread == -1){ /* error occurred, remove descriptor */
close(fd[i]);
(pollfd + i)->fd = -1;
numnow--;
}
}
}
}
for (i = 0; i < numfds; i++)
close(fd[i]);
free(pollfd);
}
对比来看,select调用对传递给它的文件描述符集进行了修改,程序每次调用select时必须重置这些描述符集。
poll函数为输人和返回值使用了相互独立的变量(events和revents),因此不必在每次调用poll之后重置被监视的描述符列表。
poll函数有很多优点。不需要在每次调用后重置掩码。与select不同,poll函数将错误当作引起poll返回的事件来处理。尽管参数timeout的范围受限,但它更易使用。此外,poll不需要使用max_fd参数。
select轮询具有一个较弱的限制,那就是由于它采用一个1024长度的数组来存储状态,所以它最多可以同时检查1024个文件描述符。poll较select方案有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查。但是当文件描述符较多的时候,它的性能还是十分低下的。
在调用阻塞I/O时,应用程序需要等待I/O完成才返回结果。阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。以读取磁盘上的一段文件为例,系统内核在完成磁盘寻道、读取数据、复制数据到内存中之后,这个调用才结束。阻塞I/O造成CPU等待I/O,CPU的处理能力不能得到充分利用。为了提高性能,内核提供了非阻塞I/O。
非阻塞I/O调用之后会立即返回。返回之后,CPU的时间片可以用来处理其他事务,此时的性能提升是明显的。
但非阻塞I/O也存在一些问题,用于完整的I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术叫做轮询。
任意技术都并非完美的。阻塞I/O造成CPU等待,非阻塞I/O带来的麻烦却是需要轮询去确认是否完全完成数据获取,它会让CPU处理状态判断,这是对CPU资源的浪费。
轮询技术满足了非阻塞I/O确保获取完整数据的需求,但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要等待I/O完全返回,依旧会费了很多时间来等待。等待期间,CPU要么用于遍历文件描述符的状态,要么用于休眠等待事件发生。
以用POSIX异步I/O将文件描述符的监视与处理工作重叠进行,使用时可以带有信号通知,也可以不带信号通知,不带信号通知时,异步I/O像方法4中那样依赖于轮询。带有信号通知时,程序在收到一个信号通知它I/O可能就绪之前,一直在进行它的有效工作。操作系统将控制权转交给一个处理程序来处理I/O。这种方法要求处理程序只能使用异步信号安全的函数。信号处理程序访问数据时,必须与程序的其余部分同步,这就使死锁和竞态条件有机可乘。尽管异步I/O可以被调整得非常高效,但这种方法很容易出错,而且很难实现。
关于pthread的使用介绍可以参考这篇博客:pthread详解
最后一种方法使用独立的线程来处理每个描述符,有效地将问题缩减到处理单个文件描述符的问题上来。线程化代码比其他的实现方式更加简单,程序可以用一种透明的方式将处理工作和等待输入重叠起来。
//示例代码
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define BUFSIZE 1
void *processfd(void *arg){
char buf[1];
int fd;
int r;
fd = *(int *)arg;
for(;;){
if((r=read(fd, buf, BUFSIZE)) > 0){
fprintf(stdout,"file descriptor %d is ready!\n", fd);
break;
}
else if(r == -1){
fprintf(stdout,"read failed in fd %d\n",fd);
break;
}
}
return NULL;
}
void monitorfd(int fd[], int numfds){
int error, i;
pthread_t *tid;
if((tid = (pthread_t *)calloc(numfds,sizeof(pthread_t))) == NULL) {
perror("Failed to calloc");
return;
}
for(i = 0; i < numfds; i++){
if(error = pthread_create((tid+i), NULL, processfd, (fd+i))){
fprintf(stderr,"Failed to create thread %d:%s\n",i,strerror(error));
tid[i] = pthread_self();
}
fprintf(stdout,"create thread %d\n",i);
}
for(i = 0; i < numfds; i++){
if(pthread_equal(pthread_self(),tid[i]))
continue;
if(error = pthread_join(tid[i],NULL))
fprintf(stderr, "Failed to join thread %d:%s\n",i,strerror(error));
}
free(tid);
return;
}
int main(){
int i;
int numfds = 3;
char *file;
int fd[numfds];
for(i = 3; i < 3+numfds; i++){
char num = (char)(i+48);
file = "file";
char str[8];
snprintf(str, sizeof(str), "%s%c", file, num);
printf("open file %s \n",str);
if((fd[i-3] = open(str, O_RDWR | O_CREAT)) == -1){
perror("open error");
return -1;
}
}
monitorfd(fd, numfds);
return 0;
}
在终端1上运行该代码编译连接后的可执行文件
t
,发现控制权一直没有返回终端,因为pthread_join使得主线程一直在等待回收用于监视文件描述符的线程资源:
此时我们另开一个终端2,并为程序打开的文件输入一些内容。
先给file3输入一些内容,我么可以发现终端1上有了相应的提示:
继续在终端2中向file4和file5添加内容,所有监视线程完成其任务,资源被回收后程序执行完毕,返还终端1的控制权,出现终端提示符: