I/O模型
通用I/O模型
所有执行I/O操作的系统调用都以文件描述符,一个非负整数(通常是小整数),来指代打开的文件。
文件描述用以表示所有类型的已打开文件,包括管道、FIFO、socket、终端、设备和普通文件
通用I/O
UNIX/IO模型的显著特点之一是其输入/输出的通用性概念。这意味着使用4个同样的系统调用open()、read(), write()和close()可以对所有类型的文件执行I/O操作
要实现通用I/O,就必须保证每一文件系统和设备驱动程序都实现了相同的I/O系统调用
传统阻塞式I/O
大部分程序使用的I/O模型都是单个进程每次只在一个文件描述符上执行I/O操作,每次I/O系统调用都会阻塞,直到完成数据传输
磁盘文件是个特例:内核采用缓冲区cache来加速磁盘I/O请求,因而一旦情感求的数据传输到内核的缓冲区cache,对磁盘的write()操作将立即返回。因而不用等到数据实际写入磁盘后才会返回(除非在打开文件时指定了O_SYNC标志)。
与之对应的是,read()调用将数据从内核缓冲区cache移动到用户的缓冲区,如果请求的数据不在内核缓冲区cache,那么内核就会让进程休眠,同时执行对磁盘的读写操作
非阻塞I/O
在打开文件时,指定O_NONBLOCK标志
若open()调用未能立即打开文件,则返回错误,而不是陷入阻塞。(有一种情况属于例外,调用open()操作FIFO可能会陷入阻塞)
调用open()成功后,后续的I/O操作也是非阻塞的。若I/O系统调用未能立即完成,则可能只会传输部分数据或系统调用失败,并返回EAGAIN 或EWOULDBLOCK错误
管道、FIFO、套接字、设备都支持非阻塞模式。因为无法通过open()来获取管道和套接字的文件描述符,所以要启用非阻塞标识,就必须使用fcntl()的F_SETFL命令
fcntl():针对一打开的文件,获取或修改其访问模式和状态标识(这些值是通过open()调用的flag参数设置的) fcntl()的F_SETFL命令:修改打开文件的某些状态标识。允许更改的标识有O_APPEND, O_NONBLOCK, O_NOATIME, O_ASYNC和O_DIRECT
使用fcntl()修改文件的状态标识,适用于如下场景
- 文件不是由调用程序打开的,所以程序也无法使用open()调用来控制文件的状态标识
- 文件描述符的获取是通过open()之外的系统调用,比如pipe(), socket()等
为了修改文件的状态标识,可以使用fcntl()的F_GETFL命令来获取当前标志的副本,然后修改需要变更的比特位,最后再次调用fcntl()的F_SETFL命令来更新此状态标志
int flags;
flags = fcntl(fd, F_GETFL);
if(flags==-1)
erExit("fcntl");
flags |= O_APPEND;
if(fcntl(fd, F_SETFL, flags)==-1)
errExit("fcntl");
非阻塞式I/O+多进程(多线程)
以非阻塞的方式检查文件描述符上是否可以进行I/O操作
同时检查多个文件描述符,看他们中的一个是否可以执行I/O操作 非阻塞式I/O可以让我们周期性的检查(“轮询”)某个文件描述符上是否可以执行I/O操作。例如,我们让一个输入文件描述符成为非阻塞式的,然后周期性的执行非阻塞的读操作。
如果我们需要同时检查多个文件描述符,那么就需要将他们都设为非阻塞的,然后依次对它们轮询。(浪费CPU)
如果不希望进程在堆文件描述符执行I/O操作时被阻塞,我们可以创建一个新的进程来执行I/O,此时父进程就可以去处理其他任务,而子进程将阻塞直到I/O操作完成。这种方案开销昂贵且复杂
使用多线程,占用较少的资源。但线程之间仍然需要通信,以告知其他线程有关I/O操作的状态,这使变成变得复杂
可选的备选方案:
- IO多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否可以执行IO操作,selct()和poll()
- 信号驱动IO是指当地有输入或数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。进程可以处理其他任务,当IO操作可执行时通过接收信号来获得通知
- epoll API是linux专有的特性,允许进程同时检查多个文件描述符,看其中任意一个是否能执行IO操作。当同时检查大量文件描述符时,epoll能提供更好的性能
以上都是实现同一个目标的技术----同时检查多个文件描述符,看他们是否准备好了执行IO操作
这些技术都不会执行实际的IO操作。他们只是告诉我们某个文件描述符已经处于就绪状态了,这时需要调用其他的系统调用来完成实际的IO操作
文件描述符的就绪状态的转化是通过一些IO时间来触发的,比如输入数据到达,套接字建立完成等。
异步IO(AIO)
允许进程将IO操作排列到一个文件中,当操作完成后得到通知。AIO的有点在于最初的IO调用将立刻返回,因此进程不会一直等待数据传输到内核或者等待操作完成。这使得进程可以通IO操作一起并行处理其他的任务
边缘触发和水平触发
水平触发通知:如果文件描述符上可以非阻塞的执行IO系统调用,此时认为它已经就绪
边缘触发通知:如果文件描述符自上次状态检查以来有了新的IO活动,此时需要触发通知
当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态,当文件描述符处于就绪状态是,就可以对它执行IO操作。然后重复检查文件描述符。水平触发模式允许我们在任意时刻重复检查IO的状态,没有必要每次当文件描述符就绪时尽可能多的执行IO
边缘触发,只有当IO事件发生时我们才会收到通知。当文件描述符收到IO事件通知时,通常并不知道要处理多少IO(例如多少字节可读),因此需要考虑如下设计规则:
- 在收到一个IO事件通知后,应该在响应的文件描述符上尽可能多的执行IO(如果仅对一个文件描述符执行大量IO操作,肯呢个会让其他文件描述符处于饥饿状态)
- 每个被检查的文件描述符应该设置为非阻塞模式
IO多路复用
允许我们同时检查多个文件描述符,看其中任意一个是否可执行IO。select()和poll() 这两个系统调用都允许进程要么一直等待我那件描述符成为就绪状态,要么在调用中指定一个超时时间
select()
select()系统调用会一直阻塞,直到一个或多个文件描述符集合成为就绪态
#include <sys/time.h>
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval*timeout);
//readfds,writefds, execfds指定了select()要检查的文件描述符集合
//nfds必须设为比3个文件描述符集合中所包含的最大文件描述符号还要大1 ,该参数让select更有效率,因为此时内核就不用去检查大于这个值的文件描述符是否属于这些集合
fd_set以位掩码的形式来实现。所有有关文件描述符集合的操作都是通过四个宏完成:
FD_ZERO() FD_SET(), FD_CLR() , FD_ISSET()
void FD_ZERO(fd_set *fdset); //将fdset所指向的集合初始化为空
void FD_SET(int fd, fd_set *fdset); //将fd添加到fdset所指向的集合
void FD_CLR(int fd, fd_set *fdset); //将fd从fdset所指向的集合中移除
int FD_ISSET(int fd, fd_set *fdset); //fd在fdset返回1,否则返回0
readfds,writefds, execfds所指向的结构体都是保存结果值的地方。在调用select()之前。这些参数指向的结构体必须初始化。之后select()调用会修改这些结构体,当selct()返回时,他们包含的就是已处于就绪太的文件描述符集合了(由于这些结构体会在调用中被修改。如果要在循环中重复调用select()必须保证每次都要重新初始化他们)
如果对某一类型的事件不感兴趣,那么相应的fd_set参数可以指定为NULL
timeout控制着select的阻塞行为,该参数为NULL时,select会一直阻塞
struct timeval{
time_t tv_sec;
suseconds_t tv_usec;
} ;
如果结构体timeval的两个域都为0的话,select()不会阻塞,它只是简单轮询指定的文件描述符集合,看其中是否有就绪的文件描述符并立刻返回
当timeout为NULL,或其字段非0时,select()将阻塞直到有下列事件发生:
- readfds,writefds或 exceptfds中指定的文件描述符中至少有一个成为就绪态
- 该调用被信号处理列程中断
- timeout中指定的时间上限已超时
select()的返回值:
- -1表示有错误发生
- 0表示在任何文件描述符成为就绪态之前select()调用已超时。这种情况下每个返回的文件描述符集合将被清空
- 正数表示有1个或多个文件描述符已达到就绪态,返回值表示处于就绪态的文件描述符个数。在这种情况下,每个返回的文件描述符集合都需要检查(通过FD_ISSET())以找出发生的IO事件是什么
//t_select.c
#include <sys/time.h>
#include <sys/select.h>
#include <stdio.h>
static void usageerror(const char* programNane){
fprintf(stderr, "Usage: %s {timeout|-} fd-num[rw]...\n", programNane);
exit(-1);
}
int main(int argc, char*argv[]){
fd_set readfds, writefds;
int ready, nfds, fd, numRead, j;
struct timeval timeout;
struct timeval *pto;
char buf[10];
if(argc<2){
usageerror(argv[0]);
}
if(strcmp(argv[1], "-")==0){
pto=NULL;
}else{
pto = &timeout;
timeout.tv_sec = atoi(argv[1]);
timeout.tv_usec = 0;
}
nfds = 0;
FD_ZERO(&readfds);
FD_ZERO(&writefds);
for(j=2; j<argc; j++){
numRead = sscanf(argv[j], "%d%2[rw]", &fd, buf);
if(numRead!=2)
usageerror(argv[0]);
if(fd>FD_SETSIZE){
printf("fd error.\n");
return -1;
}
if(fd>=nfds)
nfds = fd+1;
if(strchr(buf, 'r')!=NULL)
FD_SET(fd, &readfds);
if(strchr(buf, 'w')!=NULL)
FD_SET(fd, &writefds);
}
ready = select(nfds, &readfds, &writefds, NULL, pto);
if(ready==-1){
printf("error select. \n");
return -1;
}
printf("ready = %d\n", ready);
for(fd=0; fd<nfds; fd++){
printf("%d: %s%s\n", fd, FD_ISSET(fd, &readfds) ? "r":"", FD_ISSET(fd, &writefds)?"w":"");
}
if(pto!=NULL){
printf("timeout after select(): %ld.%03ld\n", (long)timeout.tv_sec, (long)timeout.tv_usec/10000);
}
return 0;
}
poll()
poll执行的任务同select相似,两者主要区别在于我们要如何指定待检查的文件描述符。在select()中我们提供三个集合,在每个集合中标明我们感兴趣的文件描述符。而在poll()中我们提供一系列文件描述符,并在每个文件描述符上标明我们感兴趣的事件
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
//参数fds列出了我们需要poll()来检查的文件描述符
struct pollfd{
int fd;
short events; //指定需要为描述符fd做检查的事件
short revents;//poll返回时,revents被设定一次来表示该文件描述符上实际发生的事
};
//nfds制定了数组fds中元素的个数
#include <time.h>
#include <poll.h>
int main(int argc, char*argv[]){
int numPipes, j, ready, randPipe, numWrites;
int (*pfds)[2];
struct pollfd *pollFd;
if(argc<2){
printf("argc error \n");
return -1;
}
numPipes = atoi(argv[1]);
pfds = calloc(numPipes, sizeof(int[2]));
if(pfds==NULL){
printf("calloc error.\n");
return -1;
}
pollFd = calloc(numPipes, sizeof(struct pollfd));
if(pollFd==NULL){
printf("pollfd calloc error.\n");
return -1;
}
for(j=0; j<numPipes; j++){
if(pipe(pfds[j])==-1){
printf("pipe error.\n");
return -1;
}
}
numWrites = (argc>2)?atoi(argv[2]):1;
srandom((int)time(NULL));
for(j=0; j<numWrites; j++){
randPipe = random()%numPipes;
printf("Writing to fd: %3d (read fd: %3d)\n", pfds[randPipe][1], pfds[randPipe][0]);
if(write(pfds[randPipe][1], "a", 1)==-1){
printf("write error %d\n", pfds[randPipe][1]);
}
}
for(j=0; j<numPipes; j++){
pollFd[j].fd = pfds[j][0];
pollFd[j].events = POLLIN;
}
ready = poll(pollFd, numPipes, -1);
if(ready==-1){
printf("poll err \n");
return -1;
}
printf("poll() returned %d\n", ready);
//check which piipes have data available for reading
for(j=0; j<numPipes; j++){
if(pollFd[j].revents & POLLIN)
printf("Readable: %d %3d \n", j, pollFd[j].fd);
}
return 0;
}
文件描述符何时就绪
普通文件: 代表普通文件的文件描述符总是被select标记为可读的和可写的;对于poll()来说,则会在revents字段中返回POLLIN和POLLOUT标志
套接字
条件或事件 select() poll()
有输入 r POLLIN
可输出 w POLLOUT
在监听套接字上建立连接 r POLLIN
接收到带外数据(只限TCP) x POLLPRI
流套接字的对端关闭连接 rw POLLIN|POLLOUT|
或执行力shutdown(SHUT_WR) POLLRDHUP
poll()和select()区别
select()所使用的数据类型fd_set对于被检查的文件描述符数量有一个上限限制(FD_SETSIZE).在linux下,这个上限值默认为1024,修改这个上限需要重新编译应用程序。与之相反,poll()对于被检查的文件描述符数量本质上是没有限制的
由于select()的参数fd_set同时也是保存调用结果的地方,如果要在循环中重复调用select()的话,必须每次重新初始化fd_set, poll()通过两个独立字段events和revents,避免每次都要重新初始化参数
如果其中一个被检查的文件关闭了,通过在对应的字段revents设定POLLNVAL标记,poll()会准确告诉我们是哪一个文件描述符关闭了。select()只会返回-1,并设置错误码为EBADF
poll()和select()存在的问题
每次调用poll()和selct(),内核都必须检查所有被指定的文件描述符,看他们是否处于就绪态
每次调用select()和poll(),程序都必须传递一个表示所有需要被检查的文件描述符的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序。当检查大量文件描述符时,从用户空间到内核空间来回拷贝这个数据结构将占用大量CPU时间
select()或poll()调用完成后,程序必须检查返回的数据结构中的每个元素,以查明是哪个我呢间描述符处于就绪态
信号驱动IO
当文件描述符上可执行IO操作时,进程请求内核为自己发送一个信号。之后进程就可以执行任何其他的任务直到IO就绪为止,此时内核会发送信号给进程
要使用信号驱动IO,程序需要按照如下步骤执行:
- 为内核发送的通知信号安装一个信号处理例程。默认情况下,这个通知信号为SIGIO
- 设定文件描述符的属主,也就是当文件描述符上可执行IO时会接收到通知信号的进程或进程组。通常我们让调用进程成为属主。设定属主可以通过fcntl()的F_SETOWN操作来完成
- 通过设定O_NONBLOCK标志使能非阻塞IO
- 通过打开O_ASYNC标志使能信号驱动IO。这可以和上一步合并,因为都使用fcntl()的F_SETFL flags = fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags|O_ASYNC | O_NONBLOCK);
- 调用进程现在可以去执行其他任务了。当IO操作就绪时,内核为进程发送一个信号,然后调用在步骤1中安装的信号处理例程
- 信号驱动IO提供的是边缘触发通知。这表示一旦进程被通知IO就绪,它就应该尽可能多的执行IO系统调用。假设文件描述符是非阻塞的,这表示需要在循环中执行IO系统调用直到失败为止
何时发送“IO就绪”信号
套接字:
一个输入数据到达套接字
套接字上发生异步错误
套接字上监听到了新的连接
.......
信号驱动性能高是因为内核可以“记住”要检查的文件描述符,且仅当IO事件实际发生在这些文件描述符上时才会向程序发信号
必须执行以下步骤:
- 通过专属于linux的fcntl() F_SETSIG操作赖指定一个实时信号,当文件描述符上的IO就绪时,这个实时信号应该取代SIGIO被发送
- 使用sigaction()安装信号处理例程,为步骤1中适应的实时信号指定SA_SIGINFO标记 fcntl()的F_SETSIG操作指定一个可选的信号,当问阿金描述符上的IO就绪时会取代SIGIO信号被发送
epoll编程接口
linux的epoll API可以检查多个文件描述符上的IO就绪状态
epoll API的核心数据结构被称作epoll实例,它和一个打开的文件描述符相关联。这个文件描述符不是用来做IO操作的,它是内核数据结构的句柄
- 记录在进程中声明过的感兴趣的文件描述符列表--interest list
- 维护了处于IO就绪态的文件描述符列表--ready list 对于epoll检查的每一个文件描述符,我们可以指定一个位掩码来表示我们感兴趣的事件
epoll API 系统调用
epoll_create()创建一个epoll实例,返回代表该实例的文件描述符
epoll_ctl()操作同epoll实例相关联的感兴趣列表。通过epoll_ctl(),我们可以增加新的描述符到列表,将已有文件描述符从列表删除,以及修改代表文件描述符事件类型的位掩码
epoll_wait()返回与epoll实例相关联的就绪列表中的成员
#include <sys/epoll.h>
int epoll_create(int size);
//size指定我们想要通过epoll实例来检查的文件描述符的个数
//函数返回值:新创建的epoll实例的文件描述符
//2.6.27, linux支持一个新的系统调用epoll_create1()
int epoll(int epfd, int op, int fd, struct epoll_event *ev);
//fd指明要修改感兴趣列表中的哪一个文件描述符,这个fd不能作为普通文件或目录的文件,脚舒服,可以是管道、FIFO、套接字等
//op用来指定需要执行的操作
EPOLL_CTL_ADD:将fd添加到epoll实例epfd中的感兴趣列表中(ev所指向的结构体)
EPOLL_CTL_MOD: 修改描述符fd上设定的事件,需要用到ev所执行的结构体中的信息
EPOLL_CTL_DEL:将fd从epfd的兴趣列表中移除
//ev是指向结构体epoll_event的指针
struct epoll_event {
uint32 events;
epoll_data_t data;
};
typedef unin epoll_data{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
参数ev为文件描述符fd所做的设置如下:
- 结构体epoll_event中的events字段是一个位掩码,它指定了待检查的描述符fd上所感兴趣的事件集合
- data是一个联合体,当描述符fd成为就绪态后,联合体的成员可以用来指定传回给调用进程的信息
int epfd;
struct epoll_event ev;
epfd = epoll_create(5);
if(epfd==-1)
errExit("epoll_create");
ev.data.fd = fd;
ev.events = EPOLLIN;
if(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev)==-1)
errExit("epoll_ctl");
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
//返回ready的文件描述符数量, 0:超时, -1:error
evlist所指向的结构体数据中返回的是有关就绪态文件描述符的信息。数组evlist的空间由调用者负责,所包含元素个数在参数maxevents中指定
在数组evlist中,每个元素返回的都是单个就绪态文件描述符的信息。events字段返回了在该描述符上已经发生的事件掩码,data字段返回的是我们在描述符上使用epoll_ctl()注册感兴趣事件时再ev.data中所指定的值
注意:data字段是唯一可获知同这个事件相关的文件描述符的途径。因此,当我们调用epoll_ctl()将文件描述符添加到兴趣列表中时,应该要么将ev.data.fd设为文件描述符号,要么将ev.data.ptr设位指向包含文件描述符号的结构体
timeout:
-1: 调用一直阻塞,知道感兴趣列表中的文件描述符上有事件产生,或者知道捕获一个信号为止
0: 执行一个非阻塞式检查,看感兴趣列表红的文件描述符上产生了哪个事件
>0: 调用将阻塞timeout毫秒,直到文件描述符上有事件发生,或者知道补货到一个信号为止
epoll事件
//ev.events中指定的位掩码,evlists[].events中的值
位掩码 作为epoll_ctl()的输入 作为epoll_wait()返回 描述
EPOLLIN * * 可读取非高优先级数据
EPOLLPRI * * 可读取高优先级数据
EPOLLRDHUP * * 套接字对端关闭
EPOLLOUT * * 普通数据可写
EPOLLET * 采用边缘触发时间通知
EPOLLONESHOT * 在完成事件通知后禁用检查
EPOLLERR * 有错误发生
#include <sys/epoll.h>
#include <fcntl.h>
#include <stdio.h>
#define MAX_BUF 1000
#define MAX_EVENTS 5
int
main(int argc, char *argv[])
{
int epfd, ready, fd, s, j, numOpenFds;
struct epoll_event ev;
struct epoll_event evlist[MAX_EVENTS];
char buf[MAX_BUF];
if (argc < 2 || strcmp(argv[1], "--help") == 0){
printf("argc err.\n");
return -1;
}
epfd = epoll_create(argc - 1);
if (epfd == -1){
printf("epoll_create err.\n");
return -1;
}
for (j = 1; j < argc; j++) {
fd = open(argv[j], O_RDONLY);
if (fd == -1){
printf("open err.\n");
return -1;
}
printf("Opened \"%s\" on fd %d\n", argv[j], fd);
ev.events = EPOLLIN; /* Only interested in input events */
ev.data.fd = fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1){
printf("epoll_ctl err.\n");
return -1;
}
}
numOpenFds = argc - 1;
while (numOpenFds > 0) {
/* Fetch up to MAX_EVENTS items from the ready list of the
epoll instance */
printf("About to epoll_wait()\n");
ready = epoll_wait(epfd, evlist, MAX_EVENTS, -1);
if (ready == -1) {
if (errno == EINTR)
continue; /* Restart if interrupted by signal */
else{
printf("epoll_wait err.\n");
return -1;
}
}
printf("Ready: %d\n", ready);
for (j = 0; j < ready; j++) {
printf(" fd=%d; events: %s%s%s\n", evlist[j].data.fd,
(evlist[j].events & EPOLLIN) ? "EPOLLIN " : "",
(evlist[j].events & EPOLLHUP) ? "EPOLLHUP " : "",
(evlist[j].events & EPOLLERR) ? "EPOLLERR " : "");
if (evlist[j].events & EPOLLIN) {
s = read(evlist[j].data.fd, buf, MAX_BUF);
if (s == -1){
printf("read err.\n");
return -1;
}
printf(" read %d bytes: %.*s\n", s, s, buf);
} else if (evlist[j].events & (EPOLLHUP | EPOLLERR)) {
printf(" closing fd %d\n", evlist[j].data.fd);
if (close(evlist[j].data.fd) == -1){
printf("close err.\n");
return -1;
}
numOpenFds--;
}
}
}
printf("All file descriptors closed; bye\n");
return 0;
}
epoll
当我们通过epoll_create()创建一个epoll实例时,内核在内存中创建了一个新的i-node并打开文件描述,随后在调用进程中位打开的这个文件描述分配一个新的文件描述符。epoll实例的感兴趣列表相关联的是打开的文件描述,而不是epoll文件描述符。
如果我们使用dup()复制一个epoll文件描述符,那么背复制的描述符所指代的epoll兴趣列表和就绪列表同原始epoll文件描述符相同。若要修改兴趣列表,在epoll_ctl()的参数epfd上设定文件描述符可以时原始的也可以是复制的 fork()调用之后,此时子进程通过继承复制了父进程的epoll文件描述符,而这个复制的文件描述符所知悉那个的epoll数据结构同原始的数据结构相同
当我们执行epoll_ctl()的EPOLL_CTL_ADD操作时,内核在epoll兴趣列表中添加了一个元素,这回元素同事记录了需要检查的文件描述符数量以及对应的打开文件描述符的引用。 epoll-wait()调用的目的就是让内核负责监视打开的文件描述