Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

定义 2.2.1 · 文件描述符(file descriptor)

文件描述符是进程内文件描述符表的一个非负整数下标。表项指向一个打开文件描述(open file description,内核里是 struct file),后者再指向 inode。fd 只在所属进程内有效;fork() 后子进程复制一份表,表项仍指向同一个 struct file。

三层对象各管一件事:

层内核对象保存什么谁共享
fd 表files_struct下标 → struct file *同一进程的线程共享;fork 后各自一份
打开文件描述struct file偏移 f_pos、状态标志、引用计数、f_opdup/fork 后共享
inodestruct inode文件类型、权限、大小、时间、数据块位置所有打开同一文件的进程共享
文件描述符、打开文件描述与 inode 的三层关系 fd 是进程内的下标;struct file 保存偏移与标志;inode 表示文件本身。 PROCESS · task_structOPEN FILE DESCRIPTIONINODE / DENTRY CACHEfiles_structfd 表指针0stdin→1stdout→2stderr→3access.log→4access.log→struct file偏移 off_t + 标志 + 引用计数f_count = 2dup / fork 共享同一 open file descriptionf_op->read / write具体文件系统的方法表f_pos共享偏移:两个 fd 指向同一 struct file 时互相影响struct inode类型 / 权限 / 大小 / 时间i_ino / i_nlink磁盘上的对象身份address_spacepage cache 的宿主i_op / i_fopinode 操作与文件操作fd → filefile → inode同一 inode 可被多个 struct file 打开;同一 struct file 可被多个 fd 共享(dup/fork)
读图:先看左边 fd 表:0/1/2 指向终端,3/4 指向同一日志文件。3 和 4 是两个独立的 struct file,各有自己的偏移;如果它们由 dup() 或 fork() 产生,则共享同一个 struct file,偏移也共享——这是「两个进程同时写日志却互相覆盖」的根源。

这张图解释了两个常见困惑:

  1. 同一个文件被打开两次,偏移不共享。open() 两次得到两个 struct file,各有 f_pos;一个读到 100 字节,另一个仍在 0。
  2. dup()/fork() 后偏移共享。两个 fd 指向同一个 struct file,任何一个 read 都推进同一个 f_pos。多进程写同一个日志文件时,这正是「输出交错」的根源之一。
为什么要有「打开文件描述」这一层

如果 fd 直接指向 inode,那么「偏移」和「打开标志」就没有地方放:同一个文件被两个进程用不同模式打开(一个读、一个追加),需要两套偏移与标志。struct file 就是这层「一次打开会话」的状态。它把文件本身(inode)与这次打开的行为(偏移、flags)分开,dup 才能既共享偏移又不复制 inode。

2.2.2 open 与 openat:flags、mode 与原子性

定义 2.2.2 · `open()` 的访问模式与创建标志

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 不会影响其他线程的相对路径。

两个必须记住的原子性

定理 2.2.1 · `O_CREAT|O_EXCL` 与 `O_APPEND` 的原子性

在本地文件系统上:

  1. open(path, O_CREAT|O_EXCL, mode) 的「检查不存在 + 创建」是一个原子操作。两个进程同时调用,只有一个成功,另一个得到 EEXIST。
  2. 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` 的三个误解
  1. 「O_APPEND 能保证多进程日志不交错」——只在「一条记录一次 write」时成立。用 fprintf + 行缓冲,或多条 write 拼一条记录,仍会交错。
  2. 「先 lseek(fd, 0, SEEK_END) 再 write 等价于 O_APPEND」——不等价。两条语句之间别的进程可以追加,你的写会覆盖别人的数据。O_APPEND 把这两步合成一个原子操作。
  3. 「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:返回值到底承诺了什么

定义 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;
}
五个高频错误
  1. if (read(fd, buf, n) != n) error();——把短读当错误。管道/终端/信号下必错。
  2. write(fd, buf, n); 不检查返回值——短写时静默丢数据。日志、网络、磁盘满时最危险。
  3. EINTR 一律当失败——read/write/open/close 都可能被信号打断。要么重试,要么用 sigaction 的 SA_RESTART(但不能覆盖所有情况)。
  4. read 返回 0 后继续读——EOF 后再次 read 仍返回 0;循环不退出会空转。
  5. 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` 在管道上会失败

