我前段时间帮一位同事排查一个“诡异”的服务问题:程序跑着跑着,日志文件里出现了一段莫名其妙的空白,紧接着又接着写。查了半天,最后定位到是他代码里用fopen打开文件之后,中间某个分支提前return,没调fclose,导致缓冲区没刷新。这事儿在 Linux 下特别典型,也特别容易踩——文件操作本质上是和内核打交道,你以为你写的是“文件”,实际上你操作的是“文件描述符”、是“缓冲区”、是“内核中的一张表”。干这一行,如果不懂底层的这一层 IO 机制,光靠背 API 写业务代码,迟早会被这种问题坑到怀疑人生。
这篇文章想聊的就是 Linux 文件操作里的 IO,从最基础的open/read/write系统调用,到标准 C 库的缓冲机制,再到经典的“描述符复用”“EINTR 被打断”“Page Cache 与落盘”这些高频问题。适合刚接触 Linux 系统编程的开发者,也适合写过一阵子但一直对“文件描述符到底是个什么东西”没彻底搞明白的朋友。读完你应该能理解一张文件描述符表、一张打开文件表、一张 inode 表在背后是怎么协作的,也能清楚为什么write返回了 100 不代表数据已经写到磁盘里了。
1. 内容整体设计与思路拆解
1.1 先想清楚:你写的到底是“文件”还是“描述符”
很多初学者对“文件操作”的理解停留在教科书示例上:open一个路径,得到一个 fd,然后read/write,完事close。看起来挺顺,但这里有几个问题值得琢磨。
第一,open返回的 fd 是什么?它不是文件本身,更不是文件内容,它只是进程的文件描述符表里的一个下标。内核为每个进程维护了一张文件描述符表,表的每一项指向内核维护的“打开文件表”里的一个条目。也就是说,open做的事情本质上是:
- 根据路径找到 inode(文件元数据所在的内核对象)
- 在打开文件表里新建一个条目,记录当前文件偏移、打开模式、引用计数等
- 在进程的 fd 表里分配一个最小可用下标,指向这个条目
- 返回这个下标
所以 fd 本质上是“进程和内核打开文件对象之间的句柄”,而不是文件本身。这个概念为什么重要?因为它能解释很多“奇怪”的现象:
- 为什么
fork()之后父子进程共享同一个文件偏移? - 为什么
dup和dup2复制的描述符会影响同一个文件? - 为什么同一个文件被打开两次,写入位置互不干扰?
- 为什么
close一个 fd 之后,文件内容可能还是旧的?
第二,write是“写入”还是“拷贝”?对用户态来说,write(fd, buf, n)把 buf 里的 n 个字节拷贝到内核的 Page Cache 中就返回了(准确说是拷贝到内核缓冲区的页里,再在合适的时机刷到磁盘)。对应用来说,这个调用是同步的,但数据不一定是持久的。这个区分特别重要,尤其是做数据库、日志、配置文件写入这类场景的时候。
所以设计一个稳定的文件 IO 方案,要考虑的不只是“调哪几个函数”,还要想清楚:单次写入多大合适?要不要 O_APPEND?要不要 fsync?要不要考虑信号中断?这些都是在这个标题背后真正能体现功力的地方。
1.2 为什么优先讲透系统调用,再讲 C 库封装
很多教材一上来就讲fread/fwrite,好处是好用,坏处是容易把缓冲这件事忽略掉。fread到read之间有一层 stdio 缓冲区,printf甚至有自己的行缓冲策略。如果不懂底层,遇到“代码里写了但文件里没有”这类问题根本无从下手。
反过来,直接从read/write讲起,虽然代码写起来啰嗦一点,但每条数据流都看得见摸得着,缓冲、返回、错误、偏移这些概念不会被封进黑盒。所以我建议学习路径是:
- 先彻底吃透系统调用层的
open/read/write/lseek/close - 再回头看 C 库的
fopen/fread/fwrite/fflush,这时候你才能真正理解fflush到底冲刷了什么、fclose除了释放资源还做了什么 - 最后再看高级 IO(
mmap、sendfile、io_uring)时才不会懵
这也是本文的编排逻辑:先用系统调用把地基夯实,再讲标准库封装,最后给一个综合的实操案例。这样无论你以后写网络服务、嵌入式程序还是中间件,遇到 IO 问题都能顺着这条链路往下查。
2. 核心细节解析与实操要点
2.1 open:flags、mode 与文件描述符的分配规则
open的完整签名是:
#include <fcntl.h> int open(const char *pathname, int flags, mode_t mode);flags 是必填的,第三个参数 mode 只有在 flags 包含O_CREAT时才有效。很多新手踩的第一个坑就是:创建文件时没传 mode,结果文件权限随机,甚至创建失败。这是不对的——open是可变参数函数,当O_CREAT出现时,内核会读取第三个参数作为 mode,如果不传,那个寄存器里残留的值会被当作 mode 用,结果不可控。
常用的 flags 组合我整理成了表格,方便对照:
| flags 组合 | 用途 | 注意事项 |
|---|---|---|
O_RDONLY | 只读打开 | 不可与O_WRONLY同时用 |
O_WRONLY | 只写打开 | 写日志、覆盖文件常用 |
O_RDWR | 读写打开 | 需要读改写的场景 |
O_CREAT | 不存在则创建 | 必须配合 mode 使用 |
O_APPEND | 追加写 | 每次 write 前把偏移移到末尾,详见下文 |
O_TRUNC | 清空文件 | 配合O_WRONLY或O_RDWR用 |
O_EXCL | 与O_CREAT配合 | 文件已存在则 open 失败,用于避免覆盖 |
O_NONBLOCK | 非阻塞模式 | 对普通文件无意义,对 FIFO/设备文件关键 |
关于O_APPEND,有个细节值得展开。很多人的理解是“写文件时自动追加到末尾”,这个说法不够精确。实际上O_APPEND并不是修改了文件偏移量,而是在每次write之前,内核会原子地把文件偏移定位到当前文件末尾,再执行写入。所以即使多个进程同时以追加模式打开同一个文件,每次write的内容也不会互相覆盖。这一点在多进程日志、多线程写文件场景中非常关键。
再来看描述符分配规则。内核分配 fd 时,总是选择当前进程描述符表中“最小的可用编号”。常见进程启动后,0 是标准输入,1 是标准输出,2 是标准错误。如果你先用close(1)把标准输出关了,然后再open一个新文件,它拿到的 fd 很可能就是 1。这既是经典的操作系统考题,也是重定向的实现原理。
实操时还有一个很容易忽略的点:mode的取值是权限位,比如0644,但它还会受到进程 umask 的过滤。实际创建出来的权限是mode & ~umask。如果你希望创建出来的文件就是 0644,但 umask 是 0022,那结果是 0644,没问题;但如果需要 0666,而 umask 是 0022,最终拿到的是 0644,这就容易困惑。要精确控制权限,可以临时修改 umask,或者创建后再chmod。
2.2 read / write:短读、短写与碎片化写入
read的签名很容易让人误会:
ssize_t read(int fd, void *buf, size_t count);许多人以为“read 会读满 count 个字节”,实际上它只承诺“最多读 count 个字节”。返回值表示实际读了多少字节。可能比 count 小,可能只有 1 个字节,也可能第一次返回 0(表示 EOF)。这就是教科书里反复强调的“短读”(short read)现象。
为什么会短读?常见原因:
- 普通文件读到末尾,剩余不足 count
- 管道、终端、socket 等非普通文件,缓冲区里当前可读数据不足 count
- 被信号打断,部分数据已经读到,剩余还没读
- 网络文件系统(NFS 等)上因为锁或语义限制,每次传输有上限
所以一个健壮的读取循环必须写成这样,而不是只调一次read就完事:
ssize_t n; size_t total = 0; while (total < wanted) { n = read(fd, buf + total, wanted - total); if (n == 0) break; // EOF if (n < 0) { if (errno == EINTR) continue; perror("read"); break; } total += n; }write也类似,存在“短写”(short write)。不过对普通文件来说,只要不是磁盘满、inode 耗尽、信号打断等情况,一般能一次写完。但你自己写通用工具时,还是要处理“返回的字节数小于请求字节数”的情况。不要因为“反正大多数情况一次就写完”就不处理,到了生产环境,NFS 或者某些特殊文件系统上很容易触发。
还有一个细节:write的缓冲区大小会影响系统调用次数。一次write传 1 字节和一次write传 4096 字节,系统调用开销不同。用户态到内核态的切换是有代价的,频繁小写入会明显拖慢性能。这也是标准 C 库引入用户态缓冲区的核心原因:把多次小写入攒成一次大写入,减少系统调用次数。
2.3 lseek:别忽略这个隐形的“游标”
lseek是很多新手在刚学文件操作时忽略掉的一个函数,但它实际上是理解“文件偏移”的钥匙。
off_t lseek(int fd, off_t offset, int whence);whence 有三种:
SEEK_SET:从文件开头算起SEEK_CUR:从当前偏移算起SEEK_END:从文件末尾算起
注意几点:
第一,lseek只调整内核打开文件对象里的偏移量,并不触发任何磁盘读写。它不读取文件内容,也不修改文件,所以性能开销很小,可以大胆使用。它只是移动一个数字,真正读写时内核会根据这个偏移算出要操作的位置。
第二,偏移可以是负的吗?如果是普通文件,不能移到文件开头之前,lseek会返回-1并设置errno为EINVAL。但是某些设备文件允许负偏移。
第三,偏移可以超出文件末尾,这叫“稀疏文件”。当你lseek到很远的位置再write几个字节时,中间区域不会被填充实际数据,文件系统会标记这些“空洞”。这种文件在逻辑上是完整的,但实际占用磁盘块远小于文件大小。比如你lseek(fd, 1GB, SEEK_SET)然后写 1 字节,文件大小约 1GB,但可能只占几个块。ls -lh看到的大小是 1GB,du -h看到的实际占用可能只有 4KB。做文件格式设计时,合理利用稀疏文件能省不少空间,但要留意其他工具(比如tar)对空洞的处理方式,有些归档工具会把空洞展开变成实际零字节,导致包特别大。
第四,O_APPEND和lseek的交互。以追加模式打开文件时,即使你手动lseek到了某个位置,下一次write之前偏移又会被拉回末尾。这个行为是内核保证的,用来支持多进程日志的原子追加。所以如果你既想写某个具体位置、又想用O_APPEND,二者是不可兼得的。
2.4 close:错误处理往往被忽略
close的问题主要体现在两个地方。
一是“关闭一个已经被关闭的 fd”。fd 被关闭后,如果又调了一次close(fd),返回值是-1,errno是EBADF。释放 fd 之后另一个线程又open拿到了同一个 fd,这时候再 close 就把别人的连接关了。解决办法通常是两层:逻辑上保证“谁打开谁关闭”,不要跨模块随便 close;工程上可以用封装,把 fd 设成 -1 作为“已关闭”标记,后续统一判断。
二是close的返回值以及延迟错误(delayed error)。对普通文件来说,close返回失败的概率很低,但对 NFS、FUSE 这类网络文件系统来说,close可能是最后一次写回数据的机会,如果磁盘空间不足或者网络超时,错误是在close时才暴露的。严谨的程序会在close失败时也做错误处理,但现实里很少人做到这一步。
有一个常见的纯坑:fork之后,子进程继承了父进程的 fd 表副本,如果不做特殊处理,父子进程各自close时会对同一个打开文件对象的引用计数减两次。本质上引用计数正确减到 0 文件对象才会真正释放。如果你的程序里出现“父进程 close 了但文件还是没释放”的错觉,先检查是不是其他地方(子进程、线程、第三方库)还有同一个 fd 的引用。
3. 实操过程与核心环节实现
3.1 手写一个“带缓冲的简化版 cat”
与其空谈理论,不如直接写一个能跑的小工具,把上述系统调用全部串起来。目标:实现一个简化版cat,能从命令行参数指定的文件里读取内容,写到标准输出。
第一版,最直接的写法:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "usage: %s <file>\n", argv[0]); return 1; } int fd = open(argv[1], O_RDONLY); if (fd < 0) { perror("open"); return 1; } char buf[4096]; ssize_t n; while ((n = read(fd, buf, sizeof(buf))) > 0) { ssize_t written = 0; while (written < n) { ssize_t w = write(STDOUT_FILENO, buf + written, n - written); if (w < 0) { if (errno == EINTR) continue; perror("write"); close(fd); return 1; } written += w; } } if (n < 0) { perror("read"); close(fd); return 1; } close(fd); return 0; }这段代码看起来简单,但每个点都是有用的:
- 内层
write循环处理短写 EINTR被打断时继续写而不是退出n为负时区分是EINTR还是真错误- 每次读 4096 字节,这是一个比较均衡的默认值,不大不小,一般能覆盖绝大多数文件系统的块大小
我实测过一个小实验:用同样这个程序,把缓冲区改成 1 字节,然后去读一个 10MB 的文件,耗时会比 4096 字节版本高一截。原因就是系统调用次数差了几千倍。这个实验你可以自己做一下,感受会非常直观。
3.2 用 lseek 实现“读取文件末尾指定字节”
再写一个小场景:给定一个文件,输出最后 128 字节的内容。这是日志截断、事故现场取证的常见需求。
思路并不复杂:
open文件lseek(fd, 0, SEEK_END)拿到文件大小- 如果文件大小小于 128,从 0 开始读;否则从
size - 128开始读 - 读出内容写到标准输出
这里有个容易踩的坑:lseek返回的是偏移,但你得先把它转成文件大小。很多人直接拿stat的结果,其实也能做,但用lseek更轻量,尤其当你已经持有 fd 时,没必要再开一次路径。
关键代码如下:
off_t size = lseek(fd, 0, SEEK_END); if (size < 0) { perror("lseek"); close(fd); return 1; } off_t start = (size > 128) ? size - 128 : 0; if (lseek(fd, start, SEEK_SET) < 0) { perror("lseek"); close(fd); return 1; }然后照常循环读取。
这里想强调一个工程习惯:为了防止某些平台off_t是 32 位导致不支持大文件,编译时尽量加上-D_FILE_OFFSET_BITS=64,或者用_POSIX_C_SOURCE相关宏定义。现代 64 位系统上默认就是 64 位偏移,但如果你是交叉编译或者跑在 32 位环境,不加这个宏很容易限制在 2GB 以内。
我用一个 512MB 的稀疏文件实际验证过,lseek秒回,然后读最后 128 字节也只需要一两次read。这就是偏移量设计的价值:它不用把整个文件读一遍就能定位。
3.3 用 strace 看清系统调用的真实过程
理论讲得再多,不如看一次真实发生的系统调用。这是我在带新人时最推荐的实操手段。
对一个简单的cat操作,用 strace 跟踪:
strace -e trace=open,read,write,close,lseek ./my_cat testfile.txt输出会显示出每次open返回的 fd,每次read请求多少字节、实际返回多少字节,以及write写到了哪个 fd。你会发现,哪怕是一个极小的文件,背后也是一整套描述符、缓冲区、偏移量的协作。
再对比一下标准 C 库的版本:
strace -e trace=read,write,open,close ./another_cat testfile.txt如果程序里用的是fread+fwrite,你会发现read系统调用的次数明显比fread调用次数少。因为fread内部按块预读,把数据塞进 stdio 缓冲区,后续的fread直接从缓冲区拿数据,不再触发系统调用。这就是用户态缓冲区的意义。
我经常建议所有做 Linux 服务端开发的人,遇到 IO 问题第一件事就是开strace,看真实的系统调用序列。很多所谓“灵异问题”,比如文件写入不生效、读取内容不对、fd 号异常,在系统调用层面都是一目了然的。
3.4 对比实验:一次大写入 vs 多次小写入
做个可控的性能对比实验,体会一下用户态缓冲区为什么重要。
实验设计:
- 生成一个 64MB 的文件
- 方法 A:每次
write1 字节,循环写 64MB 次 - 方法 B:每 4096 字节打包成一次
write,循环写 16384 次 - 统计两者的耗时
方法 A 的代码片段:
for (size_t i = 0; i < size; i++) { if (write(fd, "a", 1) != 1) { perror("write"); exit(1); } }方法 B 的代码片段:
char buf[4096]; memset(buf, 'a', sizeof(buf)); for (size_t i = 0; i < size / sizeof(buf); i++) { if (write(fd, buf, sizeof(buf)) != sizeof(buf)) { perror("write"); exit(1); } }在我本机的实测数据大致是:
| 方法 | 耗时(约) | 说明 |
|---|---|---|
| 1 字节 / 次 write | 数秒甚至更久 | 大量用户态/内核态切换 |
| 4096 字节 / 次 write | 极快 | 系统调用次数大幅减少 |
不是说所有场景都必须追求大缓冲区,但你要理解:缓冲区大小直接左右系统调用次数,而系统调用次数直接左右吞吐。如果你在写一个高性能转发程序,不把 IO 批量化的意识建立起来,后面优化会寸步难行。
4. 常见问题与排查技巧实录
4.1 EINTR:write 明明没写完,却被信号打断
一个非常经典的坑。场景:程序写日志时突然收到 SIGCHLD 之类信号,write返回了-1,errno是EINTR。不少人的第一反应是“完蛋了,写失败了”,于是直接报错退出。这其实把一个可恢复的错误当成了致命错误。
正确的处理是:EINTR表示这次系统调用被信号打断,但调用本身没有失败,应该重新执行。上面的代码里我已经写了if (errno == EINTR) continue;,这就是标准写法。
普通文件上遇到EINTR的概率不高,但网络 socket、管道、终端上很常见。还有一个隐含点:从 Linux 2.6.22 开始,如果信号设置了SA_RESTART,很多系统调用的中断恢复行为是自动发生的,read/write在部分设备上也会自动 restart。但为了代码的可移植性和确定性,最好还是自己处理EINTR,不要依赖系统的自动 restart 行为。
4.2 EOF 不等于“读到 0 字节就出错”
read返回 0 有两种含义:一是到了文件末尾;二是当前无数据可读(比如非阻塞模式下,管道或 socket 暂时没有数据)。对普通文件来说就是 EOF,但对其他文件类型来说,要特别注意区分。
常见误判:程序里把read返回 0 当成错误处理,然后perror,这可能完全没问题;但反过来,把read返回 -1 当成 EOF,就麻烦了。比如 socket 对端正常关闭时read返回 0,这代表连接关闭,是正常流程,不是错误;而返回 -1 才是真错误(比如 ECONNRESET)。这个区分在网络编程里尤其重要。
排查技巧:写一个循环打印每次read的返回值。如果返回值稳定是 0,那就是 EOF;如果是 -1,那就去查errno具体值。别猜,打印出来。
4.3 O_APPEND 与多进程写入:不想互相覆盖,就别手动 lseek
这个问题在日志系统里极其常见。两个进程同时往同一个日志文件里写,如果都先lseek到末尾再write,中间可能会被打断,导致互相覆盖。解决方法是使用O_APPEND打开文件,让“定位到末尾 + 写入”这两个操作在内核里变成一次原子操作。
但是注意:O_APPEND只在每次write时生效,如果你用pwrite指定了偏移,pwrite的行为不受O_APPEND影响。pwrite总是按你提供的偏移写,不会自动追加。这点很多人不知道,等你真的在日志系统里用pwrite时就会遇到“明明开了 O_APPEND 怎么还是覆盖了”的困惑。
把这张表记一下:
| 操作方式 | O_APPEND 是否生效 | 是否原子追加 |
|---|---|---|
write | 生效 | 是 |
pwrite | 不生效 | 否,按指定偏移写 |
lseek+write | 生效 | 是(仅当 O_APPEND 开启) |
lseek+write(无 O_APPEND) | 不适用 | 否 |
4.4 文件内容没写进去?先查缓冲区和关闭时机
场景:程序里写了fprintf(fp, "hello"),文件里就是没有。新手第一反应往往是代码写错了,实际上十有八九是 stdio 缓冲区没刷新。
标准 C 库的fwrite先写进用户态缓冲区,缓冲区满时才触发一次真正的write系统调用。进程正常exit会刷新缓冲区,但如果进程崩溃或者你忘了fclose,缓冲区里的数据就丢了。
排查路径:
- 检查是否调了
fclose。没有的话,先补上。 - 检查是否是行缓冲。终端上 stdout 默认行缓冲,遇到
\n就刷新;但文件是全缓冲,\n不会触发刷新。 - 如果数据必须现在就落盘,调用
fflush(fp)。 - 如果是系统调用层的数据要落盘,调用
fsync(fd)或fdatasync(fd)。
这里要强调fsync和fdatasync的区别。fsync把文件数据和元数据(比如大小、修改时间、权限)都刷到磁盘;fdatasync只刷数据。对大多数日志场景,fdatasync就够了,性能也略好。但你也别误会fsync是“立即写磁盘”的银弹,它保证的是数据在断电后能恢复,而不是说调用返回时磁盘就一定物理写完了。
4.5 描述符耗尽:Too many open files
服务长时间运行,突然开始报Too many open files。这个问题的根源通常不是“一次 open 太多没关”,而是“某个 fd 泄漏”。比如每次请求都打开文件,但异常路径没关闭,fd 表被慢慢占满。
排查思路:
# 查看进程当前打开了哪些 fd ls /proc/<pid>/fd/这个目录下列出的 fd 会告诉你进程手里的“句柄”都指向哪些文件、socket、管道。如果看到大量重复的、应该早就关闭的连接或文件,那就是泄漏点。
常见泄漏路径:
- 某分支提前
return,跳过了close - 把 fd 存在局部变量里,异常后没能释放
- 第三方库内部打开了 fd,但你没调用它的释放函数
解决办法是在代码层面做一个“所有权”约定:谁负责 open,谁就对 close 负责到底;传递时明确是“借用”还是“转移所有权”。工程上,现在很多语言和库也引入 RAII 或者 defer 机制,但在纯 C 环境下,这种纪律必须靠代码评审和工具来保障。
4.6 常见问题速查表
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 文件里没写进内容 | stdio 缓冲区未刷新 | 检查 fclose、fflush;必要时 fsync |
| 文件内容部分覆盖 | 多进程并发写入且未用 O_APPEND | 改用 O_APPEND 打开文件 |
| 日志丢失几行 | write 返回 EINTR 被当错误退出 | errno 判 EINTR 后重试 |
| read 永远读不到内容 | 打开了错误的 fd,或文件偏移已在末尾 | lseek 回开头;strace 看系统调用 |
| 大量 Too many open files | fd 泄漏 | 检查 /proc/ /fd/,定位泄漏分支 |
| 同一 fd 关闭后又被写入 | 悬垂 fd | 关闭后置为 -1,避免重复 close |
| 文件权创建出来不对 | umask 或 mode 传错 | 检查 umask 和创建目录的权限策略 |
5. 标准库 vs 系统调用:什么时候用哪个
5.1 stdio 缓冲机制拆解
标准 C 库(stdio)提供了一层用户态缓冲,它本质上是对系统调用的封装和优化。
对比一个很有意思的现象:
printf("hello"); while (1) { }你运行这个程序,按说printf已经把内容“输出”了,但屏幕上可能什么都没有。原因是 stdout 如果是终端设备,默认行缓冲,遇到\n才刷;如果不是终端(比如你重定向到文件),默认全缓冲,缓冲区满或fclose/exit才刷。while (1)死循环让进程无法正常退出,缓冲区就一直留着不刷。
这不是 bug,是 buffering 的预期行为。所以如果你要写“实时看到结果”的调试程序,要么printf("...\n")用行缓冲,要么主动fflush(stdout),要么临时改成setbuf(stdout, NULL)关闭缓冲。
fopen打开文件后的fread/fwrite/fprintf都经过这一层缓冲。这个设计能让“很多次小写入”合并成“少数几次大写入”,从而减少繁琐的系统调用。代价是程序员必须清楚fclose或fflush的责任边界。
5.2 何时跳过 stdio 直接用系统调用
我自己做选择时,会按下面几条判断:
第一,性能敏感且数据量大的场景,直接系统调用。比如网络代理、文件转发、日志采集。这些场景里缓冲区的管理需要自己控制,用read/write更直接,也更容易排查问题。
第二,需要精确控制偏移和写入位置的场景,用pwrite/pread。标准库的fseek+fread看起来方便,但它是用户态的偏移模拟,多线程场景下共享 FILE 对象的偏移也很容易出问题。直接用pread/pwrite,偏移由调用者显式传入,不会依赖文件描述符里的隐式偏移,天然适合多线程。
第三,绝大多数日常业务读写,用fopen/fread/fwrite完全足够。省心、可读性好、错误处理也比较成熟。但你必须理解它背后的缓冲区机制,否则就等着踩fflush的坑。
5.3 FILE* 和 fd 的互转
有的库用 fd 做接口,有的用 FILE*,经常需要在两者之间转换。标准库提供了fileno和fdopen:
int fd = fileno(fp); // 从 FILE* 拿 fd FILE *fp2 = fdopen(fd, "r+"); // 从 fd 包装成 FILE*但这里有两个坑一定要记住:
fdopen不会复制 fd,它会“借用”这个 fd。fclose(fp2)会把底层的 fd 一并关闭。如果你还想着之后自己在别处close(fd),就会造成 double close。fileno拿到的 fd,如果通过dup复制过,原 fp 和复制出来的 FILE* 可能会各自有各自的缓冲区,同时对同一个打开文件对象做缓冲,数据顺序会乱。
如果你要的是“独立的文件描述符”,应该先dup(fd),再把新 fd 传给fdopen。这样两个 FILE* 各自的缓冲区不会互相干扰。
6. 高性能方向:pread / pwrite、mmap 与 fsync 的取舍
6.1 pread / pwrite 在多线程场景中的价值
标准read/write依赖文件描述符里隐式的当前偏移,这对多线程很不利。多个线程共用同一个 fd 时,一个线程read改了偏移,另一个线程的read位置就被影响了。处理方式有两种:
- 加锁保护 fd 的偏移量和读写过程,繁琐且低效
- 用
pread/pwrite,每次调用显式传入偏移,不使用 fd 的当前偏移
pread不会更新 fd 的偏移,所以多个线程用同一个 fd 并发读不同区域是安全的。同理,pwrite也不会更新偏移,适合并发写不同日志分片这样的场景。
一个典型的例子:一个多线程下载工具,每个线程负责文件的不同范围,共用一个 fd,用pread读自己的区间,再用pwrite写回去。不需要任何锁,也不会互相污染偏移。
6.2 mmap:把文件映射进内存
mmap是另一个绕不开的文件 IO 手段。它的核心逻辑是让文件内容直接映射到进程地址空间,之后读写这块内存就是读写文件。对大量随机访问的场景,mmap往往比read更高效,因为避免了用户态和内核态之间的多次拷贝。
但mmap也有自己的问题:
- 映射区域的长度受限于文件大小,如果文件变长了,需要
mremap或重新mmap - 内存管理和文件系统交互更复杂,出现段错误时排查成本更高
mmap写入的数据同样要经过 Page Cache,依然需要msync或fsync才能保证落盘- 小文件用
mmap是杀鸡用牛刀,系统调用和缺页中断的开销可能比直接读还大
我的建议很实际:追求极致性能且是随机读写大文件时,优先考虑mmap;顺序读写的场景,read/write配合大缓冲区往往更简单可控。
6.3 数据落盘:什么时候该 fsync
很多人写完文件后不调fsync,理由是“我觉得数据不会丢”。这个“觉得”在普通开发和测试环境没问题,但生产环境断电或进程被强制杀掉时,Page Cache 里未写回的数据就会丢。
判断是否需要fsync,核心要看“数据丢失的代价”。如果数据丢了还能重建,那可以不调;如果丢一条日志都可能导致事故,那必须调。数据库、记账系统、消息队列的存储层,落盘策略是刚需;日志系统一般看容忍度,很多日志框架会定期fsync而不是每条都调。
注意fsync的成本很高,尤其是机械硬盘,每次fsync是一两次磁盘刷盘操作,直接影响吞吐。想鱼和熊掌兼得,可以用组提交策略:多条更新攒到一个小窗口内,统一执行一次fsync。很多数据库实现的就是这个思路。
另外fdatasync和fsync的差别再补充一点:有些情况下,只关心文件数据不关心文件大小变化,比如“覆盖已有数据”,这时fdatasync就够了;如果文件是首次创建或者重新截断,文件元数据里的“大小”改动也影响数据恢复,所以用fsync更稳妥。
7. 最后的经验之谈
我个人实际项目里最常被问的,还是“到底什么时候数据才算真正写到磁盘”和“为什么文件明明有数据,程序读不出来”。这两个问题本质上都绕不开“三层缓冲”的概念:用户态缓冲(stdio)、内核 Page Cache、磁盘控制器缓存。每一层都有延迟提交的可能,每一层也都有对应的刷新手段。
如果你现在开始设计一个涉及文件 IO 的系统,我的建议是:
首先,把“谁负责打开、谁负责关闭、谁负责刷新”在接口层定死。含混不清的所有权比慢一点更致命。
其次,多写一个strace的习惯。你代码里任何一个文件操作,第一次跑通后,都值得用strace看一遍真实的系统调用。这会帮你确认“自己以为的调用顺序”和“实际发生的调用顺序”是否一致。
最后,别盲目追求“所有都 fsync”。合理评估业务对数据损失的容忍度,在吞吐和持久性之间找平衡。文件 IO 没有绝对正确的方案,只有适合当前场景的方案。
这篇文章聊的是 Linux 文件操作里的基础 IO,但所谓基础并不是“简单”,恰恰是因为太基础,才经常被忽视。把文件描述符、偏移量、缓冲区、落盘时机这些事情想透了,往后看网络 IO、存储引擎、消息队列,都会顺很多。