首先提出问题,Linux系统调用是如何完成一个I/O操作?
Linux系统将内存分为内核区和用户区,Linux内核给管理所有的硬件资源,应用程序通过系统调用与内核交互,达到使用硬件资源的目的。例如,应用程序通过系统调用read对文件描述符fd发起一个读操作,这时候内核通过驱动程序向硬件发送读指令,并将读到的数据放在这个fd对应结构体的缓存区中,但这个结构体是在内核内存区的,需要将这个数据读到用户区,这样就完成了一次读操作。
所以一个输入操作一般有两个阶段:
对于一个socket上的操作
第一步:一般是等待数据到达网络,当分组到达时,它被拷贝到内核中某个缓冲区。
第二步:将数据从内核缓冲区拷贝到应用程序缓冲区。
下面依次讲解Unix/Linux下可用的5种I/O模型:
最流行的i/o模型。缺省时,所有套接字都是阻塞的。阻塞,使进程被挂起而等待I/O的读写就绪。
应用程序调用一个I/O函数,导致应用程序阻塞,等待数据准备好。如果数据没有准备好,一直等待…..数据准备好了,从内核拷贝到用户空间,IO函数成功指示。(为了便于理解模型,考虑 UDP 数据 报,因为 UDP 数据报比 TCP 数据报要简单一些,并且把 recvfrom 视为系统调用,下同)
当把一个套接字设置成非阻塞方式时,即通知内核:当请求的i/o操作不能完成时,不要进程睡眠,而应返回一个错误。这样我们的I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好继续测试。(返回不成功指示)
使用这种IO模型,我们一般需要循环调用,我们称此过程为轮询(polling),应用进程连续不断的查询内核,看看某操作是否准备好,这对CPU是极大的浪费,但这种模型只是偶尔才遇到。
i/o复用模型一般调用select或poll实现,进程阻塞于这两个系统调用上,而不是阻塞于真正的i/o系统调用上。
这两个函数可以同时阻塞多个I/O操作,对多个I/O操作进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。
与阻塞i/o模型相比,由于使用了系统调用select,似乎比阻塞i/o还差。但select的好处在于可以等待多个描述字准备好。
I/O 复用并非限于网络编程,许多正是应用程序也需要使用这项技术。
让内核在描述字准备好时用信号SIGIO通知进程。这种模型的好处是当等待数据报到达时,可以不阻塞。前提是允许套接口进行信号驱动i/o 。
使用这种模型,首先我们允许socket进行信号驱动I/O,并通过系统调用sigaction安装一个信号处理程序。此系统调用立即返回,进程继续工作,它是非阻塞的。
当数据报准备好被读时,就为该进程生成个sigio信号。我们随即可以在信号处理程序中调用recvfrom来读取数据报,并通知主循环数据报已准备号被处理,也可以通知主循环,让它来处理数据报。
无论我们如何处理sigio信号,这种模型的好处是当等待数据报到达时,可以不阻塞。主循环可以继续执行,只是等待信号处理程序的通知:或者数据报已准备好被处理,或者数据报已准备好被读取。
异步i/o让内核启动操作,并在整个操作完成后(包括将数据从内核拷贝到应用进程的缓冲区)通知我们。
异步i/o让与信号驱动i/o的区别是:
后者是由内核通知我们何时可以启动一个i/o操作,而前者是由内核通知我们i/o操作何时完成。
另外上述i/o模型中,前四个模型:阻塞i/o模型、非阻塞i/o模型、i/o复用模型和信号驱动模型都是同步i/o模型,因为真正的i/o操作(recvfrom)阻塞进程,只有异步i/o模型与此异步i/o定义相匹配。
问题:当客户应用程序处理两个或以上的描述字,如标准输入和tcp套接字,当客户程序被标准输入阻塞,而服务器又被杀死,尽管tcp服务器已给客户发送了FIN,但客户程序却不能读取。解决方法之一是用并发技术(上一讲介绍),但这种方法在有些情况下却显得极为昂贵。
我们需要程序具有这样的能力:如果一个或多个i/o条件满足(如输入已准备好,或者描述字可以承接更多的输出)时,我们就被通知。这个能力就称为i/o复用。
先构造一张或多张包含所有需要等待的描述符的表,然后调用一个函数,它要到这些描述符中的一个或多个已准备好进行I/O时才返回。在返回时,它告诉进程哪一个描述符已准备好可以进行I/O。
1.select函数
#include
#include
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
返回:准备好描述字的总数量,0-超时,-1-出错,大于0-总的位数
1)先看最后一个参数timeout,它指定内核等待的时间,其结构如下:
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
}
timeout指示该函数有三种执行结果:
在前两种情况的等待中,如果进程捕获了一个信号并从信号处理程序返回,那么等待一般被中断。
结构timeval指定了秒数和微秒数。但内核支持的分辨率却要粗糙得多,因此,定时并不精确。另外,如果select的三个测试指针为空,将提供一个比函数sleep更为精确的定时器(sleep睡眠以秒为最小单位)。
2)再看中间3个参数,这三个描述字集合分别指示不同测试类型的描述字集合(读、写、异常描述字),其中异常描述字支持:套接字上带外数据的到达和控制状态信息的存在。
对于这几个描述字,我们常用有4个对描述字集合操作的宏:
Void FD_ZERO(fd_set *fdset);/* 清空fd_set描述符集合 */
Void FD_SET(int fd, fd_set *fdset); /* 往fd_set中添加描述符,建立文件描述符与fd_set联系 */
Void FD_CLR(int fd, fd_set *fdset);/* 在fd_set中删除描述符 */
int FD_ISSET(int fd, fd_set *fdset);/* 检查fd_set联系的文件描述符fd是否可读写,返回非0 表示可读写 */
3)最后一个参数,maxfd代表描述字集合中最大描述字的值加1,如:需等待的描述字为1,4,5,其maxfd就应该是6。因为在进行描述字集合操作时,需要从0开始将所有描述字测试一次。
4)上面提到select函数的返回值是所有描述字集中的已准备好的描述字个数。那么什么时候 认为套接口准备好呢?
a.套接口接收缓冲区中的数据字节数大于等于套接口接收缓冲区低潮限度的当前值。对 这样的套接口的读操作将不阻塞并返回一个大于 0 的值,即准备好读入的数据量。可以用套 接口选项 SO_RCVLOWAT 来设置此低潮限度,对于 TCP 和 UDP 套接口,其值默认为 1。
b.连接的读这一半关闭,也就是说接收了 FIN 的 TCP 连接。对这样的套接口的读操作 将不阻塞且返回 0(即文件结束符) 。
c.套接口是一个监听套接口且已完成的连接数大于 0。在正常情况下,这样的监听套接 口上的 accept 不会阻塞。
d.有一个套接口错误待处理。对这样的套接口的读操作将不阻塞且返回一个错误一 1, error 则设置成明确的错误条件。这些待处理的错误(pending error)也可通过指定套接口选 项 SO_ERROR 调用 getsockopt 来取得并清除。
a.套接口发送缓冲区中的可用空间字节数大于等于套接口发送缓冲区低潮限度的当前 值,且套接口已连接或者套接口不要求连接(例如 UDP 套接口)。可以用套接口选项 SO_SNDLOWAT 来设置套接口接收缓冲区低潮限度,对于 TCP 和 UDP 套接口缺省值一般为 2048。
b.连接的写这一半关闭。对这样的套接口的写操作将产生信号 SIGPIPE。
c.有一个套接口错误待处理。对这样的套接口的写操作将不阻塞且返回一个错误一 1, error 则设置成明确的错误条件。这些待处理的错误也可通过指定套接口选项 SO_ERROR 调 用 getsockopt 来取得并清除。
如果一个套接口存在带外数据或者仍处于带外标记,那它有异常条件待处理。异常条件 指的是:套接口带外数据的到达、控制状态信息的存在。
必须注意一点,当一个套接口出错时,它由 select 标记为既可读又可写。
而在上面提到的接收和发送低潮限度的目的是:在 se1ect 返回可读或可写条件之前,应 用进程可以对有多少数据可读或有多大空间可用于写进行控制。就是说,如果将接收低潮限 度设置为 64,那么至少有 64 字节的数据可用,这样当遇到小于 64 个字节的数据准备好读时, select 函数就唤醒我们。
5)Select()函数实现I/O多路复用的步骤:
#include
#include
#include
#include
int main(void) {
fd_set rfds;
struct timeval tv;
int retval;
char temp[100];
/* Watch stdin (fd 0) to see when it has input. */
FD_ZERO(&rfds);
FD_SET(0, &rfds);
/* Wait up to five seconds. */
tv.tv_sec = 5;
tv.tv_usec = 0;
retval = select(1, &rfds, NULL, NULL, &tv);
/* Don't rely on the value of tv now! */
if (retval)
{
fgets(temp, 100, stdin);
printf("Data is available now.\n");
}
/* FD_ISSET(0, &rfds) will be true. */
else
{
printf("No data within five seconds.\n");
}
exit(0);
}
2.pselect函数
#include
#include
#include
int pselect (int maxfdp1, fd_set *readset, fd_set * writeset, fd_set * exceptset, const struct timespec * timeout, const sigset_t *sigmask);
Select和pselect都是等待一系列的文件描述符(int)的状态发生变化。
这两个函数基本上是一致,但是有三个区别:
第一点 select函数用的timeout参数,是一个timeval的结构体(包含秒和微秒),然而pselect用的是一个timespec结构体(包含秒和纳秒)
第二点 select函数可能会为了指示还剩多长时间而更新timeout参数,然而pselect不会改变timeout参数
第三点 select函数没有sigmask参数,当pselect的sigmask参数为null时,两者行为时一致的。
函数pselect增加了第六个参数:指向信号掩码的指针。这允许程序禁止递交某些信号,测试由这些当前禁止的信号的信号处理程序所设置的全局变量,然后调用pselect,告诉它临时重置信号掩码。
#include
#include
#include
#include
int main(void) {
fd_set rset;
sigset_t newsig, oldsig;
char temp[100];
FD_ZERO(&rset);
FD_SET(0, &rset);
sigemptyset(&newsig);
sigemptyset(&oldsig);
sigaddset(&newsig, SIGINT);
while (1) {
if (pselect(1, &rset, NULL, NULL, NULL, &newsig) > 0) {
fgets(temp, 100, stdin);
printf("Input string is %s\n", temp);
exit(0);
}
}
}
3.poll函数
#include
int poll (struct pollfd *fdarray , unsigned long nfds , int timeout ) ;
返回:准备好描述宇的个数,0——超时,—1——出错
poll 函数提供了与 select 函数相似的功能,但是当涉及到流设备时,它还提供一些附加 的功能。
第一个参数是指向一个结构数组第一个元素的指针,每个数组元素都是一个 pollfd 结构, 它规定了为测试一给定描述字 fd 的一些条件。下面就是 pollfd 结构:
struct pollfd {
int fd;
short events;
short revent;
};
要测试的条件由成员 events 规定,函数在相应的 revents 成员中返回描述字的状态(每个 描述字有两个变量,一个为调用值,另一个为结果,以此避免使用值—结果参数。回想一下, 函数 select 的中间三个参数都是值——结果参数)。 这两个成员中的每一个都由指定某个条件 的一位或多位组成。下面列出了用于指定标志 events 并测试标志 revents 的一些常值:
其中,前 4 个常值是处理套接口输入的,中间的 3 个是处理输出的,而最后 3 个是处理 错误的,因此只能在 revents 中返回。在流设备中,将数据分为普通(normal)、优先级带和 高优先级 3 种。
对于 TCP 和 UDP 套接口,将引起 poll 返回的 revents 具体化:
而第二个参数,结构数组中元素的个数是由参数 nfds 来规定的。
参数 timeout 同 select 中的 timeout 的功能一样,指定函数返回前等待多长时间,它是一 个指定应等待的毫秒效的正值。有三种情况:大于 0,等待知道数目的时间;等于 0,立即返 回,不阻塞;INFTIM,永远等待。
poll 函数的返回值成功为准备好描述字的个数;出错返回—1;若定时器到,还没有描述 字准备好,则返回 0。如果我们不关心某个描述字,可将其 pollfd 结构中的 fd 成员设为负数。
好像写的东西太多了,本来还想写写其他几种I/O模型实例和套接字选项的。还是到这里结束吧,最后上一个I/O复用实现的并发服务器。
一个谈话程序。双方都可以从终端输入一串字符(以回车结束),通过UDP的方式发送到对方,并显示在对方的终端上。从命令行输入目的地址、目的端口、源地址、源端口。
问题所在:双方都要读取Socket数据和标准输入数据。即调用read和fgets函数。此两个函数都会引起进程阻塞。例如:调用fgets,进程阻塞等待用户输入数据,此时对方向自已发送数据….不能处理!
解决方法,使用I/O复用,进程阻塞到select,当标准输入和socket有数据时返回。
#include
#include
#include
#include
#include
#define BUFLEN 255
#define max(x,y) (((x)>(y))?(x):(y))
int main(int argc,char**argv)
{
struct sockaddr_in peeraddr,localaddr;
int sockfd,n,maxfd,socklen;
char msg[BUFLEN+1];
fd_set infds;
if(argc!=5)
{
printf("%s ,argv[0]);
exit(0);
}
sockfd=socket(AF_INET,SOCK_DGRAM,0);
if(sockfd<0){
printf("socket creating error in udptalk.c\n");
exit(1);
}
socklen=sizeof(struct sockaddr_in);
memset(&peeraddr,0,socklen);
peeraddr.sin_family=AF_INET;
peeraddr.sin_port=htons(atoi(argv[2]));
if(inet_pton(AF_INET,argv[1],&peeraddr.sin_addr)<=0){
printf("wrong dest ip address\n");
exit(0);
}
memset(&localaddr,0,socklen);
localaddr.sin_family=AF_INET;
if(inet_pton(AF_INET,argv[3],&localaddr.sin_addr)<=0){
printf("Wrong source IP address\n");
exit(0);
}
localaddr.sin_port=htons(atoi(argv[4]));
if(bind(sockfd,(struct sockaddr *)&localaddr,socklen)<0){
fprintf(stderr,"bind local address error in udptalk.c\n");
exit(2);
}
connect(sockfd,(struct sockaddr*)&peeraddr,socklen);
for(;;)
{
FD_ZERO(&infds);
FD_SET(fileno(stdin),&infds);
FD_SET(sockfd,&infds);
maxfd=max(fileno(stdin),sockfd)+1;
if(select(maxfd,&infds,NULL,NULL,NULL)==-1)
{
fprintf(stderr,"select error in udptalk.c\n");
exit(3);
}
if(FD_ISSET(sockfd,&infds))
{
n=read(sockfd,msg,BUFLEN);
if((n==-1)||(n==0)){
printf("peer closed\n");
exit(0);
}else {
msg[n]=0;
printf("peer:%s",msg);
}
}
if(FD_ISSET(fileno(stdin),&infds))
{
if(fgets(msg,BUFLEN,stdin)==NULL){
printf("talk over!\n");
exit(0);
}
write(sockfd,msg,strlen(msg));
printf("sent:%s",msg);
}
}
}