第一次被 select 的 1024 个描述符上限绊倒是很早以前给一台工业采集网关写数据转发程序的时候。设备数量一过千程序不是报错而是行为变得很怪——有些通道的数据就是收不上来日志里干干净净CPU 却一直在高位。当时把线程数从 4 加到 32情况只好了半天第二天设备再多一点又卡住了。后来才想明白问题不在线程数量而在我一开始就没搞清楚 Linux 的 IO 模型到底是怎么回事把“并发”简单等同于“多开线程”。这篇内容聊的就是 Linux 高级 IO 这一摊子事从最基础的阻塞、非阻塞到 I/O 多路复用的 select/poll/epoll再到零拷贝和 io_uring。它主要解决的是“单个线程怎么高效地盯住成百上千个连接或文件”这个问题适合已经会写基础 read/write、但一到高并发或者大文件传输就抓瞎的开发者也适合做嵌入式、后端网关、日志采集这类需要长时间稳定跑 IO 的同学。下面我尽量把每个选择的“为什么”讲透而不是只丢一段能跑的代码给你。1. 五个IO模型背后的同一条主线等待成本转移给了谁很多人学 IO 模型是当背诵题来记的五个名字、五张图背完就忘。我后来发现如果抓住一条主线——数据没到的时候谁在等、在哪里等——这五个模型就是一条连续的演化路线根本不用死记。先明确一个前提Linux 里一次完整的读操作其实分成两个阶段。第一个阶段是等数据就绪数据可能还在网卡、磁盘或者内核的缓冲区里应用程序此时拿不到第二个阶段是把数据从内核空间拷到用户空间这一步是实打实的内存拷贝跑不掉。所有的 IO 模型区别就藏在“这两个阶段分别由谁负责阻塞、谁负责通知”里。拿点外卖打个比方会更直观。你应用程序想吃外卖数据外卖从商家到你家门口这段路是第一阶段从门口拿到你手上并拆开是第二阶段。阻塞 IO你站在门口什么都不干一直等到小哥出现再自己拿到手上。期间你完全被占住。这是最经典的 read/write 默认行为。非阻塞 IO你不站门口了每隔一会儿跑去门口看一眼没到就回来继续干别的。问题是你“跑去看”这个动作本身要消耗精力如果外卖很久不到你就在门口和客厅之间来回瞎跑白费力气。对应到代码里就是轮询polling加O_NONBLOCKCPU 空转。I/O 多路复用你装了一个门铃门铃能同时盯好几个外卖员。谁到了门铃响你再去拿。程序里就是select、poll、epoll干的事——用一个系统调用同时监听多个 fd。信号驱动 IO你跟物业说“外卖到了给我打电话”然后回去睡觉。电话响SIGIO 信号你再下楼拿。注意下楼拿这个动作还是你自己做的第二阶段仍然阻塞。异步 IO你直接叫了个跑腿让他把外卖取回来、拆开、摆到你桌上全部弄完再叫你。你连“下楼”都省了。这就是真正的异步第一阶段和第二阶段都不用你操心io_uring想做的就是这件事。把这五种模型摊开对比关键差别就在下面这张表里IO 模型第一阶段等数据第二阶段拷贝典型系统调用CPU 占用阻塞 IO阻塞阻塞read/write低非阻塞 IO轮询不阻塞阻塞read O_NONBLOCK高空转IO 多路复用阻塞在复用函数阻塞select/poll/epoll低信号驱动不阻塞等信号阻塞fcntl SIGIO低异步 IO不阻塞不阻塞io_uring / POSIX AIO低看懂这张表很多选型就不纠结了。比如一个只连单个串口、数据量不大的嵌入式程序老老实实用阻塞 IO 最省事没必要上 epoll而一个要同时接管几千路 TCP 连接的采集服务阻塞 IO 就得靠线程堆线程一多上下文切换的开销反而压垮系统这时候多路复用才是正解。这也就是我当年踩的那个坑——不是线程越多越好而是要看“等待”这件事能不能被集中管理。2. 从文件描述符、内核缓冲区到真正的阻塞点要理解高级 IO绕不开一个问题阻塞到底发生在哪一行代码上如果你只是模糊地知道“read 会阻塞”那遇到“明明设置了非阻塞却还是卡住”这种情况时就完全没思路了。所以这一节我们把 fd、内核缓冲区和阻塞点串起来看。先说文件描述符 fd。它本质上只是进程 fd 表里的一个下标是个小小的整数但通过它内核能找到一张struct file里面记录了文件偏移、访问模式以及最关键的一个函数指针集合——file_operations。VFS 这一层把各种各样的东西普通文件、管道、socket、字符设备都抽象成“可以 read/write 的对象”read 系统调用最终就是通过file_operations里注册的具体实现落到驱动或者文件系统上。理解这一点很重要同样是 read对普通文件和 socket底层行为可能完全不同一个通常立刻返回一个可能要等网络包。再说内核缓冲区。你调用read(fd, buf, n)数据往往不是直接从硬盘或网卡飞到你的buf里的而是先经过一层内核缓冲普通文件走 page cachesocket 走内核的接收缓冲区receive buffer。这个缓冲区就是“等待”发生的地方。如果缓冲区里已经有你要的数据read 立刻拷贝返回如果还没有而且 fd 是阻塞的内核就把当前进程挂到这个 fd 对应的等待队列wait queue上让出 CPU直到数据到达时被唤醒。这里就引出了最常见的误区。很多同学以为把 fd 设成非阻塞O_NONBLOCK程序就会变快其实恰恰相反。非阻塞只是把“内核替你等”改成了“你自己反复问”。数据没到时read 不再挂起进程而是直接返回 -1并把errno设成EAGAIN或EWOULDBLOCK。如果你的代码不处理这个返回值只是在一个 while 里疯狂 read那 CPU 就会被一个空转的循环吃满。我在性能排查时见过不少这种现场top里某个进程 %sys 不高但 %us 很高strace一看一秒钟几万次 read 全返回 EAGAIN。注意EAGAIN和EWOULDBLOCK在 Linux 上值相同判断时写哪个都行但别把EINTR也混为一谈——EINTR表示系统调用被信号打断语义是“这次没干完重来”处理方式和 EAGAIN 完全不同。还有一个容易被忽略的细节阻塞和非阻塞是针对 fd 状态的不是全局开关。你可以把监听 socket 设成非阻塞而 accept 出来的连接 socket 保持阻塞反之亦然。ET 模式的 epoll 就强制要求连接 fd 必须是非阻塞的原因下一节会讲。搞清楚“谁在等、等在哪”后面无论是写 epoll 还是调 io_uring思路都会顺很多。3. epoll 的红黑树与就绪链表以及 LT、ET 到底怎么选select 和 poll 的问题本质上是每次调用都要把整个 fd 集合从用户态拷进内核再由内核线性遍历一遍。假设你监听 10000 个连接但这一刻只有 3 个有数据select 也得老老实实检查 10000 个复杂度是 O(n)。而且 select 的fd_set用位图实现默认最多 1024 个想改还得注意FD_SETSIZE的编译期限制改错了反而踩坑。poll 用数组突破了数量限制但线性遍历的问题还在。epoll 把这两件事都拆开了。它用epoll_create1在内核建一个 eventpoll 对象这个对象里有两样核心结构一棵红黑树保存所有通过epoll_ctl注册进来的 fd增删改都是 O(log n)以及一个就绪链表保存当前已经就绪、等着被处理的 fd。关键在于当某个 fd 上有数据到达时内核通过回调机制把它直接挂到就绪链表上而不是等epoll_wait被调用时才去遍历。于是epoll_wait只需要检查就绪链表里有没有东西有就返回复杂度接近 O(1)。这就是它面对海量连接时依然从容的原因——没有就绪事件的连接根本不参与每次检查。三个核心 API 的分工要记牢int epfd epoll_create1(0); // 创建 epoll 实例 struct epoll_event ev; ev.events EPOLLIN; // 关心可读 ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev); // 注册 struct epoll_event events[1024]; int n epoll_wait(epfd, events, 1024, -1); // 等待-1 表示永久阻塞 for (int i 0; i n; i) { // 处理 events[i].data.fd }epoll_ctl的ADD/MOD/DEL分别对应注册、修改事件、注销。这里有个经验连接关闭时一定要 DEL虽然 fd 被 close 后内核会自动清理但如果你的逻辑是先关 fd 再让它被复用而 epoll 里还留着旧的事件就可能出现“新连接收到了旧连接的事件”这种诡异 bug。我吃过一次排查了大半天最后发现是 close 和 DEL 的顺序问题。接下来是 LT 和 ET 的取舍这是 epoll 最容易出问题的地方。LT水平触发默认的语义是只要缓冲区里还有数据没读完下一次epoll_wait就会继续通知你。它安全、好写哪怕你一次只读了一点就走下次还会被叫醒。ET边缘触发EPOLLET的语义是只在状态发生变化时通知一次比如缓冲区从空变成有数据。如果你这次没把数据读完那就再也不会收到通知除非又有新数据到来。ET 的效率略高通知次数少但代价是你必须配合非阻塞 fd并且一次把数据读到 EAGAIN 为止。原因是这样的ET 下你不知道缓冲区里到底有多少数据只能循环 read直到 read 返回 -1 且errno EAGAIN才说明读干净了。如果 fd 是阻塞的最后那一次 read 就会永远卡住把整个事件循环拖死。所以不是“ET 必须非阻塞”这句话要背下来而是它的读法天然要求非阻塞。// ET 模式下正确的读法 while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 处理数据注意可能还有 } else if (n 0) { close(fd); // 对端关闭 break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; // 读干净了 if (errno EINTR) continue; // 被信号打断重来 // 其他错误 close(fd); break; } }除了 LT/ET还有两个事件标志值得留意。EPOLLONESHOT用于“一个连接同一时间只允许一个线程处理”避免多线程下同一个 fd 被两个线程同时读读完必须重新 MOD 注册。EPOLLEXCLUSIVE则是针对惊群问题的——多个进程或线程监听同一个 listen fd 时一个新连接到达可能会唤醒所有等待者最后只有一个 accept 成功其余的醒来发现没活干又睡回去白白浪费 CPU。EPOLLEXCLUSIVE让内核只唤醒一个或者你也可以用SO_REUSEPORT让每个进程各建一个监听 socket由内核做负载分担。这两种方案我都用过前者改动小后者性能曲线更平滑具体看你的进程模型。4. 把老 select 项目迁到 epoll 的完整过程与几个真实坑理论讲完说点实操。我之前接手过一个用 select 写的老采集服务连接数一上两千就顶不住迁移到 epoll 的过程不算复杂但踩的坑一个接一个这里按我当时的改造顺序记下来你可以直接对照。第一步是换事件循环的骨架。把原来FD_ZERO/FD_SET/select那一套换成epoll_create1 注册监听 fd epoll_wait。注意epoll_wait的maxevents参数决定了单次返回最多能装多少个事件我一般设成 1024如果你连接特别多、事件特别密集可以适当调大但别调到几万那样每次返回要处理太久反而增加单次延迟。第二步是处理返回值。这一步是坑最密集的地方。第一个坑是epoll_wait返回 -1 时没判断errno。被信号打断EINTR时必须重试否则信号一来事件循环就退出了。正确写法是if (n 0 errno EINTR) continue;。第二个坑是短读短写。TCP 是字节流read返回的字节数可能少于你请求的write更是经常只写进去一部分返回的 n 小于你要写的长度。老代码里很多人直接write(fd, buf, len)就当写完了在低负载下侥幸没事一上量就出现数据丢失或半包。我的做法是给每个连接挂一个用户态发送缓冲区write返回多少就移走多少剩下的注册EPOLLOUT等下次可写再继续发发完再取消EPOLLOUT。第三个坑就出在这个EPOLLOUT上。在 LT 模式下只要发送缓冲区有空间EPOLLOUT就会一直触发于是你的epoll_wait会立刻返回、CPU 飙到 100%这就是典型的“忙轮询”现场。解决办法是按需注册平时不关心写事件只有真的攒了数据发不完才 MOD 加上EPOLLOUT发完立刻 MOD 去掉。这个习惯我建议一开始就养成比事后救火轻松得多。第四个坑是 fd 泄漏。迁移过程中如果某条错误分支忘了 close连接数会在几天内缓慢爬升最后撞上ulimit -n。排查手段是ls /proc/pid/fd | wc -l看 fd 数量再对比ss -s里的连接数数字对不上就是泄漏了。改造完得压测验证。我用的组合是iperf3测纯带宽打底自写一个多连接客户端模拟真实并发再配合ss -lnt观察Recv-Q和Send-Q有没有堆积。判断 epoll 改造是否成功不只看 QPS还要看CPU 的 %sys 有没有降下来、上下文切换vmstat里的 cs 列有没有变少。如果 QPS 涨了但 cs 还是很高说明你可能用了多线程却没有合理分配连接还有优化空间。5. 零拷贝三件套mmap、sendfile、splice 的适用边界聊完多路复用再说另一个高频场景——大文件或者大数据量传输。这时候瓶颈往往不在“等不等得到数据”而在数据被反复拷贝了多少次。传统readwrite从磁盘发一个文件到网络数据要经历四次拷贝磁盘 - 内核页缓存DMA 拷贝、页缓存 - 用户缓冲区CPU 拷贝、用户缓冲区 - socket 缓冲区CPU 拷贝、socket 缓冲区 - 网卡DMA 拷贝。中间两次 CPU 拷贝纯属白干还占着内存带宽。mmap write是第一招。用mmap把文件映射到用户地址空间省掉了“页缓存 - 用户缓冲区”那一次拷贝读的时候直接操作映射内存写还是走 write。它适合需要对文件内容做加工的场景比如你要边读边解析、边过滤。但 mmap 有个副作用文件被换出或者写入时会触发缺页中断超大文件还可能因为地址空间碎片化而失败所以在 32 位系统上要谨慎。sendfile是第二招也是传文件最常用的一招。sendfile(out_fd, in_fd, offset, count)让内核直接把文件内容从页缓存送进 socket 缓冲区完全不经过用户态四次拷贝直接砍到三次。如果网卡支持 SG-DMAscatter-gather连“页缓存 - socket 缓冲区”这一步都能省掉变成两次 DMA 拷贝这才是真正意义上的零拷贝。// 把一个文件高效地发到 socket用户态零参与 off_t offset 0; struct stat st; fstat(filefd, st); ssize_t sent sendfile(sockfd, filefd, offset, st.st_size);用 sendfile 有几个必须记住的限制。老内核要求out_fd必须是 socketin_fd必须是支持 mmap 的文件从 5.3 开始限制放宽了但如果你要兼容老系统还是按老规矩来。另外 sendfile不支持传输时修改数据它就负责原样搬运要加工内容就得回到 mmap 或者普通 read/write。splice是第三招基于管道pipe做内核态的数据搬运可以连接两个 fd比如从一个 socket 直接接到另一个 socket做转发代理时特别有用。它同样要求至少一端是管道用起来比 sendfile 稍绕一点但灵活度更高。我做过一个内网日志转发的小工具用 splice 把上游 socket 直接转到下游用户态几乎不碰数据CPU 占用低得离谱。三者的选择其实有个简单的判断顺序只搬运不加工优先 sendfile要加工用 mmap要在两个 fd 之间中转用 splice。别为了“零拷贝”三个字硬套比如数据量只有几 KB拷贝那点开销远不如多一次系统调用划算这时候老老实实用 read/write 反而最清楚。6. io_uring 值不值得现在上先看它解决了什么前面讲的 epoll严格来说还是“同步非阻塞”的范畴——它帮你解决了“等数据”的问题但真正的数据拷贝、以及发起 IO 这个动作本身还是要应用程序一次次发起系统调用。每次系统调用都有用户态到内核态的切换成本在超高频的随机小 IO 场景下这个成本会变得不可忽略。io_uring 就是冲着这个来的。它的核心设计是两个环形队列提交队列SQ和完成队列CQ。应用程序把要做的 IO 请求写进 SQ内核处理完把结果写进 CQ两边通过 mmap 共享同一块内存。这样一来提交请求很多时候不需要陷入内核应用程序只管往队列里塞内核自己去消费。你还能开 SQPOLL 模式让一个内核线程专门轮询 SQ进一步省掉通知开销。这套机制在随机读写密集、IOPS 要求高的场景下收益明显比如数据库、高速日志写入、高性能代理。但要不要现在就上我给几个实在的判断。第一是内核版本io_uring 在 5.1 引入早期 bug 不少比较稳妥的起点是 5.6 之后很多生产环境推荐 5.10 及以上的 LTS 内核。第二是生态和调试liburing库确实把裸接口封装得挺好但一旦出问题排查工具和社区经验远不如 epoll 成熟出错时你很容易一头雾水。第三是收益是否值得复杂度如果你现在的服务瓶颈根本不在系统调用次数上比如瓶颈是磁盘顺序读那上 io_uring 基本是白折腾。提示POSIX AIO也就是 glibc 里那套aio_read/aio_write在 Linux 上其实是用线程池模拟的并不是内核级的真异步性能和扩展性都有天花板。想追求真正的异步 IO方向是 io_uring而不是它。我的建议很朴素新项目、内核够新、场景确实吃 IOPS可以大胆试老项目、内核版本旧、团队对底层不熟先把 epoll 和零拷贝玩扎实收益往往来得更快也更稳。技术选型不是比谁用的东西新而是比谁的系统更能扛住线上那几个凌晨三点的告警。7. 用 strace、ss、perf 定位 IO 瓶颈的排查链路最后聊聊排查。IO 相关的问题有个特点症状看起来差不多根因可能天差地别。所以别急着改代码先按一条链路把现场看清楚。我常用的顺序是“先看系统调用再看连接状态最后看热点函数”。第一步strace看程序到底在干什么。strace -c -p pid能统计一段时间内各系统调用的次数和耗时占比一眼就能看出是不是在反复 read 返回 EAGAIN或者是不是卡在某个 poll 上。如果要看时序用strace -tt -T -e tracenetwork,read,write -p pid-T会打印每个调用的耗时那些耗时突然飙高的调用就是嫌疑点。有一次线上服务延迟抖动就是靠这个发现某次 write 卡了几十毫秒顺藤摸瓜找到了一个配置不当的发送缓冲区。第二步ss看连接和队列。ss -lnt看监听队列Recv-Q不为 0 说明应用 accept 不够快ss -s给出各种状态的汇总TIME_WAIT数量异常多半是短连接太频繁。如果发现某个方向的Send-Q一直堆积那基本可以确定是对端读得慢或者你自己的发送逻辑有问题。第三步perf看热点。perf top -p pid能实时看到 CPU 时间花在哪些函数上。如果大量时间耗在copy_user_enhanced_fast_string这类函数上说明你的瓶颈就是内存拷贝这时候该考虑零拷贝了如果耗在锁相关函数上那就是并发竞争的问题跟 IO 模型本身关系不大。还有pidstat -d 1可以看进程级的磁盘 IOiostat -x 1看设备的%util和await判断是不是磁盘本身到了极限。我一般把这几个工具做成一个检查清单出了问题按顺序扫一遍大部分情况十五分钟内能定位到方向剩下的就是细致活儿了。需要提醒的是排查时别在strace挂着的生产进程上做重操作它会给目标进程带来明显开销高 QPS 服务上可能直接把它拖慢。稳妥做法是先摘流量或者找个低峰时段再动工具。这个教训我交过学费当年在一次流量高峰图省事直接 strace结果把本来只是轻微抖动的服务整成了真故障。我自己在实际使用中的体会是Linux 高级 IO 这一块真正难的不是把 epoll 或 io_uring 的 API 记住而是能不能在任何一次选型里说清楚“为什么是它而不是别的”。当年那个网关程序最后是怎么解决的监听用 epoll 的 ET 模式配非阻塞 fd每个连接的读逻辑严格循环到 EAGAIN写侧挂了用户态缓冲加按需 EPOLLOUTCPU 直接降了三分之二设备数量翻倍也没再出问题。后来那个顺手写下的用户态发送缓冲区封装被我一路复用到好几个项目里——很多时候扎实的小工具比炫技的大框架管用。
阅读完成 · 觉得有帮助?