文件IO与系统调用
# 17.文件IO与系统调用
文件描述符本质是内核打开文件表索引、
open/read/write/lseek/close系统调用的用户态↔内核态切换代价、标准 IOFILE*的 4096 字节用户态缓冲区、setvbuf控制缓冲策略(全缓冲/行缓冲/无缓冲)、mmap文件映射零拷贝原理、msync刷盘、VFS 虚拟文件系统抽象层
# 目录
# 1. 案例引入
# 1.1 日志拖垮服务
某电商后台服务的 QPS 在促销期间从 2000 掉到了 200。运维检查发现 CPU 使用率 95%——但不是业务逻辑在吃 CPU,而是 内核态 CPU 占比高达 80%。
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
4 0 0 12345 89012 45678 0 0 0 500 12000 34000 5 80 5 10 0
↑ sy=80%
2
3
4
5
用 strace 抓了一下主进程正在做什么:
$ strace -c -p $PID
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.50 12.345678 35 352000 write
0.30 0.037212 12 3090 read
0.10 0.012405 10 1234 openat
... (其他)
2
3
4
5
6
7
现象:write 系统调用被调用了 35 万次/秒,每次平均 35 微秒——总耗时 12 秒(在采样周期内)。问题代码很简单:
// logger.c —— 日志模块
void log_message(const char* msg) {
int fd = open("/var/log/app.log", O_WRONLY | O_APPEND | O_CREAT, 0644);
if (fd >= 0) {
char buf[256];
int len = snprintf(buf, sizeof(buf), "[%ld] %s\n", time(NULL), msg);
write(fd, buf, len); // ← 每行日志调一次 write
close(fd); // ← 写完马上关
}
}
2
3
4
5
6
7
8
9
10
四个问题叠加:
- 每条日志
open+write+close——每次 open 都要走完整的文件系统路径查找 write是系统调用——每次从用户态陷入内核态再返回- 写的是几十字节的小数据——系统调用开销远大于实际数据传输
close后下次open又要重新分配 fd、重新定位—文件描述符被反复创建销毁
# 1.2 顺藤摸到根因
追查这条日志代码的完整根因链:
- 假设 1:是不是磁盘慢?——
iostat看磁盘 IO 延迟正常(~2ms),瓶颈不在磁盘。 - 假设 2:是不是
write调用太多?—— 对,每秒 35 万次write。但为什么每行日志都 open/close?因为代码里就是这么写的。 - 假设 3:能不能先把日志攒一批再写?—— 可以用
fprintf+FILE*缓冲(4096 字节攒满了才调一次write)。 - 假设 4:用
fopen/fprintf替代open/write后,write调用次数从 35 万降到 800——CPU sy 从 80% 降到 3%。 - 假设 5:为什么差距这么大?—— 用户态缓冲区(FILE 的 buffer)* 把成千上万次小
write合并成少数几次大write。系统调用的开销是固定成本(~1µs 级别),不受数据量影响。
这个 bug 的根因是 不了解系统调用(write)和库函数(fwrite)的区别——前者每次调用陷入内核,后者在用户态缓存到一定量才调用一次前者。
这个事故里藏着至少 8 个原理点:
① 文件描述符 fd 到底是个什么?为什么是整数? → 第 3 章
② write 系统调用经过了多少步才到磁盘? → 第 4 章
③ 为什么系统调用开销这么大? → 第 4.2 节
④ FILE* 的缓冲区在哪?fwrite 怎么减少系统调用次数? → 第 5 章
⑤ 全缓冲/行缓冲/无缓冲有什么区别?什么时候用哪种? → 第 5.2 节
⑥ mmap 为什么比 read+write 快?零拷贝是什么意思? → 第 6 章
⑦ Linux 怎么做到"一切皆文件"?socket 也有 fd? → 第 7 章
⑧ 文件数据什么时候真正写到磁盘?fflush 够吗? → 第 5.4/6.4 节
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这起事故是文件 IO 性能问题的典型缩影——频繁的小 write 系统调用比逻辑计算更吃 CPU。它折射出 C 语言文件 IO 的两层 API 设计哲学:底层 open/write(系统调用,精确控制但代价高),上层 fopen/fwrite(库函数,带缓冲以减少系统调用)。
本篇路线:
架构总图 (第 2 章)
↓
文件描述符本质 (第 3 章) ─→ 解开"fd 只是一个 int,背后有什么"
↓
系统调用切换代价 (第 4 章) ─→ 解开"一次 write 到底花了多少 CPU"
↓
标准IO缓冲 (第 5 章) ─→ 解开"fwrite 怎么把 100 次 write 变成 1 次"
↓
mmap 零拷贝 (第 6 章) ─→ 解开"怎么跳过用户态缓冲区直接操作文件"
↓
VFS 抽象层 (第 7 章) ─→ 解开"为什么 socket/pipe/文件都是 fd"
↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:前 16 篇讲了代码怎么写、怎么编译、怎么构建——本篇开始进入运行时:代码执行时怎么和内核交互。文件 IO 是 C 程序与"外部世界"交互的最基本通道,系统调用是这条通道的收费站——理解它的代价是写出高性能 C 程序的前提。
# 2. 架构概览
# 2.1 文件IO的两层API
C 语言有两套文件 IO API,它们在设计哲学上有根本差异:
┌─────────────────────────────────────────────────────────────────┐
│ 标准 IO (Standard I/O) │
│ │
│ fopen / fclose / fread / fwrite / fprintf / fscanf │
│ FILE* 指针 │
│ 带用户态缓冲区 (默认 4096 字节) │
│ 可移植: 跨平台统一接口 │
│ 性能: 减少系统调用次数 (批量操作) │
│ │
│ ───────────────────── 底层调用 ──────────────────────► │
│ │
│ 系统 IO (POSIX I/O) │
│ │
│ open / close / read / write / lseek / fsync │
│ int fd (文件描述符) │
│ 无用户态缓冲区 │
│ 平台相关: POSIX 标准 (Linux/Unix/macOS) │
│ 性能: 每次调用进入内核 (无批量) │
│ │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
快速对照:
| 维度 | 标准 IO (FILE*) | 系统 IO (fd) |
|---|---|---|
| 头文件 | <stdio.h> | <fcntl.h>, <unistd.h> |
| 句柄类型 | FILE* 指针 | int 文件描述符 |
| 缓冲区 | 用户态,可配置 | 无(直接传给内核) |
| 系统调用频率 | 低(攒满缓存才调) | 每次调用都系统调用 |
| 适用场景 | 普通文本/日志 | 网络/管道/精确控制 |
| 格式化 | 原生支持 (fprintf) | 需要 snprintf + write |
| 线程安全 | 有锁 (flockfile) | 无(内核层面处理) |
# 2.2 用户态到磁盘
一次 write(fd, buf, 100) 经历的完整旅程:
用户程序: write(fd, buf, 100)
│
├─ 1. glibc 的 write() 包装函数
│ └─ 把参数放入寄存器 (rdi=fd, rsi=buf, rdx=100, rax=1)
│ syscall 指令 → 陷入内核
│
├─ 2. 内核态: sys_write() 入口
│ └─ 从寄存器取出参数
│ 通过 fd → 找到 struct file
│ → 找到 struct inode
│ → 找到 address_space
│
├─ 3. VFS 层: vfs_write()
│ └─ 检查权限 (文件是否可写?)
│ 调用文件系统特定的 write 实现 (ext4_file_write)
│
├─ 4. 文件系统层: ext4_file_write()
│ └─ 计算写入位置 (文件偏移 + 写入长度)
│ 调用 generic_file_write_iter()
│
├─ 5. 页缓存层 (Page Cache):
│ └─ 把 buf 的数据拷贝到内核页缓存 (kernel page cache)
│ 标记该页为 dirty
│ ← 这里 write() 系统调用就返回了!
│
├─ 6. (异步) 回写线程: pdflush / kworker
│ └─ 定期扫描 dirty 页
│ 调用块设备驱动 → 写入磁盘
│
└─ 7. 磁盘: 磁头移动到对应扇区 → 写入
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
关键认知:write 返回 >0 不代表数据已落盘——数据可能只写到了内核页缓存(Page Cache)。必须 fsync(fd) 才能保证数据持久化到磁盘。
# 3. 文件描述符本质
# 3.1 内核三结构
疑惑:int fd = open("file.txt", O_RDONLY); —— 这个 fd 只是一个整数(通常是 3、4、5...),它怎么就能代表"一个打开的文件"?
论证——内核用三个数据结构串联成一个完整的关系图:
进程 A 内核全局 文件系统
──────── ──────── ────────
files_struct file 表 (全局) inode
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ fd_array[] │ │ struct file │ │ struct inode │
│ │ │ ──────────── │ │ ─────────────│
│ [0] ────────┼──────────► │ f_count: 2 │ │ i_ino: 42 │
│ [1] ────────┼──► stdout │ f_pos: 0 │──────┐ │ i_size: 1024 │
│ [2] ────────┼──► stderr │ f_flags: O_RD│ │ │ i_mode: 0644 │
│ [3] ────────┼──► │ f_inode ─────┼───┐ │ │ ... │
│ [4] ────────┼──► pipe │ f_op ────────┼─┐ │ │ └──────────────┘
│ ... │ └──────────────┘ │ │ │
└─────────────┘ │ │ │
↑ │ │ │
fd = 3 是一个索引 │ │ │ ┌──────────────┐
(files_struct.fd_array[3]) │ │ │ │ address_space│
│ │ │ │ ─────────────│
│ │ └────►│ page tree │
│ │ │ (管理页缓存) │
│ │ └──────────────┘
│ │
│ └────────► dentry
│ ┌──────────────┐
│ │ struct dentry│
│ │ d_name:"txt" │
│ │ d_inode ─────┼──► inode
│ └──────────────┘
│
└──────────► file_operations
┌──────────────┐
│ .read = ... │
│ .write = ... │
│ .llseek= ... │
└──────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
三层结构:
| 层级 | 数据结构 | 作用域 | 关键字段 |
|---|---|---|---|
| 进程级 | files_struct | 每进程一份 | fd_array[](fd→file 映射表) |
| 内核全局 | struct file | 每次 open 创建一个 | f_pos(文件偏移)、f_inode、f_op |
| 文件系统级 | struct inode | 每个文件一个 | i_ino(inode号)、i_size、address_space |
关键洞察:
- 同一个文件被两次
open→ 两个struct file,但同一个struct inode fork后父子进程的struct file是共享的(引用计数f_count递增)——所以父子进程共享文件偏移- fd 只是一个数字索引——它本身没有"文件"的含义,含义全在内核的数据结构中
# 3.2 fork 后的文件描述符共享
int fd = open("log.txt", O_WRONLY | O_APPEND);
pid_t pid = fork();
if (pid == 0) {
// 子进程
write(fd, "child\n", 6); // 父进程打开的 fd,子进程也能用!
close(fd);
} else {
// 父进程
write(fd, "parent\n", 7);
close(fd);
}
// 输出: (取决于调度顺序)
// parent
// child
// 或者: child\nparent
// 两者共享 struct file → 共享文件偏移 → 不会互相覆盖
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键:fork 后,子进程的 fd_array 拷贝自父进程,但指向的是同一个 struct file(f_count 从 1 变成 2)。只有当父子都 close 后,struct file 才被释放。
# 3.3 fd限制泄漏
# 每个进程的文件描述符上限
$ ulimit -n
1024 # 默认 1024
# 查看当前进程打开了多少 fd
$ ls /proc/self/fd | wc -l
$ lsof -p $PID | wc -l
2
3
4
5
6
7
fd 泄漏是最常见的内存泄漏兄弟:
// ❌ fd 泄漏——每次打开后忘记关闭
void process_file(const char* name) {
int fd = open(name, O_RDONLY);
if (fd < 0) return;
char buf[4096];
read(fd, buf, sizeof(buf));
// 忘记 close(fd)!→ fd 永远不回收
// 调用 1024 次后 → "Too many open files"
}
2
3
4
5
6
7
8
9
Linux 的 fd 分配策略——总是用最小的可用整数:
close(0); // 关闭 stdin
int fd = open("file.txt", O_RDONLY); // fd = 0 (最小的可用整数!)
// 现在 fd 0 指向 file.txt,而不是 stdin——这被利用在重定向中
2
3
# 3.4 dup2 与重定向原理
Shell 重定向 command > output.txt 的本质就是 dup2:
// Shell 执行 "ls > output.txt" 的等价代码
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); // 让 fd 1 (stdout) 指向 fd 所指的 struct file
close(fd); // fd 这个"临时"描述符可以关了
execlp("ls", "ls", NULL); // ls 的 stdout 现在写到了 output.txt
2
3
4
5
dup2 的内部操作:
dup2(fd, 1):
1. 关闭 fd 1 原来指向的 struct file (f_count--)
2. fd_array[1] 指向 fd_array[fd] 指向的 struct file (f_count++)
3. 现在 fd 和 1 都指向同一个 struct file → 共享文件偏移
2
3
4
# 4. 系统调用的切换代价
# 4.1 syscall 指令到底做了什么
疑惑:为什么 write(fd, buf, 100) 这个"函数调用"比 memcpy(dst, src, 100) 慢几百倍?
论证——memcpy 是纯用户态操作(几条 mov 指令),write 是系统调用,需要用户态↔内核态切换:
用户态 内核态
────── ──────
write(fd, buf, 100):
│
├─ 1. glibc 包装:
│ 把参数写入寄存器:
│ rax = 1 (write 的系统调用号)
│ rdi = fd
│ rsi = buf
│ rdx = 100
│
├─ 2. syscall 指令:
│ ┌─ CPU 从 Ring 3 (用户态) 切换到 Ring 0 (内核态) ← 权限切换
│ │─ 保存用户态寄存器上下文 (RCX←RIP, R11←RFLAGS) ← 现场保存
│ │─ 跳转到 IA32_LSTAR MSR 指定的内核入口 ← 地址空间切换
│ │─ 切换到内核栈 ← 栈切换
│ │
│ ├─ 内核: sys_write() 入口
│ │ ├─ 从寄存器恢复参数
│ │ ├─ 通过 fd → struct file (内核数据结构)
│ │ ├─ 安全检查 (权限、地址合法性)
│ │ ├─ vfs_write → ext4_file_write
│ │ ├─ 拷贝数据到 page cache
│ │ └─ 更新文件偏移 f_pos
│ │
│ ├─ 内核准备返回:
│ │ ├─ 返回值放入 rax
│ │ └─ sysretq 指令
│ │
│ └─ 返回到用户态:
│ ├─ CPU 从 Ring 0 回到 Ring 3
│ ├─ 恢复用户态寄存器 (RIP←RCX, RFLAGS←R11)
│ └─ 继续执行 write 之后的指令
│
└─ 3. 返回值在 rax 中,返回给调用者
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 4.2 量化一次 read 的开销
用 perf 精确测量:
// bench_syscall.c
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("/dev/zero", O_RDONLY); // /dev/zero 不涉及磁盘
char buf[1];
for (int i = 0; i < 10000000; i++) {
read(fd, buf, 1); // 每次只读 1 字节,十万次系统调用
}
close(fd);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
$ gcc -O2 bench_syscall.c -o bench
$ perf stat ./bench
Performance counter stats for './bench':
1,234.56 msec task-clock # 1.000 CPUs utilized
10,000,000 syscalls:sys_enter_read # 一千万次系统调用
2,345 page-faults
45,678,901 cycles
12,345,678 instructions
# 平均每次 read: 1234ms / 10M = 123ns
# 纯用户态 memcpy: 1 字节 ≈ 1ns
# → 系统调用比用户态操作慢 ~100 倍
2
3
4
5
6
7
8
9
10
11
12
13
系统调用的开销分解:
| 开销来源 | 大约耗时 | 说明 |
|---|---|---|
| 寄存器保存/恢复 | ~20 ns | push/pop 现场 |
| Ring 3→0 切换 | ~30 ns | 权限级别切换 |
| 内核栈切换 | ~10 ns | 加载内核栈指针 |
| 内核函数调用 | ~30 ns | sys_write 的 C 函数开销 |
| 安全检查 + 数据处理 | 可变 | 取决于具体操作 |
| 返回用户态 | ~30 ns | 恢复现场 + 切换 |
总结:纯系统调用框架开销约 100-150 ns,再加上具体操作的耗时。
# 4.3 大小读写拐点
// 测试: 读 1MB 数据, 不同块大小的吞吐量
for (int blk = 1; blk <= 65536; blk *= 2) {
char* buf = malloc(blk);
int fd = open("testfile", O_RDONLY);
double start = now();
for (int i = 0; i < 1024*1024/blk; i++)
read(fd, buf, blk);
double elapsed = now() - start;
printf("blk=%6d: %.2f MB/s\n", blk, 1.0/elapsed);
}
2
3
4
5
6
7
8
9
10
实验结果:
blk= 1: 12 MB/s ← 一百多万次系统调用!
blk= 8: 85 MB/s
blk= 64: 380 MB/s
blk= 512: 890 MB/s
blk= 4096: 2400 MB/s ← page size 对齐
blk= 16384: 2500 MB/s
blk= 65536: 2550 MB/s ← 渐近线
2
3
4
5
6
7
8
结论:读写块大小 < 4KB(一个 page)时,系统调用开销主导总时间;> 4KB 后,数据传输时间主导。推荐最小读写 4096 字节(对齐 page size)。
# 4.4 用户态缓冲
标准 IO 的 FILE* 就是为了解决"小读写频繁系统调用"的问题而设计的:
没有缓冲 (裸 write):
100 次 write(fd, buf, 1)
→ 100 次系统调用 × 150ns = 15,000ns 固定开销 + 100字节数据传输
有缓冲 (fwrite):
100 次 fwrite(buf, 1, 1, fp) → 数据攒在 FILE* 的 4096 字节缓冲区
→ 缓冲区满 → 1 次 write(fd, buf, 4096)
→ 1 次系统调用 × 150ns = 150ns 固定开销 + 4096字节数据传输
→ 快了 ~100 倍
2
3
4
5
6
7
8
9
这就是第 1 章案例的性能根因——每秒 35 万次 write 中的 80% 时间都花在系统调用的框架开销上,而不是实际写数据。
# 5. 标准IO与缓冲机制
# 5.1 FILE* 结构体剖解
glibc 中 FILE (即 _IO_FILE) 的核心字段(简化):
// glibc: libio/bits/types/struct_FILE.h (简化)
struct _IO_FILE {
int _flags; // 读写模式标志 (_IO_UNBUFFERED, _IO_LINE_BUF...)
char* _IO_read_ptr; // 读缓冲区当前指针
char* _IO_read_end; // 读缓冲区末尾
char* _IO_read_base; // 读缓冲区起始
char* _IO_write_base; // 写缓冲区起始
char* _IO_write_ptr; // 写缓冲区当前指针
char* _IO_write_end; // 写缓冲区末尾
char* _IO_buf_base; // 缓冲区的起始地址 (malloc 分配的)
char* _IO_buf_end; // 缓冲区的末尾地址
int _fileno; // 底层文件描述符 fd
// ... 锁、宽字符支持等 ...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
FILE 缓冲区的内存布局*:
_IO_buf_base _IO_buf_end
│ │
▼ ▼
┌────────────────────────────────┐
│ 已用 │ 空 │
└────────────────────────────────┘
▲ ▲
│ │
_IO_write_base _IO_write_ptr
缓冲区大小 = _IO_buf_end - _IO_buf_base (默认 4096 字节)
已用数据 = _IO_write_ptr - _IO_write_base
可用空间 = _IO_buf_end - _IO_write_ptr
2
3
4
5
6
7
8
9
10
11
12
13
当 _IO_write_ptr == _IO_buf_end 时 → 缓冲区满 → 触发 write 系统调用 → 清空缓冲区。
# 5.2 三种缓冲策略
setvbuf 可以控制标准 IO 的缓冲行为:
int setvbuf(FILE* stream, char* buf, int mode, size_t size);
三种模式:
// 1. _IOFBF (全缓冲) —— 默认对文件
// 缓冲区满时才 write
FILE* fp = fopen("file.txt", "w");
setvbuf(fp, NULL, _IOFBF, 8192); // 8KB 全缓冲
fprintf(fp, "hello"); // ← 只写入缓冲区,不调用 write
fprintf(fp, "world"); // ← 同上
// ... 直到缓冲区满 → 一次 write(fp->fd, buf, 8192)
// 2. _IOLBF (行缓冲) —— 默认对终端 (stdout)
// 遇到 \n 就 flush
fprintf(stdout, "hello"); // ← 只写入缓冲区
fprintf(stdout, "\n"); // ← \n 触发 flush → write(1, ...)
// 3. _IONBF (无缓冲) —— 默认对 stderr
// 每次 fprintf 都立即 write
fprintf(stderr, "error!"); // ← 立即 write(2, ...)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
默认策略:
| 流 | fopen 文件 | stdin (终端) | stdout (终端) | stderr |
|---|---|---|---|---|
| 默认缓冲 | 全缓冲 (4096) | 行缓冲 | 行缓冲 | 无缓冲 |
| 重定向到文件后 | 全缓冲 (4096) | 全缓冲 (4096) | 全缓冲 (4096) | 无缓冲 |
关键陷阱:stdout 重定向到文件后,行缓冲变成全缓冲——输出可能不会立即出现:
$ ./app > output.txt # stdout 变成全缓冲!
# 程序中的 printf("progress...\n") 不会立即出现
# 只有缓冲区满或程序 exit 时才写入
2
3
# 5.3 fwrite 的内部分步流程
// fwrite("hello", 1, 5, fp) 的内部流程 (简化)
size_t fwrite(const void* ptr, size_t size, size_t nmemb, FILE* fp) {
size_t total = size * nmemb;
// 1. 如果数据量 ≤ 缓冲区剩余空间 → 直接拷贝到缓冲区
if (total <= fp->_IO_write_end - fp->_IO_write_ptr) {
memcpy(fp->_IO_write_ptr, ptr, total);
fp->_IO_write_ptr += total;
return nmemb;
}
// 2. 如果缓冲区有旧数据 → 先 flush 旧的
if (fp->_IO_write_ptr > fp->_IO_write_base) {
_IO_do_flush(fp); // 内部调 write(fd, buf, len)
}
// 3. 如果数据量 > 缓冲区大小 → 直接 write,不经过缓冲区
if (total >= fp->_IO_buf_end - fp->_IO_buf_base) {
write(fp->_fileno, ptr, total); // 直接系统调用
return nmemb;
}
// 4. 拷贝到空缓冲区
memcpy(fp->_IO_write_base, ptr, total);
fp->_IO_write_ptr = fp->_IO_write_base + total;
return nmemb;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
关键:大块 fwrite(> 缓冲区大小)会绕过缓冲区直接走 write——所以 fwrite 不会比 write 慢。
# 5.4 fflush 与数据落盘的关系
疑惑:fflush(fp) 后数据在磁盘上了吗?
论证——不保证:
// write 系统调用 → 数据在 page cache,不在磁盘
// fsync(fd) → page cache 刷到磁盘
// fflush(fp) → FILE* 缓冲区刷到 page cache (调用了 write)
// fclose(fp) → fflush + close
// 完整落盘的正确姿势:
fprintf(fp, "hello\n");
fflush(fp); // 1. 用户态→内核 page cache
fsync(fileno(fp)); // 2. page cache→磁盘
2
3
4
5
6
7
8
9
数据在不同层的状态:
用户程序 FILE* 缓冲区 内核 Page Cache 磁盘
─────── ──────────── ────────────── ────
fprintf(fp, ...) → [hello\n ] (空) (旧)
fwrite(buf,...) → [hello\n world! ] (空) (旧)
fflush(fp) → [ ] → [hello\n world! ] (旧)
fsync(fd) → [ ] [hello\n world! ] → [hello\n world!]
2
3
4
5
6
fflush 只是用户态→内核的桥梁,fsync 才是内核→磁盘的保证。
# 6. mmap 零拷贝原理
# 6.1 read+write 两次拷贝的浪费
经典的文件复制代码:
// cp 的核心逻辑
char buf[4096];
ssize_t n;
while ((n = read(src_fd, buf, sizeof(buf))) > 0) {
write(dst_fd, buf, n);
}
2
3
4
5
6
数据经历的 4 次拷贝:
磁盘 ──DMA──► 内核 page cache (缓冲区) ← 拷贝1: DMA
│
├─ read() ──► 用户态 buf ← 拷贝2: CPU (内核→用户)
│
├─ write() ◄── 用户态 buf ← 拷贝3: CPU (用户→内核)
│
▼
内核 page cache (写缓存) ← 拷贝4: DMA
磁盘 ◄──DMA──
2
3
4
5
6
7
8
9
**4 次拷贝中,2 次在用户态和内核态之间来回搬数据——纯浪费。**因为用户态 buf 只做了个中转站,没有任何实质性的数据处理。
# 6.2 mmap 怎么减少一次拷贝
// mmap 版文件复制
struct stat st;
fstat(src_fd, &st);
void* src_map = mmap(NULL, st.st_size, PROT_READ,
MAP_PRIVATE, src_fd, 0);
// 文件内容直接映射到用户态虚拟地址!
// 没有 read() → 没有"内核→用户"的拷贝
int dst_fd = open("output.dat", O_RDWR | O_CREAT | O_TRUNC, 0644);
ftruncate(dst_fd, st.st_size);
void* dst_map = mmap(NULL, st.st_size, PROT_WRITE,
MAP_SHARED, dst_fd, 0);
memcpy(dst_map, src_map, st.st_size); // 只有一次 CPU 拷贝!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mmap 的数据流:
磁盘 ──DMA──► 内核 page cache (物理页) ← 拷贝1: DMA
│
├── 页表映射 ──► 用户态虚拟地址 (同一物理页!)
│ ▲
│ │ 没有拷贝! 只是页表条目指向同一个物理页
│ │
├─ memcpy() ──► 另一个 page cache 页 ← 拷贝2: CPU (内核→内核)
│ │
│ ├── 页表映射 ──► dst_map 用户态地址
│ │
磁盘 ◄──DMA────── ▼ ← 拷贝3: DMA
2
3
4
5
6
7
8
9
10
11
从 4 次变 3 次——省掉了"内核→用户→内核"的来回搬运。
# 6.3 mmap缺页联动
void* map = mmap(NULL, 1_GB, PROT_READ, MAP_PRIVATE, fd, 0);
// 1GB 映射 — 不会立刻占用 1GB 物理内存!
2
mmap 的工作方式:
- mmap 调用:只分配虚拟地址空间,不分配物理内存(类似于
malloc) - 第一次访问某页:缺页中断(page fault)→ 内核分配物理页,从文件读数据
- 后续访问:直接通过 MMU 访问,零开销
缺页的顺序访问模式:
char* map = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
for (off_t i = 0; i < size; i += 4096) {
char c = map[i]; // 第一次访问 → 缺页 → 读取文件 → 映射物理页
// 后续同页访问 → 零开销
}
2
3
4
5
mmap + madvise 预读优化:
madvise(map, size, MADV_SEQUENTIAL); // 告诉内核"我会顺序读"
// → 内核会自动预读后续页,减少缺页中断
2
# 6.4 msync 与数据持久化保证
mmap 写入后数据在 page cache 中,需要 msync 才刷到磁盘:
void* map = mmap(NULL, size, PROT_WRITE, MAP_SHARED, fd, 0);
memset(map, 0xFF, size); // 写入 → 只改 page cache (dirty)
msync(map, size, MS_SYNC); // 同步刷盘 (阻塞直到完成)
// 或
msync(map, size, MS_ASYNC); // 异步刷盘 (发起请求后立即返回)
// 或
munmap(map, size); // 解除映射,自动刷盘 (但不保证!建议显式 msync)
2
3
4
5
6
7
8
mmap vs read/write 性能拐点:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 顺序读大文件 | mmap | 减少一次拷贝 + 内核自动预读 |
| 随机读小文件 | read | mmap 的页表开销 > 拷贝开销 |
| 写后即读 | mmap | 数据已在 page cache,避免 read 系统调用 |
| 多进程共享 | mmap + MAP_SHARED | 天然共享物理页 |
# 7. VFS 抽象层
# 7.1 一切皆文件
Unix 的设计哲学之一:一切皆文件。这意味着——
// 这些操作都返回 int fd
int fd1 = open("data.txt", O_RDONLY); // 普通文件
int fd2 = socket(AF_INET, SOCK_STREAM, 0); // 网络 socket
int fd3 = open("/dev/sda", O_RDONLY); // 块设备
int fd4 = eventfd(0, 0); // 事件通知
int fd5 = timerfd_create(CLOCK_MONOTONIC, 0); // 定时器
// 这些函数对所有 fd 都适用
read(fd1, buf, n); // ✅
read(fd2, buf, n); // ✅ socket 也能 read
read(fd3, buf, n); // ✅ 磁盘也能 read
write(fd4, &val, 8); // ✅ eventfd 也能 write
2
3
4
5
6
7
8
9
10
11
12
这一设计的基础就是 VFS(Virtual File System,虚拟文件系统)——一个内核抽象层,把"文件"、"socket"、"设备"、"管道"都统一成同一套接口。
# 7.2 VFS 四大对象模型
VFS 定义了四个核心对象类型:
┌─────────────────────────────────────────────────────────────────────────┐
│ VFS 四大对象 │
│ │
│ 1. super_block (超级块) │
│ ─ 每个挂载的文件系统一个 │
│ ─ 存储: 文件系统类型、块大小、设备信息 │
│ ─ 方法: s_op→alloc_inode, s_op→write_super │
│ │
│ 2. inode (索引节点) │
│ ─ 每个文件一个 (不管文件开了多少次) │
│ ─ 存储: 文件大小、权限、时间戳、数据块位置 │
│ ─ 方法: i_op→create, i_op→lookup, i_fop (指向 file_operations) │
│ │
│ 3. dentry (目录项) │
│ ─ 每个路径分量一个 (如 /home/user/file → /, home, user, file) │
│ ─ 存储: 名字、父目录、关联的 inode │
│ ─ 作用: 加速路径查找 (dentry cache / dcache) │
│ │
│ 4. file (打开文件) │
│ ─ 每次 open 创建一个 │
│ ─ 存储: 文件偏移 (f_pos)、打开标志 (f_flags) │
│ ─ 方法: f_op→read, f_op→write, f_op→llseek │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
VFS 的多态实现——file_operations:
// 内核中 struct file_operations 的定义 (简化)
struct file_operations {
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
// ...
};
2
3
4
5
6
7
8
不同文件系统实现各自的 f_op:
用户程序: read(fd, buf, 100)
│
▼
VFS: vfs_read()
│
├─ 普通文件 (ext4) → ext4_file_read() → 读磁盘
├─ socket → sock_read() → 读网络数据
├─ 管道 (pipe) → pipe_read() → 读环形缓冲区
├─ /proc → proc_read() → 动态生成文本
└─ 设备 → device_read() → 读硬件
2
3
4
5
6
7
8
9
10
# 7.3 从 open 看 VFS 如何派发到 ext4
以 open("/home/user/data.txt", O_RDONLY) 为例,完整路径:
1. 用户态: open("/home/user/data.txt", O_RDONLY)
→ syscall → 内核 sys_openat()
2. VFS: do_sys_openat2()
→ getname() : 把用户态路径字符串拷贝到内核空间
→ get_unused_fd_flags() : 分配一个未使用的 fd (如 3)
3. VFS: do_filp_open()
→ path_openat()
路径分量遍历: "/" → "home" → "user" → "data.txt"
├─ "/": 根目录 dentry (进程的 fs->root)
├─ "home": walk_component() → 查 dcache 缓存 → 没命中 → ext4_lookup()
│ → 读磁盘 inode → 新建 dentry
├─ "user": 同上
└─ "data.txt": 同上
4. VFS: do_dentry_open()
→ 创建 struct file
→ file->f_op = inode->i_fop (指向 ext4_file_operations)
→ 调用 f_op->open() = ext4_file_open()
→ 把 fd 3 绑定到 struct file
5. 返回用户态: fd = 3
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 7.4 proc/sys原理
$ ls /proc/self/fd
0 1 2 # stdin, stdout, stderr
$ cat /proc/cpuinfo # 看起来是"读文件",其实内核动态生成的!
$ cat /sys/class/net/eth0/address # 读网卡MAC地址,也是内核动态生成
2
3
4
5
procfs 和 sysfs 都不是真正的磁盘文件系统——它们实现了一套 file_operations,当用户 read 时,内核动态生成内容而不是从磁盘读取:
// 内核中 /proc/cpuinfo 的 file_operations (简化)
const struct file_operations proc_cpuinfo_operations = {
.open = cpuinfo_open,
.read = seq_read, // ← seq_read 动态生成多行文本
.llseek = seq_lseek,
.release = seq_release,
};
// 当 cat /proc/cpuinfo 时:
// read → seq_read → cpuinfo 的 show 函数
// → 打印 CPU model name / cores / cache size ...
// → 这些信息来自内核数据结构,不是磁盘文件
2
3
4
5
6
7
8
9
10
11
12
VFS 的伟大之处:用户态程序不用关心文件系统类型——cat /proc/cpuinfo 和 cat /etc/hostname 用完全相同的 open+read+close API,底层却走了完全不同的代码路径。
# 8. 综合案例串讲
# 8.1 案例真相揭晓
回到第 1 章日志系统拖垮服务的八个疑问,逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 文件描述符 fd 是什么? | 第 3.1:进程 fd_array 的索引 → struct file → struct inode 的链条 |
| ② write 到磁盘经过多少步? | 第 4.1:syscall 切换→VFS→文件系统→page cache→异步写回 |
| ③ 系统调用开销为什么大? | 第 4.2:Ring3→Ring0 切换+寄存器保存+栈切换 ~150ns |
| ④ FILE* 缓冲区怎么减少调用? | 第 5.3:攒到 4096 字节才调一次 write |
| ⑤ 三种缓冲策略区别? | 第 5.2:全缓冲(文件)、行缓冲(终端)、无缓冲(stderr) |
| ⑥ mmap 为什么快? | 第 6.2:省去内核↔用户态的一次数据拷贝 |
| ⑦ 一切皆文件怎么做到的? | 第 7 章:VFS 通过 file_operations 多态统一接口 |
| ⑧ fflush 后数据在磁盘了吗? | 第 5.4:不保证——只到 page cache,要磁盘需 fsync |
第 1 章案例的完整根因链条:
每条日志: open(路径查找) + write(系统调用) + close(释放 fd)
× 35万次/秒
↓
系统调用框架开销 ~150ns × 35万 ≈ 52ms/秒
但实际上还有路径查找、fd分配、权限检查... → 总 CPU sy = 80%
↓
修复: fopen 一次 + fprintf 缓冲 + fclose 一次
→ 35万次 write → 800次 write (缓冲合并)
→ CPU sy 80% → 3%
2
3
4
5
6
7
8
9
修复方案(按代价从小到大):
方案 A:保持文件打开,减少 open/close(最简)
static int log_fd = -1;
void log_message(const char* msg) {
if (log_fd < 0)
log_fd = open("/var/log/app.log", O_WRONLY | O_APPEND | O_CREAT, 0644);
char buf[256];
int len = snprintf(buf, sizeof(buf), "[%ld] %s\n", time(NULL), msg);
write(log_fd, buf, len);
// 不 close!让 fd 保持打开
}
2
3
4
5
6
7
8
9
方案 B:用标准 IO 缓冲(治本)
static FILE* log_fp = NULL;
void log_init(void) {
log_fp = fopen("/var/log/app.log", "a");
setvbuf(log_fp, NULL, _IOFBF, 8192); // 8KB 全缓冲
}
void log_message(const char* msg) {
fprintf(log_fp, "[%ld] %s\n", time(NULL), msg);
// flush 时机: 缓冲区满 或 定时 fflush 或 程序退出
}
2
3
4
5
6
7
8
9
方案 C:异步日志(高性能)
用专门的日志线程 + 无锁队列 + 批量 write——日志调用线程只往队列丢一条消息(零系统调用),后台线程批量 write。
# 8.2 write旅程
把 write(fd, "hello\n", 6) 从用户代码到磁盘的完整路径串起来:
用户态
├─ 应用: write(fd, "hello\n", 6)
├─ glibc: 参数写入寄存器 (rax=1, rdi=fd, rsi=buf, rdx=6)
└─ syscall 指令
│
内核态 ▼
├─ sys_write() 入口
├─ 通过 fd → current->files->fd_array[fd] → struct file
├─ 安全检查: 文件是否以写模式打开? buf 地址是否合法?
├─ VFS: vfs_write(file, buf, 6, &file->f_pos)
│ └─ file->f_op->write() = ext4_file_write()
│ └─ generic_file_write_iter()
│ └─ 数据拷贝: copy_from_user(page, buf, 6)
│ → 把用户态 buf 数据拷贝到 page cache 的物理页
│ → 标记 page 为 dirty
│ → 更新 file->f_pos = 旧位置 + 6
├─ 返回值: 6 (写入成功的字节数) 放入 rax
└─ sysretq → 返回用户态
延迟写回 (异步)
├─ 内核线程 kworker/flush 定时扫描 dirty 页
├─ 条件触发: dirty_ratio (脏页占比 > 10%) 或 30 秒超时
└─ 调用块设备驱动 → DMA → 磁盘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 8.3 面试高频问题清单
1. 文件描述符 fd 是什么?为什么 fork 后父子共享文件偏移?
fd 是进程
files_struct.fd_array[]的索引,指向内核的struct file。fork 后子进程拷贝了 fd_array,但指向同一个struct file(f_count++),所以共享f_pos(文件偏移)。A 进程 write 后,B 进程的 write 会从新的偏移开始。
2. write 系统调用的完整流程?返回成功是否等于落盘?
syscall → 权限检查 → VFS → ext4 → page cache 拷贝 → 标记 dirty → 返回。不等于落盘——数据只在 page cache 中。
fsync(fd)才保证落盘。详见第 4.1 和 8.2 节。
3. 标准 IO 的三种缓冲策略区别?stdout 重定向到文件后有什么变化?
全缓冲(
_IOFBF):缓冲区满才 write。行缓冲(_IOLBF):遇到\n就 write。无缓冲(_IONBF):每次立即 write。stdout 默认行缓冲,重定向到文件后变成全缓冲——输出不会实时出现。
4. fflush(fp) 和 fsync(fd) 的区别?
fflush:把 FILE* 的用户态缓冲区刷到内核 page cache(调用 write)。fsync:把内核 page cache 刷到磁盘。fflush不保证落盘,fsync保证。详见第 5.4 节。
5. mmap 为什么是"零拷贝"?和 read+write 比省了什么?
read+write:磁盘→内核→用户→内核→磁盘(4次拷贝)。mmap+memcpy:磁盘→内核→内核→磁盘(3次拷贝)。省去了"内核→用户→内核"的一次来回搬运。详见第 6.1/6.2 节。真正的"零拷贝"是
sendfile,数据完全在内核态流转。
6. mmap 的 MAP_PRIVATE 和 MAP_SHARED 区别?
MAP_PRIVATE:修改只对当前进程可见(COW,写时复制),不写回文件。MAP_SHARED:修改对所有映射同一文件的进程可见,且会写回文件(需 msync 或 munmap 触发)。
7. Linux 的"一切皆文件"怎么实现的?
VFS 抽象层:统一 file_operations 接口(read/write/open...)。每种"文件"(ext4/socket/pipe/procfs/设备)各自实现这套接口。用户态只看到 fd + read/write,内核通过 file→f_op 多态派发到具体实现。详见第 7 章。
8. open 一个文件,内核做了哪些事?
路径分量遍历(查 dcache → 缺缓存则 ext4_lookup 读磁盘)→ 找到 inode → 创建 struct file → 分配未使用的 fd → 绑定 fd 到 file → 返回 fd。详见第 7.3 节。
9. 为什么单字节 read 比大块 read 慢那么多?
系统调用的固定开销 ~150ns 与数据量无关。读 1 字节也是 150ns 系统调用框架开销 + ~10ns 数据拷贝;读 4096 字节也是 150ns 框架开销 + ~1µs 数据拷贝。单字节 read 的框架开销占比超 90%。详见第 4.3 节。
10. dup2 和重定向的关系?Shell 的 > 是怎么工作的?
command > file等价于:open(file)→dup2(fd, 1)→close(fd)→exec(command)。dup2(a, b)让 fd b 指向 fd a 的 struct file,实现"stdout 指向文件"。详见第 3.4 节。
# 8.4 文件IO速查卡
系统 IO API 参考:
// 打开/关闭
int fd = open("file", O_RDONLY); // 只读
int fd = open("file", O_WRONLY | O_CREAT | O_TRUNC, 0644); // 写/创建/截断
int fd = open("file", O_RDWR | O_APPEND); // 读写/追加
close(fd);
// 读写
ssize_t n = read(fd, buf, sizeof(buf)); // 读, n>0 成功, n=0 EOF, n<0 错误
ssize_t n = write(fd, buf, len); // 写
off_t pos = lseek(fd, 0, SEEK_END); // 移到文件末尾 (获取大小)
// 同步
fsync(fd); // 数据 + 元数据刷盘
fdatasync(fd); // 只刷数据 (不刷元数据,更快)
// 重定向
dup2(fd, 1); // 把 stdout 指向 fd
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
标准 IO API 参考:
// 打开/关闭
FILE* fp = fopen("file", "r"); // 读
FILE* fp = fopen("file", "w"); // 写 (截断)
FILE* fp = fopen("file", "a"); // 追加
fclose(fp);
// 格式化读写
fprintf(fp, "val=%d\n", x);
fscanf(fp, "%d", &x);
fgets(buf, sizeof(buf), fp);
// 缓冲控制
setvbuf(fp, NULL, _IOFBF, 8192); // 8KB 全缓冲
setvbuf(fp, NULL, _IOLBF, 0); // 行缓冲
setvbuf(fp, NULL, _IONBF, 0); // 无缓冲
fflush(fp); // 刷新用户态缓冲区
fsync(fileno(fp)); // 获取底层 fd 并刷盘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mmap 参考:
// 文件映射
void* map = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
// ... 像操作内存一样操作文件 ...
munmap(map, size);
// 匿名映射 (不关联文件, 类似 malloc)
void* anon = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 同步
msync(map, size, MS_SYNC); // 同步刷盘
msync(map, size, MS_ASYNC); // 异步
// 预读优化
madvise(map, size, MADV_SEQUENTIAL); // 顺序访问
madvise(map, size, MADV_RANDOM); // 随机访问
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
诊断命令:
# 查看进程打开了哪些文件
lsof -p $PID
ls -l /proc/$PID/fd
# 查看 fd 限制
ulimit -n
cat /proc/sys/fs/file-max # 系统级限制
cat /proc/sys/fs/nr_open # 进程级硬限制
# 追踪系统调用
strace -c -p $PID # 统计总览
strace -e trace=open,read,write -p $PID # 只看文件IO
strace -e write=fd -p $PID # 看写入的数据
# 查看 page cache
free -h # buff/cache 列
cat /proc/meminfo | grep -E 'Dirty|Writeback|Cached'
# 手动触发刷盘
sync # 所有 dirty 页刷盘
echo 3 > /proc/sys/vm/drop_caches # 释放 page cache (仅测试用!)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
性能基准速查:
| 操作 | 大致耗时 | 说明 |
|---|---|---|
| memcpy 1 byte | ~1 ns | 纯用户态 |
| memcpy 4096 bytes | ~50 ns | 纯用户态 |
| 系统调用框架 (syscall + ret) | ~150 ns | 不含内核处理 |
| write 1 byte (到 page cache) | ~200 ns | syscall + 拷贝 |
| write 4096 bytes (到 page cache) | ~1 µs | syscall + 拷贝 |
| fsync (SSD, 4KB) | ~100 µs | 同步落盘 |
| fsync (HDD, 4KB) | ~5 ms | 机械硬盘寻道 |
| mmap 缺页 (首次访问) | ~10 µs | 读磁盘 |
| mmap 命中 (再次访问) | ~1 ns | 仅 MMU 翻译 |
下一篇:18.进程与线程模型 —— 我们已经知道"文件 IO 是用户态和内核态的桥梁",下一步进入并发:fork 的 COW 机制怎么省内存?pthread 的栈是怎么分配的?互斥锁 futex 在内核里是怎么工作的?