最近对IO模型做了深入的学习,参考书籍的章节如下:

Unix系统编程手册 上 2.5节,4,5章
Unix系统编程手册 下 63章
Unix环境高级编程 3,5,14章

首先最基本的是文件IO模型,在这里引出了文件描述符的概念,常用的文件IO函数以及相关的操作文件描述符的函数,比较重要的就是fcntl。基本的文件IO模型都是阻塞型的并且是不带缓冲的,后面就介绍了标准IO模型,即带缓冲的IO模型,也就是在内核进行系统调用再封装了一层缓冲区,并且引入了流的概念。在后面引入了非阻塞型IO模型,以及IO多路复用,比如select,poll,epoll,最后介绍了异步IO模型。

文件IO模型

基本的文件IO函数是不带缓冲的IO,即每个read和write都调用内核中的一个系统调用,即直接在内核中运行。

常用的文件IO函数有open(), close(), lseek(), read(), write()

read系统调用

ssize_t read(int fd, void *buf, size_t nbytes)

如果read成功,则返回读到的字节数,如果已经到达文件的尾端,则返回0。这里的buf是应用程序自己的buffer,实际对文件进行写操作的时候,都会写到内核的缓冲存储器中,然后延迟写。基本上这里的buf和缓冲不是一个概念,这里只是规定了从哪里读或者从哪里写,本质上就是一块存放数据的内存区域。写的时候还是直接对内核的缓冲区进行操作,但是标准IO模型就是在系统调用的上层多了一个缓冲区,所以不带缓冲的IO是对文件描述符进行操作,而带缓冲的标准IO是针对流的。

系统调用不会分配内存缓冲区用以返回信息给调用者,必须预先分配大小合适的缓冲区并且将缓冲区指针传递给系统调用。比如char buf[BUF_SIZE];

write系统调用

ssize_t write(int fd, const void *buf, size_t nbytes)

其返回值通常与参数nbytes的值相同,否则表示出错。出错的一个常见原因就是磁盘已经写满,或者超过了一个给定进程的文件长度限制。

IO预读与延迟写

预读:当检测到正进行顺序读取时候,系统就试图读入比应用所要求的更多数据,并且假想应用会很快读取这些数据。

延迟写:内核中设有缓冲区高速缓存或者页高速缓存,大多数磁盘IO都通过缓冲区进行,向文件中写入数据的时候,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。当内核需要重用缓冲区来存放其他磁盘块数据时候,就会将所有延迟写的数据写入磁盘。

  • sync: 将所有修改过的块缓冲区排入写队列,然后就返回,并不等待实际写磁盘操作结束。
  • fsync: 支队由文件描述符fd指定的一个文件起作用,并且等待写磁盘操作结束后才返回
  • fdatasync: 和fsync一样,但是只影响文件的数据部分,但是fsync还会影响文件的属性。

内核中IO的数据结构

内核使用3种数据结构来表示打开文件。

首先进程表中每个进程表项表示一个进程,其中每个进程表项里面包含了一张打开文件描述符表,这个文件描述符表记录了所有打开的文件描述符。

文件描述符表中每一项包含了文件描述符标志和指向文件表项的指针。

文件表项是存在于内核中的文件表中的,内核为所有打开的文件维护了一个文件表。每个文件表项包含了 文件状态标志(读,写,同步,非阻塞等)当前文件偏移量指向该文件v节点表项的指针

每个打开文件都有一个v节点结构,v节点包含了 文件类型 以及对此文件进行 各种操作函数的指针。v节点还包含了该文件的i节点,即索引节点。这些信息是打开文件时候从磁盘上读入内存的,所以文件的所有信息都是随时可用的。比如i节点包含了文件所有者,文件长度,指向文件实际数据块在磁盘上所在位置的指针。

