手拿放大镜深究文件I/O

2022-08-12 12:05:27 浏览数 (1)

linux系统下一切皆文件,我们几乎无时无刻不在跟文件打交道。内核对文件I/O做了很好的封装,使得开发人员便捷地操作文件,但也因此隐藏了很多细节。如果对其不求甚解,在实际开发中可能会碰到一些意想不到的问题。这次,让我们手拿放大镜,一起窥探文件I/O的全貌。

1. 文件件描述符

内核会为每个进程维护一个打开文件的列表,该列表称为文件表。文件表是由一些非负整数进行索引,这些非负整数称为文件描述符(file descriptor 简称fd)。列表的每一项是一个打开的文件的信息,包括指向该文件索引节点(inode)内存拷贝的指针以及关联的元数据(如文件位置指针和访问模式)[1]。图1给出了文件描述符和文件的对应关系。

默认情况下,当通过fork创建子进程时,子进程会维护一份父进程的文件表副本。在该副本中,打开文件列表及其访问模式、当前文件位置以及其他元数据,都和父进程的文件表相同。只在一个地方有区别,即当子进程关闭一个文件时,不会影响父进程的文件表。

图1 文件描述符和文件的对应关系

从图1可以得到以下几点信息。

  1. 内核为每个进程都维护了一个文件表,文件表在底层是一个数组,索引从0开始,索引即为文件描述符,几乎所有对文件的操作均以文件描述符作为基本参数。在linux系统下,每个进程可打开的文件数是有上限的,默认上限值是1024。

2. 每个文件描述符都对应一个文件指针,该指针指向系统中已经打开的文件信息,该文件信息也是一个数组,打开的文件信息包括文件偏移量、状态标志以及inode指针。

3. inode指针指向系统中的inode表,inode表中的每个数据项都对应一个具体的磁盘文件。

4. 同一个进程内可以有两个文件指针指向同一个打开的文件信息(如进程A中fd=11和fd=12,这种情况表示,同一个进程下开启了两个线程,而这两个线程打开了同一个文件)。

5. 不同进程下可以有两个文件指针指向同一个打开的文件信息(如进程A中fd=18和进程B中的fd=0),有三种可能造成这种结果

  • 进程B是进程A的子进程,子进程会维护父进程文件表副本
  • 进程B和进程A打开了同一个文件,且分配的文件描述符相同
  • 进程A通过socket将一个打开的文件描述符传递给进程B

6. 打开的文件信息包括文件偏移量和状态标志,文件偏移量跟读写文件有关,下文会有详细介绍,状态标志标识当前文件的状态,图中给出了只读、只写、读写三种模式。

另外值得一提的是,每个进程都至少包含三个文件描述符:0、1和2,分别表示标准输入(sdtin)、标准输出(sdtout)和标准错误(sdterr)。

2. 文件的读写

上一章介绍了文件描述符的概念,从这一章开始,讨论文件最基本的两个操作:文件的读写,也即文件I/O。

2.1. 打开/关闭文件

linux系统下一切皆文件,而对文件的所有操作都需要打开文件,所有操作结束之后都需要关闭文件,否则会出现预期之外的错误。

2.1.1. open

打开文件的系统函数是open。

代码语言:javascript复制
int open (const char* name, int flags);
int open (const char* name, int flags, mode_t mode);

这里重点介绍第二个重载的open函数。open函数成功调用会返回打开的文件描述符。参数name是待打开的文件名,flags表示文件的访问模式,当创建文件时,mode提供了新建文件的权限,比如0644(文件所有者可以读写,其他人只能读)。open函数中最复杂的参数是flags,这里列举一些linux系统下常用的取值(O_REONLY、O_WRONLY、O_RDWR上文已介绍且较为简单,这里不再列举)。

取值

含义

O_APPEND

文件以追加模式打开,每次写操作之前,将更新文件位置指针,指向文件末尾。

O_ASYNC

当指定的文件可读可写时,会产生一个信号。该标志位只适用于FIFO、管道、socket和终端,不适用普通文件(因为普通文件大多是可读可写的)

O_CREAT

当参数name指定的文件不存在时,内核自动创建

O_SYNC

打开文件用于同步I/O。在数据写到磁盘此前,写操作都不会完成(下文会对写文件进行详细说明)

O_NONBLOCK

文件以非阻塞模式打开。不管是open调用还是其他调用,都不会导致进程在I/O中阻塞。

2.1.2. close

相比于open函数,close就简单直接得多。

代码语言:javascript复制
int close (int fd);

close函数会取消当前进程文件描述符fd与其关联的文件之间的映射。值得一提的是,关闭文件并不意味着文件的数据已经写到磁盘,如果希望这么做,在打开文件时可指定O_SYNC模式。

2.1.3. creat

