Linux 系统编程绕不开 IO,这几乎是所有做后台服务的开发者都躲不掉的坎。我前几年写网关程序时就有过一次很深的教训:单机几万条小报文进来,程序没有崩溃,CPU 也不算高,但延迟一点一点往上爬,最后整条链路被打穿。排查到最后才发现,热点在一个不起眼的write()上——每条报文都触发了一次完整的系统调用,用户态和内核态来回切换,堆积的缓冲迟迟没有按预期刷出去。也就是从那次之后,我才意识到 IO 不是“读读写写”那么简单,它背后藏着文件描述符、缓冲层、IO 模型、页缓存、零拷贝这一整条知识链。
这篇文章就按我自己的学习路径来做一次系统总结,重点讲实际编码中容易出问题的地方,以及排查 IO 问题时怎么一步步缩小范围。内容适合两类人:一类是刚开始接触 Linux 系统编程,想把 IO 相关概念串起来的同学;另一类是已经写了几年业务代码,但遇到“进程卡住”“IO 性能下降”“缓存命中了为什么还慢”这类问题时,希望能有更清晰排查思路的开发者。下面进入正题。
1. 文件描述符不是文件本身:一张会骗人的“票据”
1.1 fd 只是打开文件表里的一个索引
很多初学者会把文件描述符(fd)和“文件”画等号,这个误解会埋下不少坑。实际上 fd 只是一个整数,它指向进程内部的文件描述符表,而这张表里的每一项,才真正对应内核里的打开文件描述对象。同一个磁盘文件如果被open()两次,你会拿到两个不同的 fd,这两个 fd 看起来是同一份文件,但文件偏移量各自独立,读写互不干扰。
反过来,如果通过dup()、dup2()或者fork()复制 fd,复制出来的新 fd 和原来的 fd 会指向同一个打开文件描述对象。这种情况下,两个 fd 共享同一个文件偏移量。最直观的副作用是:父进程和子进程同时对同一个 fd 做read(),会交替读到数据,而不是各自从头开始读。遇到“为什么这个文件读了两次,内容不一样”这类诡异问题,先查一下是不是共享了偏移量。
这段关系用一张表可以看得很清楚:
| 场景 | fd 是否相同 | 是否共享偏移量 | 读写相互影响 |
|---|---|---|---|
open()两次同一个文件 | 不同 | 否 | 无 |
dup()复制 fd | 不同 | 是 | 有 |
fork()继承 fd | 子进程新复制 | 是 | 有 |
| 普通函数间传递 fd | 相同 | 是 | 有 |
1.2 open 的 flags 里藏着的三个细节
第一个细节是O_APPEND。它保证每次写入前把偏移量挪到文件末尾,但要注意,这个保证只对“偏移量更新”是原子的。如果你有多个进程同时追加日志,用O_APPEND比手动lseek()到末尾再write()安全很多。但O_APPEND不等于所有写入都原子完成,极端情况下,一次大写入仍可能被拆分。
第二个细节是O_CLOEXEC。很多老代码习惯在open()之后再用fcntl(fd, F_SETFD, FD_CLOEXEC)来设置执行时关闭标志,但这两步之间存在竞态窗口。如果在fork()和exec()之间恰好有另一个线程打开了一个敏感 fd,exec 执行外部程序时这个 fd 就可能被继承过去,造成泄漏甚至安全问题。现在 Linux 的open()本身就支持O_CLOEXEC,能用标志位一次搞定的,就别拆成两步。
第三个细节是新建文件的权限。open()里的 mode 参数只决定文件创建时的权限位,但它会被进程的 umask 过滤。你写了0644,如果 umask 是0022,最终文件可能只有0644;如果 umask 是0077,那创建出来的文件权限就变成0600。想确认实际权限,创建后调fstat()看一眼最保险。
1.3 关闭 fd 也不是随手 close 就完事
关闭 fd 看起来简单,但实际生产环境里也翻过车。最常见的坑有两个:一是对同一个 fd 调用了两次close()。第一次关闭成功后,这个 fd 编号可能立刻被另一个open()复用,第二次close()就会把别的连接意外关掉。这个 bug 非常隐蔽,而且很难复现。习惯上应该在close(fd)之后立刻把 fd 置为无效值,并且写上注释,提醒自己别二次关闭。
二是循环里等待某个事件时误关 fd。比如你在一个事件循环里管理大量连接,某个连接超时后你把它 close 掉,但这次 close 触发的回调又去操作了同一个 fd,此时 fd 已经被回收,操作的是新打开的连接,整个状态就乱了。对这种场景,社区通用的做法是:所有对 fd 的管理都落到一个统一生命周期模块,关闭时标记为 INACTIVE,事件回调先检查状态再动手。
fd 数量本身也值得留意。ulimit -n默认可能是 1024,做高并发服务时这个值往往不够。临时生效可以用ulimit -n 65535,长期运行建议在服务启动脚本里一并设置。用lsof -p <pid>或者ls /proc/<pid>/fd可以快速看到进程当前打开了哪些 fd,连接数异常增长时先跑这两个命令,基本能定位是否 fd 泄漏。
2. read / write 的边界行为,比接口文档里写的更值得研究
2.1 短读和短写:你以为读完了,其实还没有
read()的原型很简单,但它有个经常被忽略的特性:返回值可能比请求的字节数小。这在普通文件上不常见,但在管道、终端、套接字上是常态。比如网络包只有一个 TCP 分段到达,你read()了 8192 字节,内核只会把目前已经收到的数据先返回给你,剩下的等下一个包。如果你按 8192 字节去解析协议,解析函数就认为“包还没完整”,但下一次read()读到的其实是下一段数据,协议就错乱了。
write()也有类似问题。信号中断、底层缓冲不满、磁盘配额限制,都可能导致一次write()只写入部分数据。所以可靠的程序都会封装“读写全部字节”的工具函数。下面这个read_full()是非常基础但足够稳的实现:
static ssize_t read_full(int fd, void *dst, size_t len) { char *p = (char *)dst; size_t total = 0; while (total < len) { ssize_t n = read(fd, p + total, len - total); if (n == 0) { break; /* EOF */ } if (n < 0) { if (errno == EINTR) { continue; /* 被信号打断,重试 */ } return -1; } total += (size_t)n; } return (ssize_t)total; }对应的write_full()逻辑类似,唯一区别是n == 0不代表结束,而是应该报错或继续,因为write()返回 0 通常是不正常的。要记住一个原则:凡是基于 fd 的读写,都要做好“不按请求数量返回”的准备。
2.2 EINTR:系统调用被信号打断之后,别直接当错误处理
在信号处理程序注册了 handler 之后,慢速系统调用(read()、write()、wait())有可能被信号打断,返回 -1,并且errno被设为EINTR。对于这种情况,正确的做法是重试,而不是直接按错误退出。
如果你用sigaction()注册 handler 时设置了SA_RESTART,内核会自动帮你在部分系统调用上重启,省掉手动重试的功夫。但并非所有调用都会被自动重启,比如poll()、select()、epoll_wait()在某些情况下仍然会返回EINTR。稳妥的写法还是手动判断一下EINTR。上面read_full()里已经体现了这个思想。
另外,如果进程收到SIGALRM这类信号,时间敏感的程序要额外小心。EINTR不只是“重试一次”那么简单,它意味着这段等待被打断了,你要重新评估超时时间是否到了。比如一个 5 秒超时的epoll_wait(),中间被信号打断返回后,剩余的超时时间可能已经不足 5 秒,照原值重试会把超时总时长拉长。正确做法是用单调时钟计算剩余时间,再传给epoll_wait()。
2.3 fsync、fdatasync 与 O_SYNC:数据何时真正落盘
write()返回成功,并不代表数据已经写到磁盘。它只是把数据拷进了内核的页缓存,真正落盘是由内核异步完成的。对于数据库、交易日志这类要求高可靠性的场景,必须主动调fsync()才能确认数据持久化。
fsync()会把文件数据和元数据都刷到磁盘,代价比较高。fdatasync()只刷文件数据,必要时才更新元数据,性能通常更好。如果你只是担心数据本身丢了,而不那么关心文件大小、修改时间这些精确值,优先用fdatasync()。另一种做法是在open()时加O_SYNC标志,让每次write()都同步落盘,但这种方式把整条 IO 路径上的提交延迟都变成了同步等待,生产环境的吞吐量往往会掉几个数量级,我一般只在小文件、低频写的场景才考虑。
还有个容易忽视的点:rename()之后也需要对所在目录做fsync(),否则断电可能让目录项更新丢失。这个细节在实现“写临时文件 + rename 替换”这种原子更新策略时尤其重要。我在做配置热更新时就踩过——程序重启后有时读到旧文件,有时读到新文件,最后发现是目录没 fsync。
3. 缓冲:性能与可观测性之间的拔河
3.1 三层缓冲,很多人只看到了最表面的一层
一个printf()背后其实经过了三层缓冲。第一层是 C 标准库的 stdio 缓冲,也就是printf()/fwrite()在用户态攒数据的地方。第二层是内核里的页缓存和 socket 缓冲,也就是系统调用真正写入的地方。第三层才是磁盘控制器、SSD 的 DRAM 缓存这些硬件层。
这三层里最容易让程序行为不同的,是第一层 stdio 缓冲。默认情况下,stdout如果连接到终端,是行缓冲——遇到换行符就刷出;如果重定向到了文件,就变成全缓冲——缓冲区满了才刷出。于是经常出现这种怪事:程序运行在终端里一切正常,用nohup或者重定向到日志文件后,输出莫名其妙延迟、丢失,原因就是 stdout 变成了全缓冲,程序 crash 时缓冲区还没刷出去。
解决方法是显式setvbuf(stdout, NULL, _IONBF, 0)关闭缓冲,或者_IOLBF设置行缓冲,再配合fflush()在关键节点手动刷。如果用的是自定义日志库,干脆不要走 stdio 的高层接口,直接基于 fd 自己做缓冲更可控。
3.2 大块读写是性能建议,不是朴素直觉
我见过同学把read()的 buffer 从 4KB 调到 256KB,以为一定能提速,结果测出来差不多,甚至更慢。原因是:对普通文件来说,系统调用耗时和拷贝开销主要看的是页缓存命中情况,buffer 大小达到页大小(通常 4KB)之后,继续增大 buffer 对顺序读的影响很小。真正提升吞吐的关键是减少系统调用次数。
举例来说,你要把一个 1GB 文件从 A 拷贝到 B。用read()+write()每次 1 字节,那要 20 亿次系统调用,理论上慢到无法接受;每次 4KB,就是 26 万次系统调用,已经能跑出接近磁盘上限的速度;再往上到 1MB,次数进一步减少,但收益边际递减。所以写工具时,read()的 buffer 设在 64KB 到 1MB 之间是个相对不错的选择。但如果你每次只用read()读几百字节,那就别指望大 buffer 能救你。
另一个和缓冲相关的问题是“IO 性能下降了”。遇到这类问题不要只看缓存,先确认是不是已经变成了小写放大。我在排查线上问题时常用strace -c统计系统调用次数,如果调用次数高得离谱,多半是应用层缓冲策略出了问题。
4. 五种 IO 模型,总有一款会在线上和你不期而遇
4.1 阻塞 IO 是默认姿势,它并不低级
很多人一谈高并发就把阻塞 IO 说得一文不值,其实阻塞模型是理解其他模型的基础。默认情况下,read()一个没有数据到达的 socket,线程会卡在核心里等待,这就是阻塞 IO。它的优点是代码简单、逻辑顺序和实际执行顺序一致,特别适合每个连接独立线程、连接数量可控的场景。
阻塞 IO 的问题在于“线程数=并发数”。如果一万个连接同时在线,就需要一万个线程,线程切换和内存栈的开销很快把机器压垮。于是在连接数高、单连接活跃度低的场景,阻塞模型就显得力不从心。
4.2 非阻塞 IO 让程序自己轮询,忙碌但并不优雅
把 fd 设置为O_NONBLOCK后,read()在没有数据时不会等待,而是立即返回 -1,errno为EAGAIN或EWOULDBLOCK。这样程序可以循环去问“有数据没?没有?那我去看看别的”。非阻塞模型下,单线程可以处理多个连接,但循环轮询本身消耗 CPU。如果一万个连接里只有几个活跃,这种空转浪费非常明显。
所以非阻塞 IO 很少单独用,它通常是事件驱动的底层拼图,配合多路复用函数才有意义。单独讲非阻塞,不如把它理解为“给内核的等待加了一个‘不等待’开关”,重点是为后面的事件通知机制做准备。
4.3 多路复用、信号驱动和异步 IO,三种“被通知”的方式
多路复用是最常见的事件通知机制。select()、poll()、epoll_wait()都由内核帮忙盯着多个 fd,一旦某个 fd 可读或可写,它就把信息返回给用户态。程序不用轮询所有 fd,而是等内核来叫。这是目前网络服务最主流的模型。
信号驱动则是让内核在 fd 就绪时发送SIGIO信号。这种方式在一些特定场景有用,但信号处理函数里能做的事有限、异步安全要求高,实际工程中用得比较少,更多是作为一种进阶了解。
真正的异步 IO 是让内核把数据从 fd 拷贝到用户指定的缓冲区,完成之后再通知你。传统代表作是aio_read()一类接口,但诟病不少;现代的io_uring才是真正把异步 IO 带进主流视野的技术,它通过共享内存环形队列提交和收割请求,能明显减少系统调用次数,适合存储和网络混合的高性能场景。
| IO 模型 | 内核是否帮忙等待 | 用户态是否轮询 | 典型场景 |
|---|---|---|---|
| 阻塞 | 是 | 否 | 连接数少、逻辑简单 |
| 非阻塞 | 否 | 是 | 配合多路复用使用 |
| 多路复用 | 是 | 否 | 高并发网络服务 |
| 信号驱动 | 是 | 否 | 低频信号,定制场景 |
| 异步 IO | 是 | 否 | 存储、超高吞吐 |
5. select 到 epoll:多路复用的演进,值得重刷很多次
5.1 select 和 poll 的两个历史包袱
select()的第一个包袱是 fd 数量上限。它内部用fd_set位图表示 fd,FD_SETSIZE通常是 1024,超过这个数就没法处理。第二个包袱是性能退化。每次调用select()都要把整个 fd 集合从用户态拷贝到内核态,内核再遍历一遍,事件触发后还要再从内核拷回用户态。fd 一多,这个 O(n) 的复制和遍历就成了明显瓶颈。
poll()解决了 1024 上限的问题,因为它用链表传递 fd,不再受位图限制。但性能退化问题还在,只是从“位图越界”变成了“每次调用仍然要遍历全部 fd”。如果你管理的连接只有一两百个,poll()完全够用,但一旦几千个连接同时存在,它的效率就很成问题。
5.2 epoll 的设计思路:内核替你维护监视列表
epoll之所以好,是因为它把“每次全量拷贝+全量扫描”变成了“注册一次、增量更新、就绪通知”。epoll_ctl()把关心的 fd 对象注册进内核里的一棵红黑树,epoll_wait()只是等待就绪队列里有没有新事件。活跃 fd 少的时候,复杂度是 O(事件数) 而不是 O(总 fd 数),这正是高并发场景最需要的。
epoll还提供了两种触发模式:水平触发(LT)和边缘触发(ET)。LT 模式下,只要 fd 上还有数据没读完,每次epoll_wait()都会通知你;ET 模式下,只有在就绪状态发生跳变时通知一次。ET 更高效,但要求你必须一次性把数据读到EAGAIN,否则剩下的数据可能要等下次新数据到达才被通知到,造成“数据明明在缓冲区里,程序却没反应”的假象。
5.3 一个 EPOLLET 的典型翻车案例
我曾经在一个推送服务里用了EPOLLET,结果线上出现消息延迟。现象是:服务偶尔会延迟几秒才处理一批已经到达的请求。排查过程先从抓包开始,客户端确实把数据发到了内核,应用进程也收到了通知,但读取时只读了一部分就停止循环。原因很典型:注册的是EPOLLIN | EPOLLET,但 fd 忘了设O_NONBLOCK,read()在读完缓冲区后下一次调用会阻塞,不等EAGAIN就无法正确退出读取循环。
修法如下面的代码片段:注册前先fcntl(fd, F_SETFL, O_NONBLOCK),读取时循环读到EAGAIN才算结束。
for (;;) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { handle_data(buf, n); } else if (n == 0) { close(fd); /* 对端关闭 */ break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) { break; /* 本轮已读完 */ } if (errno == EINTR) { continue; /* 被信号打断,继续读 */ } close(fd); break; } }另外一个 epoll 常见误区是 fd 关闭后没从 epoll 里摘除。事实上 fd 关闭时内核会自动把它从 epoll 里移除,但如果你在事件回调里同时做了“关闭 fd”和“从 epoll 删除”两件事,就可能因为重复删除导致误操作一个刚被复用的新 fd。经验是:把删除动作完全交给关闭逻辑统一处理,事件层不再单独调用EPOLL_CTL_DEL。
6. 性能幻觉:页缓存、mmap、零拷贝和一百次系统调用
6.1 Page Cache 把磁盘变成了“内存”,但也掩盖了真相
Linux 会把读过的文件内容缓存在内存里,这就是 Page Cache。它对重复读非常友好——第一次读可能要从磁盘搬数据,后面再读就直接命中内存。所以同一个文件读第二次、第三次,iostat 里看到的磁盘读可能是 0,但你的程序依然跑得很快,这不是玄学,而是页缓存生效了。
页缓存带来的误区也很明显。如果你看到 iostat 的 IO 指标不高,但程序延迟很大,那问题可能不在磁盘,而在锁竞争、系统调用频率或者网络。反过来,如果页缓存不断被新数据挤出去,旧数据又被频繁访问,就会产生缓存抖动,表现为“IO 不高但响应不稳定”。排查时要记住:页缓存命中率好不代表 IO 路径没问题,只是问题不在磁盘层。
6.2 mmap 并不是万能加速器
mmap()把文件映射到进程地址空间,之后你直接像操作内存一样读写文件。它最大的优点是省掉了read()里“内核缓冲到用户缓冲”的拷贝,对随机访问大文件很友好。但 mmap 也有代价:第一次访问映射页会触发缺页中断,内核要把对应页从磁盘加载进来,这会带来明显的缺页开销。如果程序只是顺序读一个大文件,用read()配合较大 buffer 往往比 mmap 更简单、更稳定。
mmap 还有一个隐患是 SIGBUS。如果文件在映射期间被截断(比如另一个进程ftruncate()),你再访问映射区域时进程可能直接收到SIGBUS崩溃。所以生产环境用 mmap 一定要考虑文件生命周期的一致性,该加锁加锁,该提前fstat()校验提前校验。
6.3 零拷贝和 O_DIRECT,什么时候才值得用
零拷贝技术的核心是减少数据在内核态和用户态之间的重复拷贝。经典路径里,你read()文件数据到用户缓冲,再write()到 socket,数据至少经过两次拷贝;用sendfile()可以直接把文件页缓存里的数据发给 socket,省掉一次用户态往返。对文件服务器、静态资源代理这类“文件到网络”的场景,sendfile()是很常规的优化手段。
O_DIRECT则是另一条路。它绕过页缓存,数据直接在用户缓冲和磁盘之间传输。常见使用场景是数据库自己管理缓存,避免双份缓存造成内存浪费。但O_DIRECT对缓冲区有严格的对齐要求,缓冲地址、文件偏移、读写长度都要按扇区大小对齐,否则read()会直接报EINVAL。性能上它不一定会更快——如果数据已经是热的,O_DIRECT跳过页缓存反而会更慢。能用好O_DIRECT的前提是,你对工作集的缓存策略非常清楚。
7. 当 IO 慢下来,我用这些命令把罪魁祸首找出来
7.1 strace:先抓住每个系统调用
IO 卡住的第一现场往往就在系统调用上。strace -p <pid>可以实时看进程正在执行什么系统调用,strace -c -p <pid>能统计一段时间内各类系统调用的次数和耗时。排查步骤一般是:先看进程是不是卡在某个read()或epoll_wait()上,再看返回的errno是EAGAIN、EINTR还是别的。
连读多个 fd 的网络服务,可以这样只跟踪 IO 相关调用:
strace -f -e trace=read,write,recvfrom,sendto,epoll_wait,accept -p <pid>如果看到一堆连续的EAGAIN,多半是事件循环在使用非阻塞 fd 时没有正确退出读取循环;如果epoll_wait超时时间很长但事件很少,则要怀疑连接是否已经半开。strace 的缺点是开销大,生产环境只适合短时间采样,不适合长时间挂机采集。
7.2 iostat 只有一个指标能判断“忙不忙”
iostat -x 1是看磁盘压力最常用的命令。很多人一眼看到%util是 99% 就以为磁盘满了,其实未必。%util只表示磁盘设备有请求的时间占比,并不直接等于“性能耗尽”。更值得看的是await(平均 IO 响应时间)和svctm(实际服务时间)。如果await很高但svctm很低,说明请求大多在排队,瓶颈可能在请求数量太多或调度问题;如果两者都很高,才更像磁盘硬件本身扛不住。
还有一个容易看漏的指标是队列长度aqu-sz。队列长期很大,意味着磁盘来不及处理,即便%util不高,也要考虑是否因为 IO 模式太碎导致磁盘忙于寻道。把块大小调大、把随机写合并成顺序写,往往比换更强硬件更直接有效。
7.3 从 vmstat 到 /proc:把内核视角补齐
vmstat 1里有两列和缓存相关:si表示从交换分区换入,so表示换出。这两个数值大,说明内存在压力下频繁交换,进程的表现往往是性能突然掉到底并且波动剧烈。这种情况下,IO 慢的真正原因可能在内存而不是磁盘,光看 iostat 会被误导。
/proc/<pid>/status和/proc/<pid>/stack也值得常备。前者可以看进程的内存状态,后者能直接看到内核栈。进程处于 D 状态(不可中断睡眠)时,几乎一定是卡在内核 IO 等待上,常见的诱因是 NFS 网络文件系统访问、磁盘故障、或者内核崩溃转储等操作。看到 D 状态先别慌,用ps -eo state,pid,wchan:30,cmd | grep ^D找出进程卡在哪个内核函数,再结合dmesg看有没有文件系统错误,基本能确定是磁盘问题还是文件系统问题。
我在线上排查的时候,习惯按“应用层 → 系统调用 → 内核状态 → 硬件指标”这个顺序走一遍。先用strace看应用层卡在哪,再用/proc看进程状态,接着用iostat看磁盘,最后用dmesg看硬件错误。这套流程看起来朴素,但大多数 IO 疑难杂症都能在这四层里现出原形。
最后分享一个实操经验:给重要服务做 IO 监控时,不要只盯“慢”,还要定期记录系统调用次数和页缓存命中率这个组合。很多性能劣化是渐进的,一开始只是几次短读没合并,慢慢变成系统调用风暴,等真正卡死的时候,现场往往已经很乱了。提前把这些基础指标打好底,IO 问题出现时你才有的放矢。