UNIX file I/O

注意事项

  1. creat以只写方式打开文件,不能进行读操作。
  2. 为什么有了open要有creat,早期的open只支持0,1,2三个flag,不能打开不存在的文件,需要有单独的系统调用创建文件。而有了新的open以后就不需要creat了。
  3. opendup返回的文件描述符一定是最小的未使用的文件描述符。
  4. O_APPEND打开的文件,如果进行write的话,即使使用lseek定位到非文件结尾处,最后也是在文件结尾处进行写。因为使用O_APPENDwrite是由两个系统调用函数lseek和“普通的”write构成的一个操作。而read操作可以使用lseek进行定位。
  5. 所有的磁盘I/O都要经过内核的block buffers块缓存区,也称为内核的(buffer cache)缓冲区高速缓存。有一个例外就是对原始磁盘设备的I/O,先不考虑这种情况。readwrite的数据都要被内核进行缓冲,术语unbuffered I/O指的是在用户的进程中不会对这两个函数进行自动缓冲,每次readwrite都会进行一次系统调用。

文件I/O

UNIX系统中的大多数文件I/O只用到了5个函数:open,read,write, lseekclose。不同的缓冲长度对readwrite的速度影响。
本章介绍的函数通常被称为不带缓冲的I/O,不带缓冲的I/O指的是每个readwrite都调用内核中的一个系统调用,它们不是ISO C的组成部分,但是,它们都是POSIX.1和SUS的组成部分。
只要涉及在多个进程之间共享资源,原子操作的概念就非常重要。本章还进行一步讨论在多个进程之间如何共享文件,以及所涉及的内核有关数据结构。相应的函数有:dup, fcntl,sync, fsyncioctl等。

文件描述符

对于内核而言,所有打开的文件都是通过文件描述符引用。文件描述符是一个非负整数。当打开或者创建一个新文件时,内核向进程返回一个文件描述符。当读,写一个文件时,使用open或者creat返回的文件描述符标识该文件,将其作为参数传递给read或者write
UNIX系统shell把文件描述符0和进程的标准输入关联,文件描述符1和标准输出关联,文件描述符2和标准错误关联。为了提高系统的可读性,通常把它们换成符号常量STDIN_FILENO,STDOUT_FILENOSTDERR_FILENO,它们都在头文件<unistd.h>中定义。
文件描述符的变化范围是0到OPEN_MAX-1,早起的UNIX系统实现采用的上限值是19,现在的很多系统将它增加到63。(对于Linux, FreeBSD等的很多版本,文件描述符的变化范围几乎是无限的,只受到硬件资源的约束)

open,openatcreat, close

函数原型:

1
2
3
4
5
6
7
8
9
10
11
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

int creat(const char *pathname, mode_t mode);

int openat(int dirfd, const char *pathname, int flags);
int openat(int dirfd, const char *pathname, int flags, mode_t mode);

参数pathname是路径名,可以是相对路径也可以是绝对路径,flags的选项有很多,它们定义在<fcntl.h>头文件中。flag参数必须在O_RDONLY,O_WRONLYO_RDWR之中选且只能选一个。然后还有很多其他的可选flag,常见的有:O_APPENDO_CREATO_EXCL, O_DIRECTORY等,使用man 2 open就可以查看。

openat vs open

openopenat返回的一定是最小的没有使用的文件描述符。可以利用这一点可以在标准输入,标准输出,或者标准错误上打开新的文件。一个应用程序可以先关闭标准输出,然后打开另一个文件,执行打开操作前就能了解到该文件一定会在文件描述符1上打开。
dirfd参数是openopenat的区别,它们之间的关系有以下三种:

  1. pathname指定的是绝对路径名,dirfd参数被忽略,openopenat一样。
  2. pathname指定的是相对路径名,dirfd制定了相对路径名在文件系统中的开始地址,dirfd参数通过打开相对路径名的目录来获取。
  3. pathname指定了相对路径名,dirfd的参数是特殊值AT_FDCWD,这种情况下,路径名是在当前工作目录中获取,openatopen在操作上类似。

openat作用

为什么增加openat函数,它的目的是解决两个问题:

  1. 让线程可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录。同一进程中的所有线程共享相同的当前工作目录。
  2. 避免time-of-check-to-time-of-use错误。它的基本思想是,如果有两个基于文件的系统调用,第二个调用的结果依赖于第一个调用的结果,那么程序是脆弱的。因为两个调用并不是原子操作,在两个函数调用之间,文件可能改变了,这就造成了第一个调用的结果不再有效,使得程序的最终结果是错误的。

