首页 / 资讯中心 / 文章详情

从文件描述符到零拷贝:IO底层机制核心梳理

从文件描述符到零拷贝:IO底层机制核心梳理 ★ FEATURED ARTICLE
我最早对IO的认知基本停留在fopen和fread这种调用层面。当时觉得文件读写是编程里最没门槛的事直到有一次线上服务出了个怪毛病CPU占用不高内存也充裕网络带宽没有打满但请求平均耗时翻了好几倍所有人都盯着代码逻辑查了半天最后发现问题恰恰出在一直被忽略的磁盘IO上。那次之后我把IO相关的底层机制重新梳理了一遍才发现很多基础概念自己根本没真正理解过。这篇就沿着我重新学习的路线把IO基础里最容易模糊、也最影响实际开发的部分串起来讲清楚适合正准备系统学IO、以及被各类IO问题困惑过的朋友。1. 我们每天在说的IO其实分成了三个战场IO的完整说法是Input/Output翻译过来就是输入输出。别看定义只有一句话落到真实场景里你会发现它无处不在程序启动时要读配置运行时要写日志和外部服务通信时要收发数据包。但归纳起来绝大多数人平时打交道的数据来源只有三个方向文件、终端、网络。这三个方向的IO行为差异很大很多时候你以为自己在处理IO问题实际上只是遇到了其中某一种的特殊性。一开始就分清楚后面才不会绕弯。1.1 文件IO最朴素也是坑最多的入口文件是大多数人学习IO的第一个对象。打开、读、写、关闭看起来只有四步但文件打开时的参数组合能玩出很多花样。只读、只写、追加、创建、截断不同模式会影响内核处理文件的方式。举个最常见的例子写日志时到底该用追加模式O_APPEND还是普通写模式很多人觉得两者差不多甚至有人习惯用写模式打开文件然后自己维护偏移量。但实际上追加模式会保证每次写都发生在文件末尾并且这个操作在内核层面是原子性的多个进程同时写同一个日志文件不会互相覆盖。如果你用读改写模式自己维护偏移量多进程一竞争数据交错错乱是迟早的事。这种细节就是文件IO的典型坑不踩一次很难长记性。文件IO里的另一个经典问题是句柄耗尽。程序跑得越久文件描述符泄露越多最终打开文件时报too many open files很多老系统线上故障的根因就是这个。1.2 终端IO你天天写却没注意它也算IO再说终端IO。printf、getchar这类操作很多人写了几年代码都没觉得它们是IO但它们的底层确实在走输入输出流程。这里最有趣的一个现象是printf输出到终端和输出到文件行为是不一样的。标准库会根据输出目标决定缓冲模式输出到终端通常采用行缓冲输出到文件则采用全缓冲。所谓行缓冲就是遇到换行符才把缓冲区里的内容真正交出去而全缓冲要等缓冲区填满或者程序正常退出才刷新。这就带来一个特别经典的翻车现场程序里用printf埋了个调试信息结果程序中途异常崩溃你发现那条printf的输出不见了就是因为缓冲区没来得及刷新。我在本地排查问题时还真遇到过最后换成fprintf(stderr, ...)才把现场救回来。1.3 网络IO性能压力真正的火山口网络IO才是今天性能问题的高发区。读socket和读文件有一个本质区别文件读取基本上是你想读就一定有那么些内容在顶多遇到EOF但网络socket是有数据才读得到没数据时怎么办——是傻等着还是立刻返回去做别的事这个选择直接引出了后面要讲的IO模型问题。另外网络IO还有一个绕不开的半包黏包现象一次recv读到的数据不一定正好对应发送方的一次send。这就是为什么网络编程里几乎没有直接拿着原始read/write就能写出稳定程序的场景通常需要自己设计消息边界和接收缓冲。不过半包黏包属于协议层处理技巧这里先按下不表你先记住网络IO的特殊性就够了。2. 每次read/write都直接碰到磁盘反正我不信我见过不少刚接触IO的人脑子里有一个很朴素的模型调用read数据就从硬盘飞进内存调用write数据立刻飞到硬盘上。真实情况要复杂得多中间隔了好几个层次。不搞明白这一层后面看缓冲区、看零拷贝、看阻塞模型全都云里雾里。2.1 文件描述符进程和一切IO资源之间的门牌号要理解IO先得知道进程是怎么抓住一个文件或一个socket的。答案是文件描述符fd。当一个进程打开文件、创建socket内核都会给这个对象分配一个整数编号这个编号就是文件描述符。内核内部维护着一张打开文件表每个fd都指向表里的某一项这一项记录了文件偏移、打开模式、读写状态等信息。之后进程发起的所有IO操作基本都长这样给我这个fd我要读给我这个fd我要写。刚启动的程序默认有三个fd标准输入0、标准输出1、标准错误2。你可能没注意过但每次调用printf、scanf实际底层操作的就是这些默认fd。理解fd还有一个实际用途排查too many open files错误时可以用lsof命令看进程打开了哪些fd、是不是有泄漏这个命令在服务器问题排查中几乎必用。2.2 系统调用和标准库函数之间隔着一个隐藏缓冲区很多人分不清read/write和fread/fwrite以为只是命名风格不同。实际上它们的层次完全不同。read和write是系统调用直接面对内核。你调用write数据会从进程的用户空间复制到内核空间内核再想办法把数据交给文件系统或网卡。fread和fwrite是标准库的封装自己维护了一层用户态缓冲区。你调用fwrite时数据通常不会立刻进入内核而是先攒在标准库的缓冲区里等缓冲区满了、或者调用fflush才会触发一次write系统调用。这一层隐藏的缓冲区非常关键。它带来的最直接收益是减少系统调用次数毕竟一次write要付出的代价比一次用户态拷贝大得多。但代价也很明显数据没有立即进入内核所以程序崩溃时缓冲在用户态的数据就丢了。这就是为什么用fwrite写文件程序宕机后发现文件内容不全的案例屡见不鲜。2.3 上下文切换的成本为什么多几次系统调用影响那么大既然提到系统调用次数就得说说它到底贵在哪。每次调用read/write这类系统调用CPU都要从用户态切换到内核态做完IO再切回用户态。一次切换不是简单的换个模式继续跑而是要保存用户态寄存器、切换特权级、执行内核入口函数、最后恢复现场整个过程会打断CPU流水线还会造成缓存污染。一次上下文切换的绝对开销其实是微秒级别听起来不吓人。但如果一个程序每秒做几十万次小规模write这个开销就会变成明显的性能瓶颈。更别说有些场景下IO路径里能有两三次切换放大效应非常可观。所以几乎所有的IO库都在做同一件事把零散的、小粒度的IO请求合并成大块减少系统调用次数省下上下文切换的成本。理解了这一层你就明白为什么很多高性能组件都在强调聚合写攒批写了。3. 缓冲区机制快不快看它数据丢不丢也看它缓冲区在IO体系里无处不在标准库有用户态缓冲内核有页缓存磁盘本身也有高速缓存。很多人只把缓冲区当成性能优化的一个选项实际上它同时决定了两个关键问题读写快不快数据会不会丢。3.1 全缓冲、行缓冲、无缓冲一张表讲清标准库常见的缓冲策略有三类它们在实际场景中对得上号缓冲模式触发刷新的时机典型场景全缓冲缓冲区写满普通文件读写fopen默认行缓冲遇到换行符输出到终端printf默认无缓冲立即输出标准错误stderr为什么stderr要设计成无缓冲因为错误信息要第一时间让人看到。如果错误信息也攒在缓冲区里程序崩溃时连什么原因都看不出来调试体验极差。所以调试时我建议用fprintf(stderr, ...)或者fflush(stdout)别让缓冲区把信息吞了。全缓冲则解释了另一个现象往文件里写一批小字符串没等到缓冲区满程序就崩溃了整个缓冲区一起没了。很多人以为写了就落盘实际上离落盘还隔着好几层。3.2 什么时候数据才算真正落盘关于落盘有几个层次需要分开讲。write系统调用执行成功只代表数据已经进入了内核的页缓存page cache。真正的磁盘写入动作是由内核的回写线程在稍后的某个时间点执行的可能是脏页攒到一定比例也可能到了周期性回写时间。很多人以为close一个文件数据就一定落盘了这也是误解。close只释放这个fd在内核里的相关资源并不保证脏页已经刷到磁盘。要想强制把某个文件的数据落盘得调用fsync它的职责就是把指定文件的数据和元数据都刷到设备。这里还有一层坑即使fsync返回成功数据也未必百分百在磁盘介质上。因为磁盘自身还有高速缓存如果断电发生在磁盘缓存还没写回介质的那一刻数据一样可能丢失。所以我们能看到一些数据库产品会额外处理设备层缓存的问题。作为IO基础这部分不用钻太深但请记住一条经验在需要持久化承诺的场景比如写数据库事务日志fsync是不可省略的动作。3.3 缓冲区大小怎么选一次实测经验缓冲区是不是越大越好也不是。我拿一个2GB的文件做顺序读测试分别用1字节、1KB、4KB、1MB的缓冲区去read结果很有意思1字节读法的耗时是4KB读法的几十倍4KB到64KB之间性能还在提升但幅度已不明显到了1MB不仅没有明显提升还白占内存。为什么缓冲区太小会导致系统调用次数爆炸这是主要瓶颈而缓冲区大到一定程度后数据搬运已经受限于内存带宽和文件系统实际吞吐能力放大缓冲区不再有正向收益超过CPU缓存容量的缓冲区反而会因为缓存失效增加额外开销。所以通用场景下选8KB到64KB网络小包场景用4KB左右是比较稳妥的经验值。不要为了追求大而盲目设几MB的buffer。4. 阻塞、非阻塞、多路复用先分清油门和档位很多学网络编程的人卡在IO模型上最主要的原因是没分清两组概念阻塞和非阻塞是一对同步和异步是另一对。它们说的是不同维度的事混在一起自然糊涂。我自己也是绕了很久才理顺。4.1 阻塞与非阻塞线程是不是被挂起以read为例。阻塞模式下如果socket上没有数据read调用会一直等在那里把当前线程挂起直到有数据到来才返回。非阻塞模式下read发现没有数据立刻返回一个错误码通常是EAGAIN线程继续往下跑。阻塞模式的编程逻辑最简单但问题也明显你不知道哪个fd有数据只能一个接一个等。如果要同时处理多个连接要么开多个线程一个线程等一个连接要么就得换非阻塞思路。线程数一多内存和切换开销立刻爆炸这就是经典的线程爆炸问题。非阻塞模式下程序可以同时盯着一百个fd挨个尝试read谁有数据读谁。但如果每次都真的轮询这100个fd绝大部分调用都在做无用功CPU浪费同样严重。所以还需要一层谁来通知我有数据的机制这就引出了多路复用。4.2 同步与异步你是不是还得自己来阻塞与非阻塞讨论的是调用方等待时线程的状态同步与异步讨论的则是IO完成的整个流程里你还要不要亲自参与。同步IO操作完成后程序自己处理数据不管你是傻等、还是轮询检查最终搬数据这个动作都是程序自己发起的。异步IO则不同你发起一个读操作后立刻返回去做别的事等内核把数据完全准备好了通过回调或事件通知你你拿到的是现成结果。举一个特别直观的类比同步阻塞像在食堂窗口排队一个人点完才能点下一个同步非阻塞像你反复走到窗口看菜好了没异步非阻塞像点外卖做完会给你打电话期间你该干嘛干嘛。异步方式适合高并发场景因为发起IO的线程几乎不被占用但它也把编程模型变复杂了你没办法简单地用从上到下的代码顺序思考逻辑。4.3 从select到epoll高并发场景逼出来的选择多路复用技术是解决同时监控大量fd的核心思路。早期select和poll的做法是把一堆fd交给内核内核挨个检查哪些可读可写然后返回结果。问题在于每次检查都要把这堆fd重新扫描一遍fd数量一多效率直线下降复杂度是O(n)。epoll改变了方式。它采用的是事件驱动你先把感兴趣的fd挂到epoll实例上内核只把真正发生了事件的fd告诉你。没有事件时线程可以彻底睡过去有事件时直接拿到哪个fd发生了什么的结果复杂度降到O(事件数量)。这就是单线程能支撑几万甚至几十万连接的关键。理解这个演进过程比死记硬背epoll是好东西有用得多。4.4 用排队吃饭来理解这四种组合把两组概念组合在一起可以得到一张经常出现在面试题里的表组合方式线程状态数据处理场景示例同步阻塞一直等待自己处理简单文件读写、小型客户端同步非阻塞不断检查自己处理单线程循环检查多个fd异步阻塞等通知但迟迟不来内核处理实际应用较少异步非阻塞发起后离开内核处理完通知高并发网络服务、消息队列消费现实中我们经常把同步非阻塞和IO多路复用搭配使用epoll通知你有事件到来你再去read这时read几乎不会空等。这种方式本质上是把准备好这个动作从程序轮询交给了内核完成程序仍然自己读取数据所以归为同步非阻塞。这一块想深了很绕但只要抓准线程是否挂起和谁搬数据两个问题基本就稳了。5. 零拷贝与mmap进阶绕不开的两把钥匙如果你的日常工作只到文件读写和基础socket通信缓冲区已经够用。但一旦接触消息队列、文件传输、日志采集这类数据密集型组件就会频繁看到两个词零拷贝和mmap。它们不是炫技是实实在在解决性能问题的方案。5.1 传统IO路径里八趟搬运以把一个文件内容发到网络上为例传统做法是read加write两步。read把数据从磁盘搬到内核缓冲区再从内核缓冲区搬到用户态缓冲区write再把数据从用户态缓冲区搬到socket内核缓冲区最终从socket缓冲区发给网卡。数一下这条路径上的动作四次数据拷贝、两次系统调用、四次上下文切换。所谓拷贝说的就是同一条数据在不同内存空间之间反复倒腾CPU在中间充当搬运工。数据量一大CPU大量时间花在搬运而不是处理业务上系统吞吐自然上不去。5.2 sendfile与mmap各自解决的问题不同sendfile系统调用就是为了减少这个搬运过程。它可以让数据直接在内核空间的缓冲区之间流转不需要先进用户态一次调用就能把文件内容从磁盘送到socket。路径缩短为磁盘到内核缓冲内核缓冲到网卡省掉了用户态这个中转站。大文件传输场景用sendfileCPU占用下降非常明显。mmap则是另一条路。它直接把文件映射到进程的地址空间用户代码可以像访问内存一样读取文件内容内核和用户空间共享同一段物理内存省掉了读文件时必须先copy到用户缓冲区的步骤。mmap在很多场景下是高性能文件读取的核心手段它跟sendfile解决的并不是同一个问题。5.3 mmap的适用边界大文件好小文件别碰mmap虽好但并不是无敌的。建立映射需要做地址空间分配、页表建立、缺页中断处理这一套初始化动作本身有开销。如果你只是读一个几千字节的小文件一次性read的成本很可能比mmap这套初始化还低。所以在读一次文件马上就丢的场景里直接用read更合适。写入频繁的场景也要小心。mmap的脏页回写时机由内核控制你没法精确知道数据什么时候落盘。如果你需要写到这一行就必须确保已落盘的语义还得额外调用msync而这个调用同样有开销。另外还有一个隐蔽的坑如果被映射的文件被截断进程可能收到SIGBUS信号直接崩溃。高可用服务里用mmap前这些边界条件必须想清楚。6. 从一次线上故障说起IO问题排查实录前面讲了那么多原理最后用一个我自己经历过的问题把思路串起来。这个案例不算罕见但排查过程很能体现IO基本功的价值。6.1 症状CPU不高但响应越来越慢当时是一个后台批处理服务职责是逐条处理消息并把中间状态写进文件。服务启动后一切正常但跑上几个小时响应速度就肉眼可见地变慢重启进程后又能恢复一段时间。一开始大家怀疑是内存泄漏但监控显示内存和CPU确实没明显异常。后来有人注意到top输出里wa这个指标越走越高才把方向转到IO上。6.2 排查链路top、iostat、strace三步走排查IO问题我习惯按三步走。第一步用top看全局确认CPU里waIO等待的占比是不是在涨。如果wa很高说明很多线程在等IO完成。第二步用iostat -x 1看磁盘细节。重点看%util、await、avgqu-sz这几个指标。当时磁盘%util基本稳定在100await远超正常值队列深度也高得离谱说明磁盘已经严重饱和请求全在排队。第三步用strace attach到进程上看它到底在发起什么系统调用。这一步最关键因为前面只能证明磁盘有问题strace才能告诉你到底是什么操作导致的。6.3 问题根因无谓的open/write/close风暴strace结果出来之后问题一目了然程序在疯狂执行open、write、close三个调用的组合。某段处理逻辑里每来一条消息就把状态文件打开一次、写一行、关闭一次。消息量一大磁盘上每秒出现几百次open加write加close的循环。这里的损耗远超普通人直觉——每次open和close不只是打开和关闭本身还要做文件元数据的更新和同步。即使写入内容还在页缓存里元数据相关的磁盘动作和锁竞争已经把整个存储子系统堵死。结果就是所有磁盘请求都开始排队服务自然越来越慢。6.4 修改后效果和数据修复方案其实不难把每处理一条消息就打开关闭文件改成常驻写入通道攒一批数据后再落盘。改完后每秒的IO次数从几百降到几次磁盘队列长度从排队状态回到接近零%util降到个位数服务恢复正常并且长时间运行不再劣化。这个案例启发我两点一是IO问题经常不表现为CPU高而是表现为整个程序变慢所以排查时要看wa和磁盘指标二是在设计阶段就考虑减少系统调用次数和合理利用缓冲区这些基础原则比出了问题再调优省太多事。我个人的体会是遇到性能问题不要急着怀疑业务逻辑先把IO这条线的基本功过一遍很多答案就藏在这些最基础的概念里。你写下的每一次read、每一次write背后都有一整套机制在运作把它们理解透了排查问题时才会知道自己到底在看什么。
阅读完成 · 觉得有帮助?
咨询建站