当指定的文件不存在时,open函数的flags可以指定为O_CREAT进行创建,linux有个系统调用支持这个功能

代码语言:javascript复制
int creat (const char* name, mode_t mode);

creat函数中mode参数含义与open相同,成功时,返回新建的文件对应的文件描述符。

2.2. 通过read读文件

上文介绍了文件I/O的准备工作,从这一节开始,就要正式进入文件I/O的主题。

文件操作涉及到的最基础、最常见的系统调用是read和write。在此之外,也有高级读写函数,将在2.6节介绍。

代码语言:javascript复制
ssize_t read (int fd, void *buf, size_t len);

每次调用read函数,会从fd指向的文件当前偏移开始读取len字节到buf所指向的内存中。调用成功后,fd的偏移量会移动,移动的长度由读取到的字节数决定。

调用read函数可能出现的结果如下:

1. 返回值等于len。表示读取的所有len个字节被存储到buf中,是正常的情况。

2. 返回值小于len,大于0。出现这种情况有2种原因。

  • 读取到文件末尾,不够len个字节。
  • 读取过程中信号中断或者读取中出错,可读数据大于0但小于len。

3. 返回值为0。表示刚好读取到文件末尾。

4. 阻塞。由于当前没有数据可读,调用阻塞。这种情况一般是读取socket文件。

5. 返回值为-1。表示出现错误,而错误原因非常多,有些重试可以解决,有些是致命的错误,即使重试也不会成功。

2.3. 通过write写文件

代码语言:javascript复制
ssize_t write(int fd, const void *buf, size_t count);

write调用会从文件描述符fd指向的文件当前偏移开始,将buf中至多count个字节写入到文件中。调用成功后,fd的偏移量会移动,移动的长度由写入的字节数决定。

相比于read函数,调用write函数的结果就简单一些。

1. 返回值-1,表示出错。

2. 返回值0,没有什么特殊含义,只是表示写入了零个字节。

3. 返回值大于0(记为n),表示写入了n个字节的数据,同时偏移量往后移动n个字节。

2.4. 文件偏移量

上文反复强调文件的偏移量,它表示对当前文件进行操作的位置,相当于window系统打开文件光标所在处。一般情况下,文件的读写不需要手动修改文件偏移量(即I/O是线性的),如果某些应用需要跳跃读取文件内容(即随机I/O),那么就需要更新文件偏移量。linux系统提供lseek系统调用支持该功能。

代码语言:javascript复制
off_t lseek(int fd, off_t pos, int origin);

lseek最常见的用法是将指针定位到文件开始、末尾或确定文件描述符的当前文件位置。

代码语言:javascript复制
lseek(fd, (off_t)1250, SEEK_SET); // 将偏移量设置为1250
lseek(fd, 0, SEEK_SET); // 将偏移量设置为文件开头
lseek(fd, 0, SEEK_END); // 将偏移量设置为文件末尾
lseek(fd, 0, SEEK_CUR); // 确定当前文件位置

2.5. 同步I/O与页回写

数据只有在被写入磁盘中才算写入成功,但是进程又无法直接读写磁盘,只能借由内核提供的系统调用函数进行操作,上文所述的write就是linux内核提供给用户空间写磁盘的系统函数,那么,write函数执行成功后,数据真的写到磁盘了吗。答案是,未必。

2.5.1. 文件写流程

图2 文件写流程

图2给出了文件写的流程。

① 进程发起写请求,调用write系统函数;

② write函数更新内核空间中的页缓存,此次写请求需要更新两个页,分别是页缓存1和页缓存2(此时,页缓存中的数据和磁盘中的数据不一致,称这样的页为“脏页”);

③ 内核空间页缓存更新完毕,向进程返回成功。注意此时并未将页缓存中的数据刷新到磁盘文件;

④ 进程收到write返回成功,继续其他操作处理。一段时间后,发起读请求,调用read系统函数;

⑤ 内核空间收到read调用请求,发现需要读取的数据部分落在页缓存2和页缓存3中(称为读缓存命中),读取其内容;

⑥ read请求发现数据不完全在页缓存2和页缓存3中,于是读取磁盘文件1,并将文件中需要读取的数据放到页缓存3中;

⑦ 系统调用read将读取到的数据组装好返回给进程;

⑧ 满足一定条件时,内核空间将页缓存1和页缓存2中的数据刷新到磁盘。

从上图中可以看到,linux系统在进程发起write系统调用时,只是将数据写入内核缓冲区中的页缓存即返回,将内核空间页缓存中的数据刷新到磁盘(步骤⑧ )是异步的。这一点,与我们平常的认知是有些出入的,这一操作称为页回写。

2.5.2. 页回写

上文中提到,write函数调用并不会同步刷磁盘,而是等到一定时机后再执行,这个过程叫页回写,这里,着重讨论三个问题。