文件名和路径名过长

当文件名和路径名过长时,是截断为系统允许的最长量还是返回出错信息?这个是由系统的历史形成的。通常BSD和Linux总是会返回出错,而System V和Solaris等不一定。
具体的可以根据POSIX.1定义的常量_POSIX_NO_TRUC决定是截断还是出错。根据文件系统的类型,这个值可以变换。可以使用fpathconf或者pathconf查询目录具体支持哪种行为。
如果_POSIX_NO_TRUC有效,当路径名超过PATH_MAX或者路径名中的任一文件名超过NAME_MAX时,返回出错,并将errno设置为ENAMETOOLONG

createopen

create其实相当于指定了open的flags为O_WRONLY|O_CREAT|O_TRUNC
为什么有了open还要有creat,在早期的UNIX版本中,flags只能为0,1或者2。无法打开一个不存在的文件。因此需要另一个系统调用creat创建新文件。现在的open系统调用提供了O_CREATO_TRUNC选项,也就不需要creat了。

creat的不足:creat只写方式打开所创建的文件,即创建新文件之后,只能对新文件进行写操作,不能进行读操作。如果要创建一个临时文件,先写文件,然后再读文件。在open的老版本时,即不能打开不存在的文件时,需要先使用creat创建新文件,然后关闭该文件,然后使用open读文件。现在的话,可以使用以下方式实现创建新文件并进行读写:

1
open(path, O_RDWR|O_CREAT|O_TRUNC, mode);

close函数

调用close关闭一个已经打开的文件。函数原型:

1
2
3
#include <unistd.h>

int close(int fd);

创建文件

  1. 创建一个只写文件(如果文件存在,将文件清零):

    1
    2
    creat(filename, mode);
    open(filename, O_WRONLY|O_CREAT|O_TRUNC, mode);

  2. 创建一个读写文件(如果文件存在,将文件清零):

    1
    open(filename, O_RDWR|O_CREAT|O_TRUNC);

  3. 检测并创建一个只写文件( 如果文件存在,错处,返回-1):

    1
    open(filename, O_WRONLY|O_CREAT|O_EXCL, mode);

  4. 检测并创建一个读写文件( 如果文件存在,错处,返回-1):

    1
    open(filename, O_RDWR|O_CREAT|O_EXCL, mode);

lseek, read, write

它们的原型如下所示:

1
2
3
4
5
6
#include <sys/types.h>
#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

当前文件偏移量

  1. 每一个打开文件都有一个和它相关联的当前文件偏移量(current file offset)。它通常是一个非负整数,用来度量从文件开始处的字节数。
  2. 通常情况下,读写操作都是从current file offset开始的,并且使偏移量增加读写的字节数。
  3. 除了指定O_APPEND选项外,打开一个文件时,默认的current file offset都是0。
  4. whence有三个取值,SEEK_SET, SEEK_CUR, SEEK_ENDlseek成功执行,返回的offset等于whence+offset,对于SEEK_CURSEEK_END来说,参数offset可正可负,只要保证返回的current file offset非负即可。
  5. lseek中的l表示long
  6. current file offset可以大于文件长度,这种情况会在文件中构成一个空洞。空洞不要求占据磁盘上的存储区。

read

1
ssize_t read(int fd, void *buf, size_t count);

read函数从文件描述符标示的文件中读取至多count个字节到buf指定的位置。如果操作成功的话,返回读到的字节数,如果已经到了文件结尾,返回0。出错的话,返回-1,设置errno。
在以下几种情况下,读到的字节数可能少于count

  1. 读普通文件时,在读满count个之前就已经到了文件尾端。
  2. 某个信号造成中断时,而已经读取了部分数据时。
  3. 从终端设备读时,通常一次最多读一行。
  4. 从网络读时,网络中的缓冲机制。
  5. 从管道或者FIFO读取时,管道包含的字节数少于count
  6. 从面向记录的设备读时,一次最多返回一个记录。
  7. 第二个参数void*表示通用指针。
  8. 返回值ssize_t是有符号类型,因为它需要返回正整数字节,0和-1。
  9. 第三个参数size_t是一个无符号类型。

write

1
ssize_t write(int fd, const void *buf, size_t count);

它的返回值通常和count一样,否则就是出错了。出错的常量原因有:

  1. 磁盘满了,
  2. 超过了一个给定进程的文件长度限制。
  3. 如果lseek返回的当前文件偏移量不在文件结尾,write会覆盖掉相应位置的数据。

