跳转至

Read/Write 基础

是什么 / 解决什么问题

open/read/write/close 是文件 I/O 的基础系统调用。理解它们与 page cache 的交互关系是理解 Linux 文件性能的关键。

使用模式

基本文件读写

#include <fcntl.h>
#include <unistd.h>

// 打开文件
int fd = open("/path/to/file", O_RDONLY);
int fd = open("/path/to/file", O_WRONLY | O_CREAT | O_TRUNC, 0644);
int fd = open("/path/to/file", O_RDWR | O_APPEND);

// openat: 相对目录 fd 打开(更安全,避免 TOCTOU)
int fd = openat(dirfd, "filename", O_RDONLY);

// 读
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
// n > 0: 实际读取字节数
// n == 0: 到达文件末尾(EOF)
// n == -1: 错误

// 写
ssize_t n = write(fd, data, data_len);
// n >= 0: 实际写入字节数(可能 < data_len)
// n == -1: 错误

// 定位
off_t pos = lseek(fd, 0, SEEK_SET);   // 文件开头
off_t pos = lseek(fd, 0, SEEK_CUR);   // 当前位置(查询)
off_t pos = lseek(fd, 0, SEEK_END);   // 文件末尾
off_t pos = lseek(fd, -100, SEEK_END); // 从末尾倒退 100 字节

// 关闭
close(fd);

pread/pwrite(原子定位+读写)

// 在指定偏移量读写,不改变 fd 的当前位置
// 多线程共享 fd 时安全
ssize_t n = pread(fd, buf, size, offset);
ssize_t n = pwrite(fd, data, size, offset);

// 等价于 lseek + read/write,但是原子操作

readv/writev(散布/聚集 I/O)

struct iovec iov[3] = {
    { .iov_base = header, .iov_len = 16 },
    { .iov_base = body,   .iov_len = body_len },
    { .iov_base = footer, .iov_len = 4 },
};
// 一次 syscall 写入多个不连续缓冲区
ssize_t n = writev(fd, iov, 3);

// 同样支持 preadv/pwritev(指定偏移)
ssize_t n = preadv(fd, iov, 3, offset);

行为与语义

Page Cache 模型

应用程序
    │ read(fd, buf, 4096)
┌──────────┐
│ Page Cache│  ← 内核维护的文件数据缓存(内存中)
│ (内存页)   │
└──────────┘
    │ 缺页时触发磁盘读取
┌──────────┐
│ 块设备层  │  ← 实际磁盘 I/O
└──────────┘

read 的实际路径: 1. 检查目标数据是否在 page cache 中 2. Cache hit:内存拷贝到用户缓冲区,微秒级返回 3. Cache miss:发起磁盘 I/O,阻塞等待数据到达 page cache,再拷贝

write 的实际路径: 1. 数据拷贝到 page cache 中对应的页 2. 标记该页为 dirty(脏页) 3. 立即返回(不等磁盘写入) 4. 后台线程(pdflush/writeback)异步将脏页写回磁盘

write 返回 ≠ 数据落盘!
只是写到了内核 page cache。
断电 = 数据丢失(除非用 fsync/O_SYNC)。

Short read / Short write

read 和 write 可能返回比请求更少的字节数:

// 请求读 1MB,但只读到 4KB(文件剩余不足、信号中断等)
ssize_t n = read(fd, buf, 1048576);
// n 可能是 4096

// 正确做法:循环读完
size_t total = 0;
while (total < expected) {
    ssize_t n = read(fd, buf + total, expected - total);
    if (n <= 0) break;
    total += n;
}

普通文件的 write 几乎不会短写(除非磁盘满或被信号中断),但 socket/pipe 会。养成检查返回值的习惯。

O_APPEND 的原子性

int fd = open("log.txt", O_WRONLY | O_APPEND);
write(fd, msg, msg_len);
// 内核保证:lseek 到末尾 + write 是原子的
// 多进程并发 append 写同一文件不会交错

文件描述符与 file description 的区别

