Linux五种I/O模型详解:从阻塞到异步的演进与选型

发布时间:2026/10/22 10:06:40
Linux五种I/O模型详解:从阻塞到异步的演进与选型 写网络服务的人迟早会撞上一个问题我的服务到底是怎么把数据等来的不管是写HTTP服务、RPC框架还是消息队列只要牵扯到网络收发I/O模型就是绕不过去的一道坎。Linux下常见的五种I/O模型——阻塞I/O、非阻塞I/O、I/O多路复用、信号驱动I/O、异步I/O——说白了就是回答一件事当数据还没准备好时你的进程该怎么办。这篇文章不打算堆教科书定义我想从一个写代码的人的角度把这五种模型拆开揉碎讲清楚它们各自的等待方式、适用场景以及实际开发里怎么选。无论你是刚接触网络编程的新手还是已经被epoll折磨过的老手应该都能从中找到点有用的东西。1. 先搞清楚I/O模型到底在解决什么问题1.1 一次网络读取数据到底经历了什么很多人一上来就背五种模型的名字结果连“阻塞”和“非阻塞”到底卡在哪一步都没弄明白。我们先看最底层的场景你调了一个read(fd, buf, len)想从TCP连接里读数据。这么简单的一个调用内核实际上要干两件事。第一件事是等数据。数据从网卡收到之后要先经过协议栈处理放到内核的接收缓冲区里。在你调用read的那一刻数据可能还没到可能到了一部分也可能已经完整到达。第二件事是拷贝数据。内核把接收缓冲区里的数据复制到你传入的用户态内存buf里然后read才返回。这两件事就是理解所有I/O模型的钥匙。所谓阻塞、非阻塞、异步、多路复用全是在“等数据”和“拷贝数据”这两个阶段上做文章。不同的模型只是选择在不同的阶段让谁等待、谁来通知、谁来做拷贝。注意这里说的网络I/O对文件的读写也基本适用只是文件I/O的“等数据”阶段几乎瞬间完成所以网络场景下这五种模型才显得尤其重要。1.2 两个阶段等待数据和拷贝数据为什么要把这两个阶段分得这么清楚因为阻塞、非阻塞管的是“等待数据”这个阶段。异步管的是“拷贝数据”这个阶段。这个区分非常关键后面你会反复用到。阻塞I/O进程调用read之后如果内核缓冲区没数据进程就睡觉直到数据到了内核把数据拷到用户态read才返回。非阻塞I/O进程调用read之后如果内核缓冲区没数据马上返回一个EAGAIN错误进程不用睡觉但得自己反复来问。I/O多路复用进程先把一堆fd交给内核内核说“其中任何一个有数据了我就告诉你”然后进程再调用read。信号驱动I/O进程先告诉内核“数据到了你发个信号给我”然后进程该干嘛干嘛信号来了再处理。异步I/O进程把read请求完整交给内核包括拷贝也交给内核内核全部干完了再通知进程。注意看前四种模型里“拷贝数据”这一步都是进程自己参与的所以它们本质上都是同步I/O。只有第五种异步I/O连拷贝都是内核代劳。这是很多人容易搞混的地方后面我会再展开。2. 阻塞I/O与非阻塞I/O最基础的两极2.1 阻塞I/O简单但容易卡死阻塞I/O是最传统的模型也是新手最容易理解的。写一个简单的TCP服务端accept之后直接recv来一个连接就处理一个处理完再接受下一个。代码写起来很顺手逻辑也直观。int client_fd accept(listen_fd, ...); char buf[1024]; int n read(client_fd, buf, sizeof(buf)); // 没有数据就卡在这里这段代码的问题我在刚写服务端程序的时候就踩过如果客户端连上来之后一直不发数据那么对应的线程就永远卡在read上。一个连接占一个线程1000个连接就是1000个线程1万个连接就是1万个线程。线程多了之后线程切换的开销、内存的占用都会把服务拖垮这就是常说的C10K问题。阻塞本身不是原罪。它的优点是程序逻辑简单代码顺序执行不容易出错。而且阻塞期间进程会让出CPU对单连接的吞吐来说效率并不差。问题在于它“一个线程只能伺候一个连接”扩缩容全靠线程数量硬扛。我曾经维护过一个老项目连接数一上来光线程栈内存就吃掉几个GB机器动不动就报警。所以阻塞I/O适合连接数不多、每个连接都有持续数据传输的场景比如一个内网的小工具几十个连接顶天了用它最省事。2.2 非阻塞I/O轮询带来的新麻烦非阻塞I/O就是把socket设置成O_NONBLOCK然后read的时候内核没数据就立刻返回不让你睡觉。你拿到EAGAIN就知道现在没数据可以先去干别的过一会儿再来问。int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); while (1) { int n read(fd, buf, sizeof(buf)); if (n -1 errno EAGAIN) { // 没数据先干别的过会儿再问 do_something_else(); continue; } // 处理数据... }听上去不错但实际用起来有个很尴尬的问题你怎么知道该什么时候再来问如果一直循环readCPU就被白白烧掉了。我用一个非阻塞模型写过一个小客户端没加延时结果一个连接就把单核CPU跑到100%数据还没收到几条。更麻烦的是如果你有几百个非阻塞fd每个fd都要轮询一遍这种“忙等”的效率比阻塞还差。所以非阻塞I/O通常不单独使用而是作为I/O多路复用的基础。它在多路复用模型里扮演的角色是当内核告诉你某个fd有数据时你要用非阻塞方式去读保证不会因为读一次没读完而卡住。这一点到后面讲边缘触发时还会再提到。3. I/O多路复用高并发服务端的基石3.1 select和poll早期方案的问题I/O多路复用的核心思路是与其让应用自己对每个fd轮询不如把一批fd交给内核让内核同时盯着它们哪个有动静了就告诉应用。这样单个线程就能管理成千上万个连接。最早的方案是select。你把fd集合传给内核内核遍历一遍发现有数据就标记返回后你再遍历一遍找出可读的fd。说起来逻辑不复杂但select有两个天生的毛病写过的人应该都深有体会。第一个是fd数量上限。select用fd_set位图管理fd默认上限是1024虽然可以改内核参数但总归是个硬限制。第二个是效率问题。每次调用select都要把整个fd集合从用户态拷到内核态返回后又要从内核态拷回来而且内核是线性扫描所有fd连接数越多扫描越慢。我在一个模拟万人连接的测试里用过selectCPU占用高得吓人大部分时间都花在拷贝和扫描上了。poll解决了fd数量上限问题因为它改用链表管理fd不再受1024限制。但是线性扫描的毛病还在fd多了照样慢。这两个模型的复杂度都是O(n)也就是说不管你有多少个fd每次都要全部过一遍。3.2 epollLinux下的高效解法epoll是Linux特有的它就是为了解决select和poll的效率问题而生的。它的核心变化是不再每次把全部fd传给内核而是先通过epoll_ctl把要监视的fd注册进内核内核维护一棵红黑树有数据到达时通过回调机制把就绪的fd加入一个就绪链表你调用epoll_wait时只需要从就绪链表里取出那些真正有事件的fd就行。int epfd epoll_create(1); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, ev); struct epoll_event events[1024]; while (1) { int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { handle_event(events[i].data.fd); } }这里要注意epoll_wait返回的是已经就绪的fd数量而不是全部fd数量。换句话说连接数再多内核也只把就绪的那几个告诉你。复杂度从O(n)降到了O(就绪fd数)这就是它能支撑高并发的根本原因。epoll还提供两种触发模式水平触发LT和边缘触发ET。LT模式是默认的只要fd还有数据没读完epoll_wait就会一直通知你。ET模式只在状态变化时通知一次数据没读完就不再通知了。ET模式更高效但要求你必须用非阻塞fd并且一次把数据尽量读完否则会漏数据。我见过不少人在ET模式下踩坑核心就是没用非阻塞模式配合循环读取。3.3 为什么多路复用能支撑高并发很多人问我是不是用了epoll就一定能扛住高并发答案没那么简单。epoll解决的只是“同时等很多fd”的问题数据到来之后怎么处理还得靠你自己。假如你有10万个连接但同一时刻只有100个连接有数据到达epoll能让你只处理这100个这是它高效的地方。但如果这100个连接的数据都很大处理起来都很耗时那么单线程处理完这100个后面的就得排队。这时候通常要搭配线程池让epoll所在的线程只负责分发事件真正耗时的业务逻辑扔给工作线程。我从实际项目里的体会是epoll本身不难用难的是把它和业务逻辑、线程模型、内存管理结合起来。一个典型的服务端架构是这样的主线程跑epoll_wait有可读事件就accept或read然后把任务丢给线程池处理写操作一般先注册EPOLLOUT等可写时再发送。这套模式我在多个项目里用过稳定性和吞吐都说得过去。4. 信号驱动I/O与异步I/O少见的后两者4.1 信号驱动I/OSIGIO的故事信号驱动I/O的思路很直接你告诉内核某个fd有数据了给我发个SIGIO信号然后你的进程该干嘛干嘛不用等着。信号来了之后再在信号处理函数里去read数据。signal(SIGIO, io_handler); fcntl(fd, F_SETOWN, getpid()); int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);听起来很美但实际用的人很少。原因也简单信号处理函数里能干的事太有限。read这种操作在信号处理函数里要非常小心因为可能中断主程序的执行产生各种不可重入的问题。你正读到一半信号来了处理函数又去读同一个fd很容易把状态搞乱。另外信号本身有频率和开销的问题。如果数据包来得特别频繁信号会被疯狂触发系统疲于处理信号性能反而变差。我在学习阶段写过一个信号驱动的小demo数据量小的时候还行稍微上点压测进程就变得不稳定日志乱得像踩踏现场。所以这个模型在实际工程里基本是“知道有这回事”的程度真正用它做产品的很少。4.2 异步I/O真正的“读完再通知”异步I/O和前四种模型有本质区别。前四种模型里就算内核帮你等数据最后“把数据从内核拷到用户态”这一步仍然是进程自己完成的所以在数据真正可用之前进程都得参与。异步I/O则是你发一个aio_read类似的请求内核把等待、拷贝全部做完然后再通知你“数据已经放在你给的buf里了”。Linux下的异步I/O实现早期有POSIX AIO但一直不太让人满意真正好用起来是后来io_uring的出现。io_uring通过内核和用户态共享环形队列来提交I/O请求和收割结果性能很高代码写起来也更复杂。很多高性能存储和数据库项目已经在用io_uring了。这里必须强调一个区分非阻塞I/O和异步I/O不是一回事。非阻塞是“我马上返回但数据还没好你得自己再问”异步是“我全部干完再通知你你不用再问”。拿点外卖类比非阻塞就是你每隔几分钟问一次外卖到了没异步就是外卖到了骑手打电话叫你下楼拿。我一直觉得这个类比比背概念管用得多。4.3 五种模型对比一张表看清差异把五种模型放一起对比很多东西就清楚了。我用一张表总结它们的核心特征模型等待数据阶段拷贝数据阶段进程是否参与拷贝典型应用阻塞I/O进程睡眠等待进程参与是简单服务、连接数少的场景非阻塞I/O进程轮询询问进程参与是配合多路复用使用I/O多路复用内核统一监视进程参与是高并发网络服务信号驱动I/O内核发信号通知进程参与是极少使用异步I/O内核代劳内核代劳否高性能存储、数据库注意前四行不管“等数据”是谁在等最后拷贝都是进程干的所以它们都是同步I/O。只有异步I/O是真正全程由内核代劳。很多面试题喜欢问epoll是不是异步的答案就是“不是epoll虽然有事件通知但数据拷贝还得你自己来它属于同步I/O多路复用”。5. 实际开发中的选型与避坑经验5.1 服务端编程到底选epoll还是异步I/O先说结论绝大多数网络服务端程序用epoll就对了。它成熟、稳定、坑少网上资料也最多。我写了几年服务端基本没遇到过必须上io_uring才能解决的业务场景。epoll加上线程池配合合理的协议设计撑住几万甚至几十万连接是完全可行的。真正需要认真考虑异步I/O的场景通常是磁盘I/O密集型的应用比如数据库引擎、日志系统、对象存储服务。因为磁盘I/O的等待时间更长而且文件I/O没有epoll这么顺手的多路复用方案内核线程阻塞在磁盘I/O上的代价很高。这种情况下io_uring才能体现出价值。这里要提醒一句如果你只是因为“异步听起来高级”就跑过去用io_uring大概率会把自己坑得很惨。它的API更底层内存管理更繁琐调试工具也更少。我在一个模拟项目中试过把简单的网络收发改用io_uring光是处理用户态缓冲区的生命周期就费了不少劲最后收益也不明显。选型要看你解决的到底是什么问题而不是追新。5.2 常见的理解误区与面试高频问题第一个误区epoll比阻塞I/O快。严格说epoll在“连接数多但活跃连接少”的场景下优势明显但如果是单个连接持续大流量传输阻塞I/O配合独立线程并不差甚至因为代码路径更短性能还更好。epoll的优势是“能同时管很多连接”不是“单连接更快”。第二个误区非阻塞I/O就是异步I/O。这个前面说过了非阻塞只是不睡觉但活还得你来干异步是活都别人干完了再叫你。两者差着一个“拷贝数据”阶段。第三个误区select已经过时了完全没用。Windows没有epoll很多跨平台网络库在Unix-like系统上还是会用poll或者select来兜底一些简单场景下它们反而够用。我在写一个小型调试工具时就用了select几十个fd完全没压力代码还简单。面试里还有个高频题为什么epoll比select高效你得从三个层面回答一是fd集合不用每次全量拷贝而是通过内核维护的红黑树管理二是内核不再线性扫描所有fd而是靠回调让就绪fd进入链表三是应用只需要遍历就绪fd而不是全部fd。把这三个点讲清楚基本就能过关。5.3 我的实操建议最后分享几个我自己的经验。第一先把阻塞I/O和多路复用吃透。这两个是基础中的基础项目中八成的网络代码用的就是它们。非阻塞I/O、信号驱动I/O和异步I/O可以理解原理但别急着在生产环境里用。第二如果用epoll的ET模式记得把socket设为非阻塞并且用循环把数据读到EAGAIN为止。我发现不少同事踩的坑都是用了ET忘了设置非阻塞结果数据没读完后续事件不再通知连接卡死。第三调试网络程序时多用strace看一下系统调用。strace -p 进程号能直接看到进程阻塞在哪个read上是等待数据还是已经拿到了数据一目了然。这个方法帮我排查过好几次线上连接异常的问题。第四架构设计上不要把业务逻辑塞进事件处理循环里。一个常见的设计是事件循环只负责收发数据收到完整报文后丢给工作线程去处理。否则一个耗时的业务操作会让整个epoll循环卡住所有连接跟着遭殃。我在实际项目里的体会是I/O模型的学习重点不是背下五种模型的名字而是建立起“数据从网卡到用户态要经历什么”的完整画面。有了这个画面再去看各种框架和中间件的源码会发现它们的选择你都能理解甚至能预判它们会在哪里遇到性能瓶颈。这个底子打好了后面学什么网络框架都快。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询