本章描述的函数经常被称为 不带缓冲 的 I/O(unbuffered I/O)。术语 不带缓冲 指的是每个 readwrite 函数都会调用内核中的一个系统调用。

文件描述符

对于内核而言,所有打开的文件都通过 文件描述符 引用。文件描述符是一个非负整数,当打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。当读、写一个文件时,使用 open 或者 create 返回的文件描述符用于标识该文件,将其作为参数传给 read 或者 write

UNIX 系统把文件描述符 0 与进程的标准输入关联,文件描述符 1 与标准输出关联,文件描述符 2 与标准错误关联。

在符合 POSIX 的应用程序中,魔法值 0、1、2 虽然已经被标准化,但应当把它们替换为符号常量 STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO 以提高可读性。这些常量都在头文件 <unistd.h> 中定义。

函数 open 和 openat

调用 open 函数或 openat 函数可以打开或创建一个文件。

  1. #include <sys/stat.h>
  2. #include <fcntl.h>
  3. int open(const char *path, int oflag, ...);
  4. int openat(int fd, const char *path, int oflag, ...);

path 参数是要打开或创建文件的名字,oflag 参数用来说明此函数的多个选项。用下列一个或多个常量进行「或」运算来构成 oflag 参数:

  • O_RDONLY 只读打开;
  • O_WRONLY 只写打开;
  • O_RDWR 读写打开;
  • O_EXEC 只执行打开;
  • O_APPEND 每次 write 时都追加内容到文件的尾端;
  • O_CREAT 若文件不存在,则会创建;
  • O_TRUNC 如果文件存在,而且为只写或者读写打开时,则会将其长度截断为 0;
  • O_NONBLOCK 当以只写或者读写打开 FIFO(进程间通信)时,或者当打开支持以非阻塞方式打开的块特殊文件和字符特殊文件时,后续的 I/O 操作将会被设置为非阻塞的;
  • O_SYNC 每次 write 等待物理 I/O 操作完成,包括由该写操作引起的更新文件属性所需的 I/O;
  • O_DSYNC 每次 write 等待物理 I/O 操作完成,忽略由该写操作引起的更新文件属性所需的 I/O;
  • ……

openat 函数时 POSIX 最新版本中新增的函数,支持让线程可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录中的文件。

函数 creat

调用 creat 函数可以创建一个新的文件。

  1. #include <sys/stat.h>
  2. #include <fcntl.h>
  3. int creat(const char *path, mode_t mode);

creat 函数等同于调用 open(path, O_WRONLY|O_CREAT|O_TRUNC, mode)

函数 close

调用 close 函数可以关闭一个打开的文件。

  1. #include <unistd.h>
  2. int close(int fildes);

关闭一个文件时,还会释放该进程加在该文件上的所有记录锁。当一个进程终止时,内核自动关闭它所有的打开文件。

函数 lseek

调用 lseek 函数可以显式地为一个打开的文件设置偏移量。

  1. #include <unistd.h>
  2. off_t lseek(int fildes, off_t offset, int whence);

每个打开的文件都有一个与其关联的「当前文件偏移量(current file offset)」。它通常是一个非负整数,用以度量文件开始处计算的字节数。通常,读、写操作都从当前文件偏移量处开始,并使偏移量增加所读写的字节数。

当打开一个文件时,除非指定 O_APPEND 选项,否则该偏移量被设置为 0。

如果文件描述符指向的是管道、FIFO、网络套接字,则 lseek 函数返回 -1,并将 errno 设置为 ESPIPE

函数 read

调用 read 函数可以从打开的文件中读取数据。

  1. #include <unistd.h>
  2. ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);
  3. ssize_t read(int fildes, void *buf, size_t nbyte);

如果 read 函数执行成功,则返回实际读取的字节数,如果已到达文件的尾端,则返回 0。

函数 write

调用 write 函数可以向打开的文件中写入数据。

  1. #include <unistd.h>
  2. ssize_t pwrite(int fildes, const void *buf, size_t nbyte,
  3. off_t offset);
  4. ssize_t write(int fildes, const void *buf, size_t nbyte);

I/O 的效率

大多数文件系统为改善性能,都会采用某种预读(read ahead)技术。当检测到正进行顺序读取时,系统就会试图读入比应用所需的更多数据,并假设应用很快就会读取到这些数据。

