TLPI 第5章 读书笔记:File I/O: Further Details
笔记和练习博客总目录见:开始读TLPI。
在本章中,我们将继续讨论上一章中开始的文件输入/输出(I/O)问题。
在继续讨论 open() 系统调用时,我们解释了原子性(atomicity)的概念——即系统调用执行的操作作为一个不可中断的单步完成。这是许多系统调用正确运行的必要条件。
我们引入了另一个与文件相关的系统调用——多用途的 fcntl(),并展示了它的一个用途:获取和设置已打开文件的状态标志。
接下来,我们将研究用于表示文件描述符和已打开文件的内核数据结构。理解这些结构之间的关系有助于澄清后续章节中讨论的一些文件 I/O 的细节。在此模型的基础上,我们随后解释如何复制文件描述符。
然后,我们考虑一些提供扩展读写功能的系统调用。这些系统调用允许我们在文件的特定位置进行 I/O 而不改变文件偏移量,并能够在程序中在多个缓冲区之间传输数据。
我们简要介绍了非阻塞 I/O 的概念,并描述了一些用于支持非常大文件 I/O 的扩展。
由于许多系统程序会使用临时文件,我们还将介绍一些库函数,这些函数允许我们创建和使用具有随机生成的唯一名称的临时文件。
5.1 Atomicity and Race Conditions
原子性是一个我们在讨论系统调用操作时会反复遇到的概念。所有系统调用都是以原子方式执行的。这里的原子性意味着内核保证系统调用的所有步骤作为一个整体操作完成,不会被其他进程或线程中断。
💡 这里说的不会被其他进程或线程中断,是指系统调用执行过程中,不会做上下文切换(context switch),但被信号中断是可能的,例如在read(2)中,就有以下说明:
EINTR The call was interrupted by a signal before any data was read; see signal(7).
原子性对于某些操作的成功完成是必不可少的。特别是,它使我们能够避免竞争条件(有时称为竞态危险)。竞争条件是指两个进程(或线程)在操作共享资源时,其产生的结果以一种非预期的方式,依赖于这两个进程获取 CPU 使用权的相对执行顺序。
接下来,我们将讨论涉及文件 I/O 的两种会发生竞争条件的情况,并展示如何通过使用保证相关文件操作原子性的 open() 标志来消除这些条件。
当我们在第 22.9 节描述 sigsuspend() 和第 24.4 节描述 fork() 时,我们将再次讨论竞争条件这一主题。
Creating a file exclusively
在第4.3.1节中,我们注意到,如果在使用O_CREAT时同时指定O_EXCL,则open()会在文件已存在时返回错误。这为进程提供了一种确保自己是文件创建者的方法。文件的先前存在检查和文件创建是原子操作。为了理解这点的重要性,可以考虑清单5-1中显示的代码,这段代码是在没有O_EXCL标志时我们可能会使用的。(在这段代码中,我们显示由getpid()系统调用返回的进程ID,这使我们能够区分该程序的两次不同运行的输出。)
Listing 5-1: Incorrect code to exclusively open a file
// fileio/bad_exclusive_open.c
fd = open(argv[1], O_WRONLY); /* Open 1: check if file exists */
if (fd != -1) { /* Open succeeded */
printf("[PID %ld] File \"%s\" already exists\n",
(long) getpid(), argv[1]);
close(fd);
} else {
if (errno != ENOENT) { /* Failed for unexpected reason */
errExit("open");
} else {
/* WINDOW FOR FAILURE */
fd = open(argv[1], O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1)
errExit("open");
printf("[PID %ld] Created file \"%s\" exclusively\n",
(long) getpid(), argv[1]); /* MAY NOT BE TRUE! */
}
}
除了使用两次 open() 调用的冗长写法之外,清单 5-1 中的代码还包含一个错误。假设当我们的进程第一次调用 open() 时,文件并不存在,但到第二次调用 open() 时,其他进程已经创建了该文件。如果内核调度器决定该进程的时间片已经用尽而将控制权交给另一个进程,如图 5-1 所示,或者在多处理器系统上两者同时运行,这种情况就可能发生。图 5-1 描述了两个进程都在执行清单 5-1 中代码的情况。在这种情况下,进程 A 会错误地认为它已经创建了文件,因为无论文件是否存在,第二次 open() 调用都会成功。
虽然进程错误地认为自己是文件创建者的可能性相对较小,但这种可能性仍然使得该代码不可靠。由于这些操作的结果取决于两个进程的调度顺序,这意味着这是一个竞态条件。
Figure 5-1: Failing to exclusively create a file
此图略,已看懂。
为了证明这段代码确实存在问题,我们可以将清单 5-1 中注释掉的 WINDOW FOR FAILURE 行替换为一段在检查文件存在性和创建文件之间制造人为长延迟的代码:
printf("[PID %ld] File \"%s\" doesn't exist yet\n", (long) getpid(), argv[1]);
if (argc > 2) { /* Delay between check and create */
sleep(5); /* Suspend execution for 5 seconds */
printf("[PID %ld] Done sleeping\n", (long) getpid());
}
sleep() 库函数会使进程的执行暂停指定的秒数。我们在第 23.4 节讨论了这个函数。
如果我们同时运行清单 5-1 中程序的两个实例,我们会看到它们都声称已经独占地创建了该文件:
$ ./bad_exclusive_open tfile sleep &
[1] 15340
$ [PID 15340] File "tfile" doesn't exist yet
$ ./bad_exclusive_open tfile &
[2] 15341
$ [PID 15341] File "tfile" doesn't exist yet
[PID 15341] Created file "tfile" exclusively
[PID 15340] Done sleeping
[PID 15340] Created file "tfile" exclusively <- 这个是错误的
[1]- Done ./bad_exclusive_open tfile sleep
[2]+ Done ./bad_exclusive_open tfile
两个进程都声称自己创建了该文件,因为第一个进程的代码在存在性检查和文件创建之间被中断了。使用指定了 O_CREAT 和 O_EXCL 标志的单个 open() 调用可以防止这种情况,因为它保证检查和创建步骤作为单个原子(即不可中断)操作执行。
💡 O_CREAT的作用是文件不存在时创建他,如果文件已存在,并且未指定O_EXCL时,open调用并不会引起错误。
Appending data to a file
需要原子性的第二个例子是当我们有多个进程向同一个文件追加数据(例如,全局日志文件)时。为此,我们可能会考虑在每个写入器中使用如下代码片段:
if (lseek(fd, 0, SEEK_END) == -1)
errExit("lseek");
if (write(fd, buf, len) != len)
fatal("Partial/failed write");
然而,这段代码与前一个例子存在相同的缺陷。如果第一个执行代码的进程在 lseek() 和 write() 调用之间被第二个执行相同操作的进程打断,那么两个进程都会将它们的文件偏移设置到相同的位置,然后当第一个进程重新调度时,它会覆盖第二个进程已经写入的数据。这又是一个竞争条件,因为结果取决于两个进程的调度顺序。
要避免这个问题,需要将移动到文件末尾下一个字节的操作和写入操作作为一个原子操作执行。这正是使用 O_APPEND 标志打开文件所保证的。
💡 按照open(2)中关于O_APPEND的描述,每一次write都是在文件末进行:
The file is opened in append mode. Before each write(2), the file offset is positioned at the end of the file, as if with lseek(2). The modification of the file offset and the write operation are performed as a single atomic step.
一些文件系统(例如 NFS)不支持 O_APPEND。在这种情况下,内核会恢复如上所示的非原子调用序列,因此可能会发生如上所述的文件损坏。
5.2 File Control Operations: fcntl()
fcntl() 系统调用对打开的文件描述符执行一系列控制操作。
#include <fcntl.h>
int fcntl(int fd, int cmd, ...);
cmd 参数可以指定各种操作。我们在接下来的章节中会讨论其中的一些操作,而对其他操作的讨论将推迟到后面的章节。
如省略号所示,fcntl() 的第三个参数可以是不同类型,也可以省略。内核使用 cmd 参数的值来确定该参数期望的数据类型(如果有的话)。
# 5.3 Open File Status Flags
fcntl() 的一个用途是检索或修改已打开文件的访问模式和文件状态标志。(这些是通过调用 open() 时指定的 flags 参数设置的值。)要检索这些设置,我们将 cmd 指定为 F_GETFL:
int flags, accessMode;
flags = fcntl(fd, F_GETFL); /* Third argument is not required */
if (flags == -1)
errExit("fcntl");
在上述代码之后,我们可以如下测试文件是否以同步写入方式打开:
if (flags & O_SYNC)
printf("writes are synchronized\n");
SUSv3 规定,只有在一个文件打开时通过 open() 指定的状态标志,或之后通过 fcntl() 的 F_SETFL 命令设置的状态标志,才应该在该打开的文件上被设置。然而,Linux 在一个方面偏离了这一规定:如果一个应用程序使用了第 5.10 节中描述的某种技术来打开大文件,那么通过 F_GETFL 获取的标志中,O_LARGEFILE 将始终被设置。
检查文件的访问模式稍微复杂一些,因为 O_RDONLY (0)、O_WRONLY (1) 和 O_RDWR (2) 常量并不对应于打开文件状态标志中的单个位。因此,要进行此检查,我们需要将标志值与常量 O_ACCMODE 进行按位与掩码操作,然后再与其中一个常量进行比较:
accessMode = flags & O_ACCMODE;
if (accessMode == O_WRONLY || accessMode == O_RDWR)
printf("file is writable\n");
💡 O_ACCMODE的定义在fcntl.h中:
#define O_ACCMODE 00000003
#define O_RDONLY 00000000
#define O_WRONLY 00000001
#define O_RDWR 00000002
我们可以使用 fcntl() 的 F_SETFL 命令来修改一些已打开文件的状态标志。可修改的标志包括 O_APPEND、O_NONBLOCK、O_NOATIME、O_ASYNC 和 O_DIRECT。尝试修改其他标志会被忽略。(某些其他 UNIX 实现允许 fcntl() 修改其他标志,如 O_SYNC。)
使用 fcntl() 修改已打开文件的状态标志在以下情况下特别有用:
- 文件不是由调用程序打开的,因此程序无法控制 open() 调用中使用的标志(例如,该文件可能是程序启动前已打开的三个标准描述符之一)。
- 文件描述符是通过其他系统调用获得的,而非 open()。此类系统调用的例子包括 pipe(),它创建一个管道并返回两个分别指向管道两端的文件描述符,以及 socket(),它创建一个套接字并返回一个指向该套接字的文件描述符。
要修改打开文件的状态标志,我们使用 fcntl() 来获取现有标志的副本,然后修改我们希望更改的位,最后再次调用 fcntl() 来更新标志。因此,要启用 O_APPEND 标志,我们可以写如下内容:
int flags;
flags = fcntl(fd, F_GETFL);
if (flags == -1)
errExit("fcntl");
flags |= O_APPEND;
if (fcntl(fd, F_SETFL, flags) == -1)
errExit("fcntl");
5.4 Relationship Between File Descriptors and Open Files
直到现在,文件描述符与打开文件之间似乎存在一一对应的关系。然而,事实并非如此。多个描述符引用同一个打开的文件是可能的——而且也是有用的。这些文件描述符可以在同一个进程中打开,也可以在不同的进程中打开。
要理解发生了什么,我们需要检查内核维护的三种数据结构:
- 每个进程的文件描述符表;
- 系统范围内的已打开文件描述表;
- 文件系统的 i 节点表。
对于每个进程,内核会维护一个已打开文件描述符的表。该表中的每一条记录都包含关于单个文件描述符的信息,包括:
- 一组控制文件描述符操作的标志(这里只有一个标志,即 exec 时关闭标志,我们在第27.4节中介绍);以及
- 对已打开文件描述的引用。
内核维护着一个系统范围的所有打开文件描述符的表。(这个表有时称为打开文件表,其条目有时称为打开文件句柄。)一个打开的文件描述符存储了与该文件相关的所有信息,包括:
- 当前的文件偏移量(由 read() 和 write() 更新,或者使用 lseek() 明确修改);
- 打开文件时指定的状态标志(即 open() 的 flags 参数);
- 文件访问模式(只读、只写或读写,如 open() 中指定);
- 与信号驱动 I/O 相关的设置(第 63.3 节);以及
- 指向此文件的 i 节点对象的引用。
每个文件系统都有一个用于存放该文件系统中所有文件的 i 节点表。i 节点结构以及文件系统的相关内容将在第 14 章中详细讨论。暂且我们注意到,每个文件的 i 节点包括以下信息:
- 文件类型(例如,普通文件、套接字或 FIFO)及权限;
- 指向该文件上锁列表的指针;
- 文件的各种属性,包括其大小以及与不同类型文件操作相关的时间戳。
在这里,我们暂且忽略 i 节点在磁盘上和内存中的表示差异。磁盘上的 i 节点记录文件的持久属性,如文件类型、权限和时间戳。当文件被访问时,会创建一个 i 节点的内存副本,这个版本的 i 节点会记录引用该 i 节点的打开文件描述符数量以及 i 节点被复制的设备主、次设备号。内存中的 i 节点还记录与文件在打开状态下相关的各种短暂属性,例如文件锁。
图5-2说明了文件描述符、打开文件描述以及i节点之间的关系。在该图中,两个进程拥有多个打开的文件描述符。

