第 5 章 文件 I/O:进一步细节 (File I/O: Further Details)

      +

      核心结论

      • 原子性 (Atomicity):所有系统调用都是原子的——内核保证系统调用的所有步骤作为单个不可中断操作执行。这是避免 race condition 的关键。

      • fcntl():通用文件描述符控制接口;可获取/设置 open file status flags、复制 fd、获取/设置文件锁、操作 signal-driven I/O 等。

      • fd vs open file description vs i-node:三个独立概念——fd 表项(进程级)→ open file description(系统级,含 offset 和 flags)→ i-node(文件系统级,含 type 和 permissions)。多个 fd 可指向同一 open file description(共享 offset)。

      • dup/dup2/fcntl F_DUPFD:复制文件描述符;dup2 精确指定目标 fd;fcntl F_DUPFD 用最小未用 fd;新 fd 与原 fd 共享 open file description。

      • pread/pwrite:在指定 offset 处读写,不修改 fd 的当前 offset;适合多线程并发读同一文件的不同区域。

      • readv/writev:分散/聚集 I/O——单次系统调用读/写多个非连续缓冲区;减少系统调用次数。

      • 非阻塞 I/OO_NONBLOCK flag 使 read/write 在数据未就绪时立即返回 -1 (EAGAIN);用于异步事件循环。

      • 大文件支持off_t 在 32 位系统上可能仅 32 位;_FILE_OFFSET_BITS=64 让所有相关函数使用 64 位 offset;支持 >2GB 的文件。

      • 临时文件mkstemp() 创建唯一名称的临时文件并立即 open;优于 tmpnam()(有竞态)。

      本章主旨

      本章是第 4 章的延伸。核心新增:(1) 原子性概念——理解为什么需要 O_CREAT|O_EXCLO_APPEND;(2) fcntl()——通用的 fd 控制接口;(3) 三层模型(fd/open file description/i-node)——理解「为什么多个 fd 可以共享 offset」;(4) 扩展 I/O 调用(pread/pwrite、readv/writev);(5) 非阻塞 I/O、大文件、临时文件。这些是写「健壮」与「高效」文件 I/O 程序的基础。

      一、核心概念

      本章围绕 7 个核心概念展开:从「原子性」到「fd 复制」再到「扩展 I/O 与临时文件」。

      概念 定义 + 重要性 实现提示

      原子性与竞态

      系统调用在执行中不可被另一个进程/线程中断;避免 race condition;典型例子:O_EXCL 原子创建、O_APPEND 原子追加。

      §5.1;lseek + write 不是原子的,多进程并发追加会丢数据。

      fcntl()

      通用 fd 控制接口;命令包括 F_GETFL/F_SETFL(状态标志)、F_GETFD/F_SETFD(fd 标志)、F_DUPFD(复制 fd)、F_GETLK/F_SETLK(文件锁)。

      §5.2-§5.3;man 2 fcntl 列出全部命令。

      fd / open file description / i-node 三层模型

      fd 表项(进程级)→ open file description(系统级,含 offset/flags)→ i-node(文件系统级);多个 fd 可共享 open file description。

      §5.4;这是理解 fork、dup、文件锁等概念的基础。

      fd 复制:dup/dup2/F_DUPFD

      创建新 fd 指向同一 open file description;dup2 精确指定目标 fd(覆盖现有);F_DUPFD 用最小未用 fd ≥ arg。

      §5.5;常用于 I/O 重定向(如 shell >file)。

      pread/pwrite

      在指定 offset 读写,不修改 fd 的当前 offset;多线程安全(无 shared offset 干扰)。

      §5.6;典型用例:多线程读同一文件的不同段。

      readv/writev (Vector I/O)

      单次系统调用读写多个缓冲区;减少系统调用开销;适合分散数据。

      §5.7;writev 用于 HTTP 头 + body 一次发送。

      非阻塞 I/O 与临时文件

      O_NONBLOCK 让 read/write 在数据未就绪时立即返回;mkstemp() 创建安全的临时文件。

      §5.9-§5.11;非阻塞 I/O 是事件循环的基础。

      二、详细笔记

      5.1 原子性与竞态条件

      What:原子性 = 系统调用的所有步骤作为一个不可中断操作执行;竞态 (race condition) = 两个并发操作的结果依赖于执行顺序的不可预测情形。

      Why:理解原子性才能理解「为什么必须用 O_CREAT|O_EXCL 而不是先 check 后 create」「为什么 O_APPENDlseek+write 安全」。

      How:两个典型反例:

      • 非原子创建(§5.1 Listing 5-1)

        // 摘自《The Linux Programming Interface》第 5 章
        // 错误:检查存在性 → 创建(两步不原子)
        int fd = open(argv[1], O_WRONLY);
        if (fd != -1) { /* 已存在 */ }
        else {
            /* WINDOW FOR FAILURE:另一进程可能在此期间创建文件 */
            fd = open(argv[1], O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
            // 此时声称「我创建了文件」,但实际是别人创建的
        }

        正确做法:

        // 摘自《The Linux Programming Interface》第 5 章
        // 正确:原子创建(O_EXCL + O_CREAT)
        int fd = open(argv[1], O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR);
        if (fd == -1 && errno == EEXIST) /* 文件已存在 */
      • 非原子追加

        // 摘自《The Linux Programming Interface》第 5 章
        // 错误:lseek + write(两步不原子)
        lseek(fd, 0, SEEK_END);
        write(fd, buf, len);
        // 进程 A 和 B 都在此期间 lseek 到同一位置,然后互相覆盖

        正确做法:open(path, O_WRONLY | O_APPEND)——内核保证 seek+write 是原子的。

      When

      • 多进程/多线程并发创建同一文件——用 O_CREAT|O_EXCL

      • 多进程并发追加同一日志文件——用 O_APPEND

      • NFS 等不支持 O_APPEND 的文件系统——内核会回退到非原子语义,可能丢数据。

      5.2 fcntl() 文件控制

      Whatfcntl() 是通用 fd 控制接口;根据 cmd 参数执行不同操作;最常用的是获取/修改 open file status flags。

      Whyfcntl() 是「文件描述符的瑞士军刀」——几乎所有 fd 级操作都通过它。

      How:常用命令(§5.2):

      命令 作用 用途

      F_GETFL

      获取 open file status flags

      检查文件是否以 O_NONBLOCK / O_APPEND 等打开

      F_SETFL

      修改 open file status flags

      给现有 fd 增加 O_NONBLOCK 等

      F_GETFD

      获取 fd 标志(如 close-on-exec)

      F_SETFD

      设置 fd 标志

      F_DUPFD

      复制 fd,返回 ≥ arg 的最小未用 fd

      F_DUPFD_CLOEXEC

      复制并设置 close-on-exec

      F_GETLK / F_SETLK / F_SETLKW

      文件锁

      F_GETOWN / F_SETOWN

      signal-driven I/O 的进程/进程组

      When

      • 想给「不是自己 open 的 fd」(如 pipe 返回的 fd)增加 O_NONBLOCK——fcntl F_SETFL

      • 检查文件是否以同步模式打开——F_GETFL & O_SYNC

      Example:给 fd 增加 O_APPEND:

      // 摘自《The Linux Programming Interface》第 5 章
      int flags = fcntl(fd, F_GETFL);
      if (flags == -1) errExit("fcntl");
      flags |= O_APPEND;
      if (fcntl(fd, F_SETFL, flags) == -1) errExit("fcntl");
      
      // 检查访问模式(注意 O_RDONLY 等不是位标志)
      int accessMode = flags & O_ACCMODE;
      if (accessMode == O_WRONLY || accessMode == O_RDWR)
          printf("writable\n");

      5.3 文件描述符、打开文件描述、i-node 三层模型

      What:内核维护三种数据结构追踪打开的文件——进程级 fd 表、系统级 open file description 表、文件系统级 i-node 表。

      Why:理解三层模型才能理解「fork 后父子进程共享 offset」「dup 后新旧 fd 共享 offset」「两个进程分别打开同一文件但不共享 offset」等现象。

      How:三层关系(§5.4 Figure 5-2):

      数据结构 作用域

      包含内容

      文件描述符表 (per-process)

      每个进程一份

      fd → open file description 引用;fd 标志 (close-on-exec)

      打开文件描述表 (system-wide)

      系统范围共享

      当前文件 offset;open file status flags;文件访问模式;i-node 引用

      i-node 表 (per-file-system)

      文件系统范围共享

      文件类型、权限、链接数、uid/gid、大小、时间戳、数据块指针

      关键洞见:

      • 多个 fd 可指向同一 open file description——共享 offset(如 dupopen 同一文件两次用同一 fd)。

      • fork 后父子进程共享同一 fd 表项(指向同一 open file description)——共享 offset。

      • 不同进程分别 open 同一文件——每个进程有自己的 offset(独立)。

      When

      • dup/dup2 复制 fd——新旧 fd 共享 offset。

      • O_APPEND——即使共享 offset,每次 write 都自动 seek 到末尾。

      • 多线程读同一文件不同段——用 pread/pwrite(避免共享 offset 干扰)。

      Example:fd 复制的内核结构(概念图):

      Process A fd table        Open file descriptions         i-node table
      +----+----+               +-----+-----+-----+           +--------+
      |  3 |----+----+          |off= |flags|ino  |           | type   |
      +----+----+    |          |1024 |O_RDWR|ref  |           | REG    |
                    ++----+     +-----+-----+--+--+           | size   |
                    ||        +------------------+            | perms  |
                    ++----+                                    +--------+
                         |                                          ^
      Process B fd table |                                          |
      +----+----+        |                                          |
      |  4 |----+--------+                                          |
      +----+----+                                                   |
                    ^                                                |
                    +------------------------------------------------+

      5.4 复制文件描述符:dup/dup2/F_DUPFD

      Whatdupdup2fcntl F_DUPFD 都创建新 fd 指向同一 open file description;区别在于目标 fd 的选择方式。

      Why:I/O 重定向(shell >file<file)、共享输出到多个 fd 都依赖 fd 复制。

      How

      调用 行为 返回值

      dup(oldfd)

      创建新 fd(最小未用)指向同一 open file description

      新 fd

      dup2(oldfd, newfd)

      若 newfd 已打开则先 close;然后 newfd 指向 oldfd 的 open file description;保证原子性

      newfd

      fcntl(oldfd, F_DUPFD, minfd)

      创建新 fd(≥ minfd 的最小未用)指向同一 open file description

      新 fd

      fcntl(oldfd, F_DUPFD_CLOEXEC, minfd)

      同上,并设置 close-on-exec

      新 fd

      关键洞见:

      • dup2(a, b) 是「原子」的——内核保证不会因信号中断而出现「newfd 已关闭但还没复制」的瞬态。

      • 复制后两个 fd 共享 offset 与 status flags(但 close-on-exec 是 fd 级别的)。

      • 关闭其中一个 fd 不会影响另一个——open file description 引用计数减 1,归零时才真正释放。

      When

      • shell 重定向——dup2(fd, STDOUT_FILENO) 后写 stdout 实际写到 fd。

      • 同时读/写同一文件——dup 出两个 fd,一个用于读,一个用于写。

      Example:shell 重定向 cmd > file 的典型实现:

      // 摘自《The Linux Programming Interface》第 5 章
      int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0644);
      if (fd == -1) errExit("open");
      if (dup2(fd, STDOUT_FILENO) == -1) errExit("dup2");
      if (close(fd) == -1) errExit("close");
      // 此后 printf("...") 写到 file

      5.5 pread/pwrite 与 readv/writev

      What

      • pread(fd, buf, count, offset) / pwrite(fd, buf, count, offset):在指定 offset 读写,不修改 fd 的当前 offset,不与其他 fd 共享 offset 操作。

      • readv(fd, iov, iovcnt) / writev(fd, iov, iovcnt):分散/聚集 I/O——单次系统调用读写多个非连续缓冲区。

      Whypread/pwrite 让多线程并发读同一文件不同段成为可能(避免 lseek+read 的 race);readv/writev 减少系统调用次数。

      How

      // 摘自《The Linux Programming Interface》第 5 章
      // pread:读 offset=1000 处的 100 字节,不影响 fd 当前 offset
      ssize_t n = pread(fd, buf, 100, 1000);
      
      // pwrite:写到 offset=2000 处,不影响 fd 当前 offset
      ssize_t n = pwrite(fd, buf, 100, 2000);
      
      // writev:单次发送 header + body
      struct iovec iov[2];
      iov[0].iov_base = header;
      iov[0].iov_len = header_len;
      iov[1].iov_base = body;
      iov[1].iov_len = body_len;
      ssize_t n = writev(fd, iov, 2);

      When

      • 多线程并发读同一文件——用 pread,各自带 offset。

      • 网络协议栈——用 writev 一次发送多个缓冲(如 HTTP 头 + 文件内容)。

      • 数据库实现——pwrite 写入特定 offset(避免共享 offset 的锁开销)。

      5.6 非阻塞 I/O

      WhatO_NONBLOCKopen 的 fd 在「数据未就绪」时 read/write 立即返回 -1 且 errno=EAGAIN(或 EWOULDBLOCK)。

      Why:非阻塞 I/O 是构建事件循环(event loop)、处理并发连接的基础——单线程可以同时监控多个 fd 而不阻塞。

      How

      // 摘自《The Linux Programming Interface》第 5 章
      // 设置非阻塞模式(用于已打开的 fd)
      int flags = fcntl(fd, F_GETFL);
      fcntl(fd, F_SETFL, flags | O_NONBLOCK);
      
      // 非阻塞读
      char buf[1024];
      ssize_t n = read(fd, buf, sizeof(buf));
      if (n == -1) {
          if (errno == EAGAIN) {
              // 数据尚未就绪——做其他事情或稍后重试
          } else {
              errExit("read");
          }
      }
      
      // 或在 open 时直接设置
      int fd = open(path, O_RDONLY | O_NONBLOCK);

      When

      • 网络服务器用 select/poll/epoll 监控多个 fd 时——所有 fd 必须是非阻塞。

      • 简单的「看是否有数据」轮询——避免阻塞。

      Example:典型非阻塞 + select 模式(伪代码):

      // 摘自《The Linux Programming Interface》第 5 章
      fd_set readfds;
      FD_ZERO(&readfds);
      FD_SET(fd, &readfds);
      struct timeval tv = {1, 0}; // 1 秒超时
      int ready = select(fd+1, &readfds, NULL, NULL, &tv);
      if (ready > 0 && FD_ISSET(fd, &readfds)) {
          ssize_t n = read(fd, buf, sizeof(buf)); // 现在不会阻塞
      }

      5.7 大文件支持与临时文件

      What

      • 大文件支持:默认 off_t 在 32 位系统上仅 32 位;编译时定义 _FILE_OFFSET_BITS=64 让所有函数使用 64 位 offset。

      • 临时文件:mkstemp(template) 创建唯一名称的文件并立即 open——安全且无竞态(优于 tmpnam)。

      Why:大文件支持让 32 位系统也能处理 >2GB 文件;mkstemp 是创建临时文件的安全方式。

      How

      // 摘自《The Linux Programming Interface》第 5 章
      // 大文件支持(在编译选项中定义)
      // gcc -D_FILE_OFFSET_BITS=64
      off_t pos = lseek(fd, 0, SEEK_END);
      printf("file size: %lld bytes\n", (long long) pos);
      
      // mkstemp 创建唯一临时文件
      char template[] = "/tmp/mytemp.XXXXXX";
      int fd = mkstemp(template);
      if (fd == -1) errExit("mkstemp");
      // template 现在包含实际文件名(如 /tmp/mytemp.aB3xY9)
      // fd 已经 open 该文件
      // 记得 close + unlink
      unlink(template);
      close(fd);

      When

      • 处理 >2GB 文件(特别是 32 位系统)——加 -D_FILE_OFFSET_BITS=64

      • 创建临时文件——永远用 mkstemp 而非 tmpnam/mktemp(后者有 race condition)。

      三、关键图表

      非可视化条目(三层模型与 fcntl 命令)
      项目 描述

      三层模型

      fd 表 → open file description 表 → i-node 表;fd 是进程级,open file desc 含 offset 是系统级,i-node 是文件系统级

      fcntl F_GETFL

      获取 open file status flags

      fcntl F_SETFL

      修改 open file status flags(O_APPEND/O_NONBLOCK/O_NOATIME/O_ASYNC/O_DIRECT)

      dup

      创建最小未用 fd 指向同一 open file description

      dup2

      精确指定目标 fd;原子

      fcntl F_DUPFD

      创建 ≥ arg 的最小未用 fd

      pread/pwrite

      在指定 offset 读写,不影响 fd 当前 offset

      readv/writev

      分散/聚集 I/O(多缓冲区)

      O_NONBLOCK

      非阻塞模式;read/write 返回 EAGAIN

      mkstemp

      安全创建唯一临时文件(避免竞态)

      _FILE_OFFSET_BITS=64

      启用 64 位 offset(大文件支持)

      四、思维导图

      mindmap
        root((第 5 章 文件 I O 进阶))
          原子性
            系统调用原子
            O_EXCL 原子创建
            O_APPEND 原子追加
            竞态条件
          fcntl
            F_GETFL F_SETFL
            F_GETFD F_SETFD
            F_DUPFD
            F_GETLK F_SETLK
          三层模型
            fd 表 进程级
            open file desc 系统级
            i-node 文件系统级
            共享 offset
          复制 fd
            dup
            dup2 原子
            F_DUPFD
            I O 重定向
          扩展 I O
            pread pwrite
            readv writev
            多线程安全
          非阻塞
            O_NONBLOCK
            EAGAIN
            select poll epoll
          大文件临时
            _FILE_OFFSET_BITS 64
            mkstemp
            tmpnam 避免

      五、重点与易错点

      1. 系统调用都是原子的:这是避免竞态的关键设计;不要假设「多步操作不会被打断」。

      2. 「lseek + write」不是原子的:多进程并发追加同一文件会丢数据;用 O_APPEND

      3. dup2 是原子的:内核保证 dup2(a, b) 不会因信号中断而出现「b 已关闭但还没复制」的状态。

      4. fd 复制共享 offset:dup 后两个 fd 共享同一 offset;这是「shell 重定向后 printf 写到 file」的原理。

      5. O_NONBLOCK 的 read 返回 -1 且 errno=EAGAIN:不是错误——是「数据未就绪」;程序应做其他事或重试。

      6. pread/pwrite 不修改 fd 当前 offset:适合多线程并发读同一文件不同段;不需要共享 offset 锁。

      7. EAGAIN == EWOULDBLOCK:POSIX 用 EAGAIN;历史系统用 EWOULDBLOCK;Linux 上两者值相同。

      8. close() 后 close-on-exec 标志失效:每个 fd 独立的标志。

      9. fork 后父子进程共享同一 fd 表项:共享 open file description;这就是「父进程打开的管道子进程能用」的原因。

      10. 跨章衔接:第 4 章是基础四大调用;本章是进阶(fcntl、dup、pread、readv、非阻塞);第 13 章详述缓冲(O_SYNC、fsync);第 47 章详述文件锁(fcntl F_SETLK);第 63 章详述 signal-driven I/O(O_ASYNC)。