2.5.2.1. linux为什么要使用页回写机制

引入页回写机制一定是因为该机制对提升性能有较大的帮助。从图1中的流程可以看到,相比于同步刷盘,引入页缓存/页回写机制,能显著提高write/read的性能。众所周知,相比于内存,磁盘的IO是非常慢的,通过页缓存,write系统调用几乎不需要写入磁盘,read系统调用在缓存命中时也不需要读取磁盘,I/O的性能得到了质的提升,而且还不会修改读写语义(写完之后能立刻读到写的内容)。

2.5.2.2. 什么情况下会执行页回写刷盘

相信通过前文的介绍,读者这里也大约能够猜到页回写的时机,一个是时间维度,一个是空间维度。

  • 时间维度 当内核缓冲区中某一个“脏页”存在的时长超过设定的阈值时,该“脏页”回写磁盘。该参数是/proc/sys/vm/dirty_expire_centisecs,单位是厘秒,默认值是3000,也就是30s,可以看到,“脏页”在内核缓冲区的最大缓存时效还是比较长的。
  • 空间维度 当内核缓冲区中“脏页”的数量达到一定比例,或者说空闲缓冲页比率小于设定的阈值时,“脏页”回写磁盘,回收缓冲页。该参数是/proc/sys/vm/dirty_background_ratio,单位是百分比,默认值是10%,即空闲的页缓存比率小于10%时,触发页回写。

2.5.2.3. 页回写机制有什么问题

了解了页缓存/页回写机制,现在来看看页回写机制会引发什么问题。最大的问题是如果页回写失败了,会丢失写操作的数据。因为数据还只在内核空间的页缓存中,并没有持久化到磁盘,当操作系统重启后,内核空间清空,数据丢失。而且,数据丢失之后,还无法通知到用户进程。因为内核并不知道某一页的数据是由哪些用户线程写入,即使知道了,也无法通知它们,因为刷盘是异步的,此时可能用户线程已经销毁。

进而,什么情况下会造成页回写失败呢?系统调用write函数的具体行为是由操作系统控制的,正常情况下write函数都会成功,除非磁盘损坏或者操作系统出现严重错误宕机。

然而这两种情况下,数据是否写入磁盘都已经无关紧要了,磁盘损坏的情况下,已经无法读取,所以即使write成功了也没有意义;操作系统宕机后,服务都无法启动,就更不用谈文件读取了。

2.5.3. 同步I/O相关系统调用函数

上一小节详细探讨了页回写机制的几个问题,提到了刷盘的问题,这里介绍3个同步磁盘的系统调用函数

2.5.3.1. sync

代码语言:javascript复制
void sync (void);

sync系统调用会一次将内核缓冲区中的数据全部写入磁盘,linux系统下该函数是同步函数,即所有“脏页”回写磁盘后才会返回。在执行之前,内核缓冲区中可能有大量的“脏页”,因此sync可能需要一段时间才能执行完,一般不建议使用,只有在需要重启linux操作系统时,在重启之前才应该执行sync函数。

2.5.3.2. fsync

相比于sync,fsync就轻量的多。

代码语言:javascript复制
int fsync (int fd);

fsync需要传入一个文件描述符fd,该fd必须以写的方式打开。fsync是对该文件描述符对应的内核缓冲区中的”脏页“刷盘,该调用会回写数据和元数据。数据即文件的内容,元数据包括文件修改的时间戳以及索引节点中的其他属性。fsync是使用最频繁的同步I/O系统调用函数之一。

2.5.3.3. fdatasync

代码语言:javascript复制
int fdatasync (int fd);

fdatasync和fsync使用方法相同,功能也类似,区别在于,fdatasync只会回写文件的数据,文件的元数据不会被更新。

就性能而言,fdatasync > fsync >>(注意这里是远远大于) sync

2.5.4. 同步I/O的应用

上一小节提到了同步I/O的相关系统调用函数,工程实践中fsync系统调用函数也有较为广泛的应用,这里给出mysql和redis的两个使用场景。

1. mysql的redo日志写磁盘

mysql在提交事务时需要将事务执行过程中执行的操作记录到redo日志中,innodb_flush_log_at_trx_commit变量控制redo刷盘的时机,取值情况如下

  • 当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步 redo 日志,这个任务是交给后台线程做的。这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将 redo 日志刷新到磁盘,那么该事务对页面的修改会丢失。
  • 当该系统变量值为1时,表示在事务提交时需要将 redo 日志同步到磁盘,可以保证事务的持久性 。1也是 innodb_flush_log_at_trx_commit的默认值。(也即调用了write fsync)
  • 当该系统变量值为2时,表示在事务提交时需要将 redo 日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。这种情况下如果数据库挂了,操作系统没挂的话事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。(即只调用了write)

2. redis AOF持久化写磁盘