Figure 5-2: Relationship between file descriptors, open file descriptions, and i-nodes
💡 这个图非常重要,要注意description和descriptor的区别,前者只有一份,后者作为handle可以有多份。
在进程 A 中,描述符 1 和 20 都指向同一个打开的文件描述符(标记为 23)。这种情况可能是由于调用 dup()、dup2() 或 fcntl()(参见第 5.5 节)造成的。
进程 A 的描述符 2 和进程 B 的描述符 2 都指向同一个打开的文件描述符(73)。这种情况可能发生在调用 fork() 之后(即进程 A 是进程 B 的父进程,或反之),或者如果一个进程通过 UNIX 域套接字将一个打开的描述符传递给另一个进程(参见第 61.13.3 节)。
最后,我们看到进程 A 的描述符 0 和进程 B 的描述符 3 指向不同的打开文件描述符,但这些描述符都指向同一个 i 节点表条目(1976)——换句话说,指向同一个文件。这是因为每个进程独立调用 open() 打开了相同的文件。如果一个进程两次打开同一个文件,也可能出现类似情况。
我们可以从上述讨论中得出若干含义:
- 两个指向同一个打开文件描述的不同文件描述符,共享同一个文件偏移量。 因此,如果通过其中一个文件描述符更改了文件偏移量(例如通过调用 read()、write() 或 lseek()),这种更改会通过另一个文件描述符可见。这一规则既适用于两个文件描述符属于同一进程的情况,也适用于它们属于不同进程的情况。
- 使用 fcntl() 的 F_GETFL 和 F_SETFL 操作来获取和更改打开文件状态标志(例如 O_APPEND、O_NONBLOCK 和 O_ASYNC)时,同样适用上述作用域规则。
- 相比之下,文件描述符标志(即 close-on-exec 标志)是进程和文件描述符私有的。 修改这些标志不会影响同一进程或其他进程中的其他文件描述符。
5.5 Duplicating File Descriptors
使用(Bourne shell)I/O 重定向语法 2>&1 告诉 shell 我们希望将标准错误(文件描述符 2)重定向到与标准输出(文件描述符 1)相同的位置。因此,以下命令(由于 shell 从左到右评估 I/O 重定向)会将标准输出和标准错误都发送到文件 results.log:
$ ./myscript > results.log 2>&1
Shell 通过复制文件描述符 2 来实现标准错误的重定向,使其指向与文件描述符 1 相同的打开文件描述(就像图 5-2 中进程 A 的描述符 1 和 20 指向相同的打开文件描述一样)。这个效果可以通过使用 dup() 和 dup2() 系统调用来实现。
💡 shell命令> out 2>&1中,如果文件out的fd为a,则重定向的过程相当于,close(1), dup(a), close(a), close(2), dup(1)
对应的c语言实现为:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
const char *file = "out";
int a;
a = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (a == -1) { perror("open"); exit(1); }
printf("fd of file out is: %d\n", a);
close(1);
dup(a);
close(a);
printf("stdout redirected to file out(fd 1)\n"); // this will be write to file out
close(2);
dup(1);
fprintf(stderr, "stderr redirected to file out(fd 2)\n"); // this will be write to file out
return 0;
}
测试:
$ ./a.out
fd of file out is: 3
$ cat out
stdout redirected to file out(fd 1)
stderr redirected to file out(fd 2)
需要注意的是,仅仅让 shell 打开 results.log 文件两次是不够的:一次在描述符 1 上,一次在描述符 2 上。原因之一是这两个文件描述符不会共享文件偏移指针,因此可能会相互覆盖输出。另一个原因是文件可能不是磁盘文件。考虑以下命令,它将标准错误发送到与标准输出相同的管道中:
$ ./myscript 2>&1 | less
dup() 调用使用 oldfd,即一个已打开的文件描述符,并返回一个新的描述符,该描述符指向相同的已打开文件描述。新的描述符保证是最小的未使用文件描述符。
#include <unistd.h>
int dup(int oldfd);
假设我们进行以下调用:
newfd = dup(1);
假设在正常情况下,shell 已经代表程序打开了文件描述符 0、1 和 2,并且没有使用其他描述符,dup() 将使用文件 3 创建描述符 1 的副本。
如果我们希望副本是描述符 2,可以使用以下方法:
close(2); /* Frees file descriptor 2 */
newfd = dup(1); /* Should reuse file descriptor 2 */
此代码仅在描述符 0 已打开时才有效。为了简化上述代码,并确保我们总是获得想要的文件描述符,我们可以使用 dup2()。
#include <unistd.h>
int dup2(int oldfd, int newfd);
dup2() 系统调用使用 newfd 提供的描述符号生成 oldfd 指定的文件描述符的副本。如果 newfd 指定的文件描述符已经打开,dup2() 会先关闭它。(在关闭过程中发生的任何错误都会被静默忽略;更安全的编程做法是在调用 dup2() 之前,如果 newfd 已打开,应显式调用 close()。)
我们可以将前面的 close() 和 dup() 调用简化为以下形式:
dup2(1, 2);
成功的 dup2() 调用会返回重复的文件描述符编号(即传入的 newfd 值)。
如果 oldfd 不是有效的文件描述符,则 dup2() 会以 EBADF 错误失败,并且 newfd 不会被关闭。如果 oldfd 是有效的文件描述符,且 oldfd 和 newfd 具有相同的值,则 dup2() 不执行任何操作——newfd 不会被关闭,并且 dup2() 返回 newfd 作为函数结果。
一个提供额外灵活性以复制文件描述符的进一步接口是 fcntl() 的 F_DUPFD 操作:
newfd = fcntl(oldfd, F_DUPFD, startfd);
此调用通过使用大于或等于 startfd 的最低未使用文件描述符来复制 oldfd。如果我们希望保证新描述符(newfd)落在某个特定范围内,这会很有用。对 dup() 和 dup2() 的调用总是可以重写为对 close() 和 fcntl() 的调用,尽管前者的调用更简洁。(另请注意,dup2() 和 fcntl() 返回的一些 errno 错误代码不同,如手册页所述。)
从图 5-2 可以看出,复制的文件描述符在它们共享的打开文件描述中共享相同的文件偏移值和状态标志。然而,新的文件描述符有自己的一组文件描述符标志,并且它的执行时关闭标志(FD_CLOEXEC)始终是关闭的。我们接下来描述的接口允许对新文件描述符的执行时关闭标志进行显式控制。
dup3() 系统调用执行的功能与 dup2() 相同,但增加了一个额外的参数 flags,它是一个位掩码,用于修改系统调用的行为。
#define _GNU_SOURCE
#include <unistd.h>
int dup3(int oldfd, int newfd, int flags);
目前,dup3() 支持一个标志 O_CLOEXEC,该标志会导致内核为新的文件描述符启用执行时关闭标志 (FD_CLOEXEC)。这个标志的用途与第 4.3.1 节中描述的 open() 的 O_CLOEXEC 标志相同。
dup3() 系统调用是在 Linux 2.6.27 中引入的,并且是 Linux 特有的。
自 Linux 2.6.24 起,Linux 还支持一个额外的 fcntl() 操作用于复制文件描述符:F_DUPFD_CLOEXEC。该标志的作用与 F_DUPFD 相同,但额外为新的文件描述符设置执行时关闭标志 (FD_CLOEXEC)。同样,这个操作的用途与 open() 的 O_CLOEXEC 标志相同。F_DUPFD_CLOEXEC 在 SUSv3 中未指定,但在 SUSv4 中有规定。
5.6 File I/O at a Specified Offset: pread() and pwrite()
pread() 和 pwrite() 系统调用的操作方式与 read() 和 write() 相同,不同之处在于文件 I/O 是在指定的偏移位置进行的,而不是在当前文件偏移位置进行的。这些调用不会改变文件偏移。
#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() 等同于原子地执行以下调用:
off_t orig;
orig = lseek(fd, 0, SEEK_CUR); /* Save current offset */
lseek(fd, offset, SEEK_SET);
s = read(fd, buf, len);
lseek(fd, orig, SEEK_SET); /* Restore original file offset */
对于 pread() 和 pwrite(),fd 引用的文件必须是可寻址的(即可以在其上调用 lseek() 的文件描述符)。
这些系统调用在多线程应用程序中尤其有用。正如我们将在第 29 章看到的,进程中的所有线程共享相同的文件描述符表。这意味着每个打开文件的文件偏移量对于所有线程是全局的。使用 pread() 或 pwrite(),多个线程可以同时对同一文件描述符执行 I/O,而不受其他线程对文件偏移量所做更改的影响。如果我们尝试使用 lseek() 加 read()(或 write()),那么就会产生类似于我们在第 5.1 节讨论 O_APPEND 标志时描述的竞争条件。(在多个进程对同一打开文件描述符拥有文件描述符的应用中,pread() 和 pwrite() 系统调用同样可以用于避免竞争条件。)
💡 尽管pread和pwrite中的p是position的意思,但由于其对多线程的好处,也可以认为有parallel的意思。
如果我们反复执行 lseek() 调用,然后进行文件 I/O,那么在某些情况下,pread() 和 pwrite() 系统调用也可以提供性能优势。这是因为单次 pread()(或 pwrite())系统调用的成本低于 lseek() 和 read()(或 write())两次系统调用的总成本。然而,系统调用的开销通常远小于实际执行 I/O 所需的时间。
5.7 Scatter-Gather I/O: readv() and writev()
readv() 和 writev() 系统调用执行散布-聚集 I/O。
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
这些函数不是接受单个要读取或写入的数据缓冲区,而是在一次系统调用中传输多个数据缓冲区。要传输的缓冲区集合由数组 iov 定义。整数 count 指定 iov 中元素的数量。iov 的每个元素都是以下形式的结构:
struct iovec {
void *iov_base; /* Start address of buffer */
size_t iov_len; /* Number of bytes to transfer to/from buffer */
};
SUSv3 允许一个实现对 iov 中元素的数量设置上限。实现可以通过在 <limits.h> 中定义 IOV_MAX 或在运行时通过调用 sysconf(_SC_IOV_MAX) 的返回值来声明其上限。(sysconf() 在第 11.2 节中有介绍。)SUSv3 要求此上限至少为 16。在 Linux 上,IOV_MAX 被定义为 1024,这对应于内核对该向量大小的限制(由内核常量 UIO_MAXIOV 定义)。
然而,glibc 对 readv() 和 writev() 的封装函数会默默执行一些额外工作。如果系统调用由于 iovcnt 过大而失败,那么封装函数会暂时分配一个足够容纳 iov 所描述的所有条目的缓冲区,并执行 read() 或 write() 调用(见下文关于 writev() 如何基于 write() 实现的讨论)。
图 5-3 显示了 iov 和 iovcnt 参数以及它们所指向的缓冲区之间关系的示例。
Figure 5-3: Example of an iovec array and associated buffers
图略。
💡 scatter-gather I/O 系统调用readv和writev有两个重要特性:连续和原子性。
Scatter input
readv() 系统调用执行分散输入:它从由文件描述符 fd 指定的文件中读取连续字节序列,并将这些字节放入由 iov 指定的缓冲区(“分散”)。每个缓冲区,从 iov[0] 定义的缓冲区开始,都会在 readv() 继续到下一个缓冲区之前被完全填满。
readv() 的一个重要特性是它是原子完成的;也就是说,从调用进程的角度来看,内核在文件 fd 和用户内存之间执行一次数据传输。这意味着,例如在从文件读取时,我们可以确定读取的字节范围是连续的,即使另一个共享同一文件偏移量的进程(或线程)同时尝试操作该偏移量。
在成功完成时,readv() 返回读取的字节数,如果遇到文件末尾则返回 0。调用者必须检查这一计数以确认是否读取了所有请求的字节。如果可用数据不足,则可能只填充了部分缓冲区,并且最后一个缓冲区可能只被部分填充。
清单 5-2 演示了 readv() 的使用。
使用前缀 t_ 后跟函数名作为示例程序的名称(例如,清单 5-2 中的 t_readv.c)是我们用来表示该程序主要演示单个系统调用或库函数使用的方法。
Listing 5-2: Performing scatter input with readv()
// fileio/t_readv.c
// 代码略
测试:
$ ./t_readv t_readv.c
total bytes requested: 248; bytes read: 248
Gather output
writev() 系统调用执行聚集输出。它将 iov 指定的所有缓冲区中的数据串联(“聚集”),并作为连续字节序列写入由文件描述符 fd 指定的文件中。这些缓冲区按数组顺序进行聚集,从 iov[0] 定义的缓冲区开始。
与 readv() 类似,writev() 是原子操作,所有数据在一次操作中从用户内存传输到 fd 指定的文件中。因此,在写入常规文件时,我们可以确定所有请求的数据会连续写入文件,而不会与其他进程(或线程)的写入交错。
与 write() 一样,可能会发生部分写入。因此,我们必须检查 writev() 的返回值,以确定是否所有请求的字节都已写入。
readv() 和 writev() 的主要优点是方便和速度。例如,我们可以用以下任一种方式替换对 writev() 的调用:
- 分配一个大的缓冲区,将要写入的数据从进程地址空间的其他位置复制到该缓冲区,然后调用 write() 输出缓冲区的代码;或者
- 一系列 write() 调用,分别输出各个缓冲区。
这两种选择中,第一种虽然在语义上等同于使用 writev(),但会带来在用户空间分配缓冲区和复制数据的不便(以及低效率)。
第二种选择在语义上并不等同于一次 writev() 调用,因为这些 write() 调用不是原子执行的。此外,执行一次 writev() 系统调用比执行多次 write() 调用的开销要低(参考第 3.1 节中对系统调用的讨论)。
Performing scatter-gather I/O at a specified offset
Linux 2.6.30 增加了两个新的系统调用,它们结合了分散-聚集 I/O 功能,并能够在指定偏移量处执行 I/O:preadv() 和 pwritev()。这些系统调用是非标准的,但在现代 BSD 系统上也可用。
#define _BSD_SOURCE
#include <sys/uio.h>
ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);
preadv() 和 pwritev() 系统调用执行的任务与 readv() 和 writev() 相同,但在指定的偏移量位置进行 I/O(类似于 pread() 和 pwrite())。
这些系统调用对希望将分散-聚集 I/O 的优势与能够在独立于当前文件偏移量的位置执行 I/O 相结合的应用程序(例如,多线程应用程序)非常有用。
5.8 Truncating a File: truncate() and ftruncate()
truncate() 和 ftruncate() 系统调用将文件的大小设置为指定的长度值。
#include <unistd.h>
int truncate(const char *pathname, off_t length);
int ftruncate(int fd, off_t length);
如果文件长度超过指定长度,多余的数据将丢失。如果文件当前长度小于指定长度,则通过填充一系列空字节或空洞来扩展文件。两种系统调用的区别在于文件的指定方式。
使用 truncate() 时,文件必须可访问且可写,并且通过路径名字符串来指定。如果路径名是符号链接,将会被解引用。ftruncate() 系统调用则接受一个已打开以便写入的文件描述符。它不会改变文件的偏移量。
如果传给 ftruncate() 的长度参数超过了当前文件大小,SUSv3 允许两种行为:要么文件被扩展(如 Linux 所做的那样),要么系统调用返回一个错误。符合 XSI 标准的系统必须采用前一种行为。SUSv3 要求,如果长度参数大于当前文件大小,truncate() 必须始终扩展文件。
truncate() 系统调用的独特之处在于,它是唯一一个无需首先通过 open()(或其他方式)获取文件描述符即可更改文件内容的系统调用。
💡 Linux中有一个对应的truncate实用程序。
5.9 Nonblocking I/O
在打开文件时指定 O_NONBLOCK 标志有两个目的:
- 如果文件无法立即打开,则 open() 会返回错误而不是阻塞。open() 可能会阻塞的一种情况是 FIFO(见第 44.7 节)。
- 在成功打开文件后,随后的 I/O 操作也将为非阻塞模式。如果 I/O 系统调用无法立即完成,则要么执行部分数据传输,要么系统调用返回 EAGAIN 或 EWOULDBLOCK 错误。返回哪个错误取决于具体的系统调用。在 Linux 上,与许多 UNIX 实现一样,这两个错误常量是同义的。
非阻塞模式可以用于设备(例如终端和伪终端)、管道、FIFO 和套接字。(由于管道和套接字的文件描述符不是通过 open() 获取的,我们必须使用第 5.3 节描述的 fcntl() F_SETFL 操作来启用此标志。)
对于常规文件,O_NONBLOCK 通常会被忽略,因为内核缓冲区缓存保证了对常规文件的 I/O 不会阻塞,如第 13.1 节所述。然而,当使用强制文件锁(见第 55.4 节)时,O_NONBLOCK 对常规文件仍然会产生影响。
我们在第 44.9 节和第 63 章中对非阻塞 I/O 进行了更多说明。
从历史上看,源自 System V 的实现提供了 O_NDELAY 标志,其语义与 O_NONBLOCK 类似。主要区别在于,在 System V 上,如果 write() 无法完成或没有输入可供 read() 使用,非阻塞的 write() 会返回 0。这个行为对于 read() 来说是有问题的,因为它无法与文件末尾条件区分开来,因此第一个 POSIX.1 标准引入了 O_NONBLOCK。一些 UNIX 实现仍然提供带有旧语义的 O_NDELAY 标志。在 Linux 上,定义了 O_NDELAY 常量,但它与 O_NONBLOCK 同义。
5.10 1/O on Large Files
用于保存文件偏移量的 off_t 数据类型通常实现为有符号长整数。(需要有符号数据类型是因为值 -1 用于表示错误条件。)在 32 位架构(如 x86-32)上,这将限制文件的大小为 2^31–1 字节(即 2 GB)。
然而,磁盘驱动器的容量早已超过此限制,因此需要 32 位 UNIX 实现来处理比此大小更大的文件。由于这是许多实现中常见的问题,一些 UNIX 供应商组成了大型文件峰会(LFS),以增强 SUSv2 规范,增加访问大文件所需的额外功能。本节概述了 LFS 的增强功能。(完整的 LFS 规范于 1996 年最终确定,可在 http://opengroup.org/platform/lfs.html 查阅。)
Linux 自内核 2.4 起就在 32 位系统上提供了 LFS 支持(还需要 glibc 2.2 或更高版本)。此外,相应的文件系统也必须支持大文件。大多数本地 Linux 文件系统提供此支持,但一些非本地文件系统则不提供(值得注意的例子是 Microsoft 的 VFAT 和 NFSv2,这两者都强制限制为 2 GB,无论是否使用 LFS 扩展)。
由于长整型在64位架构(例如Alpha、IA-64)上使用64位,这些架构通常不会受到LFS增强所针对的限制。然而,一些原生Linux文件系统的实现细节意味着文件的理论最大大小,即使在64位系统上,也可能小于2^63–1。在大多数情况下,这些限制远高于当前的磁盘容量,因此不会对文件大小产生实际限制。
我们可以通过两种方式编写需要LFS功能的应用程序:
- 使用支持大文件的替代API。该API由LFS设计,作为《单一UNIX规范》的“过渡扩展”。因此,遵循SUSv2或SUSv3的系统不要求必须提供此API,但许多符合规范的系统确实提供它。这种方法现在已过时。
- 在编译程序时,将_FILE_OFFSET_BITS宏定义为64。这是首选方法,因为它允许符合规范的应用程序在无需修改任何源代码的情况下获得LFS功能。
💡 实际上,如Linux平台为64位,则上述两种方法都无需使用。一些系统调用如lseek直接支持64位。
The transitional LFS API
要使用过渡性的 LFS API,我们必须在编译程序时定义 _LARGEFILE64_SOURCE 特性测试宏,可以在命令行上定义,也可以在源文件中包含任何头文件之前定义。该 API 提供了能够处理 64 位文件大小和偏移量的函数。这些函数与其 32 位对应函数名称相同,但在函数名称后加上 64 后缀。这些函数包括 fopen64()、open64()、lseek64()、truncate64()、stat64()、mmap64() 和 setrlimit64()。(我们已经描述了一些这些函数的 32 位对应函数;其他函数将在后续章节中描述。)
为了访问大文件,我们只需使用函数的 64 位版本。例如,要打开大文件,我们可以写如下代码:
fd = open64(name, O_CREAT | O_RDWR, mode);
if (fd == -1)
errExit("open");
调用 open64() 相当于在调用 open() 时指定 O_LARGEFILE 标志。尝试在没有此标志的情况下调用 open() 打开大于 2 GB 的文件会返回错误。
除了前面提到的函数之外,过渡性的 LFS API 还增加了一些新的数据类型,包括:
- struct stat64:stat 结构(第 15.1 节)的类比,允许支持大文件大小。
- off64_t:用于表示文件偏移的 64 位类型。
off64_t 数据类型用于(包括但不限于)lseek64() 函数,如清单 5-3 所示。该程序接受两个命令行参数:要打开的文件名和一个指定文件偏移的整数值。程序打开指定文件,定位到给定的文件偏移,然后写入一个字符串。以下 shell 会话演示了如何使用该程序定位到文件中非常大的偏移(大于 10 GB),然后写入一些字节:
$ ./large_file x 10111222333
$ ls -l x # Check size of resulting file
-rw------- 1 mtk users 10111222337 Mar 4 13:34 x
Listing 5-3: Accessing large files
// fileio/large_file.c
// 代码略
The _FILE_OFFSET_BITS macro
获得 LFS 功能的推荐方法是在编译程序时定义宏 _FILE_OFFSET_BITS 并赋值为 64。实现这一点的一种方法是通过 C 编译器的命令行选项:
$ cc -D_FILE_OFFSET_BITS=64 prog.c
或者,我们可以在包含任何头文件之前在 C 源代码中定义此宏:
#define _FILE_OFFSET_BITS 64
这会自动将所有相关的 32 位函数和数据类型转换为其 64 位对应版本。例如,对 open() 的调用实际上会被转换为对 open64() 的调用,并且 off_t 数据类型被定义为 64 位长。换句话说,我们可以重新编译现有程序来处理大文件,而无需对源代码进行任何更改。
使用 _FILE_OFFSET_BITS 显然比使用过渡性的 LFS API 更简单,但这种方法依赖于应用程序的规范编写(例如,正确使用 off_t 来声明保存文件偏移量的变量,而不是使用本地 C 整型)。
LFS 规范并不要求使用 _FILE_OFFSET_BITS 宏,该规范只是将其作为指定 off_t 数据类型大小的可选方法提及。一些 UNIX 实现使用不同的特性测试宏来实现此功能。
如果我们尝试使用 32 位函数访问大文件(即未将 _FILE_OFFSET_BITS 设置为 64 编译的程序),则可能会遇到错误 EOVERFLOW。例如,如果我们尝试使用 32 位版本的 stat()(第 15.1 节)来获取文件大小超过 2 GB 的文件信息时,可能会发生此错误。
Passing off_t values to printf()
LFS 扩展未能为我们解决的一个问题是如何将 off_t 值传递给 printf() 调用。在第 3.6.2 节中,我们指出,显示预定义系统数据类型(例如 pid_t 或 uid_t)值的可移植方法是将该值转换为 long 类型,并使用 %ld printf() 格式说明符。然而,如果我们使用 LFS 扩展,对于 off_t 数据类型,这种方法通常是不够的,因为它可能定义为比 long 更大的类型,通常为 long long。因此,要显示 off_t 类型的值,我们将其转换为 long long 并使用 %lld printf() 格式说明符,如下所示:
#define _FILE_OFFSET_BITS 64
off_t offset; /* Will be 64 bits, the size of 'long long' */
/* Other code assigning a value to 'offset' */
printf("offset=%lld\n", (long long) offset);
类似的说明也适用于相关的 blkcnt_t 数据类型,该类型用于 stat 结构(在第 15.1 节中有描述)。
如果我们在单独编译的模块之间传递类型为 off_t 或 stat 的函数参数,那么我们需要确保两个模块使用这些类型的大小相同(即,两个模块要么都在编译时设置了 _FILE_OFFSET_BITS 为 64,要么都未设置此选项)。
5.11 The /dev/fd Directory
对于每个进程,内核提供了一个特殊的虚拟目录 /dev/fd。该目录包含形如 /dev/fd/n 的文件名,其中 n 是对应于该进程的某个打开文件描述符的数字。例如,/dev/fd/0 是该进程的标准输入。(/dev/fd 功能并未由 SUSv3 规范定义,但其他一些 UNIX 实现提供了该功能。)
打开 /dev/fd 目录中的某个文件,相当于复制对应的文件描述符。因此,以下语句是等效的:
fd = open("/dev/fd/1", O_WRONLY);
fd = dup(1); /* Duplicate standard output */
open() 调用的 flags 参数会被解释,因此我们应注意指定与原始描述符使用的相同访问模式。在此上下文中指定其他标志,如 O_CREAT,是没有意义的(并且会被忽略)。
/dev/fd 实际上是指向 Linux 特定的 /proc/self/fd 目录的符号链接。后者目录是 Linux 特定的 /proc/PID/fd 目录的一个特殊情况,每个目录都包含与进程打开的所有文件对应的符号链接。
$ ls -l /proc/self/fd
total 0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 0 -> /dev/pts/0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 1 -> /dev/pts/0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 2 -> /dev/pts/0
lr-x------. 1 vagrant vagrant 64 Apr 2 08:01 3 -> /proc/17391/fd
$ pwd
/usr/include
$ cd
$ exec 4>out
$ ls -l /proc/self/fd
total 0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 0 -> /dev/pts/0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 1 -> /dev/pts/0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 2 -> /dev/pts/0
lr-x------. 1 vagrant vagrant 64 Apr 2 08:01 3 -> /proc/17392/fd
l-wx------. 1 vagrant vagrant 64 Apr 2 08:01 4 -> /home/vagrant/out
$ exec 4>&-
$ ls -l /proc/self/fd
total 0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 0 -> /dev/pts/0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 1 -> /dev/pts/0
lrwx------. 1 vagrant vagrant 64 Apr 2 08:01 2 -> /dev/pts/0
lr-x------. 1 vagrant vagrant 64 Apr 2 08:01 3 -> /proc/17393/fd
/dev/fd 目录下的文件在程序中很少使用。它们最常见的用途是在 shell 中。许多用户级命令需要文件名作为参数,有时我们希望把它们放入管道中,并让其中一个参数使用标准输入或标准输出。为此,一些程序(例如 diff、ed、tar 和 comm)发展出一种作弊方法,即使用一个单独的连字符(-)作为参数,表示“对于这个文件名参数使用标准输入或输出(根据需要)”。因此,要将 ls 的文件列表与之前构建的文件列表进行比较,我们可能会这样写:
$ ls | diff - oldfilelist
这种方法存在各种问题。首先,它要求每个程序对连字符进行特定解释,而许多程序并不进行这种解释;它们的编写只考虑文件名参数,并且没有办法将标准输入或输出指定为它们要处理的文件。其次,一些程序将单个连字符解释为标记命令行选项结束的分隔符。
使用 /dev/fd 可以消除这些困难,允许将标准输入、输出和错误作为任何需要它们的程序的文件名参数进行指定。因此,我们可以将之前的 shell 命令写成如下形式:
$ ls | diff /dev/fd/0 oldfilelist
为了方便,提供了名称 /dev/stdin、/dev/stdout 和 /dev/stderr,作为符号链接,分别指向 /dev/fd/0、/dev/fd/1 和 /dev/fd/2。
5.12 Creating Temporary Files
一些程序需要创建在程序运行期间使用的临时文件,这些文件应在程序终止时删除。例如,许多编译器在编译过程中会创建临时文件。GNU C 库提供了一系列用于此目的的库函数。(这种多样性在一定程度上是继承自各种其他 UNIX 实现的结果。)在这里,我们描述其中两个函数:mkstemp() 和 tmpfile()。
mkstemp() 函数基于调用者提供的模板生成唯一的文件名并打开该文件,返回一个可以用于 I/O 系统调用的文件描述符。
#include <stdlib.h>
int mkstemp(char *template);
模板参数的形式是一个路径名,其中最后 6 个字符必须是 XXXXXX。这 6 个字符会被一个用于使文件名唯一的字符串所替换,并且这个修改后的字符串会通过模板参数返回。由于模板会被修改,因此必须将其指定为字符数组,而不能作为字符串常量。
mkstemp() 函数创建一个文件,该文件为文件所有者具有读写权限(其他用户无权限),并以 O_EXCL 标志打开,从而保证调用者对该文件具有独占访问权。
通常,临时文件在打开后很快就会被取消链接(删除),使用 unlink() 系统调用(见第 18.3 节)。因此,我们可以如下使用 mkstemp():
int fd;
char template[] = "/tmp/somestringXXXXXX";
fd = mkstemp(template);
if (fd == -1)
errExit("mkstemp");
printf("Generated filename was: %s\n", template);
unlink(template); /* Name disappears immediately, but the file
is removed only after close() */
/* Use file I/O system calls - read(), write(), and so on */
if (close(fd) == -1)
errExit("close");
tmpnam()、tempnam() 和 mktemp() 函数也可以用于生成唯一的文件名。然而,这些函数应该避免使用,因为它们可能在应用程序中产生安全漏洞。有关这些函数的更多详细信息,请参阅手册页。
tmpfile() 函数创建一个具有唯一名称的临时文件,该文件可用于读写。(文件以 O_EXCL 标志打开,以防止另一个进程已经创建了同名文件,这种情况虽然不太可能,但仍需防范。)
#include <stdio.h>
FILE *tmpfile(void);
成功时,tmpfile() 会返回一个可与 stdio 库函数一起使用的文件流。临时文件在关闭时会被自动删除。为此,tmpfile() 会在打开文件后立即内部调用 unlink() 来移除文件名。
5.13 Summary
在本章中,我们介绍了原子性的概念,这对某些系统调用的正确操作至关重要。特别是,open() 的 O_EXCL 标志允许调用者确保自己是文件的创建者,而 open() 的 O_APPEND 标志确保多个进程向同一文件追加数据时不会覆盖彼此的输出。
fcntl() 系统调用执行多种文件控制操作,包括更改已打开文件的状态标志和复制文件描述符。使用 dup() 和 dup2() 也可以复制文件描述符。
我们研究了文件描述符、已打开文件描述以及文件 i-node 之间的关系,并注意到这三个对象各自关联不同的信息。重复的文件描述符引用相同的已打开文件描述,因此共享已打开文件的状态标志和文件偏移量。
我们描述了若干扩展传统 read() 和 write() 系统调用功能的系统调用。pread() 和 pwrite() 系统调用在指定文件位置执行 I/O,而不改变文件偏移量。readv() 和 writev() 调用执行分散-聚集(scatter-gather)I/O。preadv() 和 pwritev() 调用将分散-聚集 I/O 功能与在指定文件位置执行 I/O 的能力结合起来。
truncate() 和 ftruncate() 系统调用可以用于减小文件大小,丢弃多余字节,或用于增加文件大小,用零填充文件空洞。
我们简要介绍了非阻塞 I/O 的概念,我们将在后续章节中再次讨论它。
LFS 规范定义了一组扩展,允许在 32 位系统上运行的进程对文件执行操作,这些文件的大小过大,无法用 32 位表示。
/dev/fd 虚拟目录中的编号文件允许进程通过文件描述符号码访问其自身打开的文件,这在 shell 命令中尤其有用。
mkstemp() 和 tmpfile() 函数允许应用程序创建临时文件。
5.14 Exercises
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)