管道不是随机访问设备,没有「偏移」概念。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 的四个危险
  1. 内核不校验你的指针类型:ioctl(fd, FIONREAD, &long_var) 只写 4 字节,剩下的字节不变;传错结构体会写坏栈。
  2. _IOR/_IOW 的大小是 ABI 的一部分:32 位/64 位程序对同一 request 的结构体大小可能不同,驱动要 compat_ioctl。
  3. 不要把 ioctl 当通用「发命令」接口:新子系统优先 netlink/sysfs/configfs;ioctl 的 request 空间是稀缺资源。
  4. ENOTTY 不是「不是 tty」:它只是「这个 fd 不支持这个 request」;普通文件也会返回它。

2.2.5 原子性:文件系统层面的「要么全做,要么不做」

单条系统调用是原子的,但多条系统调用之间不是。文件系统提供了几个原语,把常见组合变成原子操作:

需求正确原语错误做法
创建新文件,已存在则失败open(O_CREAT|O_EXCL)access() + open()
追加写open(O_APPEND) + 单次 writelseek(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_PATH fd 的行为依内核而异。
  • 对 socket/pipe,open("/dev/fd/N") 可能返回 ENXIO/ENODEV,因为「重新打开」对这类对象没有意义。
  • O_CLOEXEC 的 fd 在 exec 后消失,/dev/fd/N 也就不存在;脚本里依赖它时要确认没有 close-on-exec。
处理 fd 泄漏的检查清单
  1. 每个 open/socket/accept/dup 是否都有对应的 close(包括错误分支)?
  2. fork 后子进程是否只保留需要的 fd?是否默认 O_CLOEXEC?
  3. 长连接服务是否给空闲连接设了超时?epoll 是否在 EPOLLERR/EPOLLHUP 时关闭并 epoll_ctl(DEL)?
  4. 是否把 fd 存进容器后忘了在析构/异常路径释放?(C 里用 goto cleanup,C++ 用 RAII)
  5. 上限是否合理?ulimit -n、systemd LimitNOFILE、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。

例 2.2.1 · 为什么复制大文件时 `write` 有时只写一半

在管道或 socket 上,write 的返回受接收缓冲区大小限制;在普通文件上,短写通常出现在磁盘配额、RLIMIT_FSIZE、信号打断或 O_NONBLOCK 设备。无论哪种,正确处理方式相同:循环写剩余部分,直到写完或真错误。上面的 write_full 就是最小实现。

如果 write 返回 -1 且 errno == ENOSPC,不要重试;先清理空间。如果 errno == EAGAIN,说明 fd 是非阻塞的,应该等可写事件(epoll)而不是忙等。

2.2.8 性能与页缓存

一次 read/write 在用户态、page cache 与块层之间的路径 写通常只到 page cache;读未命中才触发块层 I/O。 USER SPACEKERNEL SPACE · PAGE CACHEBLOCK / DEVICE应用缓冲区buf / countread()/write()系统调用入口copy_to/from_user用户页与内核页之间拷贝返回用户态短读 / 部分写 / EINTRpage cache以 page 为单位缓存文件内容address_spaceinode 的页集合 + xarraywriteback脏页回写(pdflush / wb)readahead顺序读预取块设备层bio / requestI/O 调度mq-deadline / kyber驱动 + DMANVMe / virtio持久介质NAND / 磁盘write 命中缓存即返回read 未命中才下盘脏页累积后回写写路径:用户 → page cache → (延迟) → 块层 → 设备;读路径:page cache 命中则完全不进块层
读图:沿实线看写路径:write() 把数据拷进 page cache 就返回,脏页由回写线程择机下盘;沿虚线看读路径:命中 page cache 时根本不经过块层。因此 write() 返回 ≠ 落盘,read() 也不一定读磁盘。

四个常被忽略的事实:

  1. write() 返回只代表数据进了 page cache(除非 O_DIRECT/O_SYNC)。机器掉电时,未回写的数据会丢;进程崩溃不会丢(页缓存仍在内核)。
  2. read() 命中 page cache 时完全不碰磁盘。第一次读触发预读(readahead),顺序读的吞吐远高于随机读。
  3. fsync() 的代价是等待设备确认。批量写 + 一次 fsync 远快于每条记录 fsync;数据库的 WAL 就是按这个原则设计的。
  4. 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 性能排查顺序
  1. 确认瓶颈在 I/O 还是 CPU:iostat -x 1 看 %util、await、aqu-sz;
  2. 看是否命中缓存:free -h 的 buff/cache、/proc/meminfo 的 Cached;
  3. 看读写比例与块大小:iostat -x 的 r/s、w/s、rareq-sz、wareq-sz;
  4. 看是否同步写:strace -c -e trace=fsync,fdatasync;
  5. 看文件系统与挂载选项:findmnt -T /path,是否有 noatime、discard;
  6. 最后才调内核参数: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。

fadvise 不是「立即释放」

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 这些「用错就性能塌方或直接失败」的标志。先把它们按作用域分组:

组flagx86-64 八进制作用失败/边界
访问模式O_RDONLY0只读与 O_WRONLY/O_RDWR 互斥
O_WRONLY01只写
O_RDWR02读写
创建O_CREAT0100不存在则创建,需 mode父目录不可写 EACCES
O_EXCL0200与 O_CREAT 合用,已存在则 EEXIST单独用被忽略
O_TRUNC01000存在则截断为 0对 FIFO/终端未定义
O_TMPFILE020200000在目录中创建无名文件需文件系统支持,否则 EOPNOTSUPP
偏移/阻塞O_APPEND02000每次写前定位到末尾与 pwrite 在 Linux 上冲突
O_NONBLOCK04000非阻塞对普通文件基本无效
O_ASYNC020000可读/可写时发 SIGIOLinux 上 open 时无效,需 F_SETFL + F_SETOWN
同步O_DSYNC010000数据+必要元数据落盘每次 write 等设备
O_SYNC04010000数据+全部元数据落盘比 O_DSYNC 更贵
O_RSYNC与 O_SYNC 同值读也同步Linux 上等同 O_SYNC
缓存/对齐O_DIRECT040000绕过 page cache地址/偏移/长度要对齐,否则 EINVAL
O_NOATIME01000000读不更新 atime非属主且无 CAP_FOWNER 返回 EPERM
路径解析O_NOFOLLOW0400000最后一段是符号链接则 ELOOP只作用于最后一段
O_DIRECTORY0200000必须是目录,否则 ENOTDIR常与 O_PATH 合用
O_PATH010000000只取「路径句柄」,不打开读写read/write 返回 EBADF
fd 继承O_CLOEXEC02000000exec 时关闭创建时原子设置
历史O_LARGEFILE010000064 位偏移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 查。

`open` flags 的六个坑
  1. 「O_TRUNC 加 O_RDONLY 也能截断」——POSIX 说未定义;Linux 上可能截断,但不要依赖。要截断就显式用 O_WRONLY/O_RDWR。
  2. 「O_EXCL 可以单独用于锁」——没有 O_CREAT 时它被忽略;锁文件必须 O_CREAT|O_EXCL。
  3. 「O_APPEND 的 fd 上 pwrite 在指定偏移写」——Linux 违反 POSIX,仍追加到末尾(见 2.2.14)。
  4. 「O_DIRECT 只要文件系统支持就行」——缓冲区地址、偏移、长度都要对齐;栈上的 char buf[4096] 不保证 4096 对齐,必须 posix_memalign。
  5. 「O_NONBLOCK 对普通文件没用,所以可以随便加」——对普通文件确实基本无效,但它会改变 open 自身在 FIFO/设备上的阻塞行为;对 FIFO 只读打开且无写者时,O_NONBLOCK 决定是立刻成功还是阻塞。
  6. 「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()无0600fclose 自动删纯本进程临时流
O_TMPFILE无由 mode/umask不 linkat 就自动回收先写完整、再原子发布
临时文件的三个坑
  1. mkstemp 的模板必须以 6 个 X 结尾;少一个就失败,多一个不会被替换。
  2. 不要在 mkstemp 后再 chmod 成 0644:临时文件默认 0600 是有意的;要给别的用户读,用 fchmod 并确认路径/目录权限。
  3. 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不允许 .. 或绝对路径逃出 dirfdEXDEV
RESOLVE_IN_ROOT把 dirfd 当作 /,绝对路径和 .. 都被限制在其中不逃逸,直接解析
RESOLVE_NO_MAGICLINKS禁止 /proc/self/fd/N 这类魔法链接ELOOP
RESOLVE_NO_SYMLINKS禁止路径中任何符号链接ELOOP
RESOLVE_NO_XDEV禁止跨越挂载点EXDEV
RESOLVE_CACHED只查缓存,不触发 I/OEAGAIN

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 安全,但仍要处理符号链接竞态。

`openat2` 能不能替代 `chroot`

不能。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 影响。

创建权限的五个坑
  1. 「umask 是减法」——是位屏蔽,mode & ~umask;umask 022 对 0600 没有影响。
  2. 「open 的 mode 可以省略」——带 O_CREAT/O_TMPFILE 时是可变参数,省略是未定义行为,通常拿到栈上的垃圾值,创建出权限诡异的文件。
  3. 「chmod 不受 umask 影响」——正确;chmod 设置的是精确权限,只有创建时受 umask 约束。
  4. 「目录的 setgid 只影响目录自身」——它还决定新建文件的属组与子目录的继承。
  5. 「默认 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、缓冲不足、信号短写:缓冲满、配额、信号推进偏移,继续
0EOF(普通文件/管道写端关闭)count==0 或设备特殊语义read 结束循环;write 视为完成
-1 + EINTR被信号打断,可能已传部分同左重试剩余部分
-1 + 其他真错误真错误按错误码处理

count == 0 时,Linux 的 read/write 返回 0,且可能不检查 fd 是否有效(POSIX 允许实现选择)。不要用 read(fd, buf, 0) 探测 fd 是否可读。

read 的错误码

errno触发条件是否重试
EBADFfd 未打开、或不是读模式(含 O_PATH)否
EFAULT缓冲区不可访问否(调用方 bug)
EINVALfd 不适合读(如目录)、O_DIRECT 未对齐、偏移非法否
EISDIRLinux 上对目录 read否,改用 getdents
EAGAIN/EWOULDBLOCKO_NONBLOCK 且暂无数据是,等可读事件
EINTR阻塞中被信号打断是
EIO设备/文件系统错误否,告警
ENOMEM内核缓冲区不足可退避重试
ETIMEDOUT网络文件系统/设备超时视语义

write 的错误码

errno触发条件是否重试
EBADFfd 未打开、或不是写模式否
EFAULT缓冲区不可访问否
EINVALO_DIRECT 未对齐、fd 不支持写否
EFBIG超过 RLIMIT_FSIZE 或文件系统最大文件否
ENOSPC设备/文件系统满否,先清理
EDQUOT用户/组配额超限否,先清理
EPIPE管道/socket 读端已关闭否;默认还会收到 SIGPIPE
EAGAINO_NONBLOCK 且缓冲满是,等可写事件
EINTR阻塞中被信号打断是
EIO设备错误否
EPERM追加-only/immutable 文件,或 O_APPEND 受限否
EROFS只读文件系统否

EPIPE 默认会先触发 SIGPIPE,进程直接终止;服务端通常 signal(SIGPIPE, SIG_IGN) 或对 socket 用 MSG_NOSIGNAL,让 write 返回 EPIPE 而不是被杀。

短读/短写的真实来源

短读不是「异常」,而是以下情况的正常表现:

  1. 读到文件末尾,剩余不足 count;
  2. 管道缓冲区里只有这么多;
  3. 终端处于行模式,一次只给一行;
  4. 信号在传输部分数据后到达(Linux 返回已读字节数,而不是 EINTR);
  5. O_DIRECT 的尾块不足一个对齐单位;
  6. /proc、/sys 的许多文件一次只产生一页左右的数据,st_size 还是 0;
  7. 设备驱动按自己的块大小返回。

短写同理:管道容量(默认 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 返回非负就认为写完了」的代码都会丢数据。

处理短读/短写的决策顺序
  1. n == -1 且 errno == EINTR:重试,不推进偏移。
  2. n == -1 且 errno == EAGAIN:等 fd 就绪(epoll/poll),不要忙等。
  3. n == 0(读):EOF;write 返回 0 且 count > 0:报 EIO,不要死循环。
  4. 0 < n < count:推进 buf 与剩余长度,继续。
  5. 其他 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触发条件
EBADFfd 未打开或不可寻址
ESPIPE管道、FIFO、socket、终端(不可随机访问)
EINVALwhence 非法,或结果偏移为负
EOVERFLOW结果超出 off_t 能表示的范围
ENXIOSEEK_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` 的五个坑
  1. 「lseek 会触发磁盘 I/O」——不会,它只改内存里的偏移;真正读写在后续 read/write。
  2. 「lseek(fd, 0, SEEK_END) 返回文件大小就一定是大小」——对普通文件通常是,但对某些设备//proc 文件返回 0;用 fstat 的 st_size。
  3. 「SEEK_DATA 在所有文件系统上都精确」——文件系统可以保守报告;只把它当优化,不当正确性依赖。
  4. 「lseek 的偏移可以随便设负数」——结果偏移为负返回 EINVAL;SEEK_CUR 向前越过 0 也失败。
  5. 「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)
EINVALoffset 为负、O_DIRECT 未对齐
EBADFfd 模式不对
EINTR/EAGAIN同 read/write
Linux 的 `pwrite` + `O_APPEND`

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
空 ioviovcnt==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` 家族的顺序
  1. 单段、顺序、单线程:read/write。
  2. 多线程共享 fd、需要指定偏移:pread/pwrite。
  3. 多段缓冲(头+体、多个字段):readv/writev。
  4. 需要每次 I/O 的同步/非阻塞语义:preadv2/pwritev2 + RWF_*。
  5. 超大文件、需要绕过缓存: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 的区别只有两点,但都很关键:

  1. flags 只接受 O_CLOEXEC,在复制的同时原子设置 FD_CLOEXEC;
  2. 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。正确处理是记录错误,继续。

fd 生命周期的五个坑
  1. 「dup2 和 dup3 只差一个 flag」——dup3 在 oldfd == newfd 时返回 EINVAL,dup2 返回 newfd;依赖这个行为做「确保 fd 已打开」的代码会坏。
  2. 「close_range(3, ~0U, 0) 在任何内核上都能用」——5.9 之前返回 ENOSYS;glibc 2.34 之前没有声明,需要 syscall(SYS_close_range, ...)。
  3. 「close 返回 EINTR 要重试」——不要;fd 可能已经释放。
  4. 「/proc/self/fd 里的数字就是稳定的 fd 列表」——读目录本身会占一个 fd;并发 open/close 时列表随时变。
  5. 「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 作对照,同样压力下会出现覆盖或长度偏短。

原子性的五个边界
  1. 「O_APPEND 保证日志行不交错」——只在「一行一次 write」时成立;fprintf 的多段写仍会交错。
  2. 「O_EXCL 在 NFS 上也原子」——NFSv2 不可靠;跨客户端要加锁服务或使用 link() 技巧。
  3. 「rename 跨文件系统也能原子」——返回 EXDEV,必须先复制到目标文件系统再 rename。
  4. 「rename 后数据一定落盘」——只保证目录项原子;数据与目录都需要 fsync。
  5. 「临时文件放 /tmp 最安全」——跨文件系统 rename 会失败;临时文件必须与目标同目录,且用 O_EXCL 或 O_TMPFILE 创建。

2.2.17 内核路径:openat2、read/write 与回写

这一节回答:write 返回成功时,数据到底在哪一层?fsync 之后又到了哪一层?

2.1.14 给了 openat/read 的骨架。这里补 write 与回写路径,因为「写成功不等于落盘」的所有讨论都落在这条链上。

write 的路径

层次位置关键动作
libcsysdeps/unix/sysv/linux/write.csyscall(SYS_write, ...)
系统调用fs/read_write.cksys_write → vfs_write
检查fs/read_write.cFMODE_WRITE、rw_verify_area、MAX_RW_COUNT
迭代器lib/iov_iter.cimport_iovec/iov_iter_ubuf 把用户缓冲包成 iov_iter
文件系统include/linux/fs.hfile->f_op->write_iter
ext4fs/ext4/file.cext4_file_write_iter → generic_file_write_iter
页缓存mm/filemap.cgeneric_perform_write → copy_page_from_iter_atomic
脏页mm/page-writeback.cbalance_dirty_pages、wakeup_flusher_threads
回写fs/ext4/inode.cext4_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);
}