对于普通文件,写操作从文件的当前偏移量开始,如果打开文件时指定了O_APPEND选项,那么文件偏移量设置在文件结尾处。在一次写成功之后,文件偏移量增加实际写的字节数。

创建含有空洞的文件

lseek使得当前文件偏移量超过了现有文件长度,再继续进行write之后,当前文件偏移量和文件长度之间的内容就是空洞,它一般不占用磁盘空间,但是使用ls时,会把它计算成字节长度。

I/O的效率

进程终止时,UNIX系统内核会关闭所有打开的文件描述符,但是并不会关闭标准输入和输出。
在选取readwrite的buffer大小时,也有一定技巧。大多数文件系统都使用了预读(read ahead)技术。当进行顺序读取时,系统试图读入比应用所要求的更多数据,并且假设应用很快就会读这些数据。
在使用ext4文件系统时,它的磁盘块长度是4096,所以当BUFFER大于等于4096时,读写时间几乎不变。

文件共享

I/O数据结构

UNIX支持在不同进程之间共享打开文件。这需要使用到内核用于I/O的数据结构。内核使用三种数据结构表示打开文件:进程表记录项,文件表项和节点表项。

  1. 进程表记录项。每个进程在进程表中都有一个记录项,记录项中包含一张打开文件描述符表,每隔描述符占用一项,其中内容有:文件描述符标志和指向文件表项的指针。
  2. 文件表项。内核为所有打开文件维持一张文件表。每个表项包含:文件状态标志,当前文件偏移量和指向该文件节点表项的指针
  3. 节点表项。每个打开设备都有一个节点结构。包含文件的所有者,文件长度,指向文件实际数据块在磁盘上所在的指针等。

如果两个进程打开了同一个文件,每个进程都会获得各自相应文件的一个文件表项,这两个文件表项中的节点表项指针指向同一个节点表项。也有可能多个进程的文件描述符指向同一个文件表项。
自己的总结,每一个文件都有一个节点表项,记录文件长度和数据存储地址,而文件表项记录的是在节点表项的哪个位置进行什么操作,进程表记录项记录了每个进程打开了几个文件,每个文件的文件表项在哪里。

writelseek对当前文件偏移量的影响

  1. write在写入完成后,在文件表项的当前文件偏移量上加上写入的字节数,如果当前文件偏移量超过了当前文件长度,更新节点表项中的文件长度,相当于文件长度增加了。
  2. lseek只修改文件表项中的当前文件偏移量,不进行任何I/O操作。
  3. lseel定位到文件尾端的时候,文件表项中当前文件偏移量被设置为节点表项中的当前文件长度。
  4. 使用O_APPEND打开文件的时候,文件表项中的文件状态标志也会被修改,对于使用O_APPEND操作打开的文件,进行write操作相当于先将当前文件偏移量设置为节点表项中的文件长度,然后再write,即使先使用lseek将当前文件偏移量设置为SEEK_SET也不行,也是进行追加。所以在每次append之前不用先进行lseeklseek了也白做。但是read可以使用lseek正常进行。

原子操作

如果一个操作是原子操作,那么这个操作的所有步骤要么不执行,要不全部执行。

追加文件

指定openO_APPEND选项实现追加操作,

1
2
fileno=open(filename, O_RDWR|O_APPEND);
write(file, buf, BUFSIZE);

追加文件是一个原子操作,如果不是原子操作的话,就相当于:

1
2
3
fileno = open(filename, O_RDWR);
lseek(fileno, 0, SEEK_END);
write(fileno, buf, BUFSIZE);

如果是单进程,上面两段代码是等价的,但是如果是多进程的话,下面代码就可能会出错。进程A lseek,进程B lseek,进程A write,进程B write。进程B的操作会覆盖进程A的操作。
所以这也就解释了使用选项O_APPEND后的操作,因为这个append的write是由两个系统调用组成的原子操作,先lseek,再普通的write。所以在调用write之前不用lseek,就算你lseek了也是白lseek

读写原子操作

读写的原子操作原型如下:

1
2
3
4
#include <unistd.h>

ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

调用pread相当于调用lseekread的原子操作,但是pread不改变当前文件偏移量。
调用write相当于调用lseekwrite的原子操作,但是pwrite不改变当前文件偏移量。

创建文件原子操作

