我们都知道I/O的速度很慢,电脑的瓶颈很大一部分就在磁盘I/O速度跟不上CPU的处理速度。I/O的部分其实分为两部分,第一步是等待,就是等待数据到来的时候;第二步是数据拷贝。通常来说等待的时间占大头,为了提高I/O的效率就需要减少等待的时间。
五种I/O模型
阻塞I/O:这是最常见的I/O方式,在内核将数据准备好之前,系统调用会一直处于等待状态。网络套接字的默认方式都是阻塞方式。
非阻塞I/O:这种方式不会等待,如果发现内核没有将数据准备好就会直接返回,同时返回EWOULDBLOCK错误。这种方式的I/O需要代码利用循环的方式不停地向文件描述符寻找数据是否到来,这种方式叫做轮询,会占用大量的CPU时间,不推荐使用。
信号驱动I/O:内核在数据准备好的时候,通过信号的方式通知应用程序进行I/O操作,这里需要使用信号。
I/O多路转接:一次性等待多个I/O的数据。看似跟阻塞I/O一样,其中的不同在于I/O多路转接等待的数量是多个。因为是等待多个,其中出现一个I/O数据到达的概率就大大增加,所以这样看起来它的效率是更高的。
异步I/O:应用程序调用另外一个进程来帮忙等待,等数据到达在通知应用程序进行数据拷贝。
高级I/O一般包括这些:非阻塞I/O、记录所、系统V流机制、I/O多路转接、readv和writev函数以及存储映射I/O。
非阻塞I/O
我们使用的网络套接字默认都是阻塞的,但是我们可以通过fcntl函数将其变成非阻塞I/O。
#include <unistd.h>
#include <fcntl.h>
//函数可以将文件描述符设置为非阻塞,这样就算使用阻塞的I/O调用也不会出现阻塞。
int fcntl(int fd, int cmd, .../*arg*/);
//成功返回依赖于cmd,失败返回-1
常见的cmd有以下五种:
- 复制一个已有的文件描述符(F_DUPFD或F_DUPFD_CLOEXEC)
- 获取/设置文件描述符标志(F_GETFD或F_SETFD)
- 获取/设置文件状态标志(F_GETFL或F_SETFL)
- 获取/设置异步I/O所有权(F_GETOWN或F_SETOWN)
- 获取/设置记录锁(F_GETLK、F_SETLK或F_SETLKW)
使用这个函数将文件描述符设置为非阻塞的方法是:先通过cmd = F_GETFL获取到文件描述符的属性,然后再通过F_SETFL设置回去,设置回去的同时加上一个O_NONBLOCK。
下面是一个通过轮询的方法使用非阻塞I/O从标准输入读取数据的例子:
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
void SetNonBlock(int fd){
int fl = fcntl(fd, F_GETFL);
if(fl < 0){
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main(){
SetNonBlock(0);
while(1){
char* buf[1024] = {0};
int num = read(0, buf, sizeof(buf)-1);
if(num < 0){
printf("errno:%d, error_string:%s\n", errno, strerror(errno));
sleep(2);
continue;
}
printf("buf:%s\n", buf);
}
return 0;
}
实验截图:
从实验中可以知道,如果非阻塞的I/O没有读取到消息的时候,返回的errno是11,这个错误码是EAGAIN,表示再试一次,因为没有数据到来,所以再次获取。
select
select是I/O多路转接的一种方式。等待文件描述符的读写异常事件的状态。
select函数原型及参数介绍
#include <sys/select.h>
int select(int nfds, struct fd_set* readfds, struct fd_set* writefds, \
struct fd_set* execptfds, struct timeval *timeout);
参数
nfds:指的是监控的文件描述符最大值+1,加一的原因是文件描述符是从0开始的,但是nfds表示的是个数。该参数的作用是指定select函数在fd_set参数中搜索的范围。
readfds、writefds、execptfds:这三个参数分别表示需要关心文件描述符的读事件、写事件、异常事件,类型是描述符集的指针。类型是fd_set结构体,我们可以将它看做是一个很大的位图。他们三个既是输入型参数也是输出型参数。
同时系统提供了四个函数,让我们方便的使用这三个描述符集。
#include <sys/select.h>
int FD_ISSET(int fd, fd_set* fdset); //如果fd在描述符集中,返回非0,否则返回0
void FD_CLR(int fd, fd_set* fdset);
void FD_SET(int fd, fd_set* fdset);
void FD_ZERO(fd_set* fdset);
这些接口可以实现为宏或者函数。调用FD_ZERO将一个fd_set变量的所有位置为o。我们可以通过FD_SET开启其中一个位,通过FD_CLE清除一个位,通过FD_ISSET判断该位是否被打开。
这里稍微解释一下fd_set结构在函数中的作用:
- 在select中,通过则这三个固定位置的描述符集表示关心fd的什么事件,例如readfds就表示这个描述符集只关心读事件的状态变化,其他的事件都不关心。
- fd_set结构为什么使用位图表示,很简单,因为我们的fd从0开始,同时是一个连续的正整型数字。
- 当fd_set作为输入型参数的时候,他的下标表示对应的文件描述符,下标对应的内容如果是1表示关心这个文件描述符上的某类事件,0表示不关心。
- 当fd_set作为输出型参数的时候,下标对应的内容如果为1,表示关心的该类事件的下标对应的文件描述符已经就绪。
- 正是因为fd_set不但是输入型参数,又是输出型参数,我们需要每次在使用的的是进行清零操作,同时还需要将源数据保存起来,用来下一次使用。
timeout:指的是愿意等待的时间。类型是struct timeval*
,该结构体包括两个参数,一个是秒(tv_sec),一个是微秒(tv_usec)。
该参数的有三种设定方法:
- timeout == NULL;select将会一直阻塞,一直到某事件的就绪或者被信号打断。
- timeout==0;非阻塞状态,检测有没有事件的发生,之后立刻返回。
- timeout>0;等待一定的时间,然后在规定时间内没有事件就绪,超时返回。
返回值,select函数的返回值有三种:
- 如果执行成功,返回事件就绪中的所以文件描述符大的个数。
- 如果返回0,表示timeout超时返回。
- 如果返回-1,表示出错,将错误原因存于errno,此时的readfds、writefds、execptfds的值将没有意义。
- errno的值可能为:EBADF:fd无效或被关闭;EINTR:select调用被信号打断;EINVAL:select参数错误;ENOMEM:内存不够。
常见的使用格式如下:
fd_set set;
FD_ZERO(set);
FD_SET(fd, set);
//表示关心fd上的读事件状态变化,同时通过阻塞的方式。
select(fd+1, &set, NULL, NULL, NULL);
socket的读写就绪
读就绪
- 接收缓冲区中的字节数大于等于低水位标记SO_RCVLOWAT,可以无阻塞的读,返回值大于0
- TCP通信中,对端关闭,可以读,返回值为0
- 监听的socket上出现了新连接
写就绪
- 发送缓冲区中的字节数大于等于低水位标记SO_SNDLOWAT,可以无阻塞的写,返回值大于0
- 对写操作被关闭的socket进行写,触发SIGPIPE信号,该信号默认中断
- socket使用非阻塞connect连接成功或失败之后。
select的特点和缺点
特点
1、 可以监控的文件描述符个数跟fd_set(文件描述符集)的大小有关,sizeof(fd_set)*8就是最大的个数。
2、 select需要一个额外的数组空间进行文件描述符集源的保存,原因有两个:第一个需要保存源,因为描述符集作为输入、输出型参数,如果监听了三个文件描述符的读事件,此时只有前两个就绪,那么第三个就会在返回的时候被清空,此时就失去了源,所以需要额外的空间进行保存。第二个,我们需要用源中设置的监听文件描述符跟返回来的文件描述符进行FD_ISSET判断,用来进行下一步操作。
缺点
1、每次调用select都需要手动的设置fd集合。
2、每次调用的时候,会将fd集合从用户态拷贝到内核态,这样的开销是很大的。
3、需要维护一个额外的空间,用来保护源。
4、select支持的文件描述符是有上限的,这个是硬伤,因为设定了使用输入输出型参数的数组来保存信息。
参考资料
- Unix环境高级编程