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

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磁盘,通常通过文件系统访问
FIFOS_ISFIFO管道,读写阻塞/短读
socketS_ISSOCK网络/本地通信,read/write 等价 recv/send
符号链接S_ISLNKreadlink 读目标,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_inoinode 号可能与 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 的三层结构。这里只看两个直接后果:

  1. fork 后父子共享打开文件描述,偏移共享;各自 open 同一文件则偏移独立。
  2. 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 到磁盘

一次 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() 也不一定读磁盘。

读图时把路径分成三段:用户态缓冲(stdio/RIO)→ 内核 page cache → 块层与设备。一次 write 通常只走到第二段就返回:数据被复制进 page cache,页被标记为脏,由内核的 writeback 线程在几秒到几十秒后写回磁盘。read 命中 page cache 时完全不进块层;未命中才触发主缺页和磁盘 I/O。

操作数据到哪掉电会丢吗
printfstdio 用户缓冲区会,甚至还没进内核
writepage 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/Oepoll + 非阻塞 fd一个线程管大量连接
极高 IOPS、批量提交io_uring减少系统调用与拷贝
必须落盘的日志write + fsync/fdatasync明确持久化边界

一个常见的错误是用 stdio 写网络 socket:fprintf 可能缓冲、可能部分写、错误码被 FILE 状态掩盖。网络代码要么用 send/recv 循环,要么用 RIO 这类明确处理短写的包。

关于系统级 I/O 的六个误解
  1. 「read 返回 0 是错误」——0 是 EOF;错误返回 -1 并设置 errno。把 0 当错误会提前结束正常文件。
  2. 「close 成功就代表数据落盘」——close 只释放 fd;数据可能还在 page cache。持久化要 fsync。
  3. 「write 返回请求长度就一定写完了」——普通文件通常如此,但管道、socket、信号打断都可能短写;必须循环。
  4. 「stdio 和 read 可以随便混用」——stdio 预读会让 fd 偏移越过尚未交出的数据;混用前要 fflush/fseek。
  5. 「O_APPEND 保证多进程日志行不交错」——只保证单次 write 的「定位 + 写」原子;一条日志分多次写仍会交错。
  6. 「目录可以用 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
inodestruct inode每个文件系统对象一份类型、权限、大小、数据块位置、时间戳

open 做两件事:创建一个新的打开文件描述(新的 struct file),再在当前进程 fd 表里找编号最小的空位,填入指针。fork 只复制 fd 表,不复制打开文件描述——父子各自的 fd 指向同一个 struct file,所以共享偏移。dup 也不复制打开文件描述,只让两个 fd 号指向同一个 struct file。

定义 1.10.1 · 打开文件描述(open file description)

内核中由 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_GETFDF_SETFD否,每个 fd 独立
状态标志struct fileF_GETFLF_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 越界EBADFnewfd 超出 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 的四个误解
  1. 「fd 就是文件」——fd 是进程 fd 表的下标,指向打开文件描述;同一个文件可以有多个 fd,同一个 fd 经 fork 可被多个进程共享。
  2. 「close(1) 之后 printf 会报错」——printf 写入 stdio 缓冲,可能不返回错误;错误在 fflush/fclose 时才出现。要可靠检测,检查 ferror(stdout)。
  3. 「dup2 会复制偏移」——不复制,两个 fd 共享同一个偏移;dup2(fd, 1) 后向 1 写会推进 fd 的偏移。
  4. 「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读满写满完成
== 0EOF(对普通文件)count == 0 或设备不接受数据read 结束循环;write 检查 count
-1 + EINTR被信号打断,未读到数据被信号打断,未写入数据重试
-1 + EAGAIN非阻塞且暂无数据非阻塞且缓冲区满等可读/可写事件后重试
-1 + EPIPE管道写端已关闭对端已关闭,同时收到 SIGPIPE忽略/处理 SIGPIPE,停止写
-1 + 其他EBADF、EIO、EISDIR、EINVAL…ENOSPC、EFBIG、EDQUOT、EIO…记录并放弃

短读为什么发生

短读不是异常,而是接口契约的一部分。常见原因:

  1. 到达 EOF:请求 4096 字节,文件只剩 100 字节,返回 100;下一次返回 0。
  2. 管道/socket 缓冲区里只有这么多:read(pipefd, buf, 4096) 时写端只写了 10 字节且尚未关闭,返回 10。
  3. 终端规范模式:一次最多返回一行,且行长受 MAX_CANON 限制。
  4. 信号在部分数据到达后打断:read 返回已读字节数,而不是 EINTR。
  5. O_DIRECT 或特殊设备:对齐要求、设备块大小导致不足一次请求。
  6. 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_COUNTLinux 把单次请求截到 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 表。

