1.10 系统级 I/O
为什么 read(fd, buf, 4096) 可能只返回 1 字节?write 成功返回后,数据到底在哪一层?printf、write、mmap 三种输出方式,什么时候该用哪一种?
写出对短读、短写、EINTR 都正确的 RIO 包;用 stat/readdir 读元数据与目录;用 dup2 实现重定向;解释 stdio 缓冲与 page cache 对「写成功」的影响。
1.9 把内存变成了页表里的映射;但程序真正与外界交换数据,靠的是一组以整数 fd 为句柄的接口。Unix 把这个接口压到极简:open、read、write、lseek、close。磁盘文件、终端、管道、socket、设备,全都套进同一套方法表。
这一章的重点不是背 API,而是三件事:返回值的语义(短读/短写/EOF/EINTR)、数据在哪一层(用户缓冲区、内核 page cache、设备缓存)、接口怎么选(fd、stdio、RIO、mmap)。2.2 会从系统编程角度重讲 open 的标志与原子性;这里先把「读写的边界」和「I/O 路径」建立起来。
1.10.1 Unix I/O:四个动作与一类句柄
Unix 把「一切皆文件」落成一组统一操作:
| 动作 | 接口 | 关键语义 |
|---|---|---|
| 打开 | open/openat | 返回进程内最小的可用 fd;失败返回 -1 |
| 读 | read | 返回实际字节数;0 表示 EOF;-1 表示错误 |
| 写 | write | 返回实际字节数;可能小于请求 |
| 定位 | lseek | 只改 f_pos,不触发 I/O |
| 关闭 | close | 释放 fd;成功不保证数据落盘 |
进程启动时默认打开三个 fd:0(stdin)、1(stdout)、2(stderr)。它们不一定连终端:shell 可以把它们重定向到文件、管道或 socket。程序不应该假设「fd 1 一定能交互」,日志库尤其如此。
文件类型不只普通文件:
| 类型 | S_IS* 宏 | 读写的含义 |
|---|---|---|
| 普通文件 | S_ISREG | 字节序列,可 lseek |
| 目录 | S_ISDIR | 不能用 read 读,要用 readdir/getdents |
| 字符设备 | S_ISCHR | 终端、/dev/null,按设备语义 |
| 块设备 | S_ISBLK | 磁盘,通常通过文件系统访问 |
| FIFO | S_ISFIFO | 管道,读写阻塞/短读 |
| socket | S_ISSOCK | 网络/本地通信,read/write 等价 recv/send |
| 符号链接 | S_ISLNK | readlink 读目标,stat 跟随 |
strace 能把「库函数」翻译成「系统调用」:
strace -e trace=openat,read,write,close -o io.log ./prog
grep -E 'openat|read|write' io.log | head
你会看到 printf 最终变成一次或多次 write(1, ...);fopen 变成 openat;fgets 可能触发一次 read(3, ..., 4096) 读入一大块,然后后续调用都在用户态缓冲区里取。
1.10.2 元数据:stat 与文件身份
文件的内容之外,还有一组由 inode 维护的元数据。stat 把它们复制到用户态:
struct stat {
dev_t st_dev; /* 所在设备 */
ino_t st_ino; /* inode 号 */
mode_t st_mode; /* 类型 + 权限 */
nlink_t st_nlink; /* 硬链接数 */
uid_t st_uid;
gid_t st_gid;
off_t st_size; /* 字节大小(普通文件) */
blksize_t st_blksize; /* 推荐 I/O 块大小 */
blkcnt_t st_blocks; /* 分配的 512B 块数 */
struct timespec st_atim, st_mtim, st_ctim;
};
四个字段最容易混淆:
st_size是文件的逻辑大小,可能小于实际分配的块(稀疏文件);st_blocks是实际占用的 512 字节块数。du看后者,ls -l看前者。st_blksize是推荐的 I/O 粒度(通常 4 KiB),不是磁盘扇区大小。st_nlink是硬链接数。删除一个名字只减少链接数,链接数为 0 且没有进程打开时才真正释放数据。st_ctim是 inode 状态改变时间,不是创建时间;Linux 没有可靠的「创建时间」字段(btime在statx里有,但依赖文件系统)。
获取元数据有三个变体:
| 接口 | 是否跟随符号链接 | 相对谁解析 |
|---|---|---|
stat(path, &st) | 跟随 | 当前工作目录 |
lstat(path, &st) | 不跟随 | 当前工作目录 |
fstat(fd, &st) | 已打开对象 | fd |
fstatat(dirfd, path, &st, flags) | 由 AT_SYMLINK_NOFOLLOW 控制 | dirfd |
遍历目录时优先用 fstatat(dirfd, name, ...),避免「先拼路径再 stat」被 rename 打断(TOCTOU)。
/* stat_demo.c:打印文件类型、大小、链接数与权限 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>
static const char *type_of(mode_t m)
{
if (S_ISREG(m)) return "regular";
if (S_ISDIR(m)) return "directory";
if (S_ISCHR(m)) return "char-device";
if (S_ISBLK(m)) return "block-device";
if (S_ISFIFO(m)) return "fifo";
if (S_ISSOCK(m)) return "socket";
if (S_ISLNK(m)) return "symlink";
return "unknown";
}
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s <path>\n", argv[0]);
return 2;
}
struct stat st;
if (lstat(argv[1], &st) == -1) {
perror("lstat");
return 1;
}
char tbuf[64];
struct tm tm;
localtime_r(&st.st_mtime, &tm);
strftime(tbuf, sizeof(tbuf), "%Y-%m-%d %H:%M:%S", &tm);
printf("path : %s\n", argv[1]);
printf("type : %s\n", type_of(st.st_mode));
printf("size : %lld bytes, %lld blocks\n",
(long long)st.st_size, (long long)st.st_blocks);
printf("inode: %llu, links=%lu\n",
(unsigned long long)st.st_ino, (unsigned long)st.st_nlink);
printf("mode : %04o, mtime=%s\n", (unsigned)(st.st_mode & 07777), tbuf);
return 0;
}
lstat 与 stat 的差别在符号链接上最明显:lstat 看到链接本身的 inode 和长度,stat 看到目标文件。写备份、find -type l、安全校验时,必须明确要哪一个。
1.10.3 目录:不是文本文件
目录在磁盘上是「名字 → inode」的表,但用户态不能 read 它(read 返回 EISDIR)。POSIX 提供 opendir/readdir/closedir,glibc 底层用 getdents64 系统调用。
DIR *d = opendir(path);
if (d == NULL) { /* errno */ }
struct dirent *e;
while ((e = readdir(d)) != NULL) {
/* e->d_name, e->d_ino, e->d_type */
}
closedir(d);
struct dirent 的关键字段:
| 字段 | 含义 | 注意 |
|---|---|---|
d_ino | inode 号 | 可能与 stat 的 st_ino 一致 |
d_off | 下一个目录项的位置 | telldir/seekdir 用 |
d_reclen | 记录长度 | 变长结构,不要按 sizeof 读 |
d_type | 文件类型 | 可能是 DT_UNKNOWN,需 fstatat 补齐 |
d_name | 名字 | 不以 / 开头,长度有限 |
readdir 返回的指针由 DIR 内部缓冲区持有,下一次 readdir 可能覆盖它。要保存名字必须复制。readdir 不保证线程安全;多线程遍历同一目录要用各自的 DIR,或加锁。
/* listdir.c:列出目录项并补齐类型 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dirent.h>
#include <limits.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
int main(int argc, char **argv)
{
const char *path = argc > 1 ? argv[1] : ".";
DIR *d = opendir(path);
if (d == NULL) {
perror("opendir");
return 1;
}
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
continue;
char full[PATH_MAX];
if (snprintf(full, sizeof(full), "%s/%s", path, e->d_name) >= (int)sizeof(full))
continue;
struct stat st;
if (lstat(full, &st) == -1)
continue;
const char *kind = S_ISDIR(st.st_mode) ? "dir" :
S_ISREG(st.st_mode) ? "file" :
S_ISLNK(st.st_mode) ? "link" : "other";
printf("%-8s %10lld %s\n", kind, (long long)st.st_size, e->d_name);
}
if (closedir(d) == -1)
perror("closedir");
return 0;
}
DT_UNKNOWN 在 XFS、NFS 等文件系统上常见:目录项本身不记录类型,readdir 只能返回 DT_UNKNOWN,必须对每个名字做 lstat。这也是 ls 在冷目录上变慢的原因之一。
1.10.4 RIO:把短读、短写和缓冲包起来
CSAPP 用一个小型 RIO(robust I/O)包演示「如何正确读写」。它分两层:无缓冲的 rio_readn/rio_writen 处理短读短写;带缓冲的 rio_read/rio_readlineb 减少系统调用。
/* rio.c:无缓冲 + 带缓冲的健壮 I/O;从标准输入按行读到标准输出 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define RIO_BUFSIZE 8192
typedef struct {
int rio_fd;
ssize_t rio_cnt; /* 缓冲区中未读字节数 */
char *rio_bufptr; /* 下一个未读字节 */
char rio_buf[RIO_BUFSIZE];
} rio_t;
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
while (rp->rio_cnt <= 0) {
ssize_t rc = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
if (rc < 0) {
if (errno != EINTR)
return -1;
} else if (rc == 0) {
return 0;
} else {
rp->rio_cnt = rc;
rp->rio_bufptr = rp->rio_buf;
}
}
size_t cnt = (size_t)rp->rio_cnt < n ? (size_t)rp->rio_cnt : n;
memcpy(usrbuf, rp->rio_bufptr, cnt);
rp->rio_bufptr += cnt;
rp->rio_cnt -= (ssize_t)cnt;
return (ssize_t)cnt;
}
ssize_t rio_readn(int fd, void *usrbuf, size_t n)
{
size_t nleft = n;
char *bufp = usrbuf;
while (nleft > 0) {
ssize_t rc = read(fd, bufp, nleft);
if (rc < 0) {
if (errno == EINTR)
continue;
return -1;
}
if (rc == 0) /* EOF */
break;
nleft -= (size_t)rc;
bufp += rc;
}
return (ssize_t)(n - nleft);
}
ssize_t rio_writen(int fd, const void *usrbuf, size_t n)
{
size_t nleft = n;
const char *bufp = usrbuf;
while (nleft > 0) {
ssize_t rc = write(fd, bufp, nleft);
if (rc < 0) {
if (errno == EINTR)
continue;
return -1;
}
if (rc == 0) {
errno = EIO;
return -1;
}
nleft -= (size_t)rc;
bufp += rc;
}
return (ssize_t)n;
}
void rio_readinitb(rio_t *rp, int fd)
{
rp->rio_fd = fd;
rp->rio_cnt = 0;
rp->rio_bufptr = rp->rio_buf;
}
static ssize_t rio_readlineb(rio_t *rp, char *usrbuf, size_t maxlen)
{
size_t i;
char c;
if (maxlen == 0)
return -1;
for (i = 1; i < maxlen; i++) {
ssize_t rc = rio_read(rp, &c, 1);
if (rc == 0 && i == 1)
return 0;
if (rc <= 0)
break;
*usrbuf++ = c;
if (c == '\n')
break;
}
*usrbuf = '\0';
return (ssize_t)i;
}
int main(void)
{
rio_t rp;
char line[256];
int lineno = 0;
rio_readinitb(&rp, STDIN_FILENO);
for (;;) {
ssize_t n = rio_readlineb(&rp, line, sizeof(line));
if (n == 0)
break;
if (n < 0) {
perror("rio_readlineb");
return 1;
}
lineno++;
printf("%3d: %s", lineno, line);
if (line[n - 1] != '\n')
putchar('\n');
}
return 0;
}
这段代码的关键不变量:
rio_read只有在缓冲区空时才调用read,且一次读 8 KiB;返回给调用者的字节数可能小于请求。EINTR不是错误,重试;read返回 0 才是 EOF。rio_readlineb的maxlen包含结尾\0;超长行会被截断,调用者要根据「最后有没有换行」判断是否还有剩余。rio_writen处理短写;返回 0 的write被视为异常,否则循环可能空转。
RIO 是网络服务器里最常见的读写骨架。它的接口看起来像 stdio,但没有全局锁,也不会在 fork 后复制缓冲区,适合并发。
1.10.5 共享文件、dup2 与重定向
2.2.1 已经画出 fd → 打开文件描述 → inode 的三层结构。这里只看两个直接后果:
fork后父子共享打开文件描述,偏移共享;各自open同一文件则偏移独立。dup2(oldfd, newfd)让newfd指向oldfd的打开文件描述,旧newfd被关闭;两个 fd 共享偏移和状态标志。
/* redirect.c:把标准输出重定向到文件,再恢复 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int fd = open("redirect.out", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
return 1;
}
int saved = dup(STDOUT_FILENO);
if (saved == -1) {
perror("dup");
close(fd);
return 1;
}
if (dup2(fd, STDOUT_FILENO) == -1) {
perror("dup2");
close(fd);
close(saved);
return 1;
}
close(fd); /* 原 fd 不再需要,stdout 仍持有引用 */
printf("this line goes to the file\n");
fflush(stdout); /* 关键:切回终端前刷新 */
if (dup2(saved, STDOUT_FILENO) == -1) {
perror("dup2 restore");
close(saved);
return 1;
}
close(saved);
printf("this line goes to the terminal\n");
return 0;
}
shell 的 >、2>&1 就是这套动作的进程版。顺序很重要:
cmd >out 2>&1 # stdout 先指向 out,再把 stderr dup 到 stdout → 两者都进 out
cmd 2>&1 >out # 先把 stderr 指向当前 stdout(终端),再把 stdout 指向 out
# 结果:stderr 仍在终端,stdout 进 out
/proc/self/fd 是观察 fd 表的快捷方式:
ls -l /proc/self/fd
# 0 -> /dev/pts/0
# 1 -> /dev/pts/0
# 2 -> /dev/pts/0
# 3 -> /proc/self/fd
ls 自己打开目录时占用一个 fd,所以会看到多出来的一项。程序里排查 fd 泄漏,可以周期性统计 /proc/<pid>/fd 的条目数。
1.10.6 I/O 路径:从 write 到磁盘
write() 把数据拷进 page cache 就返回,脏页由回写线程择机下盘;沿虚线看读路径:命中 page cache 时根本不经过块层。因此 write() 返回 ≠ 落盘,read() 也不一定读磁盘。读图时把路径分成三段:用户态缓冲(stdio/RIO)→ 内核 page cache → 块层与设备。一次 write 通常只走到第二段就返回:数据被复制进 page cache,页被标记为脏,由内核的 writeback 线程在几秒到几十秒后写回磁盘。read 命中 page cache 时完全不进块层;未命中才触发主缺页和磁盘 I/O。
| 操作 | 数据到哪 | 掉电会丢吗 |
|---|---|---|
printf | stdio 用户缓冲区 | 会,甚至还没进内核 |
write | page cache(脏页) | 会,除非已回写 |
fsync(fd) | 文件数据 + 元数据落到设备 | 通常不会(设备缓存需支持 flush) |
fdatasync(fd) | 文件数据 + 必要元数据 | 同上,省掉无关元数据 |
O_SYNC 写 | 每次 write 同步到设备 | 不会(代价大) |
O_DIRECT | 绕过 page cache,直接到设备 | 取决于设备缓存 |
「write 成功」不等于「落盘」。数据库、配置发布、rename 原子替换这类场景,必须显式 fsync:
int fd = open("config.tmp", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write_all(fd, data, len);
fsync(fd); /* 先把文件内容刷到设备 */
close(fd);
rename("config.tmp", "config"); /* 原子替换目录项 */
/* 严格来说还要 fsync 目录 fd,确保 rename 本身持久 */
stdio 的三种缓冲
FILE * 在 fd 之上加了一层用户态缓冲:
| 模式 | 何时刷新 | 典型场景 |
|---|---|---|
_IOFBF 全缓冲 | 缓冲区满、fflush、fclose | 重定向到文件 |
_IOLBF 行缓冲 | 遇到 \n、缓冲区满、fflush | 终端 |
_IONBF 无缓冲 | 每次调用都写 | stderr |
stdout 连终端时默认行缓冲,重定向到文件时默认全缓冲。这解释了两个现象:printf("done\n") 在终端立刻可见,重定向后可能攒很久;程序 _exit 或崩溃时,用户缓冲区里的内容会丢。
fork 后子进程继承父进程的 stdio 缓冲区。若父子都 exit,缓冲区会被刷新两次,输出重复。子进程应调用 _exit,或在 fork 前 fflush(NULL)。
混用 stdio 和 fd 还有一个坑:stdio 可能预读。先用 fgets 读了几行,再用 read(fd, ...) 读,fd 的偏移已经在缓冲区末尾之后,read 会跳过 stdio 还没交出的数据。要么全程用 stdio,要么全程用 fd;必须混用时,用 fileno 拿 fd,并在切换前 fflush/fseek 对齐。
1.10.7 该用哪个 I/O 接口
| 需求 | 首选 | 理由 |
|---|---|---|
| 读元数据、目录、设备控制 | 原生 fd(stat/ioctl/readdir) | stdio 不覆盖这些语义 |
| 格式化文本输入输出 | stdio(printf/fscanf) | 缓冲、格式化、错误处理成熟 |
| 网络/管道上可靠读写 | RIO 或手写循环 | 必须处理短读短写与 EINTR |
| 大文件随机访问 | mmap | 避免多次拷贝,按页缺页 |
| 高并发小 I/O | epoll + 非阻塞 fd | 一个线程管大量连接 |
| 极高 IOPS、批量提交 | io_uring | 减少系统调用与拷贝 |
| 必须落盘的日志 | write + fsync/fdatasync | 明确持久化边界 |
一个常见的错误是用 stdio 写网络 socket:fprintf 可能缓冲、可能部分写、错误码被 FILE 状态掩盖。网络代码要么用 send/recv 循环,要么用 RIO 这类明确处理短写的包。
- 「
read返回 0 是错误」——0 是 EOF;错误返回 -1 并设置errno。把 0 当错误会提前结束正常文件。 - 「
close成功就代表数据落盘」——close只释放 fd;数据可能还在 page cache。持久化要fsync。 - 「
write返回请求长度就一定写完了」——普通文件通常如此,但管道、socket、信号打断都可能短写;必须循环。 - 「stdio 和
read可以随便混用」——stdio 预读会让 fd 偏移越过尚未交出的数据;混用前要fflush/fseek。 - 「
O_APPEND保证多进程日志行不交错」——只保证单次write的「定位 + 写」原子;一条日志分多次写仍会交错。 - 「目录可以用
read读」——目录是结构化记录,read返回EISDIR;用readdir/getdents64。
1.10.8 文件描述符的完整语义:表、标志与生命周期
前面把 fd 当成「一个小整数」。这一节把它拆开:一个整数如何在进程、打开文件描述和 inode 之间建立三层关系,哪些状态跟着 dup 走,哪些不跟。
内核里与 fd 有关的数据结构是三层:
| 层 | 内核对象 | 由谁持有 | 关键内容 |
|---|---|---|---|
| 进程级 fd 表 | struct files_struct → fdtable | 每个进程一份 | 「fd 号 → struct file *」的数组 |
| 打开文件描述 | struct file | 可被多个 fd、多个进程共享 | 偏移 f_pos、状态标志 f_flags、引用计数、f_op |
| inode | struct inode | 每个文件系统对象一份 | 类型、权限、大小、数据块位置、时间戳 |
open 做两件事:创建一个新的打开文件描述(新的 struct file),再在当前进程 fd 表里找编号最小的空位,填入指针。fork 只复制 fd 表,不复制打开文件描述——父子各自的 fd 指向同一个 struct file,所以共享偏移。dup 也不复制打开文件描述,只让两个 fd 号指向同一个 struct file。
内核中由 open 创建、被一个或多个文件描述符引用的对象,保存文件偏移与状态标志(O_APPEND、O_NONBLOCK、O_ASYNC 等)。它不等同于文件本身:同一个文件被 open 两次,就有两个独立的打开文件描述、两套独立偏移。
这解释了三组经常被混淆的行为:
| 操作 | 偏移 | 状态标志 | 说明 |
|---|---|---|---|
open 同一路径两次 | 各自独立 | 各自独立 | 两个 struct file |
dup(fd) / dup2(fd, n) | 共享 | 共享 | 同一个 struct file |
fork 之后 | 共享 | 共享 | 子进程复制 fd 表,指针相同 |
传递 fd(SCM_RIGHTS) | 共享 | 共享 | 接收方拿到同一个打开文件描述 |
因此「父子进程各写同一个日志文件会不会互相覆盖」的答案是:用 fork 继承来的 fd,偏移共享,不会覆盖;父子各自 open,偏移独立,会覆盖(除非用 O_APPEND)。
fd 标志与状态标志
同一个 fd 上有两套标志,fcntl 用不同的命令读写:
| 类别 | 存放位置 | 读 | 写 | 是否随 dup 共享 |
|---|---|---|---|---|
| 描述符标志 | fd 表项 | F_GETFD | F_SETFD | 否,每个 fd 独立 |
| 状态标志 | struct file | F_GETFL | F_SETFL | 是,共享 |
描述符标志目前只有一位有意义:FD_CLOEXEC。它决定 execve 时这个 fd 是否被关闭。状态标志包括 O_APPEND、O_NONBLOCK、O_ASYNC、O_DIRECT(部分可改)。open 时的 O_CLOEXEC 是原子地设置 FD_CLOEXEC;先 open 再 fcntl(F_SETFD) 在多线程程序里有窗口:另一个线程可能在这之间 fork + exec,把不该继承的 fd 泄漏给子程序。
/* 正确:打开即带 CLOEXEC,避免 fork/exec 竞争 */
int fd = open(path, O_RDONLY | O_CLOEXEC);
/* 也可以对已有 fd 复制出一个带 CLOEXEC 的新 fd */
int nfd = fcntl(fd, F_DUPFD_CLOEXEC, 0);
/* 修改状态标志必须先读再写,不能覆盖其他位 */
int flags = fcntl(fd, F_GETFL);
if (flags == -1) { /* errno */ }
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) { /* errno */ }
F_SETFL 只能改一部分标志:O_APPEND、O_NONBLOCK、O_ASYNC、O_DIRECT(在部分文件系统上)、O_NOATIME。O_CREAT、O_TRUNC、O_EXCL 这类「打开时的一次性动作」改了也没意义,内核会忽略。
fd 用尽:两种 errno
每个进程的 fd 数量受 RLIMIT_NOFILE 限制,系统全体受 fs.file-max 与内存限制。两者失败时的 errno 不同:
| 情形 | errno | 含义 | 排查 |
|---|---|---|---|
| 进程 fd 表满 | EMFILE | 达到 RLIMIT_NOFILE 软限制 | ulimit -n、ls /proc/<pid>/fd | wc -l |
| 系统文件表满 | ENFILE | 全局上限或内存不足 | cat /proc/sys/fs/file-nr |
| fd 不存在/已关闭 | EBADF | 用了无效 fd,或方向不对(读只写 fd) | 检查返回值与 fcntl(fd, F_GETFD) |
dup2 的 newfd 越界 | EBADF | newfd 超出 RLIMIT_NOFILE 或为负 | 先校验范围 |
open 路径不存在 | ENOENT | 路径或中间目录缺失 | strace -e openat |
EMFILE 在服务器上很常见:每个连接一个 fd,accept 返回 EMFILE 后如果继续 accept,会空转。正确做法是预留一个「救急 fd」:检测到 EMFILE 时关闭它,accept 一个连接后立刻关闭,给自己留出打日志和退出的余地。
/* 救急 fd 模式:accept 遇到 EMFILE 时腾出一个 fd,避免死循环 */
int idlefd = open("/dev/null", O_RDONLY | O_CLOEXEC);
for (;;) {
int conn = accept4(listenfd, NULL, NULL, SOCK_CLOEXEC);
if (conn == -1) {
if (errno == EMFILE && idlefd >= 0) {
close(idlefd);
conn = accept(listenfd, NULL, NULL); /* 接受后立刻关闭,丢弃 */
if (conn >= 0) close(conn);
idlefd = open("/dev/null", O_RDONLY | O_CLOEXEC);
}
continue; /* 其他错误:记录并继续 */
}
/* 处理连接 */
}
引用计数:fd 什么时候真正关闭
close(fd) 做两件事:删除 fd 表项;把对应 struct file 的引用计数减一。计数归零时才调用文件的 release 操作,inode 的打开计数也才减一。所以:
dup出来的两个 fd,关掉一个,另一个仍可读写,偏移继续共享;fork后子进程退出,父进程的 fd 不受影响;- 一个文件被
unlink后,只要还有进程持有打开的 fd,数据块就不会释放——这是「临时文件」的常用手法(O_TMPFILE或open后立刻unlink); close失败(返回 -1)时,fd 的状态是未定义的:Linux 上 fd 一定已释放,但不要重试close,否则可能关掉另一个线程刚打开的同号 fd。
- 「fd 就是文件」——fd 是进程 fd 表的下标,指向打开文件描述;同一个文件可以有多个 fd,同一个 fd 经
fork可被多个进程共享。 - 「
close(1)之后printf会报错」——printf写入 stdio 缓冲,可能不返回错误;错误在fflush/fclose时才出现。要可靠检测,检查ferror(stdout)。 - 「
dup2会复制偏移」——不复制,两个 fd 共享同一个偏移;dup2(fd, 1)后向 1 写会推进fd的偏移。 - 「fd 号可以随便假定」——0/1/2 只在进程启动时成立;守护进程常把 0/1/2 重定向到
/dev/null,库函数不应假设它们可用。
1.10.9 read/write 的返回值:短读、短写、EINTR 与 EAGAIN
read 和 write 的签名一样简单,返回值却有七种情形。把它们列全,是写出健壮 I/O 的前提。
| 返回值 | read 的含义 | write 的含义 | 后续动作 |
|---|---|---|---|
> 0 且 < count | 短读:读到了部分数据 | 短写:写入了部分数据 | 移动指针,继续剩余部分 |
== count | 读满 | 写满 | 完成 |
== 0 | EOF(对普通文件) | count == 0 或设备不接受数据 | read 结束循环;write 检查 count |
-1 + EINTR | 被信号打断,未读到数据 | 被信号打断,未写入数据 | 重试 |
-1 + EAGAIN | 非阻塞且暂无数据 | 非阻塞且缓冲区满 | 等可读/可写事件后重试 |
-1 + EPIPE | 管道写端已关闭 | 对端已关闭,同时收到 SIGPIPE | 忽略/处理 SIGPIPE,停止写 |
-1 + 其他 | EBADF、EIO、EISDIR、EINVAL… | ENOSPC、EFBIG、EDQUOT、EIO… | 记录并放弃 |
短读为什么发生
短读不是异常,而是接口契约的一部分。常见原因:
- 到达 EOF:请求 4096 字节,文件只剩 100 字节,返回 100;下一次返回 0。
- 管道/socket 缓冲区里只有这么多:
read(pipefd, buf, 4096)时写端只写了 10 字节且尚未关闭,返回 10。 - 终端规范模式:一次最多返回一行,且行长受
MAX_CANON限制。 - 信号在部分数据到达后打断:
read返回已读字节数,而不是EINTR。 O_DIRECT或特殊设备:对齐要求、设备块大小导致不足一次请求。- NFS/网络文件系统:服务器返回的块小于请求。
因此「读满 N 字节」永远要循环:
/* read_full:读满 n 字节或遇到 EOF;返回已读字节数,出错返回 -1 */
ssize_t read_full(int fd, void *buf, size_t n)
{
size_t done = 0;
char *p = buf;
while (done < n) {
ssize_t r = read(fd, p + done, n - done);
if (r == -1) {
if (errno == EINTR)
continue; /* 信号打断:重试 */
return -1; /* 其他错误:交给调用者 */
}
if (r == 0)
break; /* EOF:返回短计数 */
done += (size_t)r;
}
return (ssize_t)done;
}
注意返回值的约定:返回短计数表示 EOF,调用者必须比较返回值与请求长度,不能假设「没报错就是读满」。这也是 RIO 的 rio_readn 与本节 read_full 共同的语义。
短写为什么发生
对普通文件,write 通常写满;但在以下情形会短写:
| 场景 | 原因 | 处理 |
|---|---|---|
| 管道/socket 缓冲区不足 | 内核只接受了部分字节 | 循环写剩余部分,等待可写 |
| 非阻塞 fd | 缓冲区满,可能返回 EAGAIN | 注册 EPOLLOUT 后重试 |
| 磁盘满或配额 | 先写入一部分后失败,返回短计数 | 下一次写返回 ENOSPC,必须回滚/告警 |
超过 RLIMIT_FSIZE | 达到文件大小限制,可能收到 SIGXFSZ | 处理信号或调整限制 |
| 被信号打断 | 已写入部分字节 | 从短计数处继续 |
写入超过 MAX_RW_COUNT | Linux 把单次请求截到 0x7ffff000 字节 | 分块写 |
Linux 对单次 read/write 的计数上限是 MAX_RW_COUNT = 0x7ffff000(约 2 GiB 减 4 KiB)。传入更大的 count 不会报错,内核只处理这么多。写 4 GiB 缓冲区时,必须自己分块。
/* write_full:写满 n 字节;处理 EINTR、短写与 EAGAIN */
int write_full(int fd, const void *buf, size_t n)
{
size_t done = 0;
const char *p = buf;
while (done < n) {
ssize_t w = write(fd, p + done, n - done);
if (w == -1) {
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == EWOULDBLOCK)
return (int)done; /* 非阻塞:让调用者等事件 */
return -1;
}
if (w == 0) { /* 极少见:视为错误,避免死循环 */
errno = EIO;
return -1;
}
done += (size_t)w;
}
return 0;
}
EAGAIN 与 EWOULDBLOCK 在 Linux 上是同一个值,判断一个即可;写成两个是为了可移植性。非阻塞写的正确姿势不是「忙等重试」,而是把剩余数据挂到用户态缓冲,等 epoll 报告 EPOLLOUT 再继续——第 1.11 章的事件驱动服务器会用到。
EINTR:什么时候重试,什么时候不重试
EINTR 表示「系统调用在完成前被信号处理函数打断」。是否重试取决于语义:
read/write:重试,除非已经返回了部分数据(那就从短计数继续)。open:重试可能打开两次(如果信号在打开成功后到达),但open通常不被慢设备阻塞,实践上重试可接受;更稳妥的是用O_CLOEXEC并在成功后检查。close:不要重试(见上一节)。accept/connect/recv:重试,或用SA_RESTART让内核自动重启。nanosleep:返回剩余时间,应把剩余时间再传回去继续睡。
信号处理函数安装时用 sigaction 的 SA_RESTART 标志,可以让一部分系统调用自动重启,少写重试循环;但 read 在终端、socket 上仍可能返回 EINTR,不能完全依赖它。
/* 安装不带 SA_RESTART 的处理器:让慢系统调用返回 EINTR,便于及时退出 */
struct sigaction sa = {0};
sa.sa_handler = on_signal; /* 只设置 volatile sig_atomic_t 标志 */
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 不用 SA_RESTART */
sigaction(SIGTERM, &sa, NULL);
while (!stop) {
ssize_t n = read(fd, buf, sizeof buf);
if (n == -1 && errno == EINTR)
continue;
if (n <= 0)
break;
/* 处理 n 字节 */
}
1.10.10 dup2、原子重定向与 /proc/self/fd
dup2(oldfd, newfd) 的语义只有一句话:让 newfd 指向 oldfd 的打开文件描述,若 newfd 已打开则先关闭它。真正容易出错的是它的边界:
| 情形 | 行为 | 注意 |
|---|---|---|
oldfd == newfd | 直接返回 newfd,不做任何事 | 不会关闭,也不会改标志 |
newfd 已打开 | 原子地关闭 newfd 再复制 | 关闭与复制之间没有窗口 |
oldfd 无效 | 返回 -1,EBADF,newfd 不变 | 先校验 |
newfd 超出限制 | 返回 -1,EBADF | 与 RLIMIT_NOFILE 比较 |
多线程同时 dup2 到同一 newfd | 结果是竞争 | 用 dup3 或加锁 |
dup3 是 Linux 专有扩展:多一个 flags 参数,目前只支持 O_CLOEXEC;且 oldfd == newfd 时返回 EINVAL 而不是静默成功。新代码优先用 dup3,语义更明确。
重定向的本质是「把 fd 1 指向另一个打开文件描述」。shell 的 > 先 open 文件,再 dup2(fd, 1),最后 close(fd)。顺序决定结果:
cmd >out 2>&1 # 1→out;2→复制 1(也是 out)
cmd 2>&1 >out # 2→复制 1(终端);1→out;stderr 仍在终端
cmd >out 2>err # 两个独立文件
cmd 2>&1 | less # 2→管道,1→管道
用 /proc/self/fd 可以直接看到这张表。它是内核为每个进程生成的符号链接目录,链接目标就是打开文件描述对应的对象:
ls -l /proc/self/fd
# lrwx------ 1 user user 64 ... 0 -> /dev/pts/0
# lrwx------ 1 user user 64 ... 1 -> /dev/pts/0
# lrwx------ 1 user user 64 ... 2 -> /dev/pts/0
# lr-x------ 1 user user 64 ... 3 -> /proc/self/fd
ls 自己打开目录占用 fd 3,所以总能看到这一项。程序里可以用 readlink("/proc/self/fd/N", ...) 查询某个 fd 指向什么;删除已被打开的文件后,链接目标会带上 (deleted) 后缀——这是排查「磁盘空间没释放」的第一手证据。
一个完整的小工具:打印当前进程的 fd 表并标注每个 fd 的类型与目标。它同时演示了 fcntl(F_GETFL)、readlink 与 dirfd 的用法:
/* fds.c:列出本进程打开的 fd、类型、访问模式与目标 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dirent.h>
#include <fcntl.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static void describe(int fd)
{
struct stat st;
if (fstat(fd, &st) == -1)
return;
const char *kind = S_ISREG(st.st_mode) ? "file" :
S_ISDIR(st.st_mode) ? "dir" :
S_ISCHR(st.st_mode) ? "chr" :
S_ISFIFO(st.st_mode) ? "fifo" :
S_ISSOCK(st.st_mode) ? "sock" : "other";
int fl = fcntl(fd, F_GETFL);
if (fl == -1)
return;
const char *mode = (fl & O_ACCMODE) == O_RDONLY ? "r" :
(fl & O_ACCMODE) == O_WRONLY ? "w" : "rw";
char path[64], target[PATH_MAX];
snprintf(path, sizeof path, "/proc/self/fd/%d", fd);
ssize_t n = readlink(path, target, sizeof target - 1);
if (n < 0)
return;
target[n] = '\0';
printf("%3d %-5s %-2s %s%s\n", fd, kind, mode, target,
(fcntl(fd, F_GETFD) & FD_CLOEXEC) ? " [cloexec]" : "");
}
int main(void)
{
DIR *d = opendir("/proc/self/fd");
if (d == NULL) {
perror("opendir");
return 1;
}
int self = dirfd(d); /* 目录流自己的 fd,最后跳过 */
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] == '.')
continue;
int fd = atoi(e->d_name);
if (fd != self)
describe(fd);
}
closedir(d);
return 0;
}
把它跑在重定向环境里,能同时看到 shell 留下的痕迹:
./fds 3<README.md 4>/tmp/out.txt
# 0 chr r /dev/pts/0
# 1 chr w /dev/pts/0
# 2 chr w /dev/pts/0
# 3 file r /home/user/linux/README.md
# 4 file w /tmp/out.txt
3< 与 4> 是 shell 在 exec 前用 open + dup2 装好的;程序自己完全没有参与。理解了这一点,就能解释为什么 nohup、daemon、systemd 的服务都需要显式处理 0/1/2:它们不是「程序的属性」,而是继承来的 fd 表。
open("/tmp", O_TMPFILE | O_RDWR, 0600) 在目录里创建一个没有名字的 inode,只能通过返回的 fd 访问;linkat(fd, "", AT_FDCWD, path, AT_EMPTY_PATH) 可以稍后给它命名。它避免了「先 mkstemp 再 unlink」的竞争与残留,是临时文件、原子写盘(写完后 linkat 到目标名)的现代做法。
1.10.11 完整程序一:把 EINTR、EAGAIN、短读、短写放进一个程序
前两节把返回值拆成了表。这个程序把四种情形放进一次运行:对空管道做阻塞读(信号打断 → EINTR)、把读端设为非阻塞(→ EAGAIN)、把写端设为非阻塞后一次写 1 MiB(→ 短写),再把管道里的数据读空(→ 多次短读)。
/* eintr.c:一次演示 EINTR、EAGAIN、短写与短读 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
static volatile sig_atomic_t got_alarm;
static void on_alarm(int sig)
{
(void)sig;
got_alarm = 1; /* 信号处理函数里只做异步安全的事 */
}
static double now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1e3 + (double)ts.tv_nsec / 1e6;
}
static void set_nonblock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl == -1 || fcntl(fd, F_SETFL, fl | O_NONBLOCK) == -1) {
perror("fcntl");
exit(1);
}
}
int main(void)
{
int pfd[2];
if (pipe(pfd) == -1) {
perror("pipe");
return 1;
}
/* 1) 阻塞读 + 信号:read 返回 EINTR */
struct sigaction sa = {0};
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 关键:不设 SA_RESTART */
if (sigaction(SIGALRM, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
alarm(1);
double t0 = now_ms();
char c = 0;
ssize_t n = read(pfd[0], &c, 1); /* 写端还开着,没有数据:阻塞 */
int err = errno;
printf("read : n=%zd errno=%s(%d) elapsed=%.0fms flag=%d\n",
n, strerror(err), err, now_ms() - t0, (int)got_alarm);
/* 2) 非阻塞读:没有数据时返回 EAGAIN */
set_nonblock(pfd[0]);
errno = 0;
n = read(pfd[0], &c, 1);
printf("nonblk : n=%zd errno=%s(%d)\n", n, strerror(errno), errno);
/* 3) 非阻塞写满管道:返回短写 */
set_nonblock(pfd[1]);
static char buf[1 << 20];
memset(buf, 'x', sizeof buf);
errno = 0;
ssize_t w = write(pfd[1], buf, sizeof buf);
printf("write : requested=%zu wrote=%zd errno=%s(%d)\n",
sizeof buf, w, strerror(errno), errno);
/* 4) 读回:每次 read 可能只拿到一部分 */
set_nonblock(pfd[0]);
size_t total = 0;
int calls = 0;
while ((n = read(pfd[0], buf, 4096)) > 0) {
total += (size_t)n;
calls++;
}
printf("drain : read %zu bytes in %d calls, last n=%zd errno=%s\n",
total, calls, n, strerror(errno));
close(pfd[0]);
close(pfd[1]);
return 0;
}
在 Linux 6.8 上的预期输出(时间与容量随内核配置变化):
read : n=-1 errno=Interrupted system call(4) elapsed=1000ms flag=1
nonblk : n=-1 errno=Resource temporarily unavailable(11)
write : requested=1048576 wrote=65536 errno=Success(0)
drain : read 65536 bytes in 16 calls, last n=-1 errno=Resource temporarily unavailable
逐行读:
- 第一行证明
read在没有读到任何字节时被信号打断,返回EINTR,errno是 4。如果sa_flags设了SA_RESTART,这一行会一直阻塞到有数据,程序就卡住了。 - 第二行
EAGAIN在 Linux 上是 11;macOS 上是 35,名字相同、数值不同。判断时用宏,不要写数字。 - 第三行一次请求 1 MiB,只写入 65536 字节:管道默认容量是 64 KiB(
/proc/sys/fs/pipe-max-size是单次可设的上限)。这就是短写,write不报错。 - 第四行读回时每次 4096 字节,共 16 次;最后一次返回 -1/
EAGAIN而不是 0,因为写端还没关闭——非阻塞读的「暂时没有数据」不是 EOF。
- 「
read返回 -1 就是出错,应该退出」——EINTR与EAGAIN都是可恢复状态;前者重试,后者等事件。把它们当致命错误会让服务在收到一个信号或一次缓冲区空时断开连接。 - 「非阻塞读返回 0 表示没有数据」——0 永远是 EOF(对管道/socket 是写端全部关闭)。没有数据时非阻塞读返回 -1/
EAGAIN。把两者混淆会导致「对端还活着却提前关闭连接」。
1.10.12 完整程序二:read/write 与零拷贝复制对照
复制一个文件,最直接的写法是「读进用户缓冲区再写出」。这条路径有四次数据搬运:磁盘 → page cache → 用户缓冲区 → socket/文件 page cache → 磁盘。零拷贝接口把「用户缓冲区」这一段省掉,让内核直接在 page cache 与目标之间搬。
| 接口 | 典型用途 | 数据路径 | 主要限制 |
|---|---|---|---|
read/write | 任意 | 磁盘→page cache→用户→page cache→磁盘 | 两次拷贝、两次系统调用 |
sendfile(out, in, ...) | 文件→socket | page cache→socket 缓冲区(可配合 DMA) | in 必须是支持 mmap 的文件;早期要求 out 是 socket |
splice(fd_in, ..., fd_out, ...) | 管道做中转 | 两个 fd 之间移动页引用 | 至少一端是管道 |
copy_file_range | 同文件系统内复制 | 内核内复制,可能走 reflink | 跨文件系统可能返回 EXDEV;NFS 支持依版本 |
io_uring + IORING_OP_SENDFILE | 高并发批量 | 异步提交,减少系统调用 | 需要 5.1+ 与 liburing |
/* copydemo.c:比较 read/write 与零拷贝接口复制同一个文件的吞吐 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#ifdef __linux__
#include <sys/sendfile.h>
#endif
#define CHUNK (64 * 1024)
static double now_sec(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
/* 准备一个 size 字节的源文件:内容不重要,只要真实占块 */
static int make_source(const char *path, off_t size)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1)
return -1;
char *buf = malloc(CHUNK);
if (buf == NULL) {
close(fd);
return -1;
}
memset(buf, 'A', CHUNK);
for (off_t off = 0; off < size; off += CHUNK) {
size_t n = (size - off) < CHUNK ? (size_t)(size - off) : CHUNK;
if (write(fd, buf, n) != (ssize_t)n) {
free(buf);
close(fd);
return -1;
}
}
free(buf);
return close(fd);
}
static double copy_rw(const char *src, const char *dst, off_t size)
{
int in = open(src, O_RDONLY);
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (in == -1 || out == -1)
return -1;
char *buf = malloc(CHUNK);
double t0 = now_sec();
off_t left = size;
while (left > 0) {
size_t want = left < CHUNK ? (size_t)left : CHUNK;
ssize_t r = read(in, buf, want);
if (r <= 0)
break;
ssize_t off = 0;
while (off < r) { /* 处理短写 */
ssize_t w = write(out, buf + off, (size_t)(r - off));
if (w <= 0)
goto done;
off += w;
}
left -= r;
}
done:
free(buf);
close(in);
close(out);
return now_sec() - t0;
}
#ifdef __linux__
static double copy_cfr(const char *src, const char *dst, off_t size)
{
int in = open(src, O_RDONLY);
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (in == -1 || out == -1)
return -1;
double t0 = now_sec();
off_t left = size;
while (left > 0) {
ssize_t n = copy_file_range(in, NULL, out, NULL, (size_t)left, 0);
if (n <= 0)
break;
left -= n;
}
close(in);
close(out);
return now_sec() - t0;
}
static double copy_sendfile(const char *src, const char *dst, off_t size)
{
int in = open(src, O_RDONLY);
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (in == -1 || out == -1)
return -1;
double t0 = now_sec();
off_t left = size;
while (left > 0) {
ssize_t n = sendfile(out, in, NULL, (size_t)left);
if (n <= 0)
break;
left -= n;
}
close(in);
close(out);
return now_sec() - t0;
}
#endif
int main(int argc, char **argv)
{
off_t size = argc > 1 ? (off_t)atoll(argv[1]) << 20 : 64 << 20; /* MiB */
const char *src = "/tmp/copydemo.src";
const char *dst = "/tmp/copydemo.dst";
if (make_source(src, size) == -1) {
perror("make_source");
return 1;
}
double t = copy_rw(src, dst, size);
printf("%-18s %7.3f s %8.1f MiB/s\n", "read/write", t, (double)size / t / (1 << 20));
#ifdef __linux__
t = copy_cfr(src, dst, size);
printf("%-18s %7.3f s %8.1f MiB/s\n", "copy_file_range", t, (double)size / t / (1 << 20));
t = copy_sendfile(src, dst, size);
printf("%-18s %7.3f s %8.1f MiB/s\n", "sendfile", t, (double)size / t / (1 << 20));
#else
printf("copy_file_range / sendfile:仅 Linux,跳过\n");
#endif
unlink(src);
unlink(dst);
return 0;
}
一份 256 MiB 文件、page cache 已热的参考输出(NVMe,ext4):
read/write 0.412 s 621.4 MiB/s
copy_file_range 0.208 s 1230.8 MiB/s
sendfile 0.215 s 1190.7 MiB/s
三种方法的结果长度相同,差别在 CPU 与内存带宽:read/write 多了一次用户态拷贝和两次系统调用;copy_file_range 在同一个文件系统内可以让内核直接复制(btrfs/XFS 上甚至走 reflink,几乎不搬数据);sendfile 把 page cache 的页直接挂到 socket 发送队列。
sendfile 仍然要把数据从磁盘读进 page cache(DMA 拷贝),也可能在网卡不支持 scatter-gather 时复制一次。它省掉的是内核态与用户态之间的那次拷贝,以及相应的系统调用。名字是营销术语,理解成「少一次拷贝」更准确。
1.10.13 完整程序三:不拼长路径的目录遍历
listdir 用 snprintf 拼路径,遇到深目录会超过 PATH_MAX,还会因为「先拼路径再 stat」而受 rename 影响。正确做法是全程用 openat/fstatat,以目录 fd 为锚点逐层下钻——路径永远只是「一个名字」,不会变长。
/* walk.c:用 openat/fdopendir 遍历目录树,不拼长路径、不跟随符号链接 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dirent.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define MAX_DEPTH 32
struct totals {
unsigned long dirs, files, links, others;
unsigned long long bytes;
};
static void walk(int dfd, int depth, struct totals *t)
{
DIR *d = fdopendir(dfd); /* 接管 fd 的所有权 */
if (d == NULL) {
close(dfd);
return;
}
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
continue;
struct stat st;
/* 关键:相对 dirfd 解析,且不跟随符号链接 */
if (fstatat(dirfd(d), e->d_name, &st, AT_SYMLINK_NOFOLLOW) == -1)
continue;
if (S_ISDIR(st.st_mode)) {
t->dirs++;
if (depth + 1 >= MAX_DEPTH) {
fprintf(stderr, "达到深度上限,跳过:%s\n", e->d_name);
continue;
}
int sub = openat(dirfd(d), e->d_name,
O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (sub == -1) {
perror(e->d_name); /* 权限不足等:记录后继续 */
continue;
}
walk(sub, depth + 1, t); /* walk 内部会关闭 sub */
} else if (S_ISREG(st.st_mode)) {
t->files++;
t->bytes += (unsigned long long)st.st_size;
} else if (S_ISLNK(st.st_mode)) {
t->links++;
} else {
t->others++;
}
}
closedir(d); /* 同时关闭底层 dirfd */
}
int main(int argc, char **argv)
{
const char *root = argc > 1 ? argv[1] : ".";
int fd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd == -1) {
perror("open");
return 1;
}
struct totals t = {0};
walk(fd, 0, &t);
printf("root : %s\n", root);
printf("dirs : %lu\n", t.dirs);
printf("files : %lu (%llu bytes)\n", t.files, t.bytes);
printf("links : %lu\n", t.links);
printf("others : %lu\n", t.others);
return 0;
}
三个设计点:
O_NOFOLLOW:如果目录项其实是符号链接,openat返回ELOOP,不会跑到目标目录里形成环。遍历不可信目录树时必须加。fdopendir接管 fd:成功后不能再close(dfd),closedir会关;失败时 fd 仍归调用者。这是最容易写错的所有权转移。- 递归深度上限:符号链接被挡住了,但硬链接目录(需要 root)和极深目录仍可能耗尽栈或 fd。生产工具通常用显式栈而不是递归。
输出示例:
root : /usr/share/doc
dirs : 421
files : 3120 (18446732 bytes)
links : 37
others : 0
1.10.14 实验一:用 strace 看缓冲、短读与 write 合并
目标:证明「printf 不是一次 write」「stdio 会预读」「fd 号可以被 strace 翻译成路径」。
步骤 1:观察 write 的次数与大小
cat > buffered.c <<'EOF'
#include <stdio.h>
int main(void) {
for (int i = 0; i < 1000; i++)
printf("line %04d\n", i);
return 0;
}
EOF
cc -O2 -o buffered buffered.c
strace -e trace=write -s 32 ./buffered > /dev/null
预期看到:重定向到 /dev/null 时 stdout 是全缓冲,1000 行(约 10 KB)通常被合并成 1–2 次 write(1, "line 0000\nline 0001\n...", 10240)。把输出接到终端再跑一次,会看到 1000 次 write(每次一行)。这就是 _IOFBF 与 _IOLBF 的差别。
步骤 2:让 fd 号带上路径
strace -y -e trace=openat,read,write ./buffered > /dev/null
-y 会把 write(1, ...) 打印成 write(1</dev/null>, ...),把 read(3, ...) 打印成 read(3</etc/ld.so.cache>, ...)。排查「到底写到了哪个文件」时,这个选项比读代码快。
步骤 3:观察预读
strace -e trace=read -s 16 cat /etc/services > /dev/null
cat 用 128 KiB 的缓冲区,read(3, ...) 的返回值通常是 131072 或更小;strace 只显示前 16 字节内容。若把 cat 换成逐行处理的程序,会看到 read(3, ..., 4096) 反复出现——stdio 的 BUFSIZ 是 8192,glibc 对普通文件常用 4096 的整数倍。
步骤 4:对照 strace -c
strace -c ./buffered > /dev/null
输出按系统调用聚合:write 的次数、总耗时、每次平均耗时一目了然。注意 strace 本身会把每次系统调用的开销放大 10–100 倍,只能用来数次数,不能用来测性能。
1.10.15 实验二:page cache、O_DIRECT 与 fsync 的证据
目标:看清「write 返回 ≠ 落盘」和「第二次读为什么变快」。
步骤 1:冷读与热读
dd if=/dev/zero of=/tmp/big.bin bs=1M count=512 oflag=direct # 造 512 MiB 文件
sync
echo 3 | sudo tee /proc/sys/vm/drop_caches # 清 page cache(需 root)
/usr/bin/time -f "cold: %e s" dd if=/tmp/big.bin of=/dev/null bs=1M
/usr/bin/time -f "warm: %e s" dd if=/tmp/big.bin of=/dev/null bs=1M
典型结果:冷读受设备带宽限制(SATA SSD 约 500 MB/s,NVMe 约 2–7 GB/s);热读受内存带宽限制,通常 5–20 GB/s。两者差一个数量级,说明第二次读根本没有进块层。
步骤 2:脏页与回写
grep -E 'Dirty|Writeback' /proc/meminfo
dd if=/dev/zero of=/tmp/dirty.bin bs=1M count=256 conv=fsync &
sleep 0.2; grep -E 'Dirty|Writeback' /proc/meminfo
wait
Dirty 是待回写的字节数,Writeback 是正在回写的字节数。conv=fsync 让 dd 在结束时 fsync;不加时,dd 退出后 Dirty 还会维持一段时间——这就是「write 成功但数据还在内存」。
步骤 3:fsync 到底做了什么
strace -e trace=fsync,fdatasync,write -T dd if=/dev/zero of=/tmp/f.bin bs=4k count=8 conv=fsync
会看到 8 次 write(1, ...) 之后一次 fsync(1) <...>,耗时可能是几十毫秒——这是等设备确认写入的时间。fdatasync 省掉不必要的元数据(如 mtime)同步,通常更快;O_SYNC 让每次 write 都等设备,最慢但语义最强。
步骤 4:O_DIRECT 的对齐要求
dd if=/tmp/big.bin of=/dev/null bs=4k iflag=direct
dd if=/tmp/big.bin of=/dev/null bs=1000 iflag=direct # 预期 EINVAL
O_DIRECT 要求缓冲区地址、偏移、长度都按逻辑块大小(通常 512 B 或 4 KiB)对齐;不对齐时 read 返回 EINVAL。这也是很多数据库自己管理对齐缓冲区的原因。
容器与宿主机共享内核,drop_caches 影响整机;/proc/meminfo 也是整机视角。cgroup v2 的 memory.stat 里有 file 与 inactive_file,可以看某个容器占用的页缓存。排查「容器内存高」时,先分清匿名页与 page cache:后者可回收,不会直接触发 OOM。
1.10.16 十三条系统级 I/O 陷阱
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | read 只返回一部分就继续解析 | 把短读当成完整报文 | 循环读到所需长度或 EOF |
| 2 | write 返回小于请求长度被忽略 | 管道/socket 缓冲区不足 | 循环写,非阻塞时等 EPOLLOUT |
| 3 | 收到信号后程序退出 | 把 EINTR 当致命错误 | 重试;或 SA_RESTART |
| 4 | 非阻塞读把 EAGAIN 当 EOF | 混淆「暂无数据」与「对端关闭」 | EAGAIN 等事件,0 才是 EOF |
| 5 | close 后仍用 fd | fd 号被新 open 复用 | 关闭后置 -1;用 RAII/goto cleanup |
| 6 | 多线程下 dup2 覆盖别人的 fd | 关闭与复制不是原子的组合 | 用 dup3,或加锁并集中管理 |
| 7 | fork 后子进程重复输出 | 继承了 stdio 缓冲且调用 exit | 子进程用 _exit |
| 8 | 混用 FILE * 与 read 丢数据 | stdio 预读把 fd 偏移推前 | 切换前 fflush/fseek,或统一接口 |
| 9 | 日志行交错 | 一条日志分多次 write | 拼成一次 write;O_APPEND 只保证单次原子 |
| 10 | 重定向顺序写反 | 2>&1 >out 先复制再改 1 | 记牢「从左到右、复制当前值」 |
| 11 | fsync 后仍丢数据 | 只同步了文件,没同步目录项 | rename 后 fsync 父目录 fd |
| 12 | O_DIRECT 读失败 EINVAL | 缓冲区/偏移/长度未对齐 | 用 posix_memalign,按块大小对齐 |
| 13 | EMFILE 后 accept 空转 | 没有预留救急 fd | 预留 /dev/null fd,丢弃一个连接后补回 |
- 「
write返回成功,进程崩溃也不丢」——数据只在 page cache;进程崩溃不影响内核回写,但机器掉电会丢。要抗掉电必须fsync。 - 「
fsync一次就够」——新建文件后fsync(fd)只保证数据与 inode;目录项(文件名)要fsync目录 fd 才持久。rename原子替换的经典写法是「写临时文件 →fsync→rename→fsync目录」。 - 「
O_APPEND能保证多进程日志不交错」——它只保证单次write的「定位到末尾 + 写」是原子的。一次日志若分成write(时间)、write(内容)、write(换行)三次,仍会与其他进程交错。
1.10.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 解释短读/短写 | 能写出带 EINTR 的循环 | 1.10.4 |
区分 stat/lstat | 能说出符号链接时的差异 | 1.10.2 |
用 readdir 遍历目录 | 知道 d_type 可能是 DT_UNKNOWN | 1.10.3 |
解释 dup2 与重定向顺序 | 能判断 2>&1 >out 的结果 | 1.10.5 |
说出 write 与 fsync 的边界 | 知道 page cache 与掉电语义 | 1.10.6 |
| 选择 I/O 接口 | 能根据场景给出理由 | 1.10.7 |
Q1 read(fd, buf, 4096) 返回 100,是否说明文件只剩 100 字节?怎样读满 4096 字节?
Q2 cmd 2>&1 >out 之后,stdout 和 stderr 分别去哪?画出 fd 表的变化。
Q3 一个程序用 printf 输出日志,重定向到文件后最后几行丢失,可能的原因有哪些?
Q4 write(fd, buf, n) 返回 n 后立刻断电,数据一定在磁盘上吗?要保证什么?
答案
A1 不一定。短读可能因为信号、管道缓冲区、终端行模式或文件系统块边界。要读满,循环调用 read,累加返回值,直到读满、遇到 EOF(返回 0)或出错;每次处理 EINTR。
A2 先执行 2>&1:fd 2 复制 fd 1 当前指向的终端;再执行 >out:fd 1 改为指向 out。所以 stderr 仍在终端,stdout 进文件。若写成 >out 2>&1,fd 1 先指向文件,fd 2 再复制 fd 1,两者都进文件。
A3 stdout 重定向到文件时默认全缓冲,数据还在用户态缓冲区;程序调用 _exit、被信号杀死、或没 fflush/fclose,缓冲区就没写出去。加 fflush、改用行缓冲、或保证正常 exit 可以修复。
A4 不一定。write 只把数据放进 page cache 并标记脏页;断电时尚未回写的数据会丢。要持久化,调用 fsync(fd)(数据和元数据)或 fdatasync(fd)(数据 + 必要元数据),必要时再 fsync 父目录以确保目录项持久。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C(CSAPP) | 第 10 章 系统级 I/O(10.1–10.12) | 1.10.1–1.10.7 | 完整:Unix I/O、文件、RIO、元数据、目录、共享、重定向、stdio、选型 |
| K(Kerrisk) | 第 4 章 文件 I/O:通用 I/O 模型 | 1.10.1、1.10.5 | 精选:open/read/write/close/lseek 语义 |
| K(Kerrisk) | 第 13 章 文件 I/O 缓冲 | 1.10.6 | 完整:stdio 缓冲、setvbuf、fsync |
| K(Kerrisk) | 第 5 章 深入探究文件 I/O | 1.10.5 | 背景:dup、原子操作,详见 2.2–2.3 |
CSAPP 第 10 章小节清单
状态说明:已覆盖=有独立 H2/H3 展开;合并=并入相邻小节、要点保留;背景=只给上下文;淘汰=原书内容已过时或不适用。
| CSAPP 小节 | 页码 | 状态 | 本书落点 |
|---|---|---|---|
| 10.1 Unix I/O | 657 | 已覆盖 | 1.10.1、1.10.8(fd 三层结构) |
| 10.2 文件 | 658 | 已覆盖 | 1.10.1(文件类型表)、1.10.2 |
| 10.3 打开和关闭文件 | 659 | 已覆盖 | 1.10.1、1.10.8(O_CLOEXEC、引用计数) |
| 10.4 读和写文件 | 660 | 已覆盖 | 1.10.9(返回值逐情形)、1.10.11 |
| 10.5 用 RIO 包健壮地读写 | 661 | 已覆盖 | 1.10.4(RIO 实现)、1.10.11(read_full/write_full) |
| 10.6 读取文件元数据 | 667 | 已覆盖 | 1.10.2(stat/fstatat 字段) |
| 10.7 读取目录内容 | 668 | 已覆盖 | 1.10.3、1.10.13(openat 遍历) |
| 10.8 共享文件 | 669 | 已覆盖 | 1.10.5、1.10.8(打开文件描述与偏移共享) |
| 10.9 I/O 重定向 | 672 | 已覆盖 | 1.10.10(dup2/dup3、顺序) |
| 10.10 标准 I/O | 673 | 已覆盖 | 1.10.6(三种缓冲)、1.10.14(strace 观察) |
| 10.11 综合:我该使用哪些 I/O 函数 | 673 | 已覆盖 | 1.10.7(选型表)、1.10.12(零拷贝) |
| 10.12 小结 | 675 | 合并 | 1.10.16(十三条陷阱) |
下一站:fd 不只可以指向文件,还可以指向一条网络连接。
socket返回的 fd 怎样经历bind、listen、accept?HTTP 请求在字节流上怎样切出边界?进入 1.11 网络编程与协议基础。