redis AOF持久化中,appendfsync用于控制何时将缓冲区中的文件刷新到磁盘。注意,下表的aof_buf缓冲区是进程内的缓冲区(下一节即会介绍),属于图1中的用户空间,不是内核空间的页缓存,千万不要混淆。

appendfsync选项的取值

flushApppendOnlyFile函数的行为

always

将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件(相当于执行write fsync)。

everysec

将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 如果上次同步 AOF 文件的时间距离现在超过一秒钟, 那么再次对 AOF 文件进行同步, 并且这个同步操作是由一个线程专门负责执行的(相当于write 定时fsync)。

no

将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 但并不对 AOF 文件进行同步, 何时同步由操作系统来决定(只执行write)。

在了解了linux文件写流程之后,相信对于这两个工程实践中参数的取值会有更深刻的理解。

2.6. 用户缓冲I/O

上一小节介绍了访问文件最基本的方式:系统调用。linux引入内核缓冲区,将需要写的数据更新到内核缓冲区后即返回,大大提升了write系统调用的性能。内核缓冲是由linux引入的,对于上层应用来说是“透明”的,那么,应用进程能否通过某种手段,来加速文件的读写呢?答案就是使用用户缓冲。

用户缓冲I/O是在用户空间而不是在内核中完成的,用户缓冲的作用是减少系统I/O调用的次数。

2.6.1. 带有用户缓冲I/O相关的函数

代码语言:javascript复制
#include <stdio.h>
FILE * fopen (const char *path, const char *mode); // 根据mode参数,按照指定模式打开path指向的文件,并给他关联新的流。mode取值有:"r","r ","w","w "...
FILE * fdopen (int fd, const char *mode);
int fclose (FILE *stream); // 关闭流
int fgetc (FILE *stream); // 从流中读取单个字符
int fputc (int c, FILE *stream); // 向流中写入单个字符

这些带有用户缓冲的I/O函数称为”标准I/O“,而上文介绍的只有内核缓冲区的那些I/O函数称为系统调用。

2.6.2. 用户缓冲I/O示例程序

代码语言:javascript复制
#include <stdio.h>
#define BUFFER_SIZE 100

int main(void) {
  FILE *in, *out;
  char data[]="that is spencer";
  char show_data [BUFFER_SIZE];
  char filename[] = "spencer.txt";
  out = fopen(filename,"w");
  for(char x:data){
    fputc(x, out);
  }
  fclose(out);
  in = fopen(filename, "r");
  fgets(show_data,BUFFER_SIZE, in);
  printf("%sn",show_data);
  fclose(in);
}

表面上看,写文件时每次只写一个字符,效率应该很低,实际上,只写一个字符与写一个字符串性能上并没有太大的差别,主要原因就是使用了用户缓冲,如图3。

图3 带有用户缓冲的I/O

图3中,每次fputc函数只会写1个字母,但是由于存在用户缓存,原先需要调用4次write函数(that每个字母各调用一次),汇总为"that"之后调用一次write,减少了系统调用的次数。图3中隐藏了内核与磁盘的交互,与图2一致,这里的重点是用户空间和内核空间的交互。

2.6.3 用户缓冲的类型

标准I/O实现了三种类型的用户缓冲,并为开发者提供了接口,可以控制缓冲区类型和大小。不同类型的用户缓冲提供不同功能,并适用于不同的场景 [7]。

  • 无缓冲

不执行用户缓冲,数据直接提交给内核。通常很少使用,只有标准错误采用这种模式。

  • 行缓冲

缓冲以行为单位执行,每遇到换行符,缓冲区会被提交到内核。行缓冲对把流输出到屏幕时很有用,因此,标准输出使用行缓冲模式。

  • 块缓冲

缓冲以块为单位执行,它适用于处理文件,默认情况下,和文件相关的所有流都是块缓冲模式。

2.7. 对象存储映射mmap

上文介绍了文件I/O的基本函数,包括系统调用和标准I/O,除了这些函数,还有其他的I/O方式,对象存储映射即为其中一种。

2.7.1. 函数原型

mmap调用请求内核将文件描述符fd所指向的对象len个字节数据映射到内存中,起始位置从offset开始,函数原型为

代码语言:javascript复制
#include <sys/mman.h>
void * mmap (void *addr, size_t len, int prot, int flags, int fd, off_t offset);
  • addr参数指定内核映射文件的最佳位置,一般情况下传0;
  • prot参数指定了访问权限,取值有PROT_READ(页可读)/PROT_WRITE(页可写)/PROT_EXEC(页可执行),访问权限不可与fd打开的访问模式冲突;
  • flags指定了其他操作行为,常用取值有MAP_PRIVATE(表示映射区不共享。文件映射采用了“写时复制“,进程对内存的任何改变不影响真正文件或其他进程的映射)/MAP_SHARED(表示和所有其他映射该文件的进程共享映射内存,对内存的写操作等效于写文件)。

