ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux基础IO详解:从文件描述符到缓冲与性能排查

Linux基础IO详解:从文件描述符到缓冲与性能排查 1. 从一次线上IO卡顿说起Linux基础IO到底是什么上周收到一个群里的求助说程序跑了一段时间后“io性能明显下降了”文件写入速度从每秒几十MB掉到几百KB。我第一反应不是让他上监控工具而是先把代码里写文件的那段发过来。结果一眼就发现问题程序用fprintf逐行往日志文件里写每条日志后面没有fflush数据量一大用户态和内核态的缓冲交互全乱了还顺手把小文件的open/close放在循环里反复执行。这其实不怪他是Linux基础IO这块没有完全吃透。Linux基础IO说白了就是操作系统把“数据从进程里搬到外设”这件事抽象成的一整套机制包括文件描述符、系统调用、标准库缓冲、页缓存、IO多路复用、性能排查这些知识点。只要你写Linux下的服务端程序、嵌入式程序、脚本或者做日常运维都会跟它打交道。这篇内容是我多年踩坑之后的系统梳理适合刚入门Linux编程的同学也适合写了段时间C/C/Go但一直对某些IO表现说不清楚的人。1.1 先分清两层系统调用和标准库很多人会把open/write和fopen/fwrite搞混其实它们不在同一层。open/write/read/close是操作系统提供的系统调用直接陷入内核由内核去操作设备或文件系统。而fopen/fread/fwrite/fclose是C标准库的封装它们在用户态做了一层缓冲内部最终还是调用系统调用。这两层的关系有点像公司里的前台和后台标准库是前台接待先把大量的写入请求记录下来、分批整理攒到一定量再统一提交给内核这个“后台”。这样做的核心目的是减少系统调用次数因为每次系统调用都有用户态到内核态的切换开销。比如你要写10000次1字节的数据如果每次都直接调write就有10000次系统调用如果用fwrite先攒到缓冲区可能几百次write就完成了。1.2 为什么文件描述符从3开始一个进程启动时系统会自动打开三个文件描述符0是标准输入1是标准输出2是标准错误。所以你在程序中open第一个文件时返回值往往是3。这个值本身不是凭空来的它是进程文件描述符表里第一个空闲位置的下标。文件描述符本质上是一个非负整数内核用它来索引进程的打开文件表。换句话说fd不是文件本身而是一张表的索引。这个认知很重要因为很多IO问题比如fd泄漏、文件被意外覆盖都跟这张表的管理有关。你只要记住一点Linux上一切皆文件连socket、管道、设备节点最终都是通过fd来操作的。2. 文件描述符到底在管理什么fd表、文件表与inode这一段单独拿出来讲是因为我见过太多人把“fd”当成一个简单的整数导致后面学IO多路复用、学epoll时总觉得隔了一层。实际上fd背后是三层结构的协作。2.1 三张表的协作关系进程每次open一个文件内核会做三件事在进程的文件描述符表里分配一个空闲的下标在系统的打开文件表里新建一个条目记录当前文件偏移量、访问模式、读写状态再根据文件路径找到磁盘上对应的inode把三者关联起来。fd表在进程内部文件表是系统级的inode是文件系统级的。同一个fd在多个进程里可能指向同一个打开文件表项比如fork之后也可能各自独立。这也是为什么多进程往同一个文件里写时必须考虑追加模式或者加锁否则文件偏移量会互相覆盖。2.2 open/write/read/close的细节写文件最经典的操作就四步open拿fd、write写数据、close释放fd。看似简单但每步都有讲究。open的flags是最容易出问题的。O_CREAT表示文件不存在时创建O_EXCL配合O_CREAT表示“必须由我创建”如果文件已存在就直接失败。O_APPEND表示每次write前都把偏移量移到文件末尾O_TRUNC表示打开时清空文件。很多人写日志时不假思索用O_CREAT|O_WRONLY结果把已有文件覆盖了最后才意识到少了O_APPEND。write的返回值一定不能忽略。它返回实际写入的字节数可能小于请求写入的长度比如磁盘满了、被信号打断、管道缓冲区满了。一个严谨的程序要循环write直到把剩余数据写完否则日志文件会出现截断。close也可能出问题。close失败时文件描述符是否被释放在不同系统上行为不一样Linux上是释放的。但更常见的坑是忘了close时间一长fd表满了open返回EMFILE程序开始连环报错。2.3 fork和fd的继承关系fork之后子进程会复制父进程的fd表这意味着父进程打开的文件子进程也能操作。这个特性有时候是好事比如父子进程共享同一个写日志的fd但偏移量会被两个进程轮流改写出来的内容可能互相交叉。解决这个问题的常见做法是用O_APPEND。它保证每次write都先原子地移到文件末尾再写多进程同时追加不会互相覆盖。但要注意O_APPEND只对每次write有效如果你用lseek调整偏移量再write追加模式下偏移量会被强制移到末尾这跟你想象的可能不一样。另一个被忽略的点是子进程退出时如果它继承了父进程的fd但没有主动关闭这个fd不会自动释放。原因在于fd表是进程级的子进程退出后fd表被回收但打开文件表项还有父进程持有所以内核不会关闭底层的文件句柄。正因为如此在子进程里用不到的文件描述符最好在fork后立即close掉不然fd数会慢慢上涨最终漏光。3. 数据到底什么时候落盘缓冲机制与两段式刷新这一节是很多“IO性能下降”问题的核心。很多人以为写入文件就是立刻写到磁盘实际上中间隔了两道缓冲。3.1 用户态缓冲标准库的缓冲前面说了标准库会先把数据攒到用户态缓冲区。比如fwrite写一个文件如果这个文件是全缓冲模式数据先写进缓冲区缓冲区满通常是4096或8192字节才调用一次write系统调用。如果缓冲区一直没满你不主动fflush数据就一直留在用户态。有三个常见模式要记清楚全缓冲读写普通文件时默认缓冲区满了才刷。行缓冲读写终端时默认遇到换行符就刷。无缓冲stderr默认无缓冲或者你主动setvbuf设置_IONBF。所以在终端里printf输出遇到\n就会显示出来但重定向到文件时就变成全缓冲可能很久不刷。这是很多人写脚本时发现“日志文件内容迟迟不更新进程一退出才全出来”的原因。3.2 内核缓冲页缓存数据通过write系统调用到内核后并没有立刻写入磁盘硬件。它先进入内核的页缓存也就是page cache由内核在合适的时候比如脏页达到一定比例、定时回写、sync调用统一刷到磁盘。所以完整的链路是用户态缓冲区 - 内核页缓存 - 磁盘。两层都有缓冲性能提升明显但代价是断电或宕机时任何一层没刷下去的数据都会丢。如果你在程序里写“把数据写入文件后调用fclose”只能保证把用户态缓冲刷到内核并不能保证数据真正落到磁盘。要强制落盘需要在写完后调用fsync它会通知内核把该文件相关的脏页刷到磁盘。3.3 fflush与fsync的区别fflush是C标准库函数作用是把用户态缓冲区的内容交给内核。fsync是系统调用作用是把内核页缓存的内容刷到磁盘。很多人把这两个混成一谈结果程序里只调了fflush断电时照样丢数据。对于真正需要持久化的数据比如交易记录、服务状态一定要fsync或者让数据库自己保证落盘。但在性能敏感场景也不要滥用fsync。每次fsync都要等待磁盘物理写入机械盘上尤其慢。所以很多高性能系统会折中每隔几秒批量fsync一次而不是每条数据都刷盘。比如日志系统常用“攒一批、写一批、刷一次”的策略既保证一定的持久性又不会把性能拖垮。3.4 为什么IO性能会“明显下降”回到开头的案例程序用fprintf逐行写日志行数一多如果缓冲配置不当会频繁引发系统调用或者程序写入模式很差比如每次只写几个字节、但每次都同步到磁盘更常见的是程序打开了大量文件页缓存被频繁淘汰导致每次写入都要等待磁盘。排查性能下降先分清是用户态频繁系统调用、内核页缓存抖动还是磁盘硬件瓶颈。最快捷的办法是用strace跟踪系统调用频率strace -c -p 12345它会统计一段时间内进程调用了哪些系统调用、各多少次、耗费多少时间。如果看到write被调用了几十万次那大概率是缓冲没用好。4. IO多路复用从阻塞IO到epoll的进阶之路这个话题在基础IO里算是进阶但只要做网络编程就会被问。它的本质是一个进程同时盯多个fd哪个fd有数据读哪个没有就等。4.1 阻塞IO为什么不行最常见的网络服务写法是accept一个连接后用阻塞读等数据。一个线程处理一个连接连接一多就要开很多线程。线程不是免费午餐每个线程都要占栈空间上下文切换也越来越贵所以“多线程阻塞”模型在连接数上来之后会撑不住。业界常说的C10K问题本质上就是连接多了多线程阻塞模型扛不住。非阻塞IO的思路是读不到数据立即返回程序自己反复轮询。但这样太浪费CPU于是有了多路复用。4.2 select、poll、epoll怎么选select把fd数组传给内核内核检查后返回哪些fd可读可写但它有最大fd数限制默认1024而且每次都要重新拷贝整个fd集合性能随fd数量线性下降。poll解决了fd数量限制用链表管理fd但扫描所有fd的本质没变复杂度依然是O(n)。epoll是Linux上效率最高的方案。它有两个关键机制epoll_ctl把需要监听的fd告诉内核内核维护一棵红黑树不需要每次把所有fd全量拷贝epoll_wait等待时内核只把就绪的fd通过链表返回复杂度只跟“有多少事件发生”有关而不是“有多少fd在监听”。所以在成千上万个连接、只有少量活跃时epoll优势非常明显。4.3 epoll的边缘触发和水平触发水平触发是默认模式只要fd还有数据没读完epoll_wait就会一直返回它。边缘触发只在fd状态发生变化时才通知一次所以如果用边缘触发必须一次性把数据读完否则会漏数据。实际上边缘触发要求接收方用非阻塞模式循环读取直到read返回EAGAIN才能确定读完了当前批次。这个处理不够谨慎的话容易丢包或忙轮询。在真实项目中如果业务不复杂水平触发足够。边缘触发适合追求高吞吐的场景但代码复杂度高很多。我自己做项目时通常先用水平触发把功能验证通过再考虑要不要优化成边缘触发。4.4 从NIO到AIOJava里的类比Java的NIO底层就是多路复用Selector会轮询channel对应Linux的select/poll/epoll实现。AIO在Linux的早期实现坑很多底层依赖epoll模拟收益不大所以很多高性能框架宁可基于NIO自己调度也不轻易用AIO。如果你的开发语言是Gogoroutine的阻塞读写本身就是运行时帮你封装好的异步模型底层还是绕不开epoll。理解Linux基础IO之后你再看各种语言的网络库底层逻辑都是同一套东西。5. 实操IO性能下降时我到底怎么排查这一章写给做开发和运维的人也是我平时排查问题的固定流程。按顺序来基本不会排查错方向。5.1 先看现象再用strace第一步是确认是程序自身的问题还是机器层面的问题。如果程序还跑着直接strace跟踪strace -f -tt -T -p 12345 -o /tmp/strace.log-tt显示精确时间-T显示每个系统调用的耗时-f跟踪子进程。跑几秒后看log如果write频繁且耗时高问题在程序侧如果write很少但read/write耗时都高问题可能在磁盘。5.2 用iostat看磁盘瓶颈iostat能看设备级别的读写速度、IO队列长度、利用率。重点看两列util和await。util接近100%说明磁盘接近瓶颈await很高说明单个IO请求排队太久可能是随机读写太多或磁盘本身慢了。我见过一个典型案例一个数据库的临时表目录被放在机械盘上随机读写密集iostat里await飙到几百毫秒应用层性能直接崩。后来把临时目录换到SSD一切恢复正常。排查IO性能问题磁盘类型和IO模式要优先确认。5.3 用free和/proc/meminfo看页缓存如果程序写入很多但系统还一直有大量脏页没回写free命令里的cache会很高。可以进一步看/proc/meminfo里的Dirty和Writeback两个字段Dirty高说明页缓存大量堆积还没来得及落盘。这不是让你急着清缓存而是提醒你很多写入其实并没有真正落到磁盘所谓“写入快”可能只是写到了page cache里。如果强制落盘要求高需要调整dirty_ratio、dirty_writeback_centisecs这些内核参数。5.4 用lsof和/proc检查fd泄漏fd泄漏排查起来也很简单ls /proc/12345/fd | wc -l再看看当前进程的fd上限ulimit -n如果fd数持续上涨接近上限那多半是open后没有close或者调用socket后没有释放。lsof -p 12345能列出每个fd对应的文件路径看哪些路径反复出现却没关闭基本就能定位到代码位置。5.5 dd快速做基准测试想快速知道这台机器的磁盘写入速度可以用dd if/dev/zero of/tmp/test bs1M count1024 oflagdirectoflagdirect会绕过页缓存直接测量真实磁盘写入速度。这个值用于跟应用侧观察到的写入速度对比如果应用比dd差很多问题基本在应用侧不在磁盘。6. 常见问题速查与避坑经验最后整理一份我遇到的、以及身边同事遇到的经典问题直接按症状查找。6.1 经典问题速查表症状可能原因处理方式程序退出后文件内容为空fclose/fflush没调用缓冲未刷退出前调用fclose或fflush或使用atexit注册清理断电/宕机丢最近数据只有用户态/页缓存没刷磁盘关键数据用fsync或按固定周期批量刷盘write返回部分写入目标空间不足/信号打断循环调用write直到写完或用writevopen返回EMFILEfd达到进程上限lsof定位泄漏fd用ulimit -n调大上限同时修代码多进程写同一文件内容错乱偏移量竞争用O_APPEND或用flock/文件锁日志文件不实时刷新标准库全缓冲模式写完后fflush终端行缓冲重定向后是全缓冲select最多只能监听1024个fdfd_set默认限制换poll/epollepoll边缘触发漏读数据没一次读完用非阻塞循环读到EAGAIN或改用水平触发误用lseekwrite做追加文件偏移量不可控直接O_APPEND避免手动移动偏移量6.2 实战避坑经验open后一定要检查返回值。很多C代码习惯不判断文件没打开成功后面write直接往-1上写读strace时发现是EBADF一脸懵。不要频繁open/close同一个文件。每次open都涉及路径解析、inode查找成本很高。如果循环里需要反复写同一个文件把它放在循环外或者用fopen先hold住句柄。strace在生产环境别跑太久。它把所有系统调用都挂上钩子性能损耗不小。一般跟踪几秒到几十秒足够定位别把服务拖垮。还有一个很隐蔽的坑fork出来的子进程如果继承了父进程的fd子进程退出时如果没有关闭它这个fd不会自动释放。所以在子进程里用不到的文件描述符最好在fork后立即close掉不然fd慢慢就漏光了。页缓存清理不能作为日常手段。echo 3 /proc/sys/vm/drop_caches这种方式偶尔在测试环境用一下可以生产环境强行清缓存会导致后续读写全部走磁盘性能反而会崩。规范做法是调整dirty_ratio和dirty_writeback_centisecs让内核更积极地回写脏页。最后说一个我已经养成习惯的排查技巧凡是遇到“IO性能下降”问题别一上来就猜硬件先strace看系统调用、再iostat看磁盘、再lsof看fd按这个顺序来基本都能定位。Linux基础IO看起来是些基础概念但真正用好的代码和出问题的代码差别往往就在缓冲、偏移量、fd管理这些细节里。我在实际项目里踩过的坑十个有八个都能归结到这上面。
返回列表