Linux内核之 文件I/O
Linux下一切皆文件,所以文件于Linux系统的重要性是不言而喻的。本文站在系统编程这一层来介绍Linux系统的文件I/O。
1、文件描述符
首先介绍文件相关的系统调用前,我们需要知道一个非常重要的概念,文件描述符,它是一切文件操作的基础,是内核与文件操作之间的纽带。那什么是文件描述符?
(1)文件描述符其实质是一个数字,这个数字在一个进程中表示一个特定的含义,当我们open打开一个文件时,操作系统在内存中构建了一些数据结构来表示这个动态文件,然后返回给应用程序一个数字作为文件描述符(file description),这个数字就和我们内存中维护这个动态文件的这些数据结构挂钩绑定上了,以后我们应用程序如果要操作这一个动态文件,只需要用这个文件描述符进行区分。
(2)文件描述符就是用来区分一个程序打开的多个文件。
(3)文件描述符的作用域就是当前进程,出了当前进程这个文件描述符就没有意义了。
(4)文件描述符fd的合法范围是0开始,到上限值减1。默认情况下上限值是1024,可以配置最大为1048576。
(5)open返回的fd必须记录好,以后向这个文件的所有操作都要靠这个fd去对应这个文件,最后关闭文件时也需要fd去指定关闭这个文件。如果在我们关闭文件前fd丢了,那么这个文件就没法关闭了也没法读写了。
在linux系统中,内核占用了0、1、2这三个fd,当我们运行一个程序得到一个进程时,内部就默认已经打开了3个文件,对应的fd就是0、1、2。
分别叫stdin、stdout、stderr。也就是标准输入、标准输出、标准错误。
事实上,内核会为每个进程维护一个打开文件的列表,该列表称为文件表(file table)。而文件表通过文件描述符fd进行索引,从而组成了一个进程的文件描述符表。
文件描述符表的每项包括了一个文件描述符和一个指向文件信息的指针,即指向文件表。每个文件表包括以下四项:
(1)文件的状态标志,即是否可读是否可写。
(2)当前文件的偏移量。
(3)refcnt,被引用数量。
(4)v节点指针,指向一个v节点表。
v节点表:每个文件对应一个,无论被被多少个进程打开都只有一个,它包括v节点信息(主要是stat结构体中的信息),i节点(inode)信息。
一个进程的文件描述符表示例如下:
复制文件描述符
文件表可以共享,当多个文件描述符指向同一个文件表时,文件表中的refcnt字段会相应变化。
复制前:
复制后:
复制后,两个文件描述符都指向了同一个文件表,refcnt=2。
复制文件描述符有三种方法:
(1)dup()
(2)dup2()
int dup(int oldfd); int dup2(int oldfd, int newfd);
dup()或dup2(),通过oldfd复制出一个新的文件描述符newfd,返回值为newfd。最终oldfd和newfd都指向同一个文件。
dup()的返回值newfd是调用进程文件描述符表中最小可用的文件描述符。
而dup2()则可以指定任意一个合法的数字newfd。所以dup2中如果newfd该描述符已经存在则先将其关闭;而若newfd等于oldfd,则什么都不做直接返回newfd。
(3)fcntl()
#include <fcntl.h> int fcntl(int fd, int cmd, ... /* arg */ );
功能:操纵文件描述符,改变已打开的文件的属性。由第二个参数指定操作类型,后面点的可变参数指定该命令所需的参数。
这里我们进行文件描述符复制,可将cmd设为:F_DUPFD:
fd2 = fcntl(fd, F_DUPFD, 0);
cmd其他值有FD_CLOEXEC,F_GETFD,F_SETFD等等,具体使用时可以查手册。
2、文件操作的系统调用
我们已经介绍了文件描述符,以及如何复制文件描述符,上面提到的函数都是系统调用。那么文件操作常见的系统调用还有哪些呢?
(1)open(),打开文件;成功返回文件描述符,否则返回-1。
(2)close(),关闭一个文件。注意:关闭文件,并不意味着数据已经写到磁盘,如果需要被保证,请使用后面讲的同步方法。
(3)write(),向文件中写数据。
(4)read(),读文件中数据。
(5)lseek(),修改文件偏移量。
(6)ftruncate()与truncate(),截断文件长度。两种的区别在于所带的第一个参数,前者为文件描述符,后者是路径名。文件长度可以变大变小,变大时用0填充。不修改当前文件位置。
(7)access(),判断文件是否具有读,写,可执行或者是否存在。
(8)creat(),其主要作用为创建文件,这个函数是作为保留使用。现在Linux操作系统都可以用open来替换,其参数设置为"O_WRONLY | O_CREAT | O_TRUNC"的组合即可。
其他还有很多的系统调用,例如目录、元数据stat等系列操作函数在此不一一列举,主要因为不是常见的也不是本文的重点。
3、同步I/O
上面我们简单介绍了文件的系统调用,其中最重要的是读写操作,即对文件进行读取和写入操作。那么它们的实现机制是否一样呢?答案不一样,各有各的机制。
读取时,一般是采用阻塞操作,即一直等到缓冲区有数据可读。当然也可以设置为非阻塞读!
而写入时,一般是采用非阻塞操作,即内核将数据从提供的缓冲区拷贝到内核缓冲区,就可以返回。所以并没保证数据是否真正写入目的地(常见的磁盘中)。
读取时,当然可以预先从磁盘中读取到内核缓冲区,以供下次读取,这种方式叫预读(readahead)。
写入时,内核缓冲区数据会在适当时机写到磁盘,我们把这种方式叫做写回(writeback)。我们也叫延迟写,这种机制可以极大提高系统性能,可以将写操作推迟到系统空闲时期,且可以批量操作。当然也带来丢失数据的可能,比如突然断电,没有写入磁盘还在缓冲的数据就会丢失;这样会造成一定的数据不一致问题。
以上是通常情况下的读写行为。在有必要时,我们可以手动调用同步I/O,针对写回机制,即牺牲性能换来同步操作。
Linux内核提供了一些选择:
#include <unistd.h>
int fsync(int fd); int fdatasync(int fd);
void sync(void);
(1)fsync()和fdatasync()
为了确保数据写入磁盘,最简单的方式是使用系统调用fsync(),即可以确保和文件描述符fd所指向的文件相关的所有脏数据(被修改过的)都会回写到磁盘上。文件描述符fd必须以写方式打开。该调用会回写数据和元数据,如创建的时间戳以及索引节点中的其他属性。该调用在硬件驱动器确认数据和元数据已经全部写到磁盘之后才会返回。
对于包含写缓存的硬盘(还记得我们以前说过的RAID卡吗),fsync()无法知道数据是否已经真正在物理磁盘上了。硬盘会报告说数据已经写完了,但是实际上数据还在硬盘驱动器的写缓存上。好在,在硬盘驱动器缓存中的数据会很快写入到磁盘上
fdatasync()的功能和fsync()类似,其区别在于fdatasync()只会写入数据以及以后要访问文件所需要的元数据。例如,调用fdatasync()会写文件的大小,因为以后要读该文件需要文件大小这个属性。fdatasync()不保证非基础的元数据也写到磁盘上,因此一般而言,它执行更快。对于大多数使用场景,除了最基本的事务外,不会考虑元数据如文件修改时间戳,因此fdatasync()就能够满足需求,而且执行更快。
fsync()通常会涉及至少两个I/O操作:一是回写修改的数据,二是更新索引节点的修改时间戳。因为索引节点和文件数据在磁盘上可能不是紧挨着——因而会带来代价很高的seek操作——在很多场景下,关注正确的事务顺序,但不包括那些对于以后访问文件无关紧要的元数据(比如修改时间戳),使用fdatasync()是提高性能的简单方式。
(2)sync()
sync()系统调用用来对磁盘上的所有缓冲区进行同步,虽然它效率不高,但还是被广泛应用。
该函数没有参数,也没有返回值。它总是成功返回,并确保所有的缓冲区——包括数据和元数据——都能够写入磁盘。
POSIX标准并不要求sync()一直等待所有缓冲区都写到磁盘后才返回,只需要调用它来启动把所有缓冲区写到磁盘上即可。因此,一般建议多次调用sync(),确保所有数据都安全地写入磁盘。但是对于Linux而言,sync()一定是等到所有缓冲区都写入了才返回,因此调用一次sync()就够了。
sync()的真正用途在于同步功能的实现。应用应该使用fsync()和fdatasync()将文件描述符指定的数据同步到磁盘中。注意,当系统繁忙时,sync()操作可能需要几分钟甚至更长的时间才能完成。(还记得dd磁盘读写测试吗?)
(3)O_SYNC标志位
系统调用open()可以使用O_SYNC标志位,表示该文件的所有I/O操作都需要同步。
读请求总是同步操作。如果不同步,无法保证读取缓冲区中的数据的有效性。但是,正如前面所提到的,write()调用通常是非同步操作。调用返回和把数据写入磁盘没有什么关系,而标志位O_SYNC则将二者强制关联,从而保证write()调用会执行I/O同步。
O_SYNC标志位的功能可以理解成每次调用write()操作后,隐式执行fsync(),然后才返回。这就是O_SYNC的语义,虽然Linux内核在实现上做了优化。
对于写操作,O_SYNC对用户时间和内核时间(分别指用户空间和内核空间消耗的时间)有些负面影响。此外,根据写入文件的大小,O_SYNC可能会使进程消耗大量的时间在I/O等待时间,因而导致总耗时增加一两个数量级。O_SYNC带来的时间开销增长是非常可观的,因此一般只在没有其他方式下才选择同步I/O。
一般来说,应用要确保通过fsync()或fdatasync()写数据到磁盘上。和O_SYNC相比,调用fsync()和fdatasync()不会那么频繁(只在某些操作完成之后才会调用),因此其开销也更低。
(4)O_DSYNC和O_RSYNC
POSIX标准为open()调用定义了另外两个同步I/O相关的标志位:O_DSYNC和O_RSYNC。在Linux上,这些标志位的定义和O_SYNC一致,其行为完全相同。
O_DSYNC标志位指定每次写操作后,只同步普通数据,不同步元数据。O_DSYNC的功能可以理解为在每次写请求后,隐式调用fdatasync()。因为O_SYNC提供了更严格的限制,把O_DSYNC替换成O_SYNC在功能上完全没有问题,只有在某些严格需求场景下才会有性能损失。
O_RSYNC标志位指定读请求和写请求之间的同步。该标志位必须和O_SYNC或O_DSYNC一起使用。正如前面所提到的,读操作总是同步的——只有当有数据返回给用户时,才会返回。O_RSYNC标志位保证读操作带来的任何影响也是同步的。也就是说,由于读操作导致的元数据更新必须在调用返回前写入磁盘。在实际应用中,可以理解成在read()调用返回前,文件访问时间必须更新到磁盘索引节点的副本中。在Linux中,O_RSYNC和O_SYNC的含义相同,虽然这没有什么意义(与O_SYNC和O_DSYNC的子集关系不同)。在Linux中,O_RSYNC无法通过当前行为来解释,最接近的理解是在每次read()调用后,再调用fdatasync()。实际上,这种行为极少发生。
O_RSYNC的作用用简单一句话概括,保证读到的数据都是最新的即同步的。事实上这是默认行为,所以该宏作用不大。
4、直接I/O
和其他现代操作系统内核一样,Linux内核实现了复杂的缓存、缓冲以及设备和应用之间的I/O管理的层次结构。高性能的应用可能希望越过这个复杂的层次结构,进行独立的I/O管理。但是,创建一个自己的I/O系统往往会事倍功半,实际上,操作系统层的工具往往比应用层的工具有更好的性能。此外,数据库系统往往倾向于使用自己的缓存,以尽可能减少操作系统带来的开销。
在open()中指定O_DIRECT标志位会使得内核对I/O管理的影响最小化。如果提供O_DIRECT标志位,I/O操作会忽略页缓存机制,直接对用户空间缓冲区和设备进行初始化。所有的I/O操作都是同步的,操作在完成之前不会返回。
使用直接I/O时,请求长度、缓冲区对齐以及文件偏移都必须是底层设备扇区大小(通常是512字节)的整数倍。在Linux内核2.6以前,这项要求更加严格:在Linux内核2.4中,所有的操作都必须和文件系统的逻辑块大小对齐(一般是4KB)。为了保持兼容性,应用需要对齐到更大(而且操作更难)的逻辑块大小。
5、缓冲I/O
前面我们描述的缓冲I/O特指内核的,因为前面介绍的都是系统调用,在操作系统内部。上一节也提到了Linux系统存在一个非常复杂的I/O管理层次结构。其中扮演重要角色的是各种类型的缓冲。
也就是说在现代计算机系统中缓冲无处不在,不管是硬件、内核还是应用程序。内核里有页高速缓冲,内存高速缓冲,CPU硬件的L1.L2 cache,应用程序更是多的数不清,基本写的好的软件都有,专用的硬件比如Raid卡也是自己的内存和缓冲机制。不过归根结底这些缓冲的作用是相同的,都是为了提高机器或者程序的性能。而需要缓冲大部分的情况都是为了协调两个设备或者两个系统间速度的不匹配,又是利用局部性原理。
我们这里主要介绍Linux系统中“用户缓冲I/O”。其中常见的实现方式之一是通过“标准I/O 库(C标准库)”,当然应用程序也完全可以自己实现一套缓存机制比如数据库系统。一般而言,如果只是普通文件操作或者轻量级I/O请求的程序通常会使用标准I/O。而且理解标准I/O缓冲的实现机制,也有利于自己开发一套专用的缓冲机制。
标准I/O库对应的文件操作主要是fopen(),fclose(),fread(),fwrite(),fseek()等。fopen()返回不再是文件描述符,而是FILE*指针。执行包括文件描述符在内的结构体指针。
标准I/O操作在本质上是线程安全的。在每个函数的内部实现中,都关联了一把锁、一个锁计数器、以及持有该锁并打开一个流的线程。所以,在单个函数调用中,是原子操作!
标准I/O库提供缓冲的目的是尽可能地减少使用read和write调用的次数。它也对每个I/O流自动地进行缓冲管理,从而避免了应用程序需要考虑这一点所带来的麻烦。不幸的是,标准I/O库最令人迷惑的也是它的缓冲。
标准I/O提供了三种类型的缓冲:
(1)全缓冲(块缓冲)
在填满标准I/O缓冲区后才进行实际I/O操作。常规文件(如普通文本文件)通常是全缓冲的。
(2)行缓冲
当在输入和输出中遇到换行符时,标准I/O库执行I/O操作提交给内核。这允许我们一次输出一个字符,但只有在写了一行之后才进行实际I/O操作。标准输入和标准输出对应终端设备(如屏幕)时通常是行缓冲的。
(3)不带缓冲
用户程序每次调库函数做写操作都通过系统调用写回内核。标准错误输出通常是无缓冲的,这样用户程序产生的错误信息可以尽快输出到设备。
修改IO缓冲
对于上面提到的每种文件流,IO库都默认分配一个对应的缓冲给它,但有时候我们想自己设置这些缓冲,不要默认的,那么我们可以使用下面两个函数来达到目的:
#include <stdio.h>
void setbuf(FILE *fp, char *buf); void setvbuf(FILE *fp, char *buf, int mode, size_t size);
第一个函数用来打开或者关闭缓冲机制,如果buf为NULL,则关闭缓冲,否则buf指向缓冲区,不过缓冲区的类型和文件流有关。
第二个函数修改缓冲模式和指定缓冲区,mode指定我们所需要的缓冲类型(上面提到的三种之一),buf指定一个缓冲空间,size为该空间长度。
Flush(刷新输出)流
标准I/O库提供了一个接口,可以将用户缓冲区数据写入内核,即“强制冲刷一个流”:
#include <stdio.h>
int fflush(FILE *fp);
如果fp为NULL,冲刷所有输出流。
需要注意的是,这里的冲刷只是把用户缓冲区的数据写入内核缓冲区。我们通过一张图来捋一下用户缓冲区、内核缓冲区以及同步的关系:
标准I/O的两次复制
上面我们提到,标准I/O库缓冲的目的是尽可能地减少使用read和write调用的次数。普通文件在全缓冲模式下,可能将多次I/O操作减少或者合并到一次。但是这是对于轻量级数据,也就是说每次读写数据长度较小,远小于缓冲的长度。相反如果对于数据读写粒度较大,大于该长度,这个优势就不存在了。而真正问题在于每次的标准I/O都需要两次复制,严重影响性能。
读取普通文件
第一次复制:内核缓冲区->标准I/O缓冲区;
第二次复制:标准I/O缓冲区->应用程序指定的缓冲区。
事实上,这只是标准I/O的两次复制,如果在一次实际应用中,可能涉及到四次。比如说从主机磁盘上读取数据再发送到网络。
就会加多两次拷贝,首先需要从磁盘复制到内核缓冲区,再经过标准I/O的两次复制,最后再将指定缓冲区数据拷贝到网卡的缓冲区上。
而且数据拷贝需要由CPU来调控,所以如果反复这样,将大大增加系统的开销!
常见的解决方式之一为使用mmap()系统调用。
mmap内存映射就可以绕过标准I/O缓冲区,直接将内核数据直接拷贝到指定的映射区,而mmap内存映射区可以在应用程序和操作系统之间共享,也可以在进程之间共享!
另一种减少拷贝的方式之一是使用sendfile()系统调用。
该函数用于从一个文件拷贝数据到另一个文件中,它执行的拷贝操作完全在内核中完成,避免了向用户空间进行不必要的拷贝。所以该函数适合对文件不加修改的I/O操作。
比如说上面说的应用(发送数据到网络上),那么可以使用sendfile将数据从内核缓冲区直接拷贝到内核空间socket缓冲区中!
其他方式可以直接使用read和write的系统调用而不是标准I/O的fread和fwrite这一系列函数;亦可以使用直接I/O(O_DIRECT)。
所以很多开源库大都使用了经自己高度优化的用户缓冲库。
推荐阅读:存储系列之 总结:存储分层
参考资料:
《linux系统编程》第2版
《UNIX环境高级编程》(简称APUE)第2版