图4 对象存储映射

2.7.2. 写时复制

mmap中flags参数取值为MAP_PRIVATE时表示文件映射采用”写时复制“(Copy on Write COP),写时复制的全称是“读时共享,写时复制”。

当通过 fork() 来创建一个子进程时,操作系统需要将父进程虚拟内存空间中的大部分内容全部复制到子进程中(主要是数据段、堆、栈、代码段)。这个操作不仅非常耗时,而且会浪费大量物理内存[2]。如果父子进程只有极少数的内容是不同的,那么这种浪费和性能损耗会变的尤其严重。因此,linux提供了写时复制技术。当fork子进程时,子进程保存指向父进程相同资源的指针,每当有进程修改资源时,就会复制该资源,并把副本提供给该进程,复制过程对其他进程是“透明”的。

2.7.3. 写时复制的实践应用

redis的rdb持久化中,使用fork子进程的方式。当redis进程收到bgsave命令时,会创建一个子进程,让子进程执行快照落盘的操作,父进程继续接受客户端请求[3]。

图5 fork子进程时使用COP VS 不使用COP

图5中上半部分为普通父子进程复制方式,子进程会完全拷贝父进程中的数据,复制完成后,子进程拥有独立的完整的数据,但耗时较长。下半部分为采用COP的复制方式,子进程拥有父进程的数据的指针,当父进程写数据时,发生缺页中断,并拷贝一份,子进程拥有副本,父进程在该页上修改数据不影响子进程。

2.7.4. mmap实例

代码语言:javascript复制
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>


