0x07_文件I/O
文件描述符
内核用三个相关的数据结构来表示打开的文件:
- 描述符表。每个进程有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符指向文件表中的一个表项。
- 文件表。打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项组成包括当前的文件位置、引用计数、文件状态标志(读、写、同步和非阻塞等),以及一个指向v-node表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数,除非引用计数为零,否则内核不会删除这个文件表表项。
- v-node表。所有进程共享一张v-node表。每个表项包含stat结构中大多数信息,包括文件访问、文件大小、文件类型和对此文件进行各种操作函数的指针等。对于大多数文件,v-node还包含了该文件的i-node(索引节点)。这些信息是在打开文件时从磁盘上读入内存的。i-node包含了文件的所有者、文件长度、指向文件实际数据块在磁盘上所在位置的指针等。
描述符1和4通过不同的打开文件表表项来引用两个不同的文件,没有共享文件,而且每个描述符对应一个不同的文件。
多个描述符可以通过不同的文件表表项来引用同一个文件。如果以同一个filename调用open函数两次,就会发生这种情况。关键思想是每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据。
子进程有一个父进程描述符表的副本,父子进程共享相同的打开文件表集合,因此共享相同的位置。一个很重要的结果就是,在内核删除相应文件表表项之前,父子进程必须都关闭了它们的描述符。
按照惯例,把文件描述符0与进程的标准输入关联,1与标准输出关联,2与标准错误关联。应该把它们替换成符号常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO
以提高可读性。这些常量都在头文件<unistd.h>
中定义。
文件描述符的变化范围是0~OPEN_MAX-1。早起只有几个,目前文件描述符的变化范围几乎是无限的。
函数open和openat
#include <fcntl.h> int open(const char *path, int oflag, ...); int openat(int fd, const char *path, int oflag, ...); 若成功,返回文件描述符;若出错,返回-1。
仅当创建新文件时才使用最后这个参数。path参数是要打开或创建文件的名字。oflag参数可用来说明此函数的多个选项。用下列一个或多个常量进行或运算构成oflag参数。
O_RDONLY 只读打开 O_WRONLY 只写打开 O_RDWR 读、写打开 O_APPEND 每次写时都追加到文件的尾端 O_CREAT 若此文件不存在则创建它。使用此选项时,open函数需同时说明第3个参数mode O_EXCL 如果同时指定了O_CREAT,而文件已经存在,则出错,这使测试和创建两者成为一个原子操作 O_SYNC 使每次write等待物理I/O操作完成,包括由该write操作引起的文件属性更新 O_TRUNC 如果此文件存在,而且为只写或读写成功打开,则将其长度截断为0 O_NONBLOCK 如果path引用的是一个设备文件/网络文件等,则此选项为文件的本次打开和后续的I/O设置为非阻塞
由open和openat函数返回的文件描述符一定是最小的未用描述符数值。若打开失败,返回-1。
fd参数把open和openat函数区分开:
- path参数指定的是绝对路径名,在这种情况下,fd参数被忽略,openat函数等价于open函数。
- path参数指定的是相对路径名,fd参数指出了相对路径名在文件系统中的开始地址。fd参数是通过打开相对路径名所在的目录来获取。
- path参数指定了相对路径名,fd参数具有特殊值AT_FDCWD。在这种情况下,路径名在当前工作目录中获取。
openat函数希望解决两个问题:一,让线程可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录。同一进程中的所有线程共享相同的当前工作目录,因此很难让同一进程的多个不同线程在同一时间工作在不同的目录中。二,可以避免time-of-check-to-time-of-use(TOCTTOU)错误。
TOCTTOU错误的基本思想是:如果有两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序是脆弱的。因为两个调用并不是原子操作,在两个调用函数之间文件可能改变了,这样就造成了第一个调用的结果不再有效,使得程序的最终的结果是错误的。
函数close
可调用close函数关闭一个打开文件。
#include <unistd.h> int close(int fd); 若成功,返回0;若出错,返回-1。
关闭一个文件时还会释放该进程加在该文件上的所有记录锁。当一个进程终止时,内核自动关闭它所有的打开文件。
函数lseek
每个打开文件都有一个与其关联的当前文件偏移量。它通常是一个非负整数,以度量从文件开始处计算的字节数。通常,读、写操作都从当前文件偏移量处开始,并使偏移量增加所读写的字节数。按系统默认的情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设置为0。
可以调用lseek显式地为一个打开文件设置偏移量。
#include <unistd.h> off_t lseek(int fd, off_t offset, int whence); 若成功,返回新的文件偏移量;若出错,返回-1。
对参数offset的解释与参数whence的值有关。
- 若whence是SEEK_SET,则将该文件的偏移量设置为距文件开始处offset个字节。
- 若whence是SEEK_CUR,则将该文件的偏移量设置为当前值加offset,offset可正可负。
- 若whence是SEEK_END,则将该文件的偏移量设置为文件长度加offset,offset可正可负。
若lseek成功执行,则返回新的相对文件开始处的偏移量。
可使用如下方式确定打开文件的当前偏移量。
off_t currpos; currpos = lseek(fd, 0, SEEK_CUR);
这种方法也可以用来确定所涉及的文件是否可以设置偏移量,如果文件描述符指向的是一个管道、FIFO或网络套接字,则lseek返回-1,并将errno设置为ESPIPE。
通常,文件的当前偏移量应当是一个非负整数,但是,某些设备也可能允许负的偏移量。但对于普通文件,其偏移量必须是非负值。因为偏移量可能是负值,所以在比较lseek的返回值时不要测试是否小于0,而要测试是否等于-1。
lseek仅将当前的文件偏移量记录在内核中,它并不引起任何I/O操作。然后该偏移量用于下一个读或写操作。文件偏移量可以大于文件的当前长度,在这种情况下,对该文件的下一个写将加长该文件,并在文件中构成一个空洞。这是允许的,位于文件中但没有写过的字节都被读为0。文件中的空洞并不要求在磁盘上占用存储区。具体处理方式与文件系统的实现有关,当定位到超出文件尾端之后写时,对于新写的数据需要分配磁盘块,但是对于源文件尾端和新开始写位置之间的部分则不需要分配磁盘块,有无空洞的文件,虽然长度相同,但无空洞的文件占用的磁盘块较少。
od -tcx filename 查看文件的16进制表示形式 od -tcd filename 查看文件的10进制表示形式
函数truncate
#include <unsitd.h> int truncate(const char *path, off_t length); 若成功,返回0;若失败,返回-1。
使用此函数扩展文件大小到一个指定的长度。
函数read
#include <unistd.h> ssize_t read(int fd, void *buf, size_t nbytes); 返回读到的字节数,若已到文件文件尾,返回0;若出错,返回-1。
如read成功,则返回读到的字节数。如已到达文件的尾端,则返回0。
有多种情况可使实际读到的字节数小于要求读的字节数:
- 读普通文件时,在读到要求字节数之前已到达了文件尾端。
- 当从终端设备读时,通常一次最多读一行。
- 当从网络读时,网络的缓冲机制可能造成返回值小于所要求读的字节数。
- 当从管道或FIFO读时,若管道包含的字节少于所需的数量,那么read将只返回实际可用的字节数。
- 当从某些面向记录的设备读时,一次最多返回一个记录。
- 当一信号造成中断,而已经读了部分数据量时。
读操作从文件的当前偏移量处开始,在成功返回之前,该偏移量将增加实际读到的字节数。其次,返回值必须是一个带符号整型(ssize_t),以保证能够返回正整数字节数、0(表示文件尾端)或-1(出错)。
函数write
#include <unistd.h> ssize_t write(int fd, const void *buf, size_t nbytes); 若成功,返回已写的字节数;若出错,返回-1。
其返回值通常与参数nbytes的值相同,否则表示出错。write出错的一个常见原因是磁盘已写满,或者超过了一个给定进程的文件长度限制。从某个偏移位置开始写,如果此位置有数据,则会覆盖。
I/O的效率
大多数文件系统为改善性能都采用某种预读计数。当检测到正进行顺序读取时,系统就试图读入比应用所要求的更多数据,并假想应用很快就会读这些数据。
可使用strace a.out
查看程序运行时使用的系统调用。
使用fgetc/fputc时,底层并不是一次读一个字符,而是使用一个缓冲区,默认4096(预读入,缓输出机制)。而如果read/write每次写一个字符,就会持续进行内核态和用户态的切换。
标准IO函数自带用户缓冲区,系统调用无用户级缓冲区。两种都有系统级缓冲区。
文件共享
如果两个独立进程各自打开了同一个文件,打开该文件的每个进程都获得各自的一个文件表项,但对一个给定的文件只有一个v-node表项。之所以每个进程都获得自己的文件表项,是因为可以使得每个进程都有它自己的对该文件的当前偏移量,对文件的读和写使用同一偏移位置。
- 在完成每个write后,在文件表项中的当前文件偏移量即增加所写入的字节数。如果这导致当前文件偏移量超出了当前文件长度,则将i-node表项中的当前文件长度设置为当前文件偏移量(文件加长)。
- 如果用O_APPEND标志打开一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对这种具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为i-node中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处。
- 若一个文件用lseek定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为i-node表项中的当前文件长度(与用O_APPEND标志打开文件是不同的)。
- lseek函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作。
原子操作
追加到一个文件
早期的open不支持O_APPEND选项,所以要追加到尾端被编写成以下形式:
if (lseek(fd, OL, 2) < 0) printf("lseek error"); if (write(fd, buf, 100) != 100) printf("write error");
若有多个进程同时使用这种方法将数据追加写到同一个文件,则会产生问题。问题在于逻辑操作:先定位到文件尾端,然后写,它使用了两个分开的函数调用。解决问题的方法是使这两个操作对于其他进程而言成为一个原子操作。任何要求多于一个函数调用的操作都不是原子操作,在两个函数调用之间,内核有可能会临时挂起进程。
系统为这种操作提供了一种原子操作方法,即在打开文件时设置O_APPEND标志。这样做使得内核在每次写操作之前,都将进程的当前偏移量设置到该文件的尾端处,于是在每次写之前就不再需要调用lseek。
函数pread和pwrite
#include <unistd.h> ssize_t pread(int fd, void *buf, size_t nbytes, off_t offset); 返回读到的字节数,若读到文件尾返回0;若出错,返回-1。 ssize_t pwrite(int fd, const void *buf, size_t nbytes, off_t offset); 返回已写的字节数;若出错,返回-1。
调用pread相当于调用lseek后调用read,但是又有区别:
- 调用pread时,无法中断其定位和读操作。
- 不更新当前文件偏移量。
创建一个文件
如果在open和creat之间,另一个进程创建了该文件,就会出现问题。若在这两个函数调用之间,另一个进程创建了该文件,并且写入了一些数据,然后,原进程执行这段程序中的creat,这时,刚由另一进程写入的数据就会被擦去。同时使用open函数的O_CREAT和O_EXCL选项,而该文件又已经存在时,open将失败。
函数dup和dup2
两个函数都用来复制一个现有的文件描述符:
#include <unistd.h> int dup(int fd); int dup2(int fd, int fd2);
由dup返回的新文件描述符一定是当前可用文件描述符中的最小数值。对于dup2,可以用fd2参数指定新描述符值,如果fd2已经打开,则先将其关闭。如果fd等于fd2,则dup2返回fd2,而不关闭它,否则,fd2的FD_CLOEXEC文件描述符标志就被清除(这表示该描述符在exec时仍保持有效),这样fd2在进程调用exec时是打开状态。
这些函数返回的新文件描述符与参数fd共享同一个文件表项。
两个描述符指向同一个文件表项,所以它们共享同一文件状态标志(读、写、追加等)以及同一当前文件偏移量。每个文件描述符都有它自己的一套文件描述符标志,新描述符的执行时关闭(close-on-exec)标志总是由dup函数清除。
函数sync、fsync和fdatsync
传统的UNIX系统在内核中设有缓冲区高速缓存或页高速缓存,大多数磁盘I/O都通过缓冲区进行。当向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些再写入磁盘,这种方式称为延迟写。
通常,当内核需要重用缓冲区来存放其他磁盘块数据时,它会把所有延迟写数据块写入磁盘。为了保证磁盘上实际文件系统与缓冲区中内容的一致性,UNIX提供了三个函数。
#include <unistd.h> int fsync(int fd); int fdatasync(int fd); void sync(void);
sync只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘操作结束。通常,称为update的系统守护进程周期性地调用(一般30s)sync函数,这就保证了定期刷新内核的块缓冲区。命令sync也调用sync函数。
fsync函数只对由文件描述符fd指定的一个文件起作用,并且等待写磁盘操作结束才返回。fsync可用于数据库等应用程序,需要确保修改过的块立即写到磁盘上。
fdatasync函数类似于fsync,但它只影响文件的数据部分,而除数据外,fsync还会同步更新文件的属性。
函数fcntl
#include <fcntl.h> int fcntl(int fd, int cmd, ...); 若成功,则依赖于mod;若出错返回-1。
函数fcntl改变一个已经打开的文件的访问控制属性。
F_GETFL: 对应于fd的文件状态作为函数值返回。 F_SETFL: 将文件状态标志设置为第三个参数的值。可以更改的几个标志是 O_APPEND O_NONBLOCK O_SYNC O_DSYNC O_RSYNC O_FSYNC O_ASYNC
int flags = fcntl(fd, F_GETFL); flags |= O_NONBLOCK; int ret = fcntl(fd, F_SETFL, flags);
对文件加上O_SYNC属性则开启了同步写标志,就使每次write都要等待,直至数据已写到磁盘上再返回。在UNIX系统中,通常write只是将数据排入队列,而实际的写磁盘操作则可能在以后的某个时刻进行。而数据库系统则需要使用O_SYNC,这样一来,当它从write返回时就知道数据已确实写到了磁盘上。
fsync和fdatasync两者都更新文件内容,用了O_SYNC标志,每次写入文件时都更新文件内容。
fcntl的必要性:程序在一个描述符(标准输出)上进行操作,但是不知道由shell打开的相应文件的文件名,因此不能在打开时按要求设置O_SYNC标志。使用fcnl,我们只需要知道打开文件的描述符,就可以修改描述符的属性。另外,对于管道,我们所知的只有其描述符。
/dev/fd
较新的系统都提供名为/dev/fd的目录,其目录项是名为0、1、2等的文件。打开文件/dev/fd/n等效于复制描述符n(假定描述符n是打开的)。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)