2.2 文件 I/O
一个 fd 到底是什么?open/read/write/close 的返回值各自承诺了什么、没承诺什么?为什么「写成功」不等于「落盘」,为什么「读 4 KiB」可能只给你 1 字节?
写出对短读、短写、EINTR、EAGAIN 都正确的 I/O 循环;解释 O_APPEND/O_EXCL/rename 的原子性边界;用 /proc/self/fd 与 strace 定位 fd 泄漏。
Unix 的文件 I/O 模型只有五个函数:open、read、write、lseek、close。设备、管道、socket、终端,全都套进这个模型。「一切皆文件」不是哲学口号,而是一组统一的方法表:每个打开的文件对象都挂着一张 file_operations,read 最终调到哪,由这张表决定。
这一章只讲这五个函数在普通文件上的语义。管道、socket、终端、epoll 分别在 2.14、2.16、2.18、2.19 展开;但短读、短写、EINTR 这些纪律是通用的,从这里开始就要养成。
2.2.1 三层对象:fd、open file description、inode
文件描述符是进程内文件描述符表的一个非负整数下标。表项指向一个打开文件描述(open file description,内核里是 struct file),后者再指向 inode。fd 只在所属进程内有效;fork() 后子进程复制一份表,表项仍指向同一个 struct file。
三层对象各管一件事:
| 层 | 内核对象 | 保存什么 | 谁共享 |
|---|---|---|---|
| fd 表 | files_struct | 下标 → struct file * | 同一进程的线程共享;fork 后各自一份 |
| 打开文件描述 | struct file | 偏移 f_pos、状态标志、引用计数、f_op | dup/fork 后共享 |
| inode | struct inode | 文件类型、权限、大小、时间、数据块位置 | 所有打开同一文件的进程共享 |
struct file,各有自己的偏移;如果它们由 dup() 或 fork() 产生,则共享同一个 struct file,偏移也共享——这是「两个进程同时写日志却互相覆盖」的根源。这张图解释了两个常见困惑:
- 同一个文件被打开两次,偏移不共享。
open()两次得到两个struct file,各有f_pos;一个读到 100 字节,另一个仍在 0。 dup()/fork()后偏移共享。两个 fd 指向同一个struct file,任何一个read都推进同一个f_pos。多进程写同一个日志文件时,这正是「输出交错」的根源之一。
如果 fd 直接指向 inode,那么「偏移」和「打开标志」就没有地方放:同一个文件被两个进程用不同模式打开(一个读、一个追加),需要两套偏移与标志。struct file 就是这层「一次打开会话」的状态。它把文件本身(inode)与这次打开的行为(偏移、flags)分开,dup 才能既共享偏移又不复制 inode。
2.2.2 open 与 openat:flags、mode 与原子性
open(path, flags, mode) 的 flags 由三部分组成:
- 访问模式(低两位,必须有且仅有一个):
O_RDONLY(0)、O_WRONLY(1)、O_RDWR(2); - 创建/截断:
O_CREAT、O_EXCL、O_TRUNC、O_NOCTTY; - 行为修饰:
O_APPEND、O_NONBLOCK、O_SYNC/O_DSYNC、O_DIRECT、O_CLOEXEC、O_TMPFILE。
mode 只在带 O_CREAT 或 O_TMPFILE 时有意义,且会被 umask 清位:最终权限 = mode & ~umask。
最常用的一组:
int fd = open(path, O_RDONLY); /* 只读打开,文件必须存在 */
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); /* 创建或截断,最终权限受 umask */
int fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0600); /* 原子地「创建新文件」,已存在则 EEXIST */
int fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0644); /* 每次写都追加到当前末尾 */
openat() 多一个 dirfd 参数:
int fd = openat(dirfd, "conf/nginx.conf", O_RDONLY);
int fd = openat(AT_FDCWD, "/etc/hosts", O_RDONLY); /* 等价于 open() */
openat 的价值不只是「省一个路径」:
- 避免 TOCTOU:先
open(dir),再openat(dirfd, "file"),即使目录被 rename,解析仍锚定在同一个目录 inode 上; - 限制逃逸:配合
O_NOFOLLOW、openat2()的RESOLVE_BENEATH,可以禁止..跳出沙箱; - 线程安全:不依赖进程级
cwd,多线程改chdir不会影响其他线程的相对路径。
两个必须记住的原子性
在本地文件系统上:
open(path, O_CREAT|O_EXCL, mode)的「检查不存在 + 创建」是一个原子操作。两个进程同时调用,只有一个成功,另一个得到EEXIST。open(path, O_WRONLY|O_APPEND)后,每次write()的「定位到末尾 + 写」是一个原子操作。多个进程追加写同一文件,不会互相覆盖(但不保证整条记录不交错,见下)。
O_EXCL 是锁文件与临时文件的正确做法:
/* 正确:原子创建锁文件 */
int fd = open("/var/run/mydaemon.pid", O_WRONLY | O_CREAT | O_EXCL, 0644);
if (fd == -1 && errno == EEXIST) {
/* 已有实例在跑,或上次崩溃留下 stale 文件 */
}
错误做法是先 access() 再 open():两条语句之间另一个进程可以插进来,两个进程都认为自己拿到了锁。
O_APPEND 的原子性只保证「写到当前末尾」,不保证多个 write 的字节不交错。如果两个进程各写一行 200 字节,内核不会把两次 write 拆开,但若一次 write 分多次系统调用(或 stdio 缓冲多次 flush),行与行仍可能穿插。要保证整条记录原子,要么单次 write 写完,要么加文件锁。
- 「
O_APPEND能保证多进程日志不交错」——只在「一条记录一次write」时成立。用fprintf+ 行缓冲,或多条write拼一条记录,仍会交错。 - 「先
lseek(fd, 0, SEEK_END)再write等价于O_APPEND」——不等价。两条语句之间别的进程可以追加,你的写会覆盖别人的数据。O_APPEND把这两步合成一个原子操作。 - 「NFS 上
O_APPEND也一样」——NFS 的追加语义依赖服务器实现,旧版本可能不原子;跨网络不要假设本地语义。
O_CLOEXEC:一个必须默认打开的开关
fork() 后 exec() 会保留所有没有 FD_CLOEXEC 的 fd。父进程打开的日志、socket、数据库连接会泄漏给子程序。更糟的是多线程下 open() + fcntl(F_SETFD) 之间有窗口:另一个线程刚好 fork+exec,fd 就漏过去了。
/* 正确:创建时就带 CLOEXEC,原子 */
int fd = open(path, O_RDONLY | O_CLOEXEC);
/* 错误:两步之间可能被 fork+exec 抢先 */
int fd = open(path, O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);
新代码应默认使用 O_CLOEXEC;dup() 用 fcntl(fd, F_DUPFD_CLOEXEC, 0) 或 dup3(fd, newfd, O_CLOEXEC)。
O_TMPFILE:匿名临时文件
int fd = open("/tmp", O_TMPFILE | O_RDWR, 0600);
/* 写入数据;需要命名时: */
linkat(fd, "", AT_FDCWD, "/tmp/final", AT_EMPTY_PATH);
文件从创建到 linkat 之前没有名字,崩溃也不会留下垃圾;适合「先写完整、再原子发布」的场景。需要文件系统支持(ext4/XFS/tmpfs 均支持)。
2.2.3 read 与 write:返回值到底承诺了什么
ssize_t read(int fd, void *buf, size_t count):
- 返回
>0:实际读到的字节数,可能小于count(短读); - 返回
0:EOF(对普通文件是读到末尾;对管道/终端是写端全部关闭); - 返回
-1:错误,errno说明原因;EINTR表示被信号打断,通常应重试。
ssize_t write(int fd, const void *buf, size_t count):
- 返回
>0:实际写入的字节数,可能小于count(短写); - 返回
0:极少见,通常意味着count==0; - 返回
-1:错误;EINTR可重试,EAGAIN表示非阻塞 fd 暂时不可写。
短读与短写不是「异常」
对普通文件,read 通常返回请求的字节数(除非到了 EOF 或被信号打断);但对管道、终端、socket、慢设备,短读是常态。write 同理:管道缓冲区满、socket 发送缓冲区满、磁盘配额、信号打断,都会导致只写一部分。
正确的循环只有一种写法:
/* 读满 count 字节,除非 EOF 或出错;返回实际字节数,出错返回 -1 */
ssize_t read_full(int fd, void *buf, size_t count)
{
size_t done = 0;
char *p = buf;
while (done < count) {
ssize_t n = read(fd, p + done, count - done);
if (n == -1) {
if (errno == EINTR) continue; /* 被信号打断,重试 */
return -1;
}
if (n == 0) break; /* EOF */
done += (size_t)n;
}
return (ssize_t)done;
}
/* 写完全部 count 字节,除非出错;返回实际字节数,出错返回 -1 */
ssize_t write_full(int fd, const void *buf, size_t count)
{
size_t done = 0;
const char *p = buf;
while (done < count) {
ssize_t n = write(fd, p + done, count - done);
if (n == -1) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) { errno = EIO; return -1; } /* 不该发生;防止死循环 */
done += (size_t)n;
}
return (ssize_t)done;
}
if (read(fd, buf, n) != n) error();——把短读当错误。管道/终端/信号下必错。write(fd, buf, n);不检查返回值——短写时静默丢数据。日志、网络、磁盘满时最危险。EINTR一律当失败——read/write/open/close都可能被信号打断。要么重试,要么用sigaction的SA_RESTART(但不能覆盖所有情况)。read返回 0 后继续读——EOF 后再次read仍返回 0;循环不退出会空转。count为 0 时read返回 0——这不是 EOF,不能拿来判断文件结束。
EINTR 与 SA_RESTART
慢系统调用(read/write/open/wait/accept 等)在阻塞期间收到信号,若信号处理器是用 signal() 或 sigaction() 且未设 SA_RESTART,调用会返回 -1、errno=EINTR。
struct sigaction sa = {0};
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; /* 让可重启的调用自动重启 */
sigaction(SIGCHLD, &sa, NULL);
SA_RESTART 不是万能:poll/epoll_wait/select/msgrcv 等不会自动重启;即使设了,也可能因为超时语义需要自己处理。库函数应始终处理 EINTR,不要依赖调用者设了 SA_RESTART。
非阻塞 I/O 与 EAGAIN
O_NONBLOCK 下,没有数据可读/缓冲区满时,read/write 返回 -1、errno=EAGAIN(或 EWOULDBLOCK,两者在 Linux 上同值)。这不是错误,是「现在不行,等通知」:
ssize_t n = read(fd, buf, sizeof buf);
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
/* 等 epoll 通知,稍后再试 */
} else if (errno == EINTR) {
/* 重试 */
} else {
/* 真错误 */
}
}
EAGAIN 与 EINTR 的区别:前者是状态(现在不可用),后者是事件(被信号打断)。两者都可能需要重试,但 EAGAIN 通常要等 fd 就绪,不能忙等。
2.2.4 close、dup、fcntl 与文件偏移
close() 的返回值不是废纸
close(fd) 成功返回 0,失败返回 -1。失败时 fd 是否已释放是实现相关的:Linux 上 close() 会先释放 fd,再返回错误(例如 NFS 的写回错误)。因此不要重试 close():重试可能关掉另一个线程刚打开的同号 fd。
正确做法:
if (close(fd) == -1) {
/* 记录错误;不要重试 close */
log_error("close: %s", strerror(errno));
}
对需要确保数据落盘的场景,先 fsync(fd),再 close(fd);不要用 close 的返回值判断数据是否安全。
dup 与 dup2
int fd2 = dup(fd); /* 新 fd,共享同一 struct file(偏移、flags) */
int fd3 = dup2(fd, 1); /* 把 fd 复制到 1(stdout),原子关闭原 1 */
int fd4 = fcntl(fd, F_DUPFD_CLOEXEC, 0); /* 新 fd 带 CLOEXEC */
dup2 的原子性很重要:close(1); dup(fd); 在多线程下可能被别的线程抢走 fd 1;dup2(fd, 1) 一步完成。
fcntl:改标志、复制 fd、文件锁
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK); /* 可改:O_APPEND/O_NONBLOCK/O_ASYNC */
/* 不能改访问模式(O_RDONLY/O_WRONLY/O_RDWR)——它在 open 时固定 */
fcntl 还是文件锁(F_SETLK/F_SETLKW)的入口,见 2.3。
偏移:lseek、pread、pwrite
off_t pos = lseek(fd, 0, SEEK_END); /* 返回新偏移;失败 -1 */
ssize_t n = pread(fd, buf, count, offset); /* 从指定偏移读,不改变 f_pos */
ssize_t m = pwrite(fd, buf, count, offset);
pread/pwrite 把「定位 + 读写」合成一个原子操作,且不改变共享偏移。多线程读同一文件时,用 pread 比 lseek+read 安全。
管道不是随机访问设备,没有「偏移」概念。lseek 返回 -1、errno=ESPIPE。这不是缺陷,而是统一 I/O 模型的边界:同一组函数,不同文件类型支持的操作不同。判断一个 fd 支持什么,用 fstat 看类型,或直接处理 ESPIPE/EINVAL。
ioctl:剩下的那半边接口
read/write/lseek 覆盖不了设备的所有操作:设置终端窗口大小、读网卡寄存器、给文件系统打标签、查询块设备扇区大小,都走 ioctl。
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...); /* 第三个参数通常是指针 */
request 不是随便一个整数,而是用宏编码的 32 位值:
| 位段 | 宏 | 含义 |
|---|---|---|
| 方向 | `_IOC_NONE/_IOC_READ/_IOC_WRITE/_IOC_READ | _IOC_WRITE` |
| 大小 | _IOC_SIZEBITS(通常 14 位) | 第三个参数指向的结构体大小 |
| 类型 | _IOC_TYPEBITS(8 位) | 设备/子系统的 magic |
| 序号 | _IOC_NRBITS(8 位) | 命令编号 |
常用构造宏:_IO(type,nr)、_IOR(type,nr,type)、_IOW(type,nr,type)、_IOWR(type,nr,type)。例如 TIOCGWINSZ 是「读 struct winsize」,FIONREAD 是「读一个 int」,FS_IOC_GETFLAGS 是「读文件 flags」。
/* ioctl_demo.c — 三个真实 request:窗口大小、可读字节数、文件 flags */
#include <fcntl.h>
#include <linux/fs.h> /* FS_IOC_GETFLAGS */
#include <stdio.h>
#include <sys/ioctl.h>
#include <unistd.h>
int main(void)
{
struct winsize ws;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0)
printf("terminal: %ux%u\n", ws.ws_col, ws.ws_row);
else
perror("TIOCGWINSZ"); /* 重定向到文件时 ENOTTY 是正常的 */
int n = 0;
if (ioctl(STDIN_FILENO, FIONREAD, &n) == 0)
printf("stdin readable: %d bytes\n", n);
int fd = open("/tmp/ioctl-demo", O_CREAT | O_RDWR | O_CLOEXEC, 0644);
if (fd >= 0) {
int fl = 0;
if (ioctl(fd, FS_IOC_GETFLAGS, &fl) == 0)
printf("inode flags: %#x\n", fl);
close(fd);
}
return 0;
}
errno 的分工:
ENOTTY:这个 fd/设备不支持该 request(最常见;把终端 ioctl 用在普通文件上)。EINVAL:request 认识,但参数值/结构体内容不合法。EFAULT:第三个参数指向的地址不可访问。EPERM:需要CAP_SYS_ADMIN/CAP_NET_ADMIN等。
- 内核不校验你的指针类型:
ioctl(fd, FIONREAD, &long_var)只写 4 字节,剩下的字节不变;传错结构体会写坏栈。 _IOR/_IOW的大小是 ABI 的一部分:32 位/64 位程序对同一 request 的结构体大小可能不同,驱动要 compat_ioctl。- 不要把
ioctl当通用「发命令」接口:新子系统优先 netlink/sysfs/configfs;ioctl 的 request 空间是稀缺资源。 ENOTTY不是「不是 tty」:它只是「这个 fd 不支持这个 request」;普通文件也会返回它。
2.2.5 原子性:文件系统层面的「要么全做,要么不做」
单条系统调用是原子的,但多条系统调用之间不是。文件系统提供了几个原语,把常见组合变成原子操作:
| 需求 | 正确原语 | 错误做法 |
|---|---|---|
| 创建新文件,已存在则失败 | open(O_CREAT|O_EXCL) | access() + open() |
| 追加写 | open(O_APPEND) + 单次 write | lseek(END) + write |
| 原子替换文件 | rename(tmp, dst) | 先 unlink(dst) 再 rename |
| 原子交换两个文件 | renameat2(..., RENAME_EXCHANGE) | 两次 rename |
| 不覆盖地重命名 | renameat2(..., RENAME_NOREPLACE) | access() + rename |
| 原子创建硬链接 | link(tmp, dst) | 先检查再 link |
rename() 的原子性只保证同一文件系统内:目标要么是旧文件,要么是新文件,不会出现「不存在」的中间态。跨文件系统会返回 EXDEV,需要「复制到临时文件 + fsync + rename」的发布流程。
持久化的顺序(写文件并确保崩溃后可见):
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
write_full(fd, data, len);
fsync(fd); /* 1. 数据落盘 */
close(fd);
rename(tmp, dst); /* 2. 原子替换 */
fsync(dirfd); /* 3. 目录项落盘(关键且常被忘) */
第 3 步常被忽略:rename 修改的是目录项,目录本身也需要 fsync,否则崩溃后可能看不到新名字。这是「数据库/配置原子发布」的标准套路,详见 2.3 与 3.12。
2.2.6 fd 泄漏与 /proc/self/fd
fd 是有限资源。泄漏的表现:Too many open files、accept 失败、连接随机断。排查三步:
# 1. 看进程当前 fd 数与上限
ls /proc/$PID/fd | wc -l
cat /proc/$PID/limits | grep 'open files'
# 2. 看 fd 指向什么
ls -l /proc/$PID/fd
# 3. 看哪些 fd 是最近打开的(需要 strace 跟踪)
strace -f -e trace=openat,close -p $PID 2>&1 | tail -50
/proc/self/fd 是一个魔法目录:读它时,内核把当前进程的 fd 表投影成符号链接,链接目标是打开的文件路径。对已删除文件,链接显示 (deleted)——这是「磁盘空间没释放」的经典原因:文件被删了,但仍有进程持有 fd。
$ lsof +L1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
app 1234 root 5w REG 253,0 1073741824 0 12345 /var/log/app.log (deleted)
/dev/fd/N 是 /proc/self/fd/N 的符号链接(在 devtmpfs 上)。open("/dev/fd/N", ...) 不是「按路径重新打开文件」,而是复制当前进程的 fd N(类似 dup):它继承同一个 struct file 的偏移与状态。shell 的进程替换 <(cmd) 就是让 cmd 的 stdout 成为一个 fd,再把 /dev/fd/63 传给程序。
int fd = open("/dev/fd/0", O_RDONLY); /* 复制 stdin,而不是重新打开终端 */
三个边界:
O_PATH打开的 fd 只能用于fstat/openat/fchdir等,不能read/write;/dev/fd/N对O_PATHfd 的行为依内核而异。- 对 socket/pipe,
open("/dev/fd/N")可能返回ENXIO/ENODEV,因为「重新打开」对这类对象没有意义。 O_CLOEXEC的 fd 在exec后消失,/dev/fd/N也就不存在;脚本里依赖它时要确认没有 close-on-exec。
- 每个
open/socket/accept/dup是否都有对应的close(包括错误分支)? fork后子进程是否只保留需要的 fd?是否默认O_CLOEXEC?- 长连接服务是否给空闲连接设了超时?
epoll是否在EPOLLERR/EPOLLHUP时关闭并epoll_ctl(DEL)? - 是否把 fd 存进容器后忘了在析构/异常路径释放?(C 里用
goto cleanup,C++ 用 RAII) - 上限是否合理?
ulimit -n、systemdLimitNOFILE、fs.file-max三层都要看。
2.2.7 完整示例:一个正确的文件复制程序
下面这个 copy.c 把本章的纪律串起来:O_CLOEXEC、O_EXCL 可选、短读/短写、EINTR、fsync、错误分支清理。
/* copy.c — 用法: ./copy SRC DST */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define BUF_SIZE (256 * 1024)
static void die(const char *what)
{
fprintf(stderr, "copy: %s: %s\n", what, strerror(errno));
exit(EXIT_FAILURE);
}
static ssize_t read_full(int fd, void *buf, size_t count)
{
size_t done = 0;
char *p = buf;
while (done < count) {
ssize_t n = read(fd, p + done, count - done);
if (n == -1) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) break;
done += (size_t)n;
}
return (ssize_t)done;
}
static int write_full(int fd, const void *buf, size_t count)
{
size_t done = 0;
const char *p = buf;
while (done < count) {
ssize_t n = write(fd, p + done, count - done);
if (n == -1) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) { errno = EIO; return -1; }
done += (size_t)n;
}
return 0;
}
int main(int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr, "usage: %s SRC DST\n", argv[0]);
return EXIT_FAILURE;
}
int in = open(argv[1], O_RDONLY | O_CLOEXEC);
if (in == -1) die(argv[1]);
/* 先创建临时文件,写完再 rename,避免半成品覆盖目标 */
char tmp[4096];
snprintf(tmp, sizeof tmp, "%s.tmp.%ld", argv[2], (long)getpid());
int out = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
if (out == -1) { close(in); die(tmp); }
char *buf = malloc(BUF_SIZE);
if (!buf) { close(in); close(out); unlink(tmp); die("malloc"); }
for (;;) {
ssize_t n = read_full(in, buf, BUF_SIZE);
if (n == -1) { close(in); close(out); unlink(tmp); die("read"); }
if (n == 0) break;
if (write_full(out, buf, (size_t)n) == -1) {
close(in); close(out); unlink(tmp); die("write");
}
}
if (fsync(out) == -1) { close(in); close(out); unlink(tmp); die("fsync"); }
if (close(out) == -1) { close(in); unlink(tmp); die("close out"); }
if (close(in) == -1) { unlink(tmp); die("close in"); }
if (rename(tmp, argv[2]) == -1) { unlink(tmp); die("rename"); }
/* 目录项落盘;打开目录需要 O_DIRECTORY */
int dir = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dir != -1) { fsync(dir); close(dir); }
free(buf);
return EXIT_SUCCESS;
}
编译与验证:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g copy.c -o copy
truncate -s 100M big.bin
./copy big.bin big.copy
cmp big.bin big.copy && echo "identical"
strace -e trace=openat,read,write,fsync,rename ./copy big.bin big.copy 2>&1 | tail -20
strace 里应能看到:openat(... O_RDONLY|O_CLOEXEC)、多次 read/write、fsync、rename,以及最后的目录 fsync。
在管道或 socket 上,write 的返回受接收缓冲区大小限制;在普通文件上,短写通常出现在磁盘配额、RLIMIT_FSIZE、信号打断或 O_NONBLOCK 设备。无论哪种,正确处理方式相同:循环写剩余部分,直到写完或真错误。上面的 write_full 就是最小实现。
如果 write 返回 -1 且 errno == ENOSPC,不要重试;先清理空间。如果 errno == EAGAIN,说明 fd 是非阻塞的,应该等可写事件(epoll)而不是忙等。
2.2.8 性能与页缓存
write() 把数据拷进 page cache 就返回,脏页由回写线程择机下盘;沿虚线看读路径:命中 page cache 时根本不经过块层。因此 write() 返回 ≠ 落盘,read() 也不一定读磁盘。四个常被忽略的事实:
write()返回只代表数据进了 page cache(除非O_DIRECT/O_SYNC)。机器掉电时,未回写的数据会丢;进程崩溃不会丢(页缓存仍在内核)。read()命中 page cache 时完全不碰磁盘。第一次读触发预读(readahead),顺序读的吞吐远高于随机读。fsync()的代价是等待设备确认。批量写 + 一次fsync远快于每条记录fsync;数据库的 WAL 就是按这个原则设计的。O_DIRECT绕过 page cache,但要求对齐(缓冲区地址、偏移、长度都按块大小对齐)。除非你在写数据库/存储引擎,否则不要轻易用。
/* 建议内核预读:顺序读用 POSIX_FADV_SEQUENTIAL,随机读用 RANDOM */
posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);
/* 不再需要这部分缓存:DONTNEED 可以释放 page cache */
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
- 确认瓶颈在 I/O 还是 CPU:
iostat -x 1看%util、await、aqu-sz; - 看是否命中缓存:
free -h的buff/cache、/proc/meminfo的Cached; - 看读写比例与块大小:
iostat -x的r/s、w/s、rareq-sz、wareq-sz; - 看是否同步写:
strace -c -e trace=fsync,fdatasync; - 看文件系统与挂载选项:
findmnt -T /path,是否有noatime、discard; - 最后才调内核参数:
vm.dirty_ratio、vm.dirty_background_ratio、vm.swappiness。
页缓存建议:posix_fadvise 与 madvise
posix_fadvise(fd, offset, len, advice) 不改变数据,只告诉内核「这段文件我打算怎么用」:
| advice | 含义 | 典型场景 |
|---|---|---|
POSIX_FADV_NORMAL | 默认 | 恢复默认 |
POSIX_FADV_SEQUENTIAL | 顺序读,预读加倍 | 大文件扫描 |
POSIX_FADV_RANDOM | 随机读,关闭预读 | 数据库索引 |
POSIX_FADV_WILLNEED | 提前读入页缓存 | 启动预取 |
POSIX_FADV_DONTNEED | 尝试丢弃干净页 | 备份后释放缓存 |
POSIX_FADV_NOREUSE | 只读一次,不留在缓存 | 一次性归档 |
madvise(addr, len, advice) 作用于 mmap 的虚拟区间,不是 fd;MADV_SEQUENTIAL/RANDOM/WILLNEED/DONTNEED 与 fadvise 对应,MADV_HUGEPAGE/MADV_NOHUGEPAGE 控制 THP。
POSIX_FADV_DONTNEED 只丢弃干净页;脏页要先回写。它也不保证立刻回收,只是给内核一个提示。不要把 fadvise(DONTNEED) 当成 drop_caches 或内存泄漏的解药。
2.2.9 open flags 全表:从 O_RDONLY 到 O_TMPFILE
这一节回答:open 的第三个参数之前,那一串 O_* 到底各改了什么行为?哪些能组合,哪些组合会被内核静默忽略?
2.2.2 给了最常用的四个组合。真实程序还会遇到 O_PATH、O_TMPFILE、O_DIRECT、O_SYNC 这些「用错就性能塌方或直接失败」的标志。先把它们按作用域分组:
| 组 | flag | x86-64 八进制 | 作用 | 失败/边界 |
|---|---|---|---|---|
| 访问模式 | O_RDONLY | 0 | 只读 | 与 O_WRONLY/O_RDWR 互斥 |
O_WRONLY | 01 | 只写 | ||
O_RDWR | 02 | 读写 | ||
| 创建 | O_CREAT | 0100 | 不存在则创建,需 mode | 父目录不可写 EACCES |
O_EXCL | 0200 | 与 O_CREAT 合用,已存在则 EEXIST | 单独用被忽略 | |
O_TRUNC | 01000 | 存在则截断为 0 | 对 FIFO/终端未定义 | |
O_TMPFILE | 020200000 | 在目录中创建无名文件 | 需文件系统支持,否则 EOPNOTSUPP | |
| 偏移/阻塞 | O_APPEND | 02000 | 每次写前定位到末尾 | 与 pwrite 在 Linux 上冲突 |
O_NONBLOCK | 04000 | 非阻塞 | 对普通文件基本无效 | |
O_ASYNC | 020000 | 可读/可写时发 SIGIO | Linux 上 open 时无效,需 F_SETFL + F_SETOWN | |
| 同步 | O_DSYNC | 010000 | 数据+必要元数据落盘 | 每次 write 等设备 |
O_SYNC | 04010000 | 数据+全部元数据落盘 | 比 O_DSYNC 更贵 | |
O_RSYNC | 与 O_SYNC 同值 | 读也同步 | Linux 上等同 O_SYNC | |
| 缓存/对齐 | O_DIRECT | 040000 | 绕过 page cache | 地址/偏移/长度要对齐,否则 EINVAL |
O_NOATIME | 01000000 | 读不更新 atime | 非属主且无 CAP_FOWNER 返回 EPERM | |
| 路径解析 | O_NOFOLLOW | 0400000 | 最后一段是符号链接则 ELOOP | 只作用于最后一段 |
O_DIRECTORY | 0200000 | 必须是目录,否则 ENOTDIR | 常与 O_PATH 合用 | |
O_PATH | 010000000 | 只取「路径句柄」,不打开读写 | read/write 返回 EBADF | |
| fd 继承 | O_CLOEXEC | 02000000 | exec 时关闭 | 创建时原子设置 |
| 历史 | O_LARGEFILE | 0100000 | 64 位偏移 | 64 位平台无效果 |
表里的数值是 x86-64 的,不要写死:不同架构的 O_DIRECT、O_SYNC 值可能不同,源码只用宏。
O_PATH:一个只有「路径」的 fd
O_PATH 打开的文件描述符不能读、不能写、不能 mmap,但可以:
- 作为
*at()系列的dirfd(openat、fstatat、unlinkat、linkat的源); fstat取属性;readlink("/proc/self/fd/N")取回路径;- 配合
O_NOFOLLOW安全地「引用一个符号链接本身」。
它对权限的要求也不同:只需要路径上各级目录的 x 位,不需要目标文件本身的读权限。这让它成为「先锚定、后操作」的理想句柄。
int pfd = open("/srv/data", O_PATH | O_DIRECTORY | O_CLOEXEC);
int fd = openat(pfd, "config.json", O_RDONLY | O_NOFOLLOW);
O_PATH 的 fd 还有一个特殊能力:可以传给 fchdir,也可以作为 execveat 的 dirfd(配合 AT_EMPTY_PATH)。它不能传给 read/write/fchmod 这类需要「已打开文件」的调用。
O_TMPFILE:没有名字的文件
int fd = open("/srv/app", O_TMPFILE | O_WRONLY | O_CLOEXEC, 0644);
/* 写入、fsync,然后给它一个名字 */
linkat(fd, "", AT_FDCWD, "/srv/app/conf.new", AT_EMPTY_PATH);
O_TMPFILE 的路径参数必须是目录,内核在该目录所在文件系统里创建一个没有目录项的 inode。它适合「先写完整、再原子发布」:写一半崩溃不会留下垃圾文件。限制:
- 文件系统必须支持(ext4、XFS、tmpfs、btrfs 支持;部分网络文件系统不支持);
- 只有创建者能
linkat,且不能跨文件系统; - 如果打开时带了
O_EXCL,则永久不能linkat(用于纯临时文件); - 普通用户用
linkat(fd, "", ..., AT_EMPTY_PATH)需要CAP_DAC_READ_SEARCH;替代路径是linkat(AT_FDCWD, "/proc/self/fd/N", ..., AT_SYMLINK_FOLLOW)。
O_NOFOLLOW 与 O_DIRECTORY
O_NOFOLLOW 只检查路径的最后一段。open("/a/b/link", O_NOFOLLOW) 在 link 是符号链接时返回 ELOOP(不是 EINVAL,也不是 EMLINK);中间目录里的符号链接仍会被跟随。要禁止整条路径中的符号链接,用 openat2 的 RESOLVE_NO_SYMLINKS(2.2.10)。
O_DIRECTORY 要求打开对象是目录,否则 ENOTDIR。它常与 O_PATH 组合,得到一个「只能当锚点」的目录 fd;opendir 内部也会检查目录类型。
O_SYNC、O_DSYNC、O_ASYNC
三个名字很像,语义完全不同:O_DSYNC 在 write 返回前把数据和「读回数据所必需的元数据」落盘;O_SYNC 连 mtime/ctime 等全部元数据都要落盘,等价于每次写完 fsync;O_ASYNC 与同步无关,它是 fd 可读/可写时发 SIGIO;Linux 上在 open 时指定它没有实质效果,必须用 F_SETFL 开启并配合 F_SETOWN,epoll 时代基本不用。O_SYNC 的代价是一次 4 KiB 写要等设备确认,机械盘上可能是毫秒级;批量导入时 O_DSYNC 或「写完统一 fsync」通常快一个数量级。
/* openflags.c — 验证 O_PATH/O_NOFOLLOW/O_DIRECTORY/O_TMPFILE 的边界 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static void show_err(const char *what)
{
printf("%-38s -> %s\n", what, strerror(errno));
}
int main(void)
{
char path[] = "/tmp/openflags.XXXXXX";
int tfd = mkstemp(path);
if (tfd == -1) { perror("mkstemp"); return 1; }
if (write(tfd, "x", 1) != 1) { perror("write"); return 1; }
close(tfd);
/* 1. O_PATH:能 fstat,不能 read */
int pfd = open(path, O_PATH | O_CLOEXEC);
if (pfd == -1) { perror("open O_PATH"); return 1; }
struct stat st;
printf("O_PATH fstat: %s\n", fstat(pfd, &st) == 0 ? "ok" : strerror(errno));
errno = 0;
char c;
ssize_t n = read(pfd, &c, 1);
printf("O_PATH read: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));
char proc[64], target[256];
snprintf(proc, sizeof proc, "/proc/self/fd/%d", pfd);
ssize_t k = readlink(proc, target, sizeof target - 1);
if (k > 0) { target[k] = '\0'; printf("O_PATH 指向: %s\n", target); }
close(pfd);
/* 2. O_NOFOLLOW:最后一段是符号链接 -> ELOOP */
char link[] = "/tmp/openflags.link";
unlink(link);
if (symlink(path, link) == -1) { perror("symlink"); return 1; }
errno = 0;
int lfd = open(link, O_RDONLY | O_NOFOLLOW);
printf("open(symlink, O_NOFOLLOW): fd=%d errno=%d (%s)\n",
lfd, errno, strerror(errno));
if (lfd != -1) close(lfd);
/* O_PATH|O_NOFOLLOW 可以拿到链接本身 */
lfd = open(link, O_PATH | O_NOFOLLOW | O_CLOEXEC);
printf("open(symlink, O_PATH|O_NOFOLLOW): %s\n",
lfd == -1 ? strerror(errno) : "ok");
if (lfd != -1) close(lfd);
unlink(link);
/* 3. O_DIRECTORY 对普通文件 -> ENOTDIR */
errno = 0;
int dfd = open(path, O_RDONLY | O_DIRECTORY);
printf("open(file, O_DIRECTORY): fd=%d errno=%d (%s)\n",
dfd, errno, strerror(errno));
if (dfd != -1) close(dfd);
/* 4. O_TMPFILE:无名文件,linkat 给它名字 */
int anon = open("/tmp", O_TMPFILE | O_RDWR | O_CLOEXEC, 0600);
if (anon == -1) {
show_err("open(/tmp, O_TMPFILE)");
} else {
if (write(anon, "tmp", 3) != 3) perror("write tmpfile");
char named[] = "/tmp/openflags.linked";
unlink(named);
char proc[64];
snprintf(proc, sizeof proc, "/proc/self/fd/%d", anon);
/* AT_EMPTY_PATH 需要 CAP_DAC_READ_SEARCH;失败则走 /proc 路径 */
if (linkat(anon, "", AT_FDCWD, named, AT_EMPTY_PATH) == -1 &&
linkat(AT_FDCWD, proc, AT_FDCWD, named, AT_SYMLINK_FOLLOW) == -1)
show_err("linkat");
else
printf("O_TMPFILE linkat: ok -> %s\n", named);
close(anon);
unlink(named);
}
/* 5. F_GETFL 读回状态标志(访问模式不可改,但可读) */
int fd = open(path, O_WRONLY | O_APPEND | O_NONBLOCK | O_CLOEXEC);
if (fd == -1) { perror("open flags"); return 1; }
int fl = fcntl(fd, F_GETFL);
printf("F_GETFL: append=%d nonblock=%d accmode=%d\n",
!!(fl & O_APPEND), !!(fl & O_NONBLOCK), fl & O_ACCMODE);
close(fd);
unlink(path);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 openflags.c -o openflags
./openflags
# O_PATH fstat: ok
# O_PATH read: n=-1 errno=9 (Bad file descriptor)
# open(symlink, O_NOFOLLOW): fd=-1 errno=40 (Too many levels of symbolic links)
# open(file, O_DIRECTORY): fd=-1 errno=20 (Not a directory)
# O_TMPFILE linkat: ok -> /tmp/openflags.linked
# F_GETFL: append=1 nonblock=1 accmode=1
O_NOFOLLOW 的错误码是 ELOOP 而不是 EINVAL,这是排障时最容易查错的地方。F_GETFL 的返回值里包含访问模式(用 O_ACCMODE 掩出低两位),不要把它当成纯状态位;O_CLOEXEC 属于 fd 标志,用 F_GETFD 查。
- 「
O_TRUNC加O_RDONLY也能截断」——POSIX 说未定义;Linux 上可能截断,但不要依赖。要截断就显式用O_WRONLY/O_RDWR。 - 「
O_EXCL可以单独用于锁」——没有O_CREAT时它被忽略;锁文件必须O_CREAT|O_EXCL。 - 「
O_APPEND的 fd 上pwrite在指定偏移写」——Linux 违反 POSIX,仍追加到末尾(见 2.2.14)。 - 「
O_DIRECT只要文件系统支持就行」——缓冲区地址、偏移、长度都要对齐;栈上的char buf[4096]不保证 4096 对齐,必须posix_memalign。 - 「
O_NONBLOCK对普通文件没用,所以可以随便加」——对普通文件确实基本无效,但它会改变open自身在 FIFO/设备上的阻塞行为;对 FIFO 只读打开且无写者时,O_NONBLOCK决定是立刻成功还是阻塞。 - 「
O_CLOEXEC只影响exec,不影响fork」——正确,但fork后子进程继承 fd;如果子进程立刻exec,CLOEXEC才生效。要在fork后就关闭,得在子进程里显式close或用close_range。
临时文件:mkstemp、tmpfile 与 O_TMPFILE 怎么选
自己拼 /tmp/myapp-XXXX 再 open(O_CREAT|O_EXCL) 看似安全,但很容易在「检查—创建」之间被抢占。标准库的 mkstemp 把「生成唯一名 + O_CREAT|O_EXCL|O_RDWR + 0600」合成一个调用:
#include <stdlib.h>
char tmpl[] = "/tmp/myapp-XXXXXX"; /* 必须是可写的 char 数组 */
int fd = mkstemp(tmpl); /* 成功返回 fd,tmpl 被改写为实际路径 */
if (fd < 0) { perror("mkstemp"); return 1; }
unlink(tmpl); /* 立刻删名:fd 仍可用,进程退出自动回收 */
write(fd, "data", 4);
close(fd);
| 方案 | 名字 | 权限 | 自动删除 | 适用 |
|---|---|---|---|---|
mkstemp | 有,返回路径 | 0600 | 手动 unlink | 需要把路径交给别的程序 |
tmpfile() | 无 | 0600 | fclose 自动删 | 纯本进程临时流 |
O_TMPFILE | 无 | 由 mode/umask | 不 linkat 就自动回收 | 先写完整、再原子发布 |
mkstemp的模板必须以 6 个X结尾;少一个就失败,多一个不会被替换。- 不要在
mkstemp后再chmod成 0644:临时文件默认 0600 是有意的;要给别的用户读,用fchmod并确认路径/目录权限。 tmpfile()在disk full时可能返回 NULL;它底层用O_TMPFILE或mkstemp+unlink,不是「一定成功」。
2.2.10 openat2 与路径解析的安全边界
这一节回答:怎样保证「打开的文件一定在我指定的目录下」,即使路径里有 .. 和符号链接?
openat(dirfd, "../etc/passwd", ...) 可以逃出 dirfd 指向的目录;O_NOFOLLOW 只挡最后一段。Linux 5.6 引入的 openat2 把「解析约束」变成一组显式 flag:
#include <linux/openat2.h>
#include <sys/syscall.h>
struct open_how {
__u64 flags; /* 与 open 的 flags 相同 */
__u64 mode; /* 与 open 的 mode 相同 */
__u64 resolve; /* RESOLVE_* 约束 */
};
long openat2(int dirfd, const char *pathname,
struct open_how *how, size_t size);
size 必须等于(或小于)当前内核认识的 sizeof(struct open_how);传大了返回 E2BIG,传小了按旧版本解释。glibc 2.37 起提供包装,老 glibc 用 syscall(SYS_openat2, ...)。
resolve 位 | 含义 | 违规时 errno |
|---|---|---|
RESOLVE_BENEATH | 不允许 .. 或绝对路径逃出 dirfd | EXDEV |
RESOLVE_IN_ROOT | 把 dirfd 当作 /,绝对路径和 .. 都被限制在其中 | 不逃逸,直接解析 |
RESOLVE_NO_MAGICLINKS | 禁止 /proc/self/fd/N 这类魔法链接 | ELOOP |
RESOLVE_NO_SYMLINKS | 禁止路径中任何符号链接 | ELOOP |
RESOLVE_NO_XDEV | 禁止跨越挂载点 | EXDEV |
RESOLVE_CACHED | 只查缓存,不触发 I/O | EAGAIN |
RESOLVE_BENEATH 和 RESOLVE_IN_ROOT 的区别是「逃逸是错误还是被夹住」:前者返回 EXDEV,后者把 .. 限制在根内,像 chroot 但不改进程状态。做沙箱/解压工具时,IN_ROOT 更宽容,BENEATH 更适合「必须留在这个目录」的断言。
/* safeopen.c — 用 openat2(RESOLVE_BENEATH) 把路径限制在目录内 */
#include <errno.h>
#include <fcntl.h>
#include <linux/openat2.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
static int open_beneath(int dirfd, const char *path)
{
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};
return (int)syscall(SYS_openat2, dirfd, path, &how, sizeof how);
}
int main(void)
{
char dir[] = "/tmp/safeopen.XXXXXX";
if (!mkdtemp(dir)) { perror("mkdtemp"); return 1; }
char inside[256];
snprintf(inside, sizeof inside, "%s/ok.txt", dir);
int fd = open(inside, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd == -1) { perror("create"); return 1; }
if (write(fd, "ok", 2) != 2) { perror("write"); return 1; }
close(fd);
int dfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dfd == -1) { perror("open dir"); return 1; }
int ok = open_beneath(dfd, "ok.txt");
printf("inside: fd=%d %s\n", ok, ok == -1 ? strerror(errno) : "ok");
if (ok != -1) close(ok);
errno = 0;
int bad = open_beneath(dfd, "../etc/passwd");
printf("escape: fd=%d errno=%d (%s)\n", bad, errno, strerror(errno));
if (bad != -1) close(bad);
errno = 0;
int abs = open_beneath(dfd, "/etc/passwd");
printf("absolute:fd=%d errno=%d (%s)\n", abs, errno, strerror(errno));
if (abs != -1) close(abs);
close(dfd);
unlink(inside);
rmdir(dir);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 safeopen.c -o safeopen
./safeopen
# inside: fd=3 ok
# escape: fd=-1 errno=18 (Invalid cross-device link)
# absolute:fd=-1 errno=18 (Invalid cross-device link)
EXDEV 看起来像「跨设备」,实际是 RESOLVE_BENEATH 对逃逸的统一错误码。这是 openat2 最反直觉的一点:错误码不描述原因,只描述类别。排障时先看 resolve 位,再看路径。
旧内核没有 openat2,syscall 返回 ENOSYS。回退方案是「逐段 openat + O_NOFOLLOW」:自己把路径按 / 拆开,拒绝 ..,每一段都用 O_NOFOLLOW|O_DIRECTORY 打开,最后一段再按需要打开。这比直接 open 安全,但仍要处理符号链接竞态。
不能。chroot 改的是进程的根目录,影响之后所有路径解析;openat2 只约束这一次解析。真正的沙箱还需要 mount namespace、pivot_root、seccomp 和 capability 收缩。openat2 是「单次路径解析的护栏」,不是「进程隔离」。
2.2.11 mode、umask 与创建权限
这一节回答:open(..., 0666) 创建出来的文件为什么常常是 0644?谁改了权限?
open 的 mode 只在 O_CREAT/O_TMPFILE 时参与,内核计算最终权限的公式是:
最终权限 = mode & 07777 & ~umask
umask 是进程级状态,默认通常是 022,所以 0666 & ~022 = 0644。umask 不是「减去权限」,而是「屏蔽位」:它只清位,不加位。
umask 077
touch secret.txt
stat -c '%a %n' secret.txt # 600 secret.txt
umask 022
umask 影响所有创建接口:open、mkdir、mkfifo、mknod、socket 绑定的文件、sem_open 创建的 POSIX 信号量文件。它在 fork 后继承,在 exec 后保留;多线程共享同一个值,工作线程里改它会影响其他线程。
特殊位与目录继承
mode 的低 12 位包含 setuid(04000)、setgid(02000)、sticky(01000)。创建时可以直接带上,但:
open创建的文件带 setuid/setgid,后续chown会清掉;- 目录的 setgid 位有特殊含义:在该目录下新建的文件继承目录的属组,新建的子目录继续带 setgid;
- sticky 位只对目录有意义:只有文件属主、目录属主或 root 能删除目录里的项(
/tmp的1777)。
mkdir /srv/shared && chgrp dev /srv/shared && chmod 2775 /srv/shared
touch /srv/shared/a # a 的组是 dev,不是创建者的主组
默认 ACL 会插一脚
如果父目录设置了默认 ACL(setfacl -d),新建文件的权限不再只由 mode & ~umask 决定:内核以默认 ACL 为模板,把 mode 中没有请求的权限位从 ACL 的 mask 里清掉。umask 仍生效,但作用在 mask 上;命名用户的条目可能保留,有效权限受 mask 限制;文件不会因 ACL 获得执行位,除非创建时请求了 x。同一个 open(..., 0666) 在不同目录里创建出不同 ACL,原因就在这里。
mode=0 与 O_CREAT
open(path, O_CREAT|O_WRONLY, 0) 会创建一个所有权限位为 0 的文件。普通用户之后连自己都打不开(root 可以),只能 chmod 补救。这不是「用默认权限」,而是「显式请求 0000」。creat(path, mode) 是 open(path, O_WRONLY|O_CREAT|O_TRUNC, mode) 的别名,同样受 umask 影响。
- 「
umask是减法」——是位屏蔽,mode & ~umask;umask 022对0600没有影响。 - 「
open的mode可以省略」——带O_CREAT/O_TMPFILE时是可变参数,省略是未定义行为,通常拿到栈上的垃圾值,创建出权限诡异的文件。 - 「
chmod不受umask影响」——正确;chmod设置的是精确权限,只有创建时受umask约束。 - 「目录的 setgid 只影响目录自身」——它还决定新建文件的属组与子目录的继承。
- 「默认 ACL 会覆盖
umask」——两者共同作用:umask清 mode 位,默认 ACL 提供模板,最后 mask 再限制命名条目。
2.2.12 read/write 的完整返回矩阵
这一节回答:read/write 除了 -1 和「请求的字节数」,还可能返回什么?每个 errno 该重试还是该放弃?
2.2.3 给了三条返回值规则。这里把「什么情况下返回多少」和「每个错误码怎么办」补全。
返回值的五种形态
| 返回值 | read 含义 | write 含义 | 处理 |
|---|---|---|---|
== count | 读满 | 写完 | 继续下一块 |
0 < n < count | 短读:EOF、缓冲不足、信号 | 短写:缓冲满、配额、信号 | 推进偏移,继续 |
0 | EOF(普通文件/管道写端关闭) | count==0 或设备特殊语义 | read 结束循环;write 视为完成 |
-1 + EINTR | 被信号打断,可能已传部分 | 同左 | 重试剩余部分 |
-1 + 其他 | 真错误 | 真错误 | 按错误码处理 |
count == 0 时,Linux 的 read/write 返回 0,且可能不检查 fd 是否有效(POSIX 允许实现选择)。不要用 read(fd, buf, 0) 探测 fd 是否可读。
read 的错误码
errno | 触发条件 | 是否重试 |
|---|---|---|
EBADF | fd 未打开、或不是读模式(含 O_PATH) | 否 |
EFAULT | 缓冲区不可访问 | 否(调用方 bug) |
EINVAL | fd 不适合读(如目录)、O_DIRECT 未对齐、偏移非法 | 否 |
EISDIR | Linux 上对目录 read | 否,改用 getdents |
EAGAIN/EWOULDBLOCK | O_NONBLOCK 且暂无数据 | 是,等可读事件 |
EINTR | 阻塞中被信号打断 | 是 |
EIO | 设备/文件系统错误 | 否,告警 |
ENOMEM | 内核缓冲区不足 | 可退避重试 |
ETIMEDOUT | 网络文件系统/设备超时 | 视语义 |
write 的错误码
errno | 触发条件 | 是否重试 |
|---|---|---|
EBADF | fd 未打开、或不是写模式 | 否 |
EFAULT | 缓冲区不可访问 | 否 |
EINVAL | O_DIRECT 未对齐、fd 不支持写 | 否 |
EFBIG | 超过 RLIMIT_FSIZE 或文件系统最大文件 | 否 |
ENOSPC | 设备/文件系统满 | 否,先清理 |
EDQUOT | 用户/组配额超限 | 否,先清理 |
EPIPE | 管道/socket 读端已关闭 | 否;默认还会收到 SIGPIPE |
EAGAIN | O_NONBLOCK 且缓冲满 | 是,等可写事件 |
EINTR | 阻塞中被信号打断 | 是 |
EIO | 设备错误 | 否 |
EPERM | 追加-only/immutable 文件,或 O_APPEND 受限 | 否 |
EROFS | 只读文件系统 | 否 |
EPIPE 默认会先触发 SIGPIPE,进程直接终止;服务端通常 signal(SIGPIPE, SIG_IGN) 或对 socket 用 MSG_NOSIGNAL,让 write 返回 EPIPE 而不是被杀。
短读/短写的真实来源
短读不是「异常」,而是以下情况的正常表现:
- 读到文件末尾,剩余不足
count; - 管道缓冲区里只有这么多;
- 终端处于行模式,一次只给一行;
- 信号在传输部分数据后到达(Linux 返回已读字节数,而不是
EINTR); O_DIRECT的尾块不足一个对齐单位;/proc、/sys的许多文件一次只产生一页左右的数据,st_size还是 0;- 设备驱动按自己的块大小返回。
短写同理:管道容量(默认 64 KiB)、socket 发送缓冲、RLIMIT_FSIZE、磁盘配额、非阻塞设备。唯一正确的处理是循环,但循环要区分「这次写了 0」:write 返回 0 且 count > 0 时,继续循环会死锁,应该报 EIO。
/* shortio.c — 制造短读、短写、EAGAIN、ENOSPC、EPIPE、EISDIR */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t got_pipe;
static void on_pipe(int sig) { (void)sig; got_pipe = 1; }
int main(void)
{
int p[2];
if (pipe2(p, O_CLOEXEC | O_NONBLOCK) == -1) { perror("pipe2"); return 1; }
size_t big = 1u << 20;
char *buf = malloc(big);
if (!buf) { perror("malloc"); return 1; }
memset(buf, 'A', big);
/* 1. 非阻塞管道:第一次短写,第二次 EAGAIN */
errno = 0;
ssize_t n = write(p[1], buf, big);
printf("write 1MiB to pipe: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));
errno = 0;
n = write(p[1], buf, big);
printf("write again: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));
/* 2. 读端只取一部分:短读 */
char tmp[65536];
errno = 0;
n = read(p[0], tmp, sizeof tmp);
printf("read 64KiB: n=%zd errno=%d\n", n, errno);
/* 3. 空管道再读:EAGAIN */
errno = 0;
n = read(p[0], tmp, sizeof tmp);
printf("read empty: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));
/* 4. /dev/full:ENOSPC */
int full = open("/dev/full", O_WRONLY | O_CLOEXEC);
if (full != -1) {
errno = 0;
n = write(full, "x", 1);
printf("write /dev/full: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));
close(full);
}
/* 5. 读目录:EISDIR */
int dir = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dir != -1) {
errno = 0;
n = read(dir, tmp, 16);
printf("read directory: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));
close(dir);
}
/* 6. 读端关闭后写:EPIPE(默认还会 SIGPIPE,这里先接管) */
struct sigaction sa = {0};
sa.sa_handler = on_pipe;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGPIPE, &sa, NULL) == -1) { perror("sigaction"); return 1; }
close(p[0]);
errno = 0;
n = write(p[1], "x", 1);
printf("write broken pipe: n=%zd errno=%d (%s) sigpipe=%d\n",
n, errno, strerror(errno), (int)got_pipe);
close(p[1]);
free(buf);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 shortio.c -o shortio
./shortio
# write 1MiB to pipe: n=65536 errno=0 (Success)
# write again: n=-1 errno=11 (Resource temporarily unavailable)
# read 64KiB: n=65536 errno=0
# read empty: n=-1 errno=11 (Resource temporarily unavailable)
# write /dev/full: n=-1 errno=28 (No space left on device)
# read directory: n=-1 errno=21 (Is a directory)
# write broken pipe: n=-1 errno=32 (Broken pipe) sigpipe=1
注意第一条 write 的返回值:管道容量是 64 KiB,所以 1 MiB 只写进去 65536 字节,没有报错。任何「write 返回非负就认为写完了」的代码都会丢数据。
n == -1且errno == EINTR:重试,不推进偏移。n == -1且errno == EAGAIN:等 fd 就绪(epoll/poll),不要忙等。n == 0(读):EOF;write返回 0 且count > 0:报EIO,不要死循环。0 < n < count:推进buf与剩余长度,继续。- 其他
errno:按 2.2.12 的表决定重试、退避或失败。
2.2.13 lseek:偏移、SEEK_DATA/SEEK_HOLE 与稀疏文件
这一节回答:lseek 除了「移动文件指针」还能做什么?为什么 lseek 到文件末尾之外不会立刻占磁盘?
off_t lseek(int fd, off_t offset, int whence);
whence 取 SEEK_SET(绝对)、SEEK_CUR(相对当前)、SEEK_END(相对末尾)。成功返回新偏移,失败返回 -1。lseek 只改 struct file 里的 f_pos,不触发任何 I/O。
errno | 触发条件 |
|---|---|
EBADF | fd 未打开或不可寻址 |
ESPIPE | 管道、FIFO、socket、终端(不可随机访问) |
EINVAL | whence 非法,或结果偏移为负 |
EOVERFLOW | 结果超出 off_t 能表示的范围 |
ENXIO | SEEK_DATA/SEEK_HOLE 的起点超出文件末尾 |
超出末尾的 lseek 不分配空间
int fd = open("hole.bin", O_RDWR | O_CREAT | O_TRUNC, 0644);
lseek(fd, 1024 * 1024, SEEK_SET); /* 偏移 1 MiB,但文件大小仍是 0 */
write(fd, "x", 1); /* 现在大小是 1 MiB + 1,中间是空洞 */
lseek 到 1 MiB 只改偏移;write 之后文件逻辑大小变成 1 MiB+1,但中间 1 MiB 是空洞,不占数据块。读这些位置返回 0。st_size 是逻辑大小,st_blocks * 512 是实际占用(见 2.5)。
SEEK_DATA 与 SEEK_HOLE
Linux 3.1 起支持两个特殊的 whence,用来在稀疏文件中跳读:
lseek(fd, off, SEEK_DATA):返回off之后第一个包含数据的偏移;off本身在数据区则返回off。lseek(fd, off, SEEK_HOLE):返回off之后第一个空洞的偏移;如果后面全是数据,返回文件末尾。
两者都可能返回 ENXIO(SEEK_DATA 时 off >= 文件大小)。文件系统可以保守地「把所有区域都报告为数据」(例如某些不支持 hole 的文件系统),这时 SEEK_HOLE 只返回 EOF,程序仍然正确,只是没省 I/O。
/* sparse_scan.c — 用 SEEK_DATA/SEEK_HOLE 列出文件的逻辑区段 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void)
{
const char *path = "sparse_scan.bin";
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd == -1) { perror("open"); return 1; }
const off_t size = 4 * 1024 * 1024;
if (ftruncate(fd, size) == -1) { perror("ftruncate"); return 1; }
/* 在 0 和 2 MiB 处各写 4 KiB 数据,其余是空洞 */
char page[4096];
memset(page, 'D', sizeof page);
if (pwrite(fd, page, sizeof page, 0) != (ssize_t)sizeof page) {
perror("pwrite 0"); return 1;
}
if (pwrite(fd, page, sizeof page, 2 * 1024 * 1024) != (ssize_t)sizeof page) {
perror("pwrite 2M"); return 1;
}
if (fsync(fd) == -1) { perror("fsync"); return 1; }
struct stat st;
if (fstat(fd, &st) == -1) { perror("fstat"); return 1; }
printf("logical=%lld bytes, allocated=%lld bytes\n",
(long long)st.st_size, (long long)st.st_blocks * 512);
off_t off = 0;
while (off < st.st_size) {
errno = 0;
off_t data = lseek(fd, off, SEEK_DATA);
if (data == -1) {
if (errno == ENXIO) break; /* 后面全是空洞 */
perror("SEEK_DATA"); break;
}
off_t hole = lseek(fd, data, SEEK_HOLE);
if (hole == -1) { perror("SEEK_HOLE"); break; }
printf("data [%lld, %lld) %lld bytes\n",
(long long)data, (long long)hole, (long long)(hole - data));
if (hole <= off) { /* 防御:避免死循环 */
fprintf(stderr, "no progress at %lld\n", (long long)off);
break;
}
off = hole;
}
close(fd);
unlink(path);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 sparse_scan.c -o sparse_scan
./sparse_scan
# logical=4194304 bytes, allocated=8192 bytes
# data [0, 4096) 4096 bytes
# data [2097152, 2101248) 4096 bytes
allocated=8192 说明只有两个 4 KiB 数据块真正占了磁盘;中间 2 MiB 的空洞不占空间。SEEK_DATA/SEEK_HOLE 是 cp --sparse=always、tar -S、备份工具保留稀疏结构的底层依据。
- 「
lseek会触发磁盘 I/O」——不会,它只改内存里的偏移;真正读写在后续read/write。 - 「
lseek(fd, 0, SEEK_END)返回文件大小就一定是大小」——对普通文件通常是,但对某些设备//proc文件返回 0;用fstat的st_size。 - 「
SEEK_DATA在所有文件系统上都精确」——文件系统可以保守报告;只把它当优化,不当正确性依赖。 - 「
lseek的偏移可以随便设负数」——结果偏移为负返回EINVAL;SEEK_CUR向前越过 0 也失败。 - 「
O_APPEND下lseek能改变写位置」——不能;带O_APPEND的write总是原子地定位到末尾,lseek的设置被忽略。
2.2.14 pread/pwrite 与分散聚集 I/O
这一节回答:多线程读同一个 fd,为什么 lseek + read 会串数据?怎样一次系统调用读写多段缓冲区?
pread/pwrite:把偏移变成参数
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);
它们从 offset 读写,不改变 f_pos,因此两个线程用同一个 fd 也不会互相踩偏移。返回值与 read/write 相同:可能短读/短写,-1 时看 errno。
errno | 说明 |
|---|---|
ESPIPE | 管道/FIFO/socket 不支持随机访问(Linux 上 pread 返回 ESPIPE) |
EINVAL | offset 为负、O_DIRECT 未对齐 |
EBADF | fd 模式不对 |
EINTR/EAGAIN | 同 read/write |
POSIX 规定 pwrite 忽略 O_APPEND,在 offset 处写。Linux 的内核实现不遵守:如果 fd 带 O_APPEND,pwrite 仍然追加到末尾,offset 被忽略。可移植代码要么不要在同一 fd 上混用,要么先 fcntl 清掉 O_APPEND。这个问题在 man 2 pwrite 的 BUGS 里有记录,长期未修。
readv/writev:一次调用,多段缓冲
struct iovec {
void *iov_base; /* 缓冲区起始地址 */
size_t iov_len; /* 长度 */
};
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
它们把多段内存拼成一次 I/O,减少系统调用次数,也避免为「头 + 体」临时拼一块大缓冲。返回值是总字节数,可能小于所有 iov_len 之和。
| 约束 | 值/错误 |
|---|---|
| 最大段数 | sysconf(_SC_IOV_MAX),Linux 通常 1024;原始 syscall 超过返回 EINVAL,glibc 可能拆分 |
| 总长度 | 不能超过 SSIZE_MAX;超过可能 EINVAL |
空 iov | iovcnt==0 时返回 0 |
| 错误 | EFAULT、EBADF、EINVAL、EINTR、EAGAIN |
| 原子性 | 整组不是原子的;管道上单次 writev ≤ PIPE_BUF 才保证不交错 |
短写后不能「重试整个数组」,否则会重复写已经写出的部分。正确做法是推进 iovec:跳过已写满的段,把当前段的首地址和长度按剩余量切短。2.3.3 的 writev_full() 给了完整实现,这里补一个使用 preadv2/pwritev2 的例子。
preadv2/pwritev2 与 RWF_*
ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt,
off_t offset, int flags);
ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt,
off_t offset, int flags);
flags 是每次 I/O 的行为提示:
| flag | 含义 | 典型错误 |
|---|---|---|
RWF_HIPRI | 高优先级 I/O(配合 O_DIRECT) | 不支持时忽略或 EOPNOTSUPP |
RWF_DSYNC | 本次写等同 O_DSYNC | 设备不支持 EINVAL |
RWF_SYNC | 本次写等同 O_SYNC | 同上 |
RWF_NOWAIT | 不等待 I/O,缓存未命中直接返回 | EAGAIN |
RWF_APPEND | 本次写追加到末尾(4.16+) | 与 O_APPEND 类似 |
RWF_NOWAIT 适合「有活就干,没活就切走」的线程池:它不会因为缺页或回写而阻塞,但会在数据不在 page cache 时返回 EAGAIN。这比 O_NONBLOCK 对普通文件更可控。
/* rwv.c — pwritev2(RWF_DSYNC) 写头+体,preadv2(RWF_NOWAIT) 读回 */
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/uio.h>
#include <unistd.h>
struct hdr { uint32_t len; uint32_t crc; };
int main(void)
{
const char *path = "rwv_demo.bin";
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd == -1) { perror("open"); return 1; }
const char *body = "scatter-gather";
struct hdr h = { .len = (uint32_t)strlen(body), .crc = 0 };
struct iovec out[2] = {
{ .iov_base = &h, .iov_len = sizeof h },
{ .iov_base = (void *)body, .iov_len = strlen(body) },
};
ssize_t n = pwritev2(fd, out, 2, 0, RWF_DSYNC);
if (n == -1) { perror("pwritev2"); return 1; }
printf("pwritev2 wrote %zd bytes\n", n);
struct hdr back = {0};
char buf[64] = {0};
struct iovec in[2] = {
{ .iov_base = &back, .iov_len = sizeof back },
{ .iov_base = buf, .iov_len = sizeof buf - 1 },
};
errno = 0;
n = preadv2(fd, in, 2, 0, RWF_NOWAIT);
if (n == -1 && (errno == EAGAIN || errno == EOPNOTSUPP)) {
printf("RWF_NOWAIT unsupported/unavailable (%s), fallback\n",
strerror(errno));
n = preadv(fd, in, 2, 0);
}
if (n == -1) { perror("preadv2"); return 1; }
printf("read %zd bytes: len=%u body=%s\n", n, back.len, buf);
close(fd);
unlink(path);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 rwv.c -o rwv
./rwv
# pwritev2 wrote 18 bytes
# read 18 bytes: len=14 body=scatter-gather
pwritev2 返回 18(4 字节头 + 14 字节体)。如果 RWF_DSYNC 不被文件系统支持,errno 可能是 EINVAL;生产代码应降级到普通 pwritev + fdatasync,而不是直接失败。
- 单段、顺序、单线程:
read/write。 - 多线程共享 fd、需要指定偏移:
pread/pwrite。 - 多段缓冲(头+体、多个字段):
readv/writev。 - 需要每次 I/O 的同步/非阻塞语义:
preadv2/pwritev2+RWF_*。 - 超大文件、需要绕过缓存:
O_DIRECT+ 对齐缓冲(2.3.8)。
2.2.15 fd 生命周期:dup3、close_range 与 /proc/self/fd
这一节回答:fork/exec 前后怎样精确控制「哪些 fd 还在」?dup2 和 dup3 差在哪?
dup3:带 CLOEXEC 的原子复制
int dup3(int oldfd, int newfd, int flags);
dup3 与 dup2 的区别只有两点,但都很关键:
flags只接受O_CLOEXEC,在复制的同时原子设置FD_CLOEXEC;oldfd == newfd时返回EINVAL(dup2直接返回newfd)。
失败:EBADF(oldfd 无效)、EINVAL(flags 非法或两参数相等)、EMFILE(超过 RLIMIT_NOFILE)。dup3 在 Linux 2.6.27 引入,glibc 2.9 起有包装。
int newfd = dup3(oldfd, 1, O_CLOEXEC); /* 原子把 oldfd 复制到 stdout,并带 CLOEXEC */
close_range:一次关闭一段 fd
int close_range(unsigned int first, unsigned int last, unsigned int flags);
Linux 5.9 引入,glibc 2.34 起有包装。它关闭 [first, last] 区间内的所有 fd,等价于循环 close,但在内核里一次完成,不会因为信号或并发 open 而漏掉。常用 flag:
| flag | 含义 |
|---|---|
0 | 直接关闭 |
CLOSE_RANGE_UNSHARE | 先 unshare fd 表,避免影响共享该表的线程/进程 |
CLOSE_RANGE_CLOEXEC | 不关闭,只设置 FD_CLOEXEC |
first > last 返回 EINVAL;内核不支持返回 ENOSYS,回退是 for (fd = first; fd <= last; fd++) close(fd);。close_range(3, ~0U, 0) 是「exec 前清空所有非标准 fd」的标准写法,比遍历 /proc/self/fd 可靠。
/* fdlife.c — dup3、F_DUPFD_CLOEXEC、close_range 与 fd 计数 */
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
static int count_fds(void)
{
DIR *d = opendir("/proc/self/fd");
if (!d) return -1;
int n = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL)
if (de->d_name[0] != '.') n++;
closedir(d);
return n - 1; /* 减去 opendir 自己占的那个 */
}
int main(void)
{
int a = open("/etc/hosts", O_RDONLY | O_CLOEXEC);
int b = open("/etc/hosts", O_RDONLY | O_CLOEXEC);
if (a == -1 || b == -1) { perror("open"); return 1; }
int c = dup3(a, 100, O_CLOEXEC);
printf("dup3(a,100,CLOEXEC)=%d fd0_cloexec=%d\n", c,
!!(fcntl(a, F_GETFD) & FD_CLOEXEC));
int d = fcntl(b, F_DUPFD_CLOEXEC, 3);
printf("F_DUPFD_CLOEXEC(b,3)=%d\n", d);
printf("fds before close_range: %d\n", count_fds());
/* 只关我们自己造的 100 与 d,避免误关 stdout/stderr */
if (c != -1) close(c);
if (d != -1) close(d);
errno = 0;
#ifdef SYS_close_range
if (syscall(SYS_close_range, 100U, 200U, 0U) == -1)
printf("close_range: %s(老内核?)\n", strerror(errno));
#else
printf("SYS_close_range 不可用\n");
#endif
printf("fds after close_range: %d\n", count_fds());
close(a);
close(b);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 fdlife.c -o fdlife
./fdlife
# dup3(a,100,CLOEXEC)=100 fd0_cloexec=1
# F_DUPFD_CLOEXEC(b,3)=3
# fds before close_range: 7
# fds after close_range: 5
count_fds() 的结果包含 stdin/stdout/stderr(3 个),所以数字比「自己打开的 fd 数」大 3。close_range 关闭区间后,fd 号立刻可被复用;如果之后还有代码持有这些数字,就会指向新打开的对象——这正是「关闭 fd 后不要再用旧数字」的原因。
close() 的错误与重试
close(fd) 失败时返回 -1,errno 可能是 EBADF(fd 未打开)、EINTR(被信号打断)、EIO(NFS 写回失败)。不要重试 close:Linux 上 fd 在返回前已经释放,重试可能关掉另一个线程刚打开的同号 fd。正确处理是记录错误,继续。
- 「
dup2和dup3只差一个 flag」——dup3在oldfd == newfd时返回EINVAL,dup2返回newfd;依赖这个行为做「确保 fd 已打开」的代码会坏。 - 「
close_range(3, ~0U, 0)在任何内核上都能用」——5.9 之前返回ENOSYS;glibc 2.34 之前没有声明,需要syscall(SYS_close_range, ...)。 - 「
close返回EINTR要重试」——不要;fd 可能已经释放。 - 「
/proc/self/fd里的数字就是稳定的 fd 列表」——读目录本身会占一个 fd;并发open/close时列表随时变。 - 「
FD_CLOEXEC只在open时能设」——fcntl(F_SETFD)、dup3、F_DUPFD_CLOEXEC、close_range(CLOEXEC)都能设,但只有创建时设置才是原子的。
2.2.16 原子性再审视:O_APPEND、O_EXCL、rename 的边界
这一节回答:O_APPEND 到底保证到哪一步?O_EXCL 在 NFS 上还可信吗?rename 的原子性有没有例外?
2.2.5 给了正确原语对照表。这里补「保证的边界」和「什么时候会失效」。
O_APPEND 保证什么
在本地文件系统上,write 的「取末尾偏移 + 写入」在 inode 锁内完成,所以多个进程各自 open(O_APPEND) 并单次 write 不会互相覆盖。它不保证:
- 多次
write组成的记录不交错; writev的多段被视为一个不可分割的日志条目(整次调用定位一次,但写入过程仍可能被拆);- NFS 上跨客户端仍然原子(服务器实现相关);
pwrite尊重你给的偏移(Linux 上仍追加)。
要保证「一条记录不交错」,记录必须一次 write 且长度不超过文件系统的原子写上限(普通文件没有 PIPE_BUF 那样的常量,实践上单次几 KiB 是安全的,但不是规范保证)。更强的要求用文件锁(2.3.6)。
O_EXCL 的边界
open(O_CREAT|O_EXCL) 在本地文件系统上原子。几个例外:
- NFSv2 不支持原子独占创建,两个客户端可能都成功;NFSv3 起用
CREATE的 exclusive 模式,通常可靠,但服务器实现仍有差异; - 路径最后一段是符号链接时,
O_EXCL让open返回EEXIST(即使链接悬空),这是安全特性:不会跟随链接创建目标; O_EXCL不带O_CREAT时被忽略;O_TMPFILE|O_EXCL创建的文件永远不能linkat。
锁文件还要处理 stale lock:进程崩溃后文件还在,但没人持锁。正确做法不是「看文件存在就退出」,而是「open(O_CREAT|O_EXCL) 失败后,尝试对已有文件加锁,判断持有者是否还活着」,或者直接用 flock/fcntl 锁(锁随进程退出自动释放)。
rename 的原子性边界
rename(old, new) 在同一文件系统内保证:new 要么指向旧内容,要么指向新内容,不会出现「不存在」的中间态。边界:
| 场景 | 结果 |
|---|---|
| 跨文件系统 | EXDEV,需「复制 + fsync + rename + 删除」 |
old 是目录,new 是非空目录 | ENOTEMPTY |
old 是目录,new 是文件 | ENOTDIR |
old 是文件,new 是目录 | EISDIR |
new 存在且是只读文件 | 允许(权限看目录,不看文件) |
new 是挂载点 | EBUSY |
| 目标在另一个挂载命名空间 | 可能成功但不可见,取决于挂载传播 |
rename 只保证目录项替换原子,不保证数据落盘。数据要先 fsync,目录项要再 fsync 父目录(2.3.7)。
验证 O_APPEND 是否覆盖,最便宜的办法是让 N 个进程各写一条固定长度、内容可识别的记录,再检查文件长度是否等于 N * 记录长度、每条记录是否完整;去掉 O_APPEND 改用 lseek(END) + write 作对照,同样压力下会出现覆盖或长度偏短。
- 「
O_APPEND保证日志行不交错」——只在「一行一次write」时成立;fprintf的多段写仍会交错。 - 「
O_EXCL在 NFS 上也原子」——NFSv2 不可靠;跨客户端要加锁服务或使用link()技巧。 - 「
rename跨文件系统也能原子」——返回EXDEV,必须先复制到目标文件系统再rename。 - 「
rename后数据一定落盘」——只保证目录项原子;数据与目录都需要fsync。 - 「临时文件放
/tmp最安全」——跨文件系统rename会失败;临时文件必须与目标同目录,且用O_EXCL或O_TMPFILE创建。
2.2.17 内核路径:openat2、read/write 与回写
这一节回答:write 返回成功时,数据到底在哪一层?fsync 之后又到了哪一层?
2.1.14 给了 openat/read 的骨架。这里补 write 与回写路径,因为「写成功不等于落盘」的所有讨论都落在这条链上。
write 的路径
| 层次 | 位置 | 关键动作 |
|---|---|---|
| libc | sysdeps/unix/sysv/linux/write.c | syscall(SYS_write, ...) |
| 系统调用 | fs/read_write.c | ksys_write → vfs_write |
| 检查 | fs/read_write.c | FMODE_WRITE、rw_verify_area、MAX_RW_COUNT |
| 迭代器 | lib/iov_iter.c | import_iovec/iov_iter_ubuf 把用户缓冲包成 iov_iter |
| 文件系统 | include/linux/fs.h | file->f_op->write_iter |
| ext4 | fs/ext4/file.c | ext4_file_write_iter → generic_file_write_iter |
| 页缓存 | mm/filemap.c | generic_perform_write → copy_page_from_iter_atomic |
| 脏页 | mm/page-writeback.c | balance_dirty_pages、wakeup_flusher_threads |
| 回写 | fs/ext4/inode.c | ext4_writepages → ext4_bio_write_page → 块层 |
generic_perform_write() 的核心循环(删节):
for (;;) {
struct folio *folio = __filemap_get_folio(mapping, pos >> PAGE_SHIFT,
FGP_WRITEBEGIN, mapping_gfp_mask(mapping));
size_t offset = pos & (PAGE_SIZE - 1);
size_t bytes = min_t(size_t, bytes, PAGE_SIZE - offset);
a_ops->write_begin(file, mapping, pos, bytes, &folio);
copied = copy_page_from_iter_atomic(&folio->page, offset, bytes, i);
a_ops->write_end(file, mapping, pos, bytes, copied, folio);
pos += copied; written += copied;
balance_dirty_pages_ratelimited(mapping);
}
这段代码解释了三个现象:
write返回时数据在 page cache,不在磁盘。copy_page_from_iter_atomic只把用户数据拷进内核页;write_end把页标记为脏,之后由回写线程刷盘。balance_dirty_pages会主动限速。脏页超过vm.dirty_ratio时,write会被内核睡眠拖慢,而不是无限占用内存。O_SYNC/O_DSYNC在write_iter返回前调用generic_write_sync,最终走vfs_fsync_range→file->f_op->fsync→ ext4 的ext4_sync_file,这才是等设备确认的地方。
openat2 的额外检查
do_sys_openat2 在普通 open 路径上多了一步 build_open_flags,它校验 how->flags 与 how->resolve 的合法组合。非法组合返回 EINVAL,例如:
O_TMPFILE没有搭配O_WRONLY/O_RDWR;resolve带了未知位;how->mode在非创建场景非 0;how->flags带了O_CREAT但没有mode。
路径解析在 path_openat 里,open_last_lookups 处理最后一段;RESOLVE_BENEATH/IN_ROOT 的检查在 link_path_walk 的每一步比较 nd->path 与根,逃逸时返回 -EXDEV。
fsync(fd) 做两件事:把该文件的所有脏页回写(filemap_write_and_wait_range),再让文件系统提交元数据(ext4 的日志提交)。fdatasync(fd) 只保证数据与「读回数据必需的元数据」,不保证 mtime 等。sync() 刷所有文件系统,syncfs(fd) 只刷该 fd 所在文件系统。对数据库 WAL,fdatasync 通常够;对「重命名发布」,目录的 fsync 不能省。
2.2.18 两个实验:strace 解码 flags 与 /proc/self/fdinfo
这一节回答:怎样确认一个程序真正用了哪些 flags、fd 当前处于什么状态?
实验一:strace 解码 openat 的 flags
strace -yy -s 64 -e trace=openat,read,write,close,fsync ./copy /etc/hosts /tmp/hosts.copy 2>&1 | head -20
典型输出:
$ strace -yy -e trace=openat,read,write,close,fsync ./copy /etc/hosts /tmp/hosts.copy 2>&1 | head -12
openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 3</etc/hosts>
openat(AT_FDCWD, "/tmp/hosts.copy.tmp.48211", O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0644) = 4</tmp/hosts.copy.tmp.48211>
read(3</etc/hosts>, "# Copyright...\n"..., 262144) = 221
write(4</tmp/hosts.copy.tmp.48211>, "# Copyright...\n"..., 221) = 221
read(3</etc/hosts>, "", 262144) = 0
fsync(4</tmp/hosts.copy.tmp.48211>) = 0
close(4</tmp/hosts.copy.tmp.48211>) = 0
close(3</etc/hosts>) = 0
rename("/tmp/hosts.copy.tmp.48211", "/tmp/hosts.copy") = 0
openat(AT_FDCWD, ".", O_RDONLY|O_DIRECTORY|O_CLOEXEC) = 3</tmp>
fsync(3</tmp>) = 0
读法:
O_EXCL出现在临时文件的openat里,说明程序防止并发覆盖;0644是mode参数,实际权限还要& ~umask。read的最后一次返回0,这是 EOF,不是错误。fsync(4)与fsync(3)分别对应「文件数据」与「目录项」,缺一个都不算完整发布。-yy把 fd 映射到路径;没有它只能看到read(3, ...),fd 复用时会误判。
故障注入:让第一次 openat 返回 EACCES,验证错误分支是否清理临时文件:
strace -e inject=openat:error=EACCES:when=1 ./copy /etc/hosts /tmp/x
ls /tmp/x.tmp.* 2>/dev/null && echo "临时文件泄漏" || echo "清理正确"
-e inject 可以注入 error=、retval=、signal=,是测试错误路径最便宜的工具。
实验二:/proc/self/fdinfo 与 (deleted)
exec 3<>/tmp/fdinfo.demo
cat /proc/self/fdinfo/3
# pos: 0
# flags: 0100002
# mnt_id: 28
# ino: 1234567
ls -l /proc/self/fd/3
# lrwx------ 1 user user 64 ... 3 -> /tmp/fdinfo.demo
flags 是八进制:0100002 去掉 O_LARGEFILE(0100000)后是 02,即 O_RDWR;0100001 是 O_WRONLY,0100000 是 O_RDONLY。pos 是共享偏移,mnt_id 可以和 /proc/self/mountinfo 对上是哪个挂载。
删除文件但不关闭 fd:
exec 4>/tmp/fdinfo.deleted
rm /tmp/fdinfo.deleted
ls -l /proc/self/fd/4 # -> /tmp/fdinfo.deleted (deleted)
lsof +L1 | grep fdinfo # 找到仍持有它的进程
(deleted) 表示目录项没了但 inode 还有引用;df 不会降,直到 fd 关闭。这是「日志轮转后磁盘不释放」的标准原因。
2.2.19 本章陷阱清单
- 现象:
open(path, O_CREAT|O_WRONLY)创建的文件权限是随机值。原因:漏了第三个参数mode。修复:带O_CREAT/O_TMPFILE必须传mode。 - 现象:锁文件在进程崩溃后永久残留。原因:
O_EXCL文件不会自动删除。修复:改用fcntl/flock锁,或记录 PID 并检测存活。 - 现象:
read返回 100 而不是 4096,循环提前结束。原因:把短读当 EOF。修复:只在n == 0时结束。 - 现象:
write返回 65536,程序认为 1 MiB 写完了。原因:管道/socket 短写。修复:循环推进缓冲区。 - 现象:
dup2(fd, fd)之后 fd 被关闭。原因:误以为dup2会复制;相同参数直接返回。修复:判断oldfd != newfd,或改用dup3并处理EINVAL。 - 现象:
close返回EINTR后重试,关掉了别人的 fd。原因:Linux 上 fd 已释放。修复:记录错误,不重试。 - 现象:
exec后子进程继承了数据库连接。原因:fd 没有CLOEXEC。修复:O_CLOEXEC、F_DUPFD_CLOEXEC、close_range(CLOEXEC)。 - 现象:
fsync文件后断电,改名后的文件仍丢失。原因:目录项没fsync。修复:fsync(file)→rename→fsync(dir)。
这些坑的共性是:把「一次系统调用成功」当成「整个操作完成」;跨调用的组合需要显式原语(O_APPEND、O_EXCL、rename、fsync)来补。
2.2.20 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
画出 fd → struct file → inode 三层图 | 能说清哪层共享偏移 | 2.2.1 |
解释 O_APPEND 与 lseek+write 的区别 | 说出「原子」与「竞争窗口」 | 2.2.2 |
写出处理短读/短写/EINTR 的循环 | 代码通过编译与测试 | 2.2.3 |
解释 close()/close_range 的正确用法 | 不重试 close,能清空非标准 fd | 2.2.4、2.2.15 |
说出 O_APPEND/O_EXCL/rename 的原子边界 | 至少说对两点,含目录 fsync | 2.2.5、2.2.16 |
用 /proc/self/fd 与 lsof +L1 定位泄漏 | 能解释 (deleted) | 2.2.6 |
Q1 两个进程各自 open("log", O_WRONLY|O_APPEND),各写一行 100 字节。会不会互相覆盖?会不会交错?
Q2 read(fd, buf, 4096) 返回 100,可能的原因有哪些?列出至少三种。
Q3 为什么 open(O_CREAT|O_EXCL) 比 access() + open() 安全?在 NFS 上还安全吗?
Q4 write() 返回成功,进程被 kill -9,数据会丢吗?机器断电呢?
答案
A1 不会互相覆盖(O_APPEND 保证每次写定位到末尾是原子的);但一行 100 字节若由多次 write 完成,仍可能与其他进程的写交错。一次 write 写完一行则不会交错。
A2 ① 管道/终端/socket 缓冲区里只有 100 字节;② 被信号打断后部分读取;③ 读到文件末尾只剩 100 字节;④ 非阻塞 fd 当前只有 100 字节;⑤ 设备/驱动按块返回。
A3 O_EXCL 把「检查 + 创建」合成一个原子操作,消除了 TOCTOU 窗口。NFSv2 的 O_EXCL 不可靠;NFSv3+ 通常支持,但跨服务器仍需谨慎,建议用 link() 或专用锁服务。
A4 进程被 kill -9 不会丢:数据已在 page cache,内核会继续回写。机器断电会丢:除非已 fsync/O_SYNC 或文件系统有日志保证(日志只保证元数据一致性,不保证数据不丢)。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| Kerrisk | 第 4 章 文件 I/O:通用 I/O 模型 | 2.2.1–2.2.4、2.2.9–2.2.14 | 完整(flags 全表、返回矩阵、lseek、preadv2) |
| Kerrisk | 第 5 章 深入探究文件 I/O | 2.2.2、2.2.5 | 完整(原子性、O_TMPFILE、pread) |
| Kerrisk | 第 13 章 文件 I/O 缓冲 | 2.2.8 | 精选(页缓存与 O_DIRECT) |
| CSAPP | 第 10 章 系统级 I/O | 2.2.1–2.2.3 | 完整(fd 模型、RIO、短读) |
| 鸟哥 | 第 6 章 文件与目录管理 | 2.2.2 | 背景(命令层) |
| OSTEP | 第 39 章 文件与目录 | 2.2.1、2.2.5 | 背景(inode 与目录项) |
下一站:2.3 高级文件 I/O把
fcntl、mmap、文件锁与原子发布讲透;如果你只想先跑起来,直接去做项目 D2 的文件工具。