TLPI:文件 I/O
1. 文件描述符原理
所有执行 I/O 操作的系统调用都以文件描述符(一个非负整数)来指代打开的文件,文件描述符用以表示所有类型的已打开文件,包括管道、FIFO、socket、终端设备和普通文件。
inode:系统级,不考虑硬链接时,它与文件数据一一对应
打开文件句柄(open file handle):系统级,不同的 handle 可能指向同一个 inode(e.g. open 同一个文件)
文件描述符:进程级,不同的描述符可能指向同一个 handle(e.g. dup、fcntl、fork)
2. 通用 I/O 模型
4 个基本的 I/O 系统调用为:open、read、write、close,它们可以对所有类型的文件执行 I/O 操作。这些系统调用实现一个简易的 copy 命令如下。
#include <sys/stat.h>
#include <fcntl.h>
#include "tlpi_hdr.h"
#ifndef BUF_SIZE /* Allow "cc -D" to override definition */
#define BUF_SIZE 1024
#endif
int
main(int argc, char *argv[])
{
int inputFd, outputFd, openFlags;
mode_t filePerms;
ssize_t numRead;
char buf[BUF_SIZE];
if (argc != 3 || strcmp(argv[1], "--help") == 0)
usageErr("%s old-file new-file\n", argv[0]);
/* Open input and output files */
inputFd = open(argv[1], O_RDONLY); // 仅设置访问模式
if (inputFd == -1)
errExit("opening file %s", argv[1]);
openFlags = O_CREAT | O_WRONLY | O_TRUNC;
filePerms = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP |
S_IROTH | S_IWOTH; /* rw-rw-rw- */
outputFd = open(argv[2], openFlags, filePerms); // 设置访问模式、访问权限
if (outputFd == -1)
errExit("opening file %s", argv[2]);
/* Transfer data until we encounter end of input or an error */
while ((numRead = read(inputFd, buf, BUF_SIZE)) > 0)
if (write(outputFd, buf, numRead) != numRead)
fatal("write() returned error or partial write occurred");
if (numRead == -1)
errExit("read");
if (close(inputFd) == -1)
errExit("close input");
if (close(outputFd) == -1)
errExit("close output");
exit(EXIT_SUCCESS);
}
我对该命令程序进行实验,发现当指定终端/dev/pts/1
为输入文件时,会出现终端敲击的字符无法被全部读入的情况,且一次只能读入 1 个字节(少数时候是 2 字节),哪些字符能被读入看起来是随机的。但如果代码中指定STDIN_FILENO
为输入文件,然后从运行命令程序的终端输入字符,此时运行效果是符合预期的。暂时没搞清楚其中的原理,只先记录现象如下(读入字节数的提示是额外加上的),
2.1. 部分读和部分写
“部分读”是指一次 read 调用读取的字节数小于请求的字节数,它可能在以下情况发生:
读取到普通文件的末尾(EOF),此时会返回已经读取的字节数,并设置
errno
为 0(表示没有错误);当终端为标准输入时,遇到换行符;
其它场景(后续遇到再总结)
“部分写”是指一次 write 调用写入的字节数小于请求的字节数,它可能在以下情况发生:
磁盘已满;
进程资源对文件大小的限制;
其它场景(后续遇到再总结)
3. 高级特性
3.1. 原子操作和竞争条件
TLPI [1] 中关于系统调用原子性的表述如下,我的疑问是:系统调用都是原子操作吗,有没有例外?
All system calls are executed atomically. By this, we mean that the kernel guarantees that all of the steps in a system call are completed as a single operation, without being interrupted by another process or thread.
“竞争条件”是指程序执行的结果会因为进程的调度顺序而变得无法预测,而原子操作通过规避竞争条件来保证结果的正确性。下面我们举一个例子。
考虑问题:两个进程如何写同一个文件而不发生冲突?
首先,从文件描述符原理可以知道,当两个进程各自调用 open 函数打开同一个文件时,内核“打开文件表”会创建两个打开文件句柄,且它们指向同一个实际的文件。由于文件偏移量 offset 是与 open file handle 关联的,所以在下图中 h1 和 h2 的 offset 相互独立。如果 process1 和 process2 一起运行,必然会出现写冲突(覆盖)。
我们通过下面的程序做个实验。两个进程各自打印 1000 个字符,当它们同时运行时最终只能打印出 1000 个字符,因为每个进程都是各写各的,每一轮打印都会出现写覆盖。
// ./non_exclusively_write tmp.txt &./non_exclusively_write tmp.txt 并发运行
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int
main(int argc, char *argv[])
{
int fd = open(argv[1], O_RDWR);
if (fd == -1) {
printf("failed to open\n");
return 1;
}
for (int j = 0; j < 1000; j++) {
if (write(fd, "0", 1) != 1) {
printf("write() failed");
return 1;
}
}
printf("%ld done\n", (long) getpid());
}
那如果每次写之前都更新一下 open file handle 的文件偏移量到末尾,是不是就不会出现覆盖了?答案是仍然无法避免。因为 lseek 和 write 两个动作并非一个原子操作。如果当某个进程执行完 lseek 之后,CPU 立刻切换到另一个进程,那么此时两个进程会在相同的偏移量上写入,导致写覆盖,也就是说存在竞争条件。对于下面的例子,如果两个进程同时运行,最终文件中字符的个数基本不会是 2000,而是少于 2000,且个数是不确定的,因为其中某些轮次的打印发生了写覆盖。
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int
main(int argc, char *argv[])
{
int fd = open(argv[1], O_RDWR);
if (fd == -1) {
printf("failed to open\n");
return 1;
}
for (int j = 0; j < 1000; j++) {
if (lseek(fd, 0, SEEK_END) == -1) { // 手动调整 offset
printf("lssek() failed");
return 1;
}
if (write(fd, "0", 1) != 1) {
printf("write() failed");
return 1;
}
}
printf("%ld done\n", (long) getpid());
}
那有没有办法让 lseek 和 write 操作成为一个原子操作呢?有办法。只需要在打开文件时添加 O_APPEND 标志,即可自动在 write 时将 offset 调整到文件末尾然后再写入(此时无需再手动调用 lseek),且保证这是一个原子操作。优化过后,对于下面的程序,每次两个进程并发运行都能够完整打印出 2000 个字符。
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int
main(int argc, char *argv[])
{
int fd = open(argv[1], O_RDWR | O_APPEND); // 添加 O_APPEND 标记
if (fd == -1) {
printf("failed to open\n");
return 1;
}
for (int j = 0; j < 1000; j++) {
if (write(fd, "0", 1) != 1) {
printf("write() failed");
return 1;
}
}
printf("%ld done\n", (long) getpid());
}
3.2. 复制文件描述符
考虑问题:如何实现下面的标准错误的重定向?
./myscript > results.log 2>&1
该 shell 语句的效果是把标准错误和标准输出的内容都打印到 results.log 文件中,那么通过打开 results.log 文件两次获得两个文件描述符,然后分别打印常规信息和错误信息,是否可行?显然不可行,因为这样会产生两个 open file handle,它们无法共享相同的文件偏移量,会导致写覆盖。此时,新的系统调用 dup 出场了。
dup/dup2/dup3 会复制一个已经打开的文件描述符,并返回一个新的描述符,二者指向同一个 open file handle,因而共享文件偏移量。
3.3. I/O 缓冲
read 和 write 系统调用并不会直接发起磁盘访问,而是仅在用户空间缓冲和内核空间缓冲之间复制数据。举个例子,write(fd, "abc", 3)
在将数据从用户空间缓冲写到内核空间缓冲之后会立即返回,在后续的某个时刻内核会将数据落到磁盘。 同理,read 也是先从内核缓冲开始读取数据,直到其中的数据全部读完,内核才会将文件的下一段内容读入内核空间缓冲(简单理解是这样,实际会复杂一些)。道理很容易理解,因为磁盘访问很慢,内核的这一机制就是尽量减少对磁盘的访问次数。
上述是内核层面对 I/O 的优化,在用户层面,当需要传输的数据量不变时,通过增加单次传输的数据量(BUF_SIZE)尽可能减少系统调用也是一个常见的优化手段(相关的基准测试数据参 13.1节)。这是因为当系统调用次数很多时,系统调用本身上下文切换的开销是很客观的。C 语言函数库的 I/O 函数,如 fprintf()、fscanf()、fgets() 等,就是这么做的!
标准库和内核提供了相关的机制,让我们有办法控制用户空间缓冲、内核空间缓冲和磁盘间的数据传输行为,或者跳过其中的某一个环节,详细方法不在这里赘述。下图很好地总结了 I/O 缓冲原理和可能的干预手段。
3.4. 非阻塞 I/O
如果没有特殊声明,I/O 系统调用都会阻塞直到完成数据传输。这里所言的“阻塞”就是程序会停留在系统调用函数上,不会继续往下执行(不宜将其与 OS 调度中的线程休眠联系在一起,因为 I/O 系统调用完成前线程是否会休眠是不一定的。我们说“任务阻塞”可能比“进程/线程阻塞”要更好理解些)。如果数据传输无法马上完成(e.g. 管道、socket 空间不足无法写全部数据,类比 golang channel 就很容易理解),都会导致空等待。但有时我们不希望 I/O 操作产生空等待,此时可以通过 O_NONBLOCK 标识设置非阻塞 I/O 模式。管道、FIFO、socket、终端都支持该模式,不过管道和 socket 无法通过 open 系统调用来设置 O_NONBLOCK,只能使用 fcntl 调用。
4. 参考资料
《Linux/Unix 系统编程手册》(Ch4、5、13、63)