检查文件是否存在和创建文件是一个原子操作。如果这个操作不是原子操作,比如说是由opencreat两个函数调用组成的一个操作,它们不是一个原子操作。当前进程确定一个文件不存在,决定创建该文件。在opencreat调用之间,另一个进程创建了这个文件,并写入了数据。当前进程会再次创建这个文件,覆盖掉另一个进程写入的数据。

dupdup2复制文件描述符

UNIX系统提供了两个原子操作dupdup2对一个指定的文件描述符进行复制。如果得到的新文件描述符和fd不同,那么这两个文件描述符共享同一个文件表项。

1
2
3
4
5
6
7
8
9
#include <unistd.h>

// dup返回的文件描述符一定是当前可用文件描述符中的最小值。和open一个文件类似。
int dup(int fd);

//可以通过fd2指定返回的新的文件描述符。
// 如果fd2和fd相等,返回fd2
// 如果fd2和fd不等,关闭fd2,然后返回fd2。
int dup2(inf fd, int fd2);

非原子操作的文件描述符复制可以通过fcntl实现。

sync,fsyncfdatasync

UNIX系统在内核中设置了缓冲区高速缓存或者页高速缓存,大多数磁盘的I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些写入磁盘,这方方式叫做延迟写
等到内核需要使用缓冲区存放其他磁盘块数据时,它会把所有延迟写数据写入磁盘。为了保证磁盘上实际文件系统和缓冲区中内容的一致性,UNIX提供了三个函数,它们的原型如下:

1
2
3
4
5
6
7
8
9
10
11
#include <unistd.h>

// 1.将所有修改过的块缓冲区排入队列,然后就返回,并不等待实际写磁盘操作结束。
// 通常情况下,update系统守护进程一般每隔30秒调用一次`sync`函数,这就保证了定期将内核块缓冲区的内容写入磁盘。命令sync(1)也会调用`sync`函数。
void sync(void);

// 2.只对文件描述符`fd`指定的一个文件起作用,等到写磁盘操作结束才返回。更新文件的数据和属性。
int fsync(int fd);

// 3.只对文件描述符`fd`指定的一个文件起作用,等到写磁盘操作结束才返回。只更新文件的数据。
int fdatasync(int fd);

fcntl

fcntl是文件控制函数,它的原型如下:

1
2
3
4
#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

fcntl有很多种功能,这一节先介绍以下五种:

  1. 复制一个已有的描述符,设置cmdF_DUPFD或者F_DUPFD_CLOEXEC
  2. 获取和设置文件描述符标志,设置cmdF_GETFD或者F_SETFD。当前只有一个文件描述符标志,就是FD_CLOEXEC
  3. 获取和设置文件状态标志,设置cmdF_GETFL或者F_SETFL
    获取文件状态标志时,介绍open时给出了许多文件状态标志。对于五个互斥的权限,需使用O_ACCMODE取得访问方式位,然后与相应的权限比对。对于其他的权限,将返回值和相应的标志进行与操作,判断是否设置了相应位。
    设置文件状态标志位时,可以更改的几个权限有,O_APPEND, O_NONBLOCK, O_SYNC, O_DSYNC, O_RSYNC, O_FSYNC, O_ASYNC
  4. 获取和设置异步I/O所有权,设置cmdF_GETOWN或者F_SETOWN
  5. 获取和设置记录锁,设置cmdF_GETLK,或者F_SETLK或者F_SETLKW

在修改文件描述符标志或者文件状态标志时,必须要先获得现在的标志值,然后对它进行修改,获得新的标志值,然后进行设置。不能单单设置一个标志值,否则会关闭以前设置的标志位。

ioctl

这个有点看不懂。

/dev/fd

UNIX提供了/dev/fd目录,其中包含了名为0, 1, 2的文件。打开/dev/fd/0,/dev/fd/1, /dev/fd/2相当于复制描述符n。即:

1
2
fd = open("/dev/df/0", mode);
fd = dup(0);

上述两行代码是相等的。文件描述符0和fd共享同一个文件表项。在Linux中,文件描述符被映射成指向底层物理文件的符号链接。比如打开/dev/fd/0时,实际上打开的是与标准输入关联的文件。返回的新文件描述符的mode和/dev/fd文件描述符的mode并不相关。所以,即使我们使用O_RDWR mode打开/dev/fd/0,也不能对fd进行写操作。
Linux下提供了/dev/stdin/dev/stdout, /dev/stderr,它们和/dev/fd/0等都是一样的。在shell中,可以使用dev/fd作为参数,把标准输入和输出当做一个文件,可以像处理其他文件一样进行操作。

参考文献

1.《APUE》第三版