原子操作

追加文件内容

早期版本的 UNIX 系统不支持 open 函数的 O_APPEND 选项,因此追加文件内容的逻辑会被写成:

  1. if (lseek(fd, OL, 2) < 0) {
  2. err_sys("lseek error");
  3. }
  4. if (write(fd, buf, 100) != 100) {
  5. err_sys("write_error");
  6. }

被拆分成两个函数调用的「先定位到文件尾端,然后写入内容」逻辑代码是非原子性的,因为在两个函数调用之间,内核有可能会临时挂起进程。

UNIX 系统为这样的操作提供了一种保证原子性的方法,即在打开文件时设置 O_APPEND 选项。

函数 pread 和 pwrite

调用 pread 函数和 pwrite 函数可以原子性地定位并执行 I/O 操作。

  1. #include <unistd.h>
  2. ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);
  3. ssize_t pwrite(int fildes, const void *buf, size_t nbyte,
  4. off_t offset);

调用 pread 函数或 pwrite 函数相当于先调用 lseek 函数再调用 read 函数或 write 函数,但又有一些区别:

  • 调用 pread 函数或 pwrite 函数时,无非中断其定位和读写操作;
  • 不会更新当前文件的偏移量。

创建一个文件

open 函数的 O_CREAT 选项支持原子性的「检查文件是否存在,当文件不存在时则创建文件」的逻辑操作。

函数 dup 和 dup2

调用 dup 函数或 dup2 函数可以复制一个现有的文件描述符。

  1. #include <unistd.h>
  2. int dup(int fildes);
  3. int dup2(int fildes, int fildes2);

函数 sync、fsync 和 fdatasync

传统的 UNIX 系统实现,在内核中设有缓冲区高速缓存或页高速缓存,大多数磁盘 I/O 都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式被称为「延迟写(delayed write)」。

通常,当内核需要重用缓冲区来存放其它磁盘的数据块时,它会把所有延迟写的数据块写入磁盘。为了保证磁盘上实际文件系统与缓冲区中的内容一致,UNIX 系统提供了 syncfsyncfdatasync 三个函数。

  1. #include <unistd.h>
  2. void sync(void);
  3. int fsync(int fildes);
  4. int fdatasync(int fildes);

sync 函数只是将所有修改过的在缓冲区中的数据块排入写队列,然后就返回,它并不等待实际写磁盘的操作结束。通常,称为 update 的系统守护进程会周期性地调用(一般间隔 30s)sync 函数。

fsync 函数只对由文件描述符 fd 指定的一个文件起作用,并且等待写磁盘的操作结束时才返回。fsync 可用于数据库这样的应用程序,这类应用程序需要确保修改过的数据块立即写到磁盘上。

fdatasync 函数类似于 fsync 函数,但它只影响文件的数据部分。而 fsync 函数除了对数据部分,还会同步更新文件的属性。

函数 fcntl

调用 fcntl 函数可以修改已经打开文件描述符/文件状态的标志。

  1. #include <fcntl.h>
  2. int fcntl(int fildes, int cmd, ...);

fcntl 函数支持以下 5 种功能:

  1. 复制一个已有的描述符;
  2. 获取/设置文件描述符的标志;
  3. 获取/设置文件状态的标志;
  4. 获取/设置异步 I/O 所有权;
  5. 获取/设置记录锁。

在 UNIX 系统中,通常 write 只是将数据排入队列,而实际的写磁盘操作则可能在之后的某个时刻进行。数据库系统则会需要使用 O_SYNC 选项,当它从调用 write 函数返回时就可以确保数据已经的确写入到磁盘上了,以系统在异常时导致数据丢失。

程序在运行时,设置 O_SYNC 标志会增加系统时间和时钟时间,其原因是内核需要从进程中复制数据,并将数据排入队列以便由磁盘驱动器将其写到磁盘上。当使用同步写入时,系统时间和时钟时间便会显著增加。

函数 ioctl

ioctl 函数一直是 I/O 操作的杂物箱,不能用本章中其它函数表示的 I/O 操作通常都能用 ioctl 函数来完成。终端 I/O (例如树莓派的 GPIO)是使用 ioctl 最多的地方。

  1. #include <stropts.h>
  2. int ioctl(int fildes, int request, ... /* arg */);