如果两个进程同时打开了一个文件,那么这两个进程分别有不同的文件表项,但是对一个给定的文件只有一个v节点表项,之所以每个进程获得自己的文件表项,因为这可以使得每个进程都有它自己的对该文件的偏移量。那么lseek这样的函数,修改的是文件表项中的文件偏移量。但是对于写入,修改的是i节点中的当前文件长度信息。

可能有多个文件描述符项指向同一个文件表项,比如dup函数(复制一个文件描述符),fork之后父进程,子进程各自的每一个打开文件描述符共享同一个文件表项。

文件描述符标志,当前只定义了一个文件描述符标志FD_CLOEXEC,设置则表示当程序执行exec函数的时候,fd将被系统自动关闭。设置文件描述符标志只会影响一个文件描述符。

文件状态标志,比如O_RDONLY表示只读打开。可以使用fcntl进行设置,应用于指向该给定文件表项的任何进程中的所有描述符。

复制一个文件描述符

int dup(int fd)

由dup返回的新文件描述符是当前可用文件描述符的最小值。

int dup2(int fd, int fd2)

fd2参数是指定新描述符的值,如果fd2已经打开,则先将其关闭。如果fd等于fd2,那么直接返回fd2,不会关闭。

返回的新文件描述符与参数fd共享同一个文件表项,所以共享同一个文件状态标志与偏移量。但是每一个文件描述符都由其特有的文件描述符标志,新描述符的执行时关闭FD_CLOEXEC会被dup函数清除。

fcntl

int fcntl(int fd, int cmd, ...)

其中fcntl有以下5中功能:

  1. 复制一个已有的描述符,使用cmd为F_DUPFD或者F_DUPFD_CLOSEXEC,前者返回的文件描述符值是大于或者等于第三个参数的最小的值,与fd共享一套文件表项,但是其FD_CLOEXEC会被清除。后者可以设置FD_CLOEXEC的值,然后返回一个文件描述符值。
  2. 获取/设置文件描述符标志,使用cmd为F_GETFD或者F_SETFD,也就是FD_CLOEXEC
  3. 获取/设置文件状态标志,使用cmd为F_GETFL或者F_SETFL,比如O_RDONLY之类的参数,但是设置的范围要比获取范围要小
  4. 获取/设置异步IO所有权,使用cmd为F_GETOWN或者F_SETOWN,前者获取当前接收SIGIO和SIGURG信号的进程ID或者进程组ID,后者设置接收SIGIO和SIGURG信号的进程ID或者进程组ID
  5. 获取/设置记录锁,使用cmd为F_GETLK, F_SETLK或者F_SETLKW

所以在使用fcntl,只要知道打开文件的描述符,就可以修改描述符的属性。

标准IO

标准IO处理了很多细节,比如缓冲区分配,以优化的块长度执行IO等。

流和FILE对象

前面的IO函数是围绕文件描述符的,打开一个文件返回一个文件描述符,然后该文件描述符就用于后面的IO操作。但是标准IO里面,都是围绕流的,使用标准IO库打开或者创建一个文件,就会使得一个流与一个文件相关联。

打开一个流的时候,标准IO函数fopen返回一个指向FILE对象的指针,该对象是一个结构,包含了标准IO库为管理该流需要的所有信息,包括用于实际IO的文件描述符,指向用于该流缓冲区的指针,缓冲区的长度,当前在缓冲区的字符数以及出错标志等。为了引用一个流,需要将FILE指针作为参数传递给每个标准IO函数。

缓冲

提供缓冲的目的是减少read和write系统调用的次数,毕竟在内核中进行系统调用是一个比较耗费资源的事情,必须切换上下文等等。它也对每个IO流自动的进行缓冲管理。分为以下缓冲类型:

输入和输出的缓冲区是在一起

  • 全缓冲:在填满标准IO缓冲区后才进行实际的IO操作,磁盘上的文件使用的是全缓冲。
  • 行缓冲:当输入和输出中遇到换行符的时候,进行IO操作。当流涉及一个终端的时候,使用的是行缓冲。但是只要填满了缓冲区,那么也会进行IO操作。如果要输入数据(从内核),那么会先冲洗所有行缓冲输出流。
  • 不带缓冲:标准错误流stdeer通常是不带缓冲的,这样可以使得出错信息可以尽快显示出来。