int mmapDemo(char* filename){
  struct stat sb;
  off_t len;
  char*p;
  int fd = open(filename, O_RDWR);
  // 获取fd的元信息并将其存入sb结构中
  if(fstat(fd, &sb) == -1){
    perror("fstat");
    return 1;
  }
  // 建立存储映射
  p = (void *)mmap(0, sb.st_size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
  if(p == MAP_FAILED){
    perror("mmap");
    return 1;
  }
  if(close(fd) == -1){
    perror("close");
    return 1;
  }
  // 读文件内容
  for(len=0;len<sb.st_size;len  ){
    putchar(p[len]);
  }

  // 写部分文件内容
  for(len=3;len<sb.st_size;len  ){
    p[len] = 's';
  }
  p[len-1] = 'n';

  // 取消存储映射
  if(munmap(p, sb.st_size) == -1){
    perror("munmap");
    return 1;
  }
  return 0;
}

int main(){
  char filename[] = "spencer.txt";
  return mmapDemo(filename);
}

图6 mmap执行的结果

上文程序清单中使用mmap映射了文件spencer.txt,并读取了该文件的内容,同时修改了部分内容。从中也可以看到,即使已经关闭文件(调用了close函数),依旧可以写入文件,因为存储映射还在。写文件内容时,从第三个字节开始,将文件后续的内容全部刷为字母’s‘。

程序中使用了munmap函数取消存储映射,一旦取消了存储映射,再对p进行操作就不会反应到文件中。

2.7.5. mmap评价

2.7.5.1. mmap的优点

相对于系统调用read&write而言,使用mmap有很多优点,主要体现在:

1. 使用read和write系统调用时,需要从用户缓冲区进行数据的读写,一次读写需要经历4次拷贝过程(磁盘---->内核缓冲区,内核缓冲区---->用户缓冲区;用户缓冲区---->内核缓冲区,内核缓冲区---->磁盘),而使用mmap只需要2次拷贝(磁盘---->内核缓冲区,内核映射区--→磁盘);

2. 读写映射文件不会带来系统上下文切换的开销,而使用read&write,CPU需要从用户态切换到内核态,再从内核态切换到用户态。

图7 基本文件读写与mmap的对比

2.7.5.2. mmap的限制

相比于read&write,mmap在性能上有明显的提升,但是mmap使用却比前者更受限。从mmap的api也可以看到,mmap必须将文件的len字节信息映射到进程空间的映射区,需要开发人员在一开始就确定len的大小,不太适用于随机读写的场景,read&write则更加通用一些。另外,由于需要进行映射,会占用进程映射区的空间,因此映射的大小有限制,一般在1.5G~2G之间。

2.7.6. mmap的实践应用

kafka之所以吞吐量高,其中的一个重要原因就是使用了mmap的方式[4]。producer将数据写到broker中,comsumer从broker读取数据时使用了mmap。因为此时broker的log文件对于consumer是只读的,而且,kafka会存储consumer读取的偏移量,也就刚好对应mmap中的offset。

3. I/O多路复用

上文详细介绍了磁盘文件的I/O,现在,将目光聚集到网络文件I/O,看看网络文件的I/O与磁盘文件I/O有何区别。

磁盘上的文件在正常情况下总是可读且有数据,但是,网络文件却并非如此,即使socket文件处于可读状态,数据也不一定就绪。如果进程需要监听多个网络文件,每个文件就需要开启一个线程处理,可以想象当网络文件数量达到一定程度,进程处理就显得捉襟见肘,I/O多路复用就是解决此类问题。

I/O多路复用支持应用在多个文件描述符上阻塞,并在其中某个带监听的事件发生时收到通知。linux提供了三种I/O多路复用方案,分别为select、poll、epoll,下面对其进行详细介绍。

3.1. select

最基础的I/O多路复用机制是select,与select相关的系统调用函数如下。

代码语言:javascript复制
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
FD_CLR (int fd, fd_set *set);   // 向指定集合中删除一个文件描述符
FD_ISSET (int fd, fd_set *set); // 检查一个文件描述符是否在给定集合中
FD_SET (int fd, fd_set *set);   // 向指定集合中添加一个文件描述符
FD_ZERO (fd_set *set);          // 清空一个文件描述符集合

select函数可以监听3中类别的文件描述符,分别是读文件描述符集合,写文件描述符集合和异常文件描述符集合。假定readfds集合中一开始有两个文件描述符7和9,当调用返回时,如果描述符7还在readfds集合中,说明fd=7的文件处于可读状态,而fd=9的文件在读取时很可能发生阻塞。

3.1.1. select示例

下面通过一个具体的实例,了解select的用法及原理,并从中找出select的局限性。

下面代码想要达到的效果是,监听两个已打开的文件(分别是标准输入和标准错误,实际使用时,可以替换成任意已打开的文件的文件描述符)读事件,当这些文件中任意一个有数据可读时,拿到这些可读的文件描述符,随后进行读取。

代码语言:javascript复制
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

#define TIMEOUT 5 // 设置select的超时时间
#define BUF_LEN 1024 // 读缓冲区大小

int readFile(int fd){
  char buf[BUF_LEN   1];
  int len = read(fd, buf, BUF_LEN);
  if (len == -1) {
    perror("read");
    return 1;
  }
  if (len) {
    buf[len] = '';
    printf("read:%sn", buf);
  }
  return 0;
}

int readFile(int fd, fd_set *fds) {
  if (!FD_ISSET(fd, fds)) {
    // 该文件不一定可读
    return -1;
  }
  return readFile(fd);
}

int main(void) {
  struct timeval tv;
  fd_set readfds;
  int ret;
  // STDIN_FILENO一个宏,表示标准输入,本质上是一个打开的文件描述符,STDERR_FILENO表示标准错误
  int fds[] = {STDIN_FILENO, STDERR_FILENO};
  FD_ZERO(&readfds); // 初始化(清空)读文件描述符集合
  for (int fd: fds)
    FD_SET(fd, &readfds);

  // 设置select的超时时间
  tv.tv_sec = TIMEOUT;
  tv.tv_usec = 0;

  ret = select(STDERR_FILENO   1, &readfds, NULL, NULL, &tv);
  if (ret == -1) {
    perror("select");
    return 1;
  } else if (!ret) {
    printf("%d seconds elapsed.n", TIMEOUT);
    return 0;
  }
  for (int fd: fds)
    readFile(fd, &readfds);
  return 0;
}

在TIMEOUT时间内,如果select解除阻塞返回,则必定是因为#bf9000 readfds事件集合中有文件描述符数据可读,且可读的文件描述符在readfds中。

那么如何进行读取呢?上述程序使用了一个fds数组保存所有的文件描述符,对其进行遍历,如果在readfds中,则进行读取,读取函数封装在readFile函数中。

3.1.2. select缺陷

缺陷一:效率较低。

可以看到,每次select函数返回,readfds都将那些数据可能未就绪的文件描述符剔除集合,保存那些数据一定就绪的文件描述符,而且,非常关键的是,系统没有提供遍历readfds的api,每当select返回时,需要先遍历fds,判定每一个fd是否在readfds中。试想这样的场景,select监听了1000个文件描述符,只有2个文件描述符数据可读,此时select返回,但是为了找到这两个文件描述符,需要遍历1000个文件,效率低下。

缺陷二:readfds不能复用。

每当select返回,说明有文件数据可读,此时的readfds已经不是最初的fds集合了(已经剔除了一些可能未就绪的文件描述符),如果这一次select处理完成,希望继续监听相同的事件,则不能复用readfds,因为readfds集合发生了变动,如果想继续监听,需要遍历fds,将每一个文件描述符再重新加入到readfds中。

缺陷三:能够监听的事件个数有限。

select函数第一个参数n,表示监听的最大文件描述符 1。上述程序中,监听了2个文件读事件,最大文件描述符是STDERR_FILENO,因此n=STDERR_FILENO 1。而linux系统为每一个进程分配的文件描述符是有限的,默认值最大为1024,而select只能够监听读、写、异常事件,因此select能够监听的事件个数最大为1024*3。如果只是监听这几千个事件,可以不用I/O多路复用,进程为每个事件开辟一个线程处理即可(进程开启上千个线程问题不大)。

3.1.3. 对select的评价

select函数存在上述的三个缺陷,导致这个I/O多路复用机制非常"鸡肋",工程实践中使用较少,select的历史价值大于实用价值,在此之前,监听文件事件几乎均是使用开启线程方式,select的横空出世,打开了I/O多路复用的大门,后续的poll和epoll均是在此基础上进行改进,得到了广泛的应用。

3.2. poll

由于select不能复用readfds,而且仅能够监听3k左右的事件,因此,poll出现了。poll的函数原型是

代码语言:javascript复制
#include <poll.h>
struct pollfd {
  int fd;        // 待监听的文件描述符 
  short events;  // 待监听的文件事件的位掩码,取值有POLLIN(文件有数据可读)/POLLOUT(文件写操作不阻塞)等,可使用按位或监听多个事件
  short revents; // 结果事件的位掩码
}
int poll (struct pollfd*fds, nfds_t nfds, int timeout);

3.2.1. poll示例

代码语言:javascript复制
#include <stdio.h>
#include <unistd.h>
#include <poll.h>
int main(){
  struct pollfd fds[2];
  fds[0].fd = STDIN_FILENO;
  fds[0].events = POLLIN;

  fds[1].fd = STDOUT_FILENO;
  fds[1].events = POLLOUT;

  int ret = poll(fds, 2, TIMEOUT*1000);
  if(-1 == ret){
    perror("poll");
    return 1;
  }
  if(!ret){
    printf("%d seconds elapsed.n", TIMEOUT);
    return 0;
  }

  if(fds[0].revents & POLLIN){
    printf("stdin is readablen");
    readFile(fds[0].fd);
  }

  if(fds[1].revents & POLLOUT){
    printf("stdout is writablen");
  }
  return 0;
}

3.2.2. poll分析

从上面的程序清单可以看的,poll的代码和select的使用基本一致,poll与select不同的地方在于(这些不同也是poll改进select的地方):

1. poll监听的文件事件种类比select多很多。

select只能监听三种事件,读/写/异常,分别对应select中的三个fd_set 指针;而poll将想要监听的事件放置到pollfd结构的events变量中,该变量取值除了读/写/异常外,还有POLLHUP/POLLMSG等,大大丰富了需要监听的事件。

2. 当系统调用返回时,poll比select能更方便遍历所有的文件。

select在调用之前,需要保存所有监听的文件,对其进行遍历,并依次判断是否在想监听的文件集合中(主要原因是fd_set不支持遍历);而poll使用了pollfd数组,poll返回直接对其进行遍历即可,不需要再保存一遍。

3. 当系统调用返回时,如果需要继续监听,select需要重新初始化;而poll直接继续调用即可。

select在调用返回时,read_fds的内容已经发生了变化,只有事件到达的文件描述符还在该集合中,如果想继续监听,必须将所有的文件描述符重新添加到read_fds中。而poll就不用,因为poll使用了pollfd数组,调用返回后,数组依旧保存着所有的文件描述符,这一点是poll优于select最出彩的点。

运行上述代码,并将一个文件重定向到标准输入,可以看到两个事件

代码语言:javascript复制
/*
spencer.txt 文件内容是
this is spencer
*/

// 运行
./a.out < spencer.txt
// 结果
stdin is readable
read:this is spencer

stdout is writable

3.2.3. poll的缺陷

poll将需要监听的事件、事实监听到的事件与文件描述符绑定在一起,将其封装为一个pollfd结构,比select更加清晰易懂,但是,poll在返回后依旧需要遍历所有的fds,如果监听1k个文件,每次返回只有几个文件有事件到达,那么遍历的性能消耗就不可忽略。

3.3. epoll

由于select和poll的局限,linux2.6内核引入了epoll机制,虽然epoll的实现比select和poll要复杂的多,但是epoll确实解决了select和poll的性能问题。

3.3.1. epoll初始化

epoll的使用也比select和poll复杂,主要体现在初始化部分,epoll的初始化包括两个函数调用

代码语言:javascript复制
#include <sys/epoll.h>
struct epoll_event {
	_u32 events; // 待监听的事件,可按位或。取值有EPOLLIN(文件有数据可读)/EPOLLOUT(文件可写)/EPOLLHUP(文件被挂起)
	union {
		void *ptr;
		int fd;
		_32 u32;
		_u64 u64;
	} data;
}


int epoll_create1 (int flags); // 调用成功时,会创建并返回epoll实例。flags支持修改epoll的行为,目前只有一个合法的取值,即EPOLL_CLOEXEC
int epoll_ctl (int epfd, // epfd即epoll的实例,也就是epoll_create1的返回值
               int op,   // op是指定fd文件执行的操作,取值有EPOLL_CTL_ADD(增加)/EPOLL_CTL_DEL(删除)/EPOLL_CTL_MOD(修改)
               int fd,   // 待操作的文件描述符
               struct epoll_event *event); 

3.3.2. 等待epoll事件

代码语言:javascript复制
#include <sys/epoll.h>
int epoll_wait (int epfd, 
		struct epoll_event *events, // 待监听的文件列表
		int maxevents,           
                int timeout);

当调用epoll_wait时,等待epoll实例epfd中文件fd上的事件,时限为timeout毫秒。调用成功则返回待监听的文件事件就绪的文件数。

### 3.3.3. epoll示例

代码语言:javascript复制
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <stdlib.h>

#define MAX_EVENTS 64

int epollDemo(){
  struct epoll_event event;
  struct epoll_event *events;
  events = (epoll_event *)malloc(sizeof(epoll_event)*MAX_EVENTS);
  event.data.fd = STDIN_FILENO;
  event.events = EPOLLIN;
  events[0] = event;

  // 创建epoll实例
  int epfd = epoll_create1(0);
  // 循环添加待监听的事件
  epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event);
  int nr_events = epoll_wait(epfd, events, MAX_EVENTS, -1);
  for(int i=0;i<nr_events;i  ){
    printf("event=%ld on fd=%dn", events[i].events, events[i].data.fd);
  }
  printf("overn");
}