int fd1 = open("file", O_RDONLY);
int fd2 = dup(fd1);
// fd1 和 fd2 是不同的 fd(文件描述符)
// 但共享同一个 file description(内核 struct file)
// 共享偏移量!fd1 的 read 会影响 fd2 的位置

int fd3 = open("file", O_RDONLY);
// fd3 有独立的 file description,独立的偏移量

性能考量

读取性能

// 1. 对齐读取:按 4KB (page size) 对齐
//    避免跨页读取导致多次 page fault
char buf[4096] __attribute__((aligned(4096)));
pread(fd, buf, 4096, offset & ~4095);

// 2. 预读 (readahead):提前加载后续数据到 page cache
posix_fadvise(fd, offset, length, POSIX_FADV_WILLNEED);
// 或
readahead(fd, offset, count);

// 3. 顺序读提示
posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);
// 内核会增大预读窗口(默认 128KB,顺序可到 256KB+)

// 4. 随机读提示
posix_fadvise(fd, 0, 0, POSIX_FADV_RANDOM);
// 内核关闭预读(避免浪费 I/O)

写入性能

// 1. 批量写:减少 syscall 次数
// 差:每行一个 write
// 好:缓冲多行后一次 write

// 2. 预分配空间:避免文件系统碎片和元数据更新
fallocate(fd, 0, 0, file_size);

// 3. 控制脏页回写
// 系统级:
// /proc/sys/vm/dirty_ratio          = 20  (内存 20% 为脏页时阻塞写入)
// /proc/sys/vm/dirty_background_ratio = 10 (10% 时开始后台回写)

读取后不再需要的数据

// 告诉内核:这段数据不用缓存了
posix_fadvise(fd, offset, length, POSIX_FADV_DONTNEED);
// 内核尝试释放对应 page cache
// 适合:大文件顺序扫描,读完一段就释放

常见陷阱与 FAQ

1. write 返回成功 ≠ 持久化

write(fd, data, len);   // 只到 page cache
fsync(fd);              // 等待数据和元数据落盘
fdatasync(fd);          // 只等数据落盘(不含 mtime 等元数据)

数据库必须 fsync。日志文件通常不需要(可以容忍少量丢失)。

2. O_CREAT 忘记指定权限

// 错误:权限未定义(取决于栈上的垃圾值)
int fd = open("file", O_WRONLY | O_CREAT);

// 正确:第三个参数指定权限
int fd = open("file", O_WRONLY | O_CREAT | O_TRUNC, 0644);

3. 读到的内容与文件大小不匹配

文件可以有"空洞"(hole):

int fd = open("sparse", O_WRONLY | O_CREAT | O_TRUNC, 0644);
lseek(fd, 1000000, SEEK_SET);
write(fd, "x", 1);
// 文件逻辑大小 1000001 字节,但磁盘只占一个块
// read 空洞区域返回 \0

4. EINTR 处理

ssize_t safe_read(int fd, void *buf, size_t count) {
    ssize_t n;
    do {
        n = read(fd, buf, count);
    } while (n == -1 && errno == EINTR);
    return n;
}

观测与调试

# 查看 page cache 使用情况
free -h  # buffers/cache 列

# 查看文件是否在 page cache 中
vmtouch /path/to/file
# 输出:Pages in cache: 1024/1024 (100%)

# 查看进程的文件 I/O 统计
cat /proc/<pid>/io
# rchar: 总读取字节
# wchar: 总写入字节
# syscr: read syscall 次数
# syscw: write syscall 次数
# read_bytes: 实际磁盘读取
# write_bytes: 实际磁盘写入

# strace 看 I/O 模式
strace -e trace=read,write,openat -T ./app

# 观察 page cache 命中率
perf stat -e cache-references,cache-misses ./app

延伸阅读

  • man 2 open / man 2 read / man 2 write
  • man 2 pread / man 2 readv
  • man 2 posix_fadvise — 预读控制
  • man 2 fallocate — 预分配