int fflush(FILE *fp);

此函数使得该流所有未写的数据都被传送至内核,如果fp是NULL,则此函数会导致所有输出被清洗。

IO多路复用

非阻塞IO

非阻塞IO可以使我们发出open,read和write这样的IO操作,并且使得这些操作不会永远阻塞,如果这种操作不能完成,则调用立即出错返回,表示该操作如继续将会被阻塞。

有两种方法指定一个描述符为非阻塞IO

  1. 调用open获得描述符的时候,指定O_NONBLOCK标志
  2. 对于已经打开的描述符,使用fcntl,由该函数打开O_NONBLOCK文件状态标志

出错对应的标志为EAGAIN,使用这种非阻塞IO进行IO操作,一种方式就是轮询,每次去调用,如果报错则继续询问,直到可以进行IO,所以就有可能进行了很多次系统调用(read, write等),只有少数真正的输出了数据。

这种方式非常消耗以及浪费CPU时间,因此一般会用到基于非阻塞描述符的IO多路复用,也就是对一大堆描述符进行轮询的方式。

当然可以使用阻塞型描述符+多线程的方式来进行,但是线程同步的开销有时会增加复杂性,当然如果每个描述符之间没有什么关系,是可以使用多线程的模式的,这样还简化了程序设计。

IO多路复用,先构造一张感兴趣的描述符列表,然后调用一个函数,直到这些描述符中的一个已经准备好进行IO时候,函数才返回。函数返回的时候,进程会被告知哪些描述符已经准备好可以进行IO。

select

int select(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict tvptr)

  • maxfdp1 指定最大文件描述符编号加1,最大容量限制由常量FD_SETSIZE决定,Linux上,该值是1024
  • readfds 用来检测输入是否就绪的文件描述符集合
  • writefds 用来检测输出是否就绪的文件描述符集合
  • exceptfds 用来检测异常情况是否就绪的文件描述符集合
  • tvptr 愿意等待的时间长度,NULL为永远等待

  • 返回值-1表示出错,比如在没有描述符准备好的时候返回,这样不会修改描述符集合。

  • 返回值0表示没有描述符准备好但是超时了。所有描述符集合都会置为0.
  • 返回正值表示已经准备好的描述符数目。内核会修改相应的描述符集合,就绪的描述符仍旧为1,未就绪的是0.

每次select调用的时候,都需要初始化,将要检查的描述符集合都置为1,传给内核,然后内核检查从0-maxfdp1+1的描述符集合,看是否在readfds,writefds和exceptfds中,是否完成了这些事件,如果完成,则将相应的位置为1,否则为0,直到所有的检查完。检查完之后,将所有描述符集合又传回去,应用程序再检查一遍传回来的描述符集合,已经为1的进行IO操作。然后循环这一过程直到结束。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int main()
{
int sock;
FILE* fp;
struct fd_set fds;
struct timeval timeout = {3, 0}; // select 等待 3 秒,3 秒轮询, 要非阻塞就置 0
char buffer[256] = {0}; // 256 字节的接收缓冲区
/* 假设已经建立 UDP 连接,具体过程不写,简单,当然 TCP 也同理,主机 IP 和 port 都已经给定,要写的文件已经打开 */
sock = socket(...);
bind(...);
fp = fopen(...); */
while(1)
{
FD_ZERO(&fds); // 每次循环都要清空,否则不能检测描述符变化
FD_SET(sock, &fds); // 添加套接字描述符
FD_SET(fp, &fds); // 添加文件描述符
maxfdp = sock>fp ? sock+1 : fp+1; //描述符最大值加1
switch(select(maxfdp, &fds, &fds, NULL, &timeout)) // select 使用
{
case SOCKET_ERROR: exit(-1); break; //select 错误,退出程序
case 0: break; //再次轮询
default:
if(FD_ISSET(sock, &fds)) // 测试sock是否可读,即是否网络上有数据
{
recvfrom(sock, buffer, 256, .... ); //接受网络数据
if(FD_ISSET(fp, &fds)) //测试文件是否可写
fwrite(fp, buffer...); //写入文件
// buffer清空;
} //end if break
} //end switch
} //end while
} //end main