`O_TMPFILE`:没有名字的临时文件

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。
两个关于 EAGAIN 的误解
  1. 「read 返回 -1 就是出错,应该退出」——EINTR 与 EAGAIN 都是可恢复状态;前者重试,后者等事件。把它们当致命错误会让服务在收到一个信号或一次缓冲区空时断开连接。
  2. 「非阻塞读返回 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, ...)文件→socketpage 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;
}

三个设计点:

  1. O_NOFOLLOW:如果目录项其实是符号链接,openat 返回 ELOOP,不会跑到目标目录里形成环。遍历不可信目录树时必须加。
  2. fdopendir 接管 fd:成功后不能再 close(dfd),closedir 会关;失败时 fd 仍归调用者。这是最容易写错的所有权转移。
  3. 递归深度上限:符号链接被挡住了,但硬链接目录(需要 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。这也是很多数据库自己管理对齐缓冲区的原因。

容器里的 page cache

容器与宿主机共享内核,drop_caches 影响整机;/proc/meminfo 也是整机视角。cgroup v2 的 memory.stat 里有 file 与 inactive_file,可以看某个容器占用的页缓存。排查「容器内存高」时,先分清匿名页与 page cache:后者可回收,不会直接触发 OOM。

1.10.16 十三条系统级 I/O 陷阱

#现象原因修复
1read 只返回一部分就继续解析把短读当成完整报文循环读到所需长度或 EOF
2write 返回小于请求长度被忽略管道/socket 缓冲区不足循环写,非阻塞时等 EPOLLOUT
3收到信号后程序退出把 EINTR 当致命错误重试;或 SA_RESTART
4非阻塞读把 EAGAIN 当 EOF混淆「暂无数据」与「对端关闭」EAGAIN 等事件,0 才是 EOF
5close 后仍用 fdfd 号被新 open 复用关闭后置 -1;用 RAII/goto cleanup
6多线程下 dup2 覆盖别人的 fd关闭与复制不是原子的组合用 dup3,或加锁并集中管理
7fork 后子进程重复输出继承了 stdio 缓冲且调用 exit子进程用 _exit
8混用 FILE * 与 read 丢数据stdio 预读把 fd 偏移推前切换前 fflush/fseek,或统一接口
9日志行交错一条日志分多次 write拼成一次 write;O_APPEND 只保证单次原子
10重定向顺序写反2>&1 >out 先复制再改 1记牢「从左到右、复制当前值」
11fsync 后仍丢数据只同步了文件,没同步目录项rename 后 fsync 父目录 fd
12O_DIRECT 读失败 EINVAL缓冲区/偏移/长度未对齐用 posix_memalign,按块大小对齐
13EMFILE 后 accept 空转没有预留救急 fd预留 /dev/null fd,丢弃一个连接后补回
三个关于「文件已写入」的误解
  1. 「write 返回成功,进程崩溃也不丢」——数据只在 page cache;进程崩溃不影响内核回写,但机器掉电会丢。要抗掉电必须 fsync。
  2. 「fsync 一次就够」——新建文件后 fsync(fd) 只保证数据与 inode;目录项(文件名)要 fsync 目录 fd 才持久。rename 原子替换的经典写法是「写临时文件 → fsync → rename → fsync 目录」。
  3. 「O_APPEND 能保证多进程日志不交错」——它只保证单次 write 的「定位到末尾 + 写」是原子的。一次日志若分成 write(时间)、write(内容)、write(换行) 三次,仍会与其他进程交错。

1.10.17 本章出口检查

检查项达标线没达标去哪
解释短读/短写能写出带 EINTR 的循环1.10.4
区分 stat/lstat能说出符号链接时的差异1.10.2
用 readdir 遍历目录知道 d_type 可能是 DT_UNKNOWN1.10.3
解释 dup2 与重定向顺序能判断 2>&1 >out 的结果1.10.5
说出 write 与 fsync 的边界知道 page cache 与掉电语义1.10.6
选择 I/O 接口能根据场景给出理由1.10.7
即时自测 · 1.10

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/O1.10.5背景:dup、原子操作,详见 2.2–2.3

CSAPP 第 10 章小节清单

状态说明:已覆盖=有独立 H2/H3 展开;合并=并入相邻小节、要点保留;背景=只给上下文;淘汰=原书内容已过时或不适用。

CSAPP 小节页码状态本书落点
10.1 Unix I/O657已覆盖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/O673已覆盖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 网络编程与协议基础。