int main(void) {
  epollDemo();
}

// 运行结果为
// event=1 on fd=0
// over

3.3.4. epoll分析

从以上代码清单中可以看到,epoll在select和poll基础上做的最大的改进就是将监听到的事件个数返回(nr_events),有了该值,epoll可以只用遍历nr_events次,就能处理完所有的文件事件,而select和epoll必须遍历全部的文件描述符才行。正因为如此,epoll比select和poll的效率都高。

3.4. I/O多路复用总结

不同于磁盘文件,网络文件上的数据并不总是”就绪“的,如果此时对其读取,有可能发生阻塞,由此也产生了阻塞I/O与非阻塞I/O,本文的重点落在I/O上,对select、poll、epoll进行了详细的介绍,尤其是给出了每个复用机制的使用案例,通过程序清单剖析其存在的不足,当然I/O多路复用的机制原理并不止这些,感兴趣的读者可以参考libevent深入浅出[5]或网络IO演变过程[6]。

4. 总结

文件I/O是linux系统下最常见、最基本的操作之一。本文从最基础的系统调用说起,手拿放大镜,一步一步挖掘文件I/O底层的秘密,依次介绍了什么是页回写机制(2.5.1),linux为什么要使用页回写机制(2.5.2.1),什么时候会触发页回写机制(2.5.2.2),如何手动执行页回写(2.5.2.3);并由此过渡到标准I/O(2.6),同时介绍了使用mmap读写文件的机制(2.7),看到了应用程序在系统调用的基础上如何加速文件操作;最后介绍了网络I/O(3),并将其与普通的磁盘文件I/O进行了对比。

