第 5 章 文件 I/O:进一步细节 (File I/O: Further Details)
核心结论
-
原子性 (Atomicity):所有系统调用都是原子的——内核保证系统调用的所有步骤作为单个不可中断操作执行。这是避免 race condition 的关键。
-
fcntl():通用文件描述符控制接口;可获取/设置 open file status flags、复制 fd、获取/设置文件锁、操作 signal-driven I/O 等。
-
fd vs open file description vs i-node:三个独立概念——fd 表项(进程级)→ open file description(系统级,含 offset 和 flags)→ i-node(文件系统级,含 type 和 permissions)。多个 fd 可指向同一 open file description(共享 offset)。
-
dup/dup2/fcntl F_DUPFD:复制文件描述符;dup2 精确指定目标 fd;fcntl F_DUPFD 用最小未用 fd;新 fd 与原 fd 共享 open file description。
-
pread/pwrite:在指定 offset 处读写,不修改 fd 的当前 offset;适合多线程并发读同一文件的不同区域。
-
readv/writev:分散/聚集 I/O——单次系统调用读/写多个非连续缓冲区;减少系统调用次数。
-
非阻塞 I/O:
O_NONBLOCKflag 使read/write在数据未就绪时立即返回 -1 (EAGAIN);用于异步事件循环。 -
大文件支持:
off_t在 32 位系统上可能仅 32 位;_FILE_OFFSET_BITS=64让所有相关函数使用 64 位 offset;支持 >2GB 的文件。 -
临时文件:
mkstemp()创建唯一名称的临时文件并立即open;优于tmpnam()(有竞态)。
|
本章主旨
本章是第 4 章的延伸。核心新增:(1) 原子性概念——理解为什么需要 |
一、核心概念
本章围绕 7 个核心概念展开:从「原子性」到「fd 复制」再到「扩展 I/O 与临时文件」。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
原子性与竞态 |
系统调用在执行中不可被另一个进程/线程中断;避免 race condition;典型例子: |
§5.1; |
fcntl() |
通用 fd 控制接口;命令包括 F_GETFL/F_SETFL(状态标志)、F_GETFD/F_SETFD(fd 标志)、F_DUPFD(复制 fd)、F_GETLK/F_SETLK(文件锁)。 |
§5.2-§5.3;man 2 fcntl 列出全部命令。 |
fd / open file description / i-node 三层模型 |
fd 表项(进程级)→ open file description(系统级,含 offset/flags)→ i-node(文件系统级);多个 fd 可共享 open file description。 |
§5.4;这是理解 fork、dup、文件锁等概念的基础。 |
fd 复制:dup/dup2/F_DUPFD |
创建新 fd 指向同一 open file description;dup2 精确指定目标 fd(覆盖现有);F_DUPFD 用最小未用 fd ≥ arg。 |
§5.5;常用于 I/O 重定向(如 shell |
pread/pwrite |
在指定 offset 读写,不修改 fd 的当前 offset;多线程安全(无 shared offset 干扰)。 |
§5.6;典型用例:多线程读同一文件的不同段。 |
readv/writev (Vector I/O) |
单次系统调用读写多个缓冲区;减少系统调用开销;适合分散数据。 |
§5.7;writev 用于 HTTP 头 + body 一次发送。 |
非阻塞 I/O 与临时文件 |
|
§5.9-§5.11;非阻塞 I/O 是事件循环的基础。 |
二、详细笔记
5.1 原子性与竞态条件
What:原子性 = 系统调用的所有步骤作为一个不可中断操作执行;竞态 (race condition) = 两个并发操作的结果依赖于执行顺序的不可预测情形。
Why:理解原子性才能理解「为什么必须用 O_CREAT|O_EXCL 而不是先 check 后 create」「为什么 O_APPEND 比 lseek+write 安全」。
How:两个典型反例:
-
非原子创建(§5.1 Listing 5-1):
// 摘自《The Linux Programming Interface》第 5 章 // 错误:检查存在性 → 创建(两步不原子) int fd = open(argv[1], O_WRONLY); if (fd != -1) { /* 已存在 */ } else { /* WINDOW FOR FAILURE:另一进程可能在此期间创建文件 */ fd = open(argv[1], O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR); // 此时声称「我创建了文件」,但实际是别人创建的 }正确做法:
// 摘自《The Linux Programming Interface》第 5 章 // 正确:原子创建(O_EXCL + O_CREAT) int fd = open(argv[1], O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR); if (fd == -1 && errno == EEXIST) /* 文件已存在 */ -
非原子追加:
// 摘自《The Linux Programming Interface》第 5 章 // 错误:lseek + write(两步不原子) lseek(fd, 0, SEEK_END); write(fd, buf, len); // 进程 A 和 B 都在此期间 lseek 到同一位置,然后互相覆盖正确做法:
open(path, O_WRONLY | O_APPEND)——内核保证 seek+write 是原子的。
When:
-
多进程/多线程并发创建同一文件——用
O_CREAT|O_EXCL。 -
多进程并发追加同一日志文件——用
O_APPEND。 -
NFS 等不支持
O_APPEND的文件系统——内核会回退到非原子语义,可能丢数据。
5.2 fcntl() 文件控制
What:fcntl() 是通用 fd 控制接口;根据 cmd 参数执行不同操作;最常用的是获取/修改 open file status flags。
Why:fcntl() 是「文件描述符的瑞士军刀」——几乎所有 fd 级操作都通过它。
How:常用命令(§5.2):
| 命令 | 作用 | 用途 |
|---|---|---|
|
获取 open file status flags |
检查文件是否以 O_NONBLOCK / O_APPEND 等打开 |
|
修改 open file status flags |
给现有 fd 增加 O_NONBLOCK 等 |
|
获取 fd 标志(如 close-on-exec) |
|
设置 fd 标志 |
|
复制 fd,返回 ≥ arg 的最小未用 fd |
|
复制并设置 close-on-exec |
|
文件锁 |
|
signal-driven I/O 的进程/进程组 |
When:
-
想给「不是自己 open 的 fd」(如 pipe 返回的 fd)增加
O_NONBLOCK——fcntl F_SETFL。 -
检查文件是否以同步模式打开——
F_GETFL & O_SYNC。
Example:给 fd 增加 O_APPEND:
// 摘自《The Linux Programming Interface》第 5 章
int flags = fcntl(fd, F_GETFL);
if (flags == -1) errExit("fcntl");
flags |= O_APPEND;
if (fcntl(fd, F_SETFL, flags) == -1) errExit("fcntl");
// 检查访问模式(注意 O_RDONLY 等不是位标志)
int accessMode = flags & O_ACCMODE;
if (accessMode == O_WRONLY || accessMode == O_RDWR)
printf("writable\n");
5.3 文件描述符、打开文件描述、i-node 三层模型
What:内核维护三种数据结构追踪打开的文件——进程级 fd 表、系统级 open file description 表、文件系统级 i-node 表。
Why:理解三层模型才能理解「fork 后父子进程共享 offset」「dup 后新旧 fd 共享 offset」「两个进程分别打开同一文件但不共享 offset」等现象。
How:三层关系(§5.4 Figure 5-2):
| 数据结构 | 作用域 |
|---|---|
包含内容 |
文件描述符表 (per-process) |
每个进程一份 |
fd → open file description 引用;fd 标志 (close-on-exec) |
打开文件描述表 (system-wide) |
系统范围共享 |
当前文件 offset;open file status flags;文件访问模式;i-node 引用 |
i-node 表 (per-file-system) |
文件系统范围共享 |
文件类型、权限、链接数、uid/gid、大小、时间戳、数据块指针 |
关键洞见:
-
多个 fd 可指向同一 open file description——共享 offset(如
dup、open同一文件两次用同一 fd)。 -
fork 后父子进程共享同一 fd 表项(指向同一 open file description)——共享 offset。
-
不同进程分别
open同一文件——每个进程有自己的 offset(独立)。
When:
-
用
dup/dup2复制 fd——新旧 fd 共享 offset。 -
用
O_APPEND——即使共享 offset,每次 write 都自动 seek 到末尾。 -
多线程读同一文件不同段——用
pread/pwrite(避免共享 offset 干扰)。
Example:fd 复制的内核结构(概念图):
Process A fd table Open file descriptions i-node table
+----+----+ +-----+-----+-----+ +--------+
| 3 |----+----+ |off= |flags|ino | | type |
+----+----+ | |1024 |O_RDWR|ref | | REG |
++----+ +-----+-----+--+--+ | size |
|| +------------------+ | perms |
++----+ +--------+
| ^
Process B fd table | |
+----+----+ | |
| 4 |----+--------+ |
+----+----+ |
^ |
+------------------------------------------------+
5.4 复制文件描述符:dup/dup2/F_DUPFD
What:dup、dup2、fcntl F_DUPFD 都创建新 fd 指向同一 open file description;区别在于目标 fd 的选择方式。
Why:I/O 重定向(shell >file、<file)、共享输出到多个 fd 都依赖 fd 复制。
How:
| 调用 | 行为 | 返回值 |
|---|---|---|
|
创建新 fd(最小未用)指向同一 open file description |
新 fd |
|
若 newfd 已打开则先 close;然后 newfd 指向 oldfd 的 open file description;保证原子性 |
newfd |
|
创建新 fd(≥ minfd 的最小未用)指向同一 open file description |
新 fd |
|
同上,并设置 close-on-exec |
新 fd |
关键洞见:
-
dup2(a, b)是「原子」的——内核保证不会因信号中断而出现「newfd 已关闭但还没复制」的瞬态。 -
复制后两个 fd 共享 offset 与 status flags(但 close-on-exec 是 fd 级别的)。
-
关闭其中一个 fd 不会影响另一个——open file description 引用计数减 1,归零时才真正释放。
When:
-
shell 重定向——
dup2(fd, STDOUT_FILENO)后写 stdout 实际写到 fd。 -
同时读/写同一文件——dup 出两个 fd,一个用于读,一个用于写。
Example:shell 重定向 cmd > file 的典型实现:
// 摘自《The Linux Programming Interface》第 5 章
int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) errExit("open");
if (dup2(fd, STDOUT_FILENO) == -1) errExit("dup2");
if (close(fd) == -1) errExit("close");
// 此后 printf("...") 写到 file
5.5 pread/pwrite 与 readv/writev
What:
-
pread(fd, buf, count, offset)/pwrite(fd, buf, count, offset):在指定 offset 读写,不修改 fd 的当前 offset,不与其他 fd 共享 offset 操作。 -
readv(fd, iov, iovcnt)/writev(fd, iov, iovcnt):分散/聚集 I/O——单次系统调用读写多个非连续缓冲区。
Why:pread/pwrite 让多线程并发读同一文件不同段成为可能(避免 lseek+read 的 race);readv/writev 减少系统调用次数。
How:
// 摘自《The Linux Programming Interface》第 5 章
// pread:读 offset=1000 处的 100 字节,不影响 fd 当前 offset
ssize_t n = pread(fd, buf, 100, 1000);
// pwrite:写到 offset=2000 处,不影响 fd 当前 offset
ssize_t n = pwrite(fd, buf, 100, 2000);
// writev:单次发送 header + body
struct iovec iov[2];
iov[0].iov_base = header;
iov[0].iov_len = header_len;
iov[1].iov_base = body;
iov[1].iov_len = body_len;
ssize_t n = writev(fd, iov, 2);
When:
-
多线程并发读同一文件——用
pread,各自带 offset。 -
网络协议栈——用
writev一次发送多个缓冲(如 HTTP 头 + 文件内容)。 -
数据库实现——
pwrite写入特定 offset(避免共享 offset 的锁开销)。
5.6 非阻塞 I/O
What:O_NONBLOCK 让 open 的 fd 在「数据未就绪」时 read/write 立即返回 -1 且 errno=EAGAIN(或 EWOULDBLOCK)。
Why:非阻塞 I/O 是构建事件循环(event loop)、处理并发连接的基础——单线程可以同时监控多个 fd 而不阻塞。
How:
// 摘自《The Linux Programming Interface》第 5 章
// 设置非阻塞模式(用于已打开的 fd)
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
// 非阻塞读
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN) {
// 数据尚未就绪——做其他事情或稍后重试
} else {
errExit("read");
}
}
// 或在 open 时直接设置
int fd = open(path, O_RDONLY | O_NONBLOCK);
When:
-
网络服务器用
select/poll/epoll监控多个 fd 时——所有 fd 必须是非阻塞。 -
简单的「看是否有数据」轮询——避免阻塞。
Example:典型非阻塞 + select 模式(伪代码):
// 摘自《The Linux Programming Interface》第 5 章
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
struct timeval tv = {1, 0}; // 1 秒超时
int ready = select(fd+1, &readfds, NULL, NULL, &tv);
if (ready > 0 && FD_ISSET(fd, &readfds)) {
ssize_t n = read(fd, buf, sizeof(buf)); // 现在不会阻塞
}
5.7 大文件支持与临时文件
What:
-
大文件支持:默认
off_t在 32 位系统上仅 32 位;编译时定义_FILE_OFFSET_BITS=64让所有函数使用 64 位 offset。 -
临时文件:
mkstemp(template)创建唯一名称的文件并立即open——安全且无竞态(优于tmpnam)。
Why:大文件支持让 32 位系统也能处理 >2GB 文件;mkstemp 是创建临时文件的安全方式。
How:
// 摘自《The Linux Programming Interface》第 5 章
// 大文件支持(在编译选项中定义)
// gcc -D_FILE_OFFSET_BITS=64
off_t pos = lseek(fd, 0, SEEK_END);
printf("file size: %lld bytes\n", (long long) pos);
// mkstemp 创建唯一临时文件
char template[] = "/tmp/mytemp.XXXXXX";
int fd = mkstemp(template);
if (fd == -1) errExit("mkstemp");
// template 现在包含实际文件名(如 /tmp/mytemp.aB3xY9)
// fd 已经 open 该文件
// 记得 close + unlink
unlink(template);
close(fd);
When:
-
处理 >2GB 文件(特别是 32 位系统)——加
-D_FILE_OFFSET_BITS=64。 -
创建临时文件——永远用
mkstemp而非tmpnam/mktemp(后者有 race condition)。
三、关键图表
|
非可视化条目(三层模型与 fcntl 命令)
|
四、思维导图
mindmap
root((第 5 章 文件 I O 进阶))
原子性
系统调用原子
O_EXCL 原子创建
O_APPEND 原子追加
竞态条件
fcntl
F_GETFL F_SETFL
F_GETFD F_SETFD
F_DUPFD
F_GETLK F_SETLK
三层模型
fd 表 进程级
open file desc 系统级
i-node 文件系统级
共享 offset
复制 fd
dup
dup2 原子
F_DUPFD
I O 重定向
扩展 I O
pread pwrite
readv writev
多线程安全
非阻塞
O_NONBLOCK
EAGAIN
select poll epoll
大文件临时
_FILE_OFFSET_BITS 64
mkstemp
tmpnam 避免
五、重点与易错点
-
系统调用都是原子的:这是避免竞态的关键设计;不要假设「多步操作不会被打断」。
-
「lseek + write」不是原子的:多进程并发追加同一文件会丢数据;用
O_APPEND。 -
dup2 是原子的:内核保证
dup2(a, b)不会因信号中断而出现「b 已关闭但还没复制」的状态。 -
fd 复制共享 offset:dup 后两个 fd 共享同一 offset;这是「shell 重定向后 printf 写到 file」的原理。
-
O_NONBLOCK 的 read 返回 -1 且 errno=EAGAIN:不是错误——是「数据未就绪」;程序应做其他事或重试。
-
pread/pwrite 不修改 fd 当前 offset:适合多线程并发读同一文件不同段;不需要共享 offset 锁。
-
EAGAIN == EWOULDBLOCK:POSIX 用 EAGAIN;历史系统用 EWOULDBLOCK;Linux 上两者值相同。
-
close() 后 close-on-exec 标志失效:每个 fd 独立的标志。
-
fork 后父子进程共享同一 fd 表项:共享 open file description;这就是「父进程打开的管道子进程能用」的原因。
-
跨章衔接:第 4 章是基础四大调用;本章是进阶(fcntl、dup、pread、readv、非阻塞);第 13 章详述缓冲(O_SYNC、fsync);第 47 章详述文件锁(fcntl F_SETLK);第 63 章详述 signal-driven I/O(O_ASYNC)。