这段代码解释了三个现象:

  1. write 返回时数据在 page cache,不在磁盘。copy_page_from_iter_atomic 只把用户数据拷进内核页;write_end 把页标记为脏,之后由回写线程刷盘。
  2. balance_dirty_pages 会主动限速。脏页超过 vm.dirty_ratio 时,write 会被内核睡眠拖慢,而不是无限占用内存。
  3. 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` 的关系

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 本章陷阱清单

文件 I/O 的九个坑
  1. 现象:open(path, O_CREAT|O_WRONLY) 创建的文件权限是随机值。原因:漏了第三个参数 mode。修复:带 O_CREAT/O_TMPFILE 必须传 mode。
  2. 现象:锁文件在进程崩溃后永久残留。原因:O_EXCL 文件不会自动删除。修复:改用 fcntl/flock 锁,或记录 PID 并检测存活。
  3. 现象:read 返回 100 而不是 4096,循环提前结束。原因:把短读当 EOF。修复:只在 n == 0 时结束。
  4. 现象:write 返回 65536,程序认为 1 MiB 写完了。原因:管道/socket 短写。修复:循环推进缓冲区。
  5. 现象:dup2(fd, fd) 之后 fd 被关闭。原因:误以为 dup2 会复制;相同参数直接返回。修复:判断 oldfd != newfd,或改用 dup3 并处理 EINVAL。
  6. 现象:close 返回 EINTR 后重试,关掉了别人的 fd。原因:Linux 上 fd 已释放。修复:记录错误,不重试。
  7. 现象:exec 后子进程继承了数据库连接。原因:fd 没有 CLOEXEC。修复:O_CLOEXEC、F_DUPFD_CLOEXEC、close_range(CLOEXEC)。
  8. 现象: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,能清空非标准 fd2.2.4、2.2.15
说出 O_APPEND/O_EXCL/rename 的原子边界至少说对两点,含目录 fsync2.2.5、2.2.16
用 /proc/self/fd 与 lsof +L1 定位泄漏能解释 (deleted)2.2.6
即时自测 · 2.2

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/O2.2.2、2.2.5完整(原子性、O_TMPFILE、pread)
Kerrisk第 13 章 文件 I/O 缓冲2.2.8精选(页缓存与 O_DIRECT)
CSAPP第 10 章 系统级 I/O2.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 的文件工具。