5. 后记

本文大部分内容出自《linux系统编程》一书,书中对linux内核进行了详细而清楚的介绍,本人截取”文件I/O“部分(大约涉及到原书的第2章、第三章、第四章、第八章内容),整理成文。但本文并非原书的简单搬运,而是融合了作者个人的思考,文中的许多问题是原书中并未提及的,答案也是本人根据实践及查阅相关资料给出个人浅见。另外,本文也调整了文件I/O介绍的顺序,增强了章节之间的逻辑性和连贯性。

为成此文,本人亦耗费大量时间与精力查阅相关资料,写作之前也思考良久,成文之后,亦反复校对,然终究能力有限,文中纰漏在所难免,如能指出,感激之至。

读到此处,不妨来个三连;若有收获,给个打赏未尝不可。

6. 参考文献

[1] [文件描述符](Linux文件描述符到底是什么?)

[2] [操作系统写时复制 Copy-on-write]( 【操作系统】写时复制 Copy-on-write)

[3] [Redis持久化之父子进程与写时复制](Redis持久化之父子进程与写时复制 - 等不到的口琴 - 博客园)

[4] [kafka的零拷贝技术](消失er:Kafka零拷贝)

[5] [libevent深入浅出](1 Libevent官方 · libevent深入浅出)

[6] [网络IO演变过程](​网络 IO 演变发展过程和模型介绍)

[7] [linux系统编程](https://shukai.oss-cn-hangzhou.aliyuncs.com/file/linux/Linux系统编程(中文版).pdf)

0 人点赞