poll

int poll(struct pollfd fdarray[], nfds_t nfds, int timeout)

poll是构造了一个pollfd的数组,每个数组元素可以指定一个描述符编号以及对描述符感兴趣的条件。pollfd结构如下:

1
2
3
4
5
struct pollfd{
int fd;
short events;
short revents;
}

其中events是描述符感兴趣的事件,revents是内核设置的,每个描述符实际发生了什么事件。

nfds是fdarray数组中的元素数目。

所以同样,poll系统调用也需要将感兴趣的描述符集合传给内核,然后内核检查后修改revents后传回来,然后应用程序去检查。和select不同的是,select每次调用前都需要初始化,并且在检查上select必须检查0-maxfdp1+1的描述符集合,而poll只需要检查感兴趣的描述符集合就行了。

在有大量的描述符集合中,select和poll的效率就变得非常低了,所以出现了在Linux上特有的epoll。

libevent库提供了检查文件描述符I/O事件的抽象,移植到了多个Unix系统中,其底层机制可以任意一种I/O技术,select(), poll(), 信号驱动I/O或者epoll

边缘触发和水平触发

  • 水平触发通知: 文件描述符上可以非阻塞地执行I/O系统调用,则就绪时候发送通知
  • 边缘触发通知:文件描述符自从上次状态检查以来有了新的I/O活动,则触发通知

select,poll支持水平触发,信号驱动IO支持边缘触发,epoll两者都支持。

水平触发模式允许在任意时刻重复检查I/O状态,没有必要在就绪后尽可能多的执行IO,因为可以随时检查状态。

但是边缘触发是当I/O事件发生的时候才会收到通知,在另一个I/O事件到来之前是不会收到任何新的通知,而且文件描述符收到I/O事件通知的时候,通常并不知道要处理多少I/O。所以

  1. 在文件描述符接受到I/O事件通知的时候,应该尽可能多的执行I/O,要不然等待下一个I/O事件到来都不知道什么时候了。这样却又会导致其他文件描述符处于饥饿状态。
  2. 如果程序用循环对文件描述符执行尽可能多的I/O,那么每个被检查的文件描述符都应该被设置为非阻塞模式,在得到I/O事件通知后重复执行I/O操作,直到相应的系统调用失败。

epoll

event poll的缩写,也可以检查多个文件描述符上的I/O就绪状态。epoll的核心数据结构为epoll实例,它和一个打开的文件描述符相关联,这个文件描述符是内核数据结构的句柄,实现了两个目的。

  • 记录了进程中声明过的感兴趣的文件描述符列表
  • 维护了处于I/O就绪态的文件描述符列表

epoll API主要有以下三个系统调用:epoll_create()epoll_ctl()epoll_wait()

  • int epoll_create(int size);

创建一个新epoll,对应的兴趣列表为空,size是想要epoll检查的文件描述符的个数。返回值就是创建好的epoll实例的文件描述符。如果这个文件描述符不在需要的时候,使用close()关闭。多个文件描述符可能引用到相同的epoll实例,比如调用了fork()函数或者dup函数,就会导致不同的文件描述符指向同一个文件表项,因此引用到相同的epoll实例。

  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);

