不知不觉已经讲到第五讲了。前面几讲把命令、权限、进程这些基础打下来之后IO这个主题就顺理成章该登场了。原因很简单Linux下一切皆文件而文件操作的底层全是IO。你要是能把这一讲的内容真正吃透后续看网络编程、看服务端高并发、甚至看数据库的存储引擎实现都会顺畅很多。这一讲的内容我打算换一种组织方式不再按照传统的知识点清单来罗列而是围绕两条主线展开一条是“一条命令从敲下到落盘中间经历了什么”另一条是“用户态和内核态之间数据是怎么流动的”。这两条线走通了基础IO的骨架就搭起来了。先说清楚这一讲适合谁。如果你已经知道open、read、write这些系统调用的名字但始终没搞明白文件描述符到底是个什么东西或者你用C语言写过文件读写但说不清楚缓冲区的机制、重定向的原理又或者你被stdin、stdout、stderr这三个名字困扰过不知道怎么区分它们那这一讲就是为你准备的。我不太喜欢讲那种碎片化的知识点什么“open函数的参数详解”单独拎出来讲一遍那样学完还是不会用。我把IO相关的知识揉碎了重新按照实操链路组织你在读完这一讲之后应该能做到四个“能”能看懂系统调用和库函数之间的边界能解释重定向和管道的底层实现能理解缓冲区带来的坑能动手写出带IO操作的小程序并定位线上问题。1. 内容整体设计与思路拆解基础IO这个主题往大了说可以覆盖整个Linux内核的VFS虚拟文件系统层往小了说就是open和close两个函数。教学最忌讳的就是在两个极端之间摇摆所以我定下的基调是站在用户态的视角把内核态的关键机制讲清楚但不深入内核源码。1.1 核心需求解析这一讲要解决什么问题我在前面几讲的留言区看到不少同学反馈了类似的问题用C语言写文件读写有时候数据没写进去有时候读到的数据不对排查半天发现是缓冲区的问题用Shell写脚本重定向输出结果21和12写反了日志丢了一大半还有同学面试被问到“文件描述符0、1、2分别是什么”当场答不上来。这些问题看似零散但底层逻辑全部指向同一个知识模块基础IO。把这个模块梳理清楚这些问题就不再是孤立的知识点而是一个体系下的各种分支。我给自己定的教学设计目标是学完这一讲读者应该能画出下面这张逻辑图——用户程序调用C库函数C库函数调用系统调用系统调用进入内核内核通过VFS找到具体文件系统最终操作硬件设备。这张图不是靠背的而是靠理解每一层之间为什么要这么设计来建立的。1.2 方案选型为什么从“文件描述符”讲起我刻意把文件描述符放在最前面讲比系统调用的列表还靠前。原因是文件描述符是整个IO体系的基石它就是打开文件在进程内的句柄其他所有操作都是基于它展开的。很多教材喜欢从系统调用接口开始讲然后讲到文件描述符最后才讲文件系统。但实际学习中尤其对于初学者更容易发生的情况是系统调用太多太散学着学着就迷失了方向。所以我把顺序调整了一下先讲清楚文件描述符是什么再讲它从哪里来最后讲它怎么用。这样学习曲线会平缓很多每一个新概念都能挂到已经理解的知识主轴上。2. 核心细节解析与实操要点这一部分我拆成两个区块来讲文件描述符的运作规则以及系统调用与C库函数的分工。这两块内容密度不小建议结合代码实验边看边做。2.1 文件描述符进程与文件之间的“交往凭证”文件描述符这个翻译其实挺误导人的它既不“文件”也不“描述”。按我的理解它更像是一张“入场券”。进程要访问一个文件必须先向内核申请内核审核通过后发给进程一个编号这个编号就是文件描述符。之后进程的所有读写操作都要出示这个编号才能进行。文件描述符本质上是一个非负整数在进程内部它作为下标指向一张文件描述符表。这张表是进程私有的记录了每个文件描述符当前指向的内核文件对象。重点在于两个进程即使打开了同一个文件它们的文件描述符也是各自独立的互不干扰。关于文件描述符的规则我总结成三个关键点分配规则是最小可用内核在分配新的文件描述符时会从0开始找找到当前进程里最小的那个空闲编号就用它。这个规则在重定向的底层实现中会反复用到。0、1、2默认是打开的进程启动时内核会默认分配三个文件描述符0是标准输入1是标准输出2是标准错误。这三个默认打开的描述符是所有IO操作的起点。关闭后编号可复用close掉一个文件描述符之后这个编号就释放了后续新打开的文件可能会拿到这个编号。这里我要特意强调一个反直觉的结论文件描述符表是有上限的这个上限由内核参数控制。日常开发中如果遇到“打开的文件过多”这种错误大概率就是文件描述符耗尽而不是磁盘满了。可以用ulimit -n查看当前进程的文件描述符上限。2.2 系统调用与C库函数分工与合作既然要系统学习基础IO就绕不开系统调用和C库函数这两个层次。我直接用一个表格来对比省得讲一堆抽象的概念对比项系统调用C库函数代表read、write、openfread、fwrite、fopen运行层级内核态由内核实现用户态由C库实现是否带缓冲不带直接读写带缓冲先写入用户态缓冲使用场景底层操作、高性能要求普通读写、文本处理开销大小较大涉及用户态/内核态切换较小缓冲减少切换次数这个表格的信息量其实很大我解释一下。系统调用每次执行都要发生用户态到内核态的切换这个切换是有成本的包括保存现场、校验参数、执行内核代码、恢复现场等。C库函数为了避免频繁切换引入了缓冲区机制先把数据写入用户态缓冲区攒到一定量再调用一次系统调用。这个设计非常好理解类比一下就是你每天去楼下取快递是系统调用快递柜是缓冲区物业把快递统一签收后再通知你去取就是一次系统调用处理批量数据。有了缓冲区效率自然就上来了。但缓冲区也带来了新问题如果程序崩溃或者没有正常刷新缓冲区缓冲区里的数据就丢了。这就是为什么很多C语言教材反复强调fclose之前要fflush以及进程退出时要调用exit而不是_exit。exit会刷新缓冲区_exit不会。2.3 重定向的底层真相文件描述符的“偷梁换柱”Shell里天天用的重定向底层其实非常朴素就是在进程启动前后悄悄地更换文件描述符指向的内核文件对象。比如执行ls out.txtShell会做这样几件事先创建一个新的文件out.txt然后打开它得到一个可能是3或者4的文件描述符接着把文件描述符1标准输出关闭再把刚才得到的那个文件描述符复制成1。这样一来进程里文件描述符1指向的不再是终端而是out.txt。而进程自身以为还是在往标准输出写实际上数据已经进了文件。这一个机制解释了多年以来很多人搞不清楚的21写法。21的含义是把文件描述符2重定向到文件描述符1当前指向的位置。关键点是这个操作是运行时复制它复制的是文件描述符1当下指向的内核文件对象而不是字符串。所以command out.txt 21和command 21 out.txt执行结果完全不同。第一条是先把标准输出指向文件再把标准错误指向标准输出指向的文件结果两者都进文件第二条是先把标准错误指向标准输出当前指向的终端再把标准输出指向文件结果就是标准错误仍然打到终端只有标准输出进了文件。我见过太多线上事故是这两条命令写反导致的日志一半在文件里一半在屏幕上排查起来极其折磨。理解了这个底层机制就不会犯这种错误了。3. 实操过程与核心环节实现理论铺垫得差不多了现在进入实操环节。我不会直接给一堆代码然后让你自己跑而是带着你一步步把实验做出来每一个环节解释设计意图。3.1 实验一摸清文件描述符的分配规律实验目标很简单写一个小程序观察文件描述符是怎么分配的。#include stdio.h #include fcntl.h #include unistd.h int main() { int fd1 open(/tmp/test_io_1.txt, O_CREAT | O_RDWR, 0644); int fd2 open(/tmp/test_io_2.txt, O_CREAT | O_RDWR, 0644); printf(fd1 %d, fd2 %d\n, fd1, fd2); close(fd1); int fd3 open(/tmp/test_io_3.txt, O_CREAT | O_RDWR, 0644); printf(fd3 %d\n, fd3); close(fd2); close(fd3); return 0; }预期输出是fd1 3, fd2 4, fd3 3。0、1、2被标准输入、标准输出、标准错误占用了所以第一个打开的文件从3开始。关闭fd1之后3就空出来了下一次open就会复用3。这个实验看起来简单背后藏着三个重要结论文件描述符的最小可用分配规则0、1、2默认占用的既定事实以及关闭后编号可复用。把这些基础结论亲手验证一遍相当于在地基上打了几个桩子后续学习会稳很多。3.2 实验二亲手实现一个“重定向”直接操作文件描述符来实现重定向是理解Shell重定向的最佳途径。我用C语言做一个mini版#include stdio.h #include fcntl.h #include unistd.h int main() { int fd open(/tmp/mini_redirect.txt, O_CREAT | O_WRONLY | O_TRUNC, 0644); if (fd 0) { perror(open); return 1; } // 标准输出指向内核里的终端文件对象 // 现在让标准输出指向刚才打开的文件 close(1); int new_fd dup(fd); // dup会复制fd到最小可用编号也就是1 close(fd); // 此时printf输出已经进文件了 printf(hello, mini redirect!\n); fflush(stdout); return 0; }代码的关键在于close(1)把标准输出释放掉然后dup(fd)利用最小可用规则把新复制的文件描述符挂到1上。执行这段程序后你去/tmp/mini_redirect.txt里看能看到那行hello, mini redirect!。dup和dup2这两个函数是IO里的“搬家工具”。dup返回最小可用编号dup2可以指定目标编号。Shell实现里大量使用dup2来精确控制重定向目标因为它的语义是“强制让oldfd变成newfd指向的地方”。3.3 实验三缓冲区现象实锤缓冲区讲了一堆理论不如一个实验来得直观#include stdio.h #include unistd.h int main() { printf(before fork); // 注意这里没有换行没有fflush fork(); return 0; }你觉得屏幕上会出现几行before fork如果完全理解了缓冲区机制答案是在某些终端环境下可能看到两行。原因在于printf把数据写入了用户态缓冲区在没有换行且没有刷新缓冲区的情况下fork把整个进程地址空间复制了一份缓冲区里的数据也复制了一份。两个进程退出时各自刷新缓冲区就出现了同一行数据被打印两次的现象。这个实验是面试高频题也是理解“缓冲区和fork关系”的经典切入口。核心考点在于缓冲区是进程地址空间的一部分而不是内核的。fork复制进程也就复制了缓冲区。3.4 实验四系统调用与库函数的性能对比这个实验我强烈建议每个人都亲手做一次。写两个大文件一个用write循环一百万次一个用fwrite循环一百万次测一下各自的耗时。实测下来差距通常是数量级级别的。原因就是系统调用每次都要陷入内核而fwrite先把数据攒到缓冲区攒够了再一次性write。这个性能差距直观到让人印象深刻很多人在这个实验后才能真正理解“减少用户态/内核态切换”在工程上的意义。注意实验时最好把文件设置成几MB到几十MB太小看不出明显差距太大又会把时间拉太长。我用10MB数据循环写入write版本耗时大概在几百毫秒到一秒之间fwrite版本大概只有前者的十分之一甚至更少。4. 常见问题与排查技巧实录实操环节结束进入我认为最宝贵的部分真实项目中踩过的坑。这些坑不一定在教材里能找到但线上环境里几乎都会遇到。4.1 “日志丢失”问题的排查套路很多服务端程序用C库函数写日志突然某天发现日志缺了一段查代码逻辑也没问题。这时候我建议先检查进程是怎么退出的。我之前排查过一个案例程序在某些异常分支调用了_exit(1)直接退出跳过了所有清理逻辑包括刷新缓冲区的fclose。日志就丢在了缓冲区的“肚子里”永远没机会落盘。解决方式就是统一改用exit并确保在关键路径上有fflush兜底。排查日志丢失的快捷方法strace -f -e write -p 进程号实时跟踪进程的write系统调用。如果write根本没出现说明数据还在用户态缓冲区里压根没进内核那问题就定位在刷新时机上。如果write出现了但文件里没有那就要检查文件描述符指向哪了是不是被某些重定向操作悄悄改了。4.2 文件描述符泄漏的识别与修复文件描述符泄漏是服务端程序最常被忽视的资源问题。表现就是程序跑着跑着突然报Too many open files但此时磁盘还很空内存也不紧张。泄漏的本质通常是打开了文件但在某个错误分支忘了close。程序逻辑复杂后错误分支一多漏关的概率就上去了。推荐排查手段是lsof -p 进程号查看进程打开了哪些文件重点看有没有大量指向同一类文件的描述符。也可以ls /proc/进程号/fd | wc -l快速数一下进程当前打开的描述符数量如果这个数字持续上涨而流量没涨基本可以断定是泄漏。解决办法分为治标和治本。治标是调大ulimit -n治本是代码评审时专门检查所有的返回路径是否都正确关闭了文件。我更推荐一种编程习惯用RAII思想封装文件操作生命周期结束自动释放C语言里虽然没有RAII但可以用goto集中管理清理逻辑或者用__attribute__((cleanup))这个编译器扩展。4.3 标准错误与标准输出的混用问题线上排查故障时经常遇到一种情况日志里明明有报错信息但打开日志文件却是空的。十有八九是标准错误和标准输出的重定向没处理好21的位置放错了或者干脆漏了。我整理了一个速查表方便日常使用命令写法标准输出去向标准错误去向cmd log.txtlog.txt终端cmd 2 err.txt终端err.txtcmd log.txt 21log.txtlog.txtcmd 21 log.txtlog.txt终端第四行就是最典型的错误写法。理解了文件描述符复制的底层逻辑就不会再记错顺序21是把标准错误复制为标准输出当前指向的位置而标准输出当前指向的位置在执行顺序上由左侧决定。想在管道里同时处理标准错误和标准输出也有类似的注意事项管道符两侧的进程都有各自的文件描述符空间。4.4 “数据未落盘”的深层原因有位读者问过一个问题程序正常退出文件也close了数据就是没落到磁盘上。关闭系统或者断电之后文件损坏。这里就涉及“两层缓冲”的概念了用户态缓冲区C库管和内核页缓存内核管。close只能保证用户态缓冲区刷到内核页缓存但不保证页缓存立刻写回磁盘。数据在页缓存里对应用来说已经成功写入但对存储设备来说可能还在“路上”。如果需要强制落盘要用fsync或fdatasync让内核把指定文件相关的页缓存强制刷到磁盘。数据库系统就特别依赖这个机制来保证事务持久性。日常开发中如果想在性能和安全性之间取得平衡不必每条数据都fsync可以每隔一段时间或写入一定量后统一fsync一次。这个方法在写日志、写消息队列的落地文件时都很实用。5. 深入内核视角VFS与文件系统的关系基础IO学到这有必要站高一层看看文件描述符指向的最终目的地。这也是很多同学在把基础IO和前面几讲的知识串联时会产生的疑问虚拟文件系统VFS到底起了什么作用。5.1 VFS统一接口的“翻译官”Linux支持非常多的文件系统类型常见的有针对机械硬盘的有面向闪存优化的有网络文件系统还有各种特殊文件系统。如果每种文件系统的操作方式都不同那用户态的程序就得针对每种文件系统写不同的代码这显然不现实。VFS就是来解决这个问题的。它在内核里定义了一套统一的文件操作接口包括打开、读、写、关闭等。各种实际的文件系统只要能实现这套接口就可以被挂载到VFS上。对用户态进程来说调用系统调用时接触到的就是VFS层它把请求分发给具体的文件系统去执行。用一个比喻解释VFS像插线板的国标接口具体的文件系统就是各种电器。电器内部的电路五花八门但只要插头符合国标就能正常工作。用户态程序只需要知道插线板长什么样不需要关心里面插的是什么电器。5.2 文件描述符与inode的关系文件描述符、文件表项、inode这三者之间的关系经常被混淆。我直接说结论文件描述符是进程视角的索引文件表项是内核维护的打开文件实例inode是文件系统层面的元数据结构。多个文件描述符可以指向同一个文件表项比如通过dup就能做到这一点。它们的共享点是文件偏移量——一个描述符移动了读写位置另一个也跟着变。而通过多次open同一个文件得到的是独立的文件表项各自的偏移量互不影响。理解这一层关系能解释很多怪现象。比如两个程序往同一个日志文件里写为什么内容会交错甚至覆盖就是因为它们各自维护偏移量没有协同机制。这也是为什么很多日志系统会要求不同进程写入不同文件再统一做归集而不是多个进程直接写同一个文件。5.3 特殊文件与设备文件/dev/null、/dev/zero、/dev/random这些特殊文件在基础IO的世界里非常常用但很少有人去深究它们的实现。这些设备文件在VFS层表现得和普通文件一样有文件描述符、有读写操作但实际的读写逻辑由设备驱动程序实现。/dev/null的写操作就是直接丢弃读操作返回EOF扮演“黑洞”的角色。/dev/zero是读多少就给多少零字节常用于初始化内存或制造固定大小的文件。/dev/random和/dev/urandom则是内核熵源的门面读它们就能拿到随机数。知道它们的存在很有用。排查问题时经常需要快速生成一个大文件测试填充空间一条dd if/dev/zero oftestfile bs1M count100就能搞定而不需要真的往磁盘写入有意义的数据。想把某个程序的输出全部丢弃command /dev/null而不是去创建临时文件这就是特殊文件在日常中的价值。6. IO多路复用基础IO的自然延伸既然已经掌握了基础的读写模型我想在这里提一下IO多路复用因为它是高性能服务端程序的基石也是基础IO学完之后最顺理成章的进阶方向。6.1 阻塞与非阻塞IO的基础分类默认情况下read一个没有数据到达的文件描述符时进程会进入睡眠等待直到内核唤醒它。这种行为叫阻塞IO。阻塞IO的优点是代码简单、逻辑直观缺点是一个进程同时只能等待一个描述符效率很低。非阻塞IO则是read的时候发现没数据就立即返回返回一个错误码或者0进程可以去做别的事情过一会儿再回来尝试读取。非阻塞配合轮询可以在单线程里同时管理多个描述符但轮询本身也浪费CPU。6.2 为什么说多路复用是基础IO的集大成者IO多路复用解决的核心问题是如何在一个线程里同时等待多个文件描述符就绪。select、poll和epoll是Linux下三个典型的实现。我建议学的时候分两步走先用select写一个小型的“同时监听多个管道”的Demo理解多路复用的模型再切到epoll感受一下事件驱动的高效模式。两者解决的问题相同但实现思路差异巨大理解了差异也就理解了为什么高并发场景清一色选择epoll。这里我给一个比较形象的说法select和poll像每天早上把所有快递员的包裹都翻一遍看看有没有新件epoll更像是快递到了打个电话通知你你只需要接电话就行。6.3 从基础IO到高并发思维的跃迁基础IO的学习真正的终点不是能写文件读写而是建立一种“事件驱动”的思维方式你的程序不是在“顺序执行操作”而是在“响应已经就绪的事件”。一旦切换到这种思维再看Nginx的架构、Redis的单线程模型、Netty的事件循环都会觉得顺理成章。它们本质上都是在用IO多路复用把海量连接的文件描述符管起来哪个就绪就处理哪个尽量减少线程切换和阻塞等待。我见过有些同学花很长时间去啃epoll的事件类型、边缘触发和水平触发的区别但效果一般因为地基不够牢很多概念其实都能追溯回基础IO的范畴。先把文件描述符、缓冲区、内核态用户态这些基础掰扯清楚再去碰epoll会快得多。这一讲的内容量不小但每一条线都是围绕“用户态和内核态之间数据如何流动”来展开的。我自己在实际学习中体会最深的一点是基础IO是一个需要反复回头看的知识域。刚开始接触可能只是学会了几个函数但每学一个新主题——进程管理、网络编程、并发模型——回过头来再看基础IO都会有更深的理解。最后再分享一个小技巧学习基础IO效果最好的方式是带着strace去“偷看”程序的行为。跑任何一条命令、任何一个小程序都试试strace -e open,read,write,close去跟踪它到底做了哪些系统调用观察文件描述符是怎么分配和释放的。几次实验下来很多抽象的概念会变得非常具体甚至比看十篇教程都管用。
阅读完成 · 觉得有帮助?