修改epoll的兴趣列表:

  • epfd 是epoll实例
  • op 用来指定要执行的操作,有EPOLL_CTL_ADD, EPOLL_CTL_MODEPOLL_CTL_DEL
  • fd 表示要修改兴趣列表中哪一个文件描述符的设定,也可以是一个epoll实例,这样就形成了一种层次关系
  • ev 是指向结构体epoll_event的指针,其中结构体包含了events和data,events是一个位掩码,指定了为待检查的描述符fd上所感兴趣的事件集合。data是一个联合体,当描述符fd成为就绪态的时候,联合体成员可用来指定传回给调用进程的信息。
1
2
3
4
5
6
7
8
9
10
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)

系统调用epoll_wait()返回epoll实例中处于就绪态的文件描述符信息,单个epoll_wait()调用能返回多个就绪态文件描述符的信息。返回就绪的文件描述符的个数,0表示超时,-1表示错误

evlist指向的结构体数组中返回的是有关就绪态文件描述符的信息。所包含的元素个数为maxevents。evlist中,每个元素返回的都是单个就绪态文件描述符的信息。events字段返回了在该描述符上发生的事件掩码。Data字段返回的是在描述符使用epoll_ctl()注册感兴趣事件时候在ev.data中指定的值。data字段是唯一可以获知同这个事件相关的文件描述符的途径。

多线程程序中,可以在一个线程中使用epoll_ctl()将文件描述符添加到另一个线程中由epoll_wait()所监视的epoll实例的兴趣列表中去。这些对兴趣列表的修改将立刻得到处理,而epoll_wait()调用将返回有关新添加文件描述符的就绪信息。

通过epoll_ctl指定了需要监视的文件描述符时候,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行I/O操作使得文件描述符成为就绪态时候,内核就在epoll描述符的就绪列表中添加一个元素。单个打开的文件描述上下文的一次I/O事件可能导致与之相关的多个文件描述符成为就绪态。这样epoll_wait()调用从就绪列表中简单的取出这些元素。epoll在内核空间创建了一个数据结构,该数据结构会将待监视的文件描述符都记录下来。

因此epoll的性能在大规模的文件描述符检查情况下,效率远高于select和poll,常见的能高效使用epoll的应用场景就是需要同时处理许多客户端的服务器,需要监视大量的文件描述符,但大部分处于空闲状态,只有少数文件描述符处于就绪状态。

异步IO

发起IO操作后不阻塞,用户得递一个回调函数待IO结束后被调用。进程请求内核执行一次I/O操作,内核启动该操作之后立即将控制权还给调用进程,稍后当I/O操作完成或者有错误发生的时候,该进程会得到通知。一般是使用信号来实现的。

总结

linux一般使用non-blocking IO提高IO并发度。当IO并发度很低时,non-blocking IO不一定比blocking IO更高效,因为后者完全由内核负责,而read/write这类系统调用已高度优化,效率显然高于一般得多个线程协作的non-blocking IO。

但当IO并发度愈发提高时,blocking IO阻塞一个线程的弊端便显露出来:内核得不停地在线程间切换才能完成有效的工作,一个cpu core上可能只做了一点点事情,就马上又换成了另一个线程,cpu cache没得到充分利用,程序整体性能往往也会随之下降。

而non-blocking IO一般由少量event dispatching线程和一些运行用户逻辑的worker线程组成,这些线程往往会被复用(换句话说调度工作转移到了用户态),event dispatching和worker可以同时在不同的核运行(流水线化),内核不用频繁的切换就能完成有效的工作。线程总量也不用很多,所以对thread-local的使用也比较充分。这时候non-blocking IO就往往比blocking IO快了。不过non-blocking IO也有自己的问题,它需要调用更多系统调用,比如epoll_ctl,由于epoll实现为一棵红黑树,epoll_ctl并不是一个很快的操作,特别在多核环境下,依赖epoll_ctl的实现往往会面临棘手的扩展性问题。non-blocking需要更大的缓冲,否则就会触发更多的事件而影响效率。non-blocking还得解决不少多线程问题,代码比blocking复杂很多。