资讯动态

Linux五种IO模型精讲:阻塞、非阻塞、同步异步与epoll实战选型

发布时间:2026/10/18 5:03:00 来源:尧图企业网站定制
很多人第一次接触“Linux IO模型”这个概念往往是在某个网络框架的文档里——文档写着“基于epoll支持百万连接”你兴奋地往下读结果发现要真正理解这句话先得把五种IO模型的区别搞清楚。我自己也是这么过来的先被select、poll、epoll三个词绕晕后来又发现同步异步和阻塞非阻塞根本就是两对不同的概念直到系统地把内核处理IO的完整流程梳理了一遍才终于看懂了这些框架背后的设计逻辑。这篇文章就是把我这些年理解和实战Linux IO模型的经验做一个完整梳理。没有特别高深的理论重点是把“阻塞、非阻塞、同步、异步”这几个词一次性理清再把五种经典模型逐层拆开最后落到实际项目里的选型思路和典型坑点。不管你是刚接触网络编程的新手还是写了好几年业务代码却没系统看过IO模型的老兵读完应该都能建立起一个清晰的坐标系回头再看那些网络框架的源码和文档会轻松很多。1. 一切得从read()系统调用说起IO模型到底在讨论哪件事在讨论IO模型之前得先把“一次IO读操作”的完整路径看清楚。否则我们谈的所有模型都是在空中楼阁上做比较。1.1 用户态与内核态IO绕不开的一道墙现代操作系统把内存访问分成了内核空间和用户空间两个层级。我们平时写的应用程序运行在用户态而文件系统、网络协议栈、设备驱动这些核心资源都运行在内核态。用户程序不能直接访问硬件也不能直接操作网卡收到的那块数据一切IO都得通过系统调用陷入内核去完成。这个隔离不是人为制造麻烦而是操作系统稳定性的基础。如果每个程序都能随意读写设备寄存器一个野指针就能让整个系统宕机。所以你在代码里调一次read()实际上是做了一次用户态到内核态的切换让内核帮你把数据准备好再拷贝回用户空间的缓冲区。这个来回切换本身就有成本但更影响性能的是接下来的等待过程。1.2 一次read()被分成了两个阶段一次典型的网络read操作可以拆成两个阶段第一阶段等待数据就绪。数据可能还在网卡上、在网络的某个链路里、或者已经到了内核的套接字接收缓冲区但还没完整。对于文件IO来说可能是磁盘还在转、数据还没从扇区读到内核页缓存。第二阶段数据拷贝。数据就绪之后内核把它从内核缓冲区拷贝到用户空间缓冲区这时候你的应用程序才能真正拿到这份数据。记住这两个阶段是理解五种IO模型的关键。任何IO模型的改进本质上都是在“第一阶段怎么等”和“第二阶段怎么拷贝”上做文章。比如最简单的阻塞IO两个阶段都让调用线程傻等非阻塞IO第一个阶段不等了你不断去问内核“数据好了没”IO多路复用则是让内核帮你在第一阶段看着一大堆fd哪个就绪了就告诉你。到这里你应该已经能感觉到不同模型之间的差异主要是在处理第一阶段等待策略上的取舍。2. 先理清四个关键词阻塞、非阻塞、同步、异步很多文章把阻塞/非阻塞和同步/异步混在一起讲导致初学者一上来就懵。我自己也在这上面栽过跟头。其实这是两组不同维度的问题可以分开来定义。2.1 阻塞与非阻塞数据没就绪时这个线程在干嘛阻塞和非阻塞讨论的是第一阶段等待数据就绪时的行为。如果调用read()时数据还没准备好阻塞模型会让当前线程挂起让出CPU直到内核通知它数据来了才恢复执行。非阻塞模型则让read()立刻返回一个错误码比如EAGAIN告诉你“数据还没好”你的线程不挂起可以继续干别的。用生活里的例子类比你在餐厅点了菜坐在座位上什么都不干、一直等菜上桌这是阻塞。你点完菜去刷手机、打游戏过一会儿去问服务员“菜好了没”没好就继续刷这是非阻塞。阻塞模型代码简单、逻辑直观但代价是线程被白白占着什么都干不了非阻塞模型把“等”的压力交给了应用程序你可以趁机处理别的任务代价是自己得反复去问。2.2 同步与异步数据拷贝完成之后谁负责通知你同步和异步讨论的则是整个IO操作的完成方式。同步IO指的是发起IO请求之后必须等这次IO彻底完成包括第二个阶段的拷贝完成才能返回或继续后续逻辑。异步IO是你发起请求后立刻返回内核在数据准备完毕并且拷贝到你的缓冲区后主动通知你“搞定了”。注意区分非阻塞解决了第一阶段“不等”但它仍然是同步的因为轮到第二阶段时你的程序还是要亲自执行或等待这次数据拷贝结束。真正能把第二阶段也外包出去的才是异步IO。基本判断口诀先放在这里阻塞和非阻塞看的是调用者的线程在等待时是否挂起同步和异步看的是请求发出后最终的完成动作由谁去做、做完怎么告诉你。2.3 一个最容易踩的误解非阻塞不等于异步我在很多技术群里看到过这样的说法“非阻塞IO就是异步IO”。这是最常见的误解。非阻塞IO的read()返回EAGAIN之后你后面还是要调用一次或多次read()来完成数据读取这个“最终执行读取动作”的人依然是你的程序所以它是同步的。要区分其实不难同步意味着“做一件事我要等结果”异步意味着“我交代给你做好了叫我”。一个非阻塞的read()调用只是把“等待”变成了“轮询”最终的读取动作并没有被委托给某个独立机制。这个概念如果不理清后面看select、epoll、io_uring的时候很容易越看越迷糊。3. 五种经典IO模型逐一拆解从阻塞到io_uring基础概念理清了就可以把五种模型串起来讲了。我会按演进顺序来写这样你能看到每一步改动到底解决了什么问题、又带来了什么新问题。3.1 阻塞IO最简单但也是并发噩梦阻塞IO是最原始的模型。代码长这样int fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr { ... }; connect(fd, (struct sockaddr*)addr, sizeof(addr)); char buf[1024]; int n read(fd, buf, sizeof(buf)); // 如果没数据线程就在这里挂起read()一调用线程就停在那里直到数据从对端发过来、经内核协议栈处理、拷到用户空间缓冲区才带着实际读取的字节数返回。这个模型下服务端的典型写法是“一个连接分配一个线程”主线程只负责accept每来一个连接就新建一个线程去处理读写。在小并发的场景下这没什么问题但高并发时隐患很大。线程是系统资源创建、切换、销毁都要成本。C10K时代大家发现如果一万个连接就起一万个线程内存和CPU切换开销会直接把服务拖垮。因为大部分连接其实不是一直有数据流动的大多数线程大部分时间都阻塞在read()上纯属浪费。这就是阻塞IO必须被改进的根源。3.2 非阻塞IO把“等”变成“轮询”非阻塞IO的思路很简单既然read()会阻塞那就让它不阻塞。把fd设置成非阻塞模式之后read()在没有数据时立刻返回EAGAIN或EWOULDBLOCK。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 0) { // 读到数据了 break; } else if (errno EAGAIN || errno EWOULDBLOCK) { // 还没数据去干点别的过会儿再来看 } else { // 出错了 break; } }单个连接这样处理还不算太糟但如果你管理几百上千个fd每个fd都用轮询去问就会出现一个尴尬的问题你的主循环永远在疯狂执行read()系统调用每次read()都要切到内核去检查一次有没有数据。大量CPU时间消耗在“问了也白问”的系统调用上这比阻塞浪费得还厉害。所以非阻塞IO在实践中很少直接用于大规模连接管理。它更多是作为一个基础能力被后面的多路复用机制和框架内部使用比如配合epoll的边缘触发模式时就必须保持fd非阻塞。3.3 IO多路复用让内核帮你盯着所有fd多路复用解决了非阻塞轮询的“空转”问题。它不是让应用自己一个个去问而是把所有要盯的fd交给内核内核在任意一个fd就绪时告诉应用程序“有几个fd就绪、是哪些fd就绪了”。fd_set readfds; FD_ZERO(readfds); FD_SET(fd1, readfds); FD_SET(fd2, readfds); int ret select(fd2 1, readfds, NULL, NULL, NULL); if (FD_ISSET(fd1, readfds)) { // fd1 可读 } if (FD_ISSET(fd2, readfds)) { // fd2 可读 }从调用方式来看select会把用户传入的fd集合整份拷贝到内核内核线性扫描这些fd看哪些可以读写然后把就绪的fd标记出来再拷贝回用户态。你拿到结果后还要再次遍历fd集合才知道到底是哪些fd就绪了。这种模型比“一个连接一个线程”的阻塞IO强在哪里强在用一个线程就能同时管理成千上万个连接。只要有一个或多个fd就绪程序就有事干没有fd就绪时线程可以阻塞在select调用上休息不浪费CPU。这就是高并发服务的基础。不过select有fd数量上限、有O(n)的扫描代价所以后面又演化出了poll和epoll这部分我会放到第4节细讲。3.4 信号驱动IO第一阶段靠信号通知信号驱动IO在Linux里存在感一直不高但它是理解异步IO的一个很好过渡。它的思路是把fd设置成信号驱动模式注册一个SIGIO信号处理函数然后你的程序可以继续做手头的事。当内核检测到这个fd有数据时就发出SIGIO信号你的信号处理函数被触发后再执行read()去读取。从第一阶段来看信号驱动IO确实做到了异步通知你不用自己轮询内核主动叫你。但从第二阶段来看它依然是同步的因为你的信号处理函数里必须亲自调用read()把数据从内核拷贝到用户空间拷贝的过程依然占用你的执行流。这个模型的痛点也显而易见信号处理函数里能做的事非常有限read()可能还不是非阻塞的一旦数据量大、并发高信号会源源不断涌进来处理不过来还可能丢信号。实际项目里已经很少直接用信号驱动IO做网络服务了但理解它的“第一阶段靠通知第二阶段亲自来”这个结构对理解AIO很有帮助。3.5 异步IO提交完就撒手数据到位再叫我异步IO是五种模型里的终极形态。你发起一个异步读请求传入用户缓冲区地址然后调用立刻返回。内核负责等待数据就绪、完成数据拷贝全部搞定之后通过某种通知方式信号、回调、或者完成事件告诉你“数据已经在你的缓冲区里了”。过去的Linux下有POSIX AIOlibaio但它在文件IO上的表现还行在网络IO上一直不算特别成熟使用门槛和限制也不少。很多人对AIo的印象就是“文档少、坑多、用起来不顺手”。这种情况在io_uring出现之后有了明显的改变。io_uring是较新的异步IO框架它通过两个用户态和内核态共享的环形队列来提交请求和收割结果。因为你发起IO请求不需要再逐个做系统调用而是把一系列请求一股脑塞进队列内核批量处理完成后再批量通知你。这种机制极大地减少了系统调用次数也降低了拷贝开销。目前在高性能存储和数据库场景里已经有不少成功的实践。虽然网络IO方面Linux社区的讨论也不少但想要让它彻底替代epoll成为服务端网络编程的主流还需要时间沉淀。4. 多路复用三兄弟select、poll、epoll对比与深入多路复用是当前Linux高并发网络服务的主流方案一般认为它有三次演进select、poll、epoll。搞懂它们之间的差异才算真正理解了“高并发”背后的机制。4.1 select有上限还要来回拷贝select是最早出现的多路复用接口。它对fd数量有硬性限制默认是FD_SETSIZE通常是1024。这在今天的连接规模下完全不够用。而且前面也提到了每次调用都要把fd集合从用户空间拷贝到内核再拷贝回来内核扫描fd是O(n)的应用扫描返回的fd集合又是O(n)的。当fd数量大、但就绪fd很少的时候这个O(n)的代价会非常扎眼。我把select的核心局限总结一下可监控的fd数量受FD_SETSIZE限制fd集合每次调用都要完整拷贝拷贝开销随fd数量线性增长内核用线性遍历确认就绪fd就绪数量少时效率低返回结果没有明确告知哪些fd就绪应用需要再次遍历4.2 poll取消了上限但没解决根本问题poll用pollfd数组替代了fd_set从设计上取消了FD_SETSIZE的限制。你可以监控任意数量的fd也不需要构建FD_SET那套宏了。但它本质上仍然是“所有fd传给内核、内核线性遍历、再全部拷贝回来、应用再遍历一遍”复杂度还是O(n)。这个模型在连接数少、活动连接频繁的场景下是够用的但在“连接数巨大、活跃连接占比很小”的典型高并发场景下就会力不从心。4.3 epoll事件驱动只通知就绪的fdepoll是Linux专有的多路复用机制也是目前高性能网络框架的事实标准。它和select/poll最大的区别在于不再每次调用都把全部fd传给内核而是通过三个系统调用分工配合。// 1. 创建epoll实例 int epfd epoll_create(1); // 2. 注册fd和关注的事件 struct epoll_event ev; ev.events EPOLLIN; ev.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev); // 3. 等待就绪事件 struct epoll_event events[1024]; int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { handle_read(events[i].data.fd); }第一次epoll_create创建一个epoll实例内核里会维护一棵红黑树来存储你注册的fd以及一个就绪链表。每次epoll_ctl往红黑树上增删改fd。当某个fd有事件发生时内核通过回调机制把这个fd直接放到就绪链表里。epoll_wait返回时你拿到的就是内核已经帮你挑好的就绪fd数组不用再O(n)遍历全部连接。这套机制带来的两个直接好处一是注册的fd多了内核不需要每次从头扫一遍二是就绪事件由回调机制主动上报就绪fd数量少时你拿到的结果往往就只有那几个活跃的fd处理效率非常高。这也是为什么它能支撑百万连接的根基。4.4 水平触发LT与边缘触发ET很多线上问题从这个选择开始使用epoll时一个绕不开的概念就是触发模式。水平触发LT是默认的模式也是select/poll一直在用的方式只要fd还有未读完的数据每次epoll_wait都会把你叫醒。边缘触发ET则是当fd状态发生变化比如从无数据变为有数据时只会通知你一次如果你这次没把数据全部读干净它不会再提醒你。ET模式听起来更“省事件”性能理论上更好但它很考验编程功力。因为内核只通知一次你必须想办法把数据全部读完。怎么判断读完了通常是把fd设为非阻塞然后循环调用read()直到返回EAGAIN为止。很多线上偶发的问题都出在这个“读到EAGAIN”的边界上循环条件写错读到一半就停了或者用了阻塞fd在最后一个read上卡住线程。这些都是非常真实的坑。对于大多数项目我建议先从LT模式开始。LT模式代码写起来简单安全不至于因为一次漏读就把数据卡在缓冲区里。只有在并发量确实大、热点fd很多并且你能把ET模式的读取循环写得足够严谨时再考虑切到ET模式去省掉那部分重复唤醒的开销。5. 生产环境选型与几个高频坑点掌握了模型之后更重要的是在项目里怎么选型。这里我结合自己的实战经验给几条参考线和避坑建议。5.1 别盲目上epoll先看业务规模和连接特点很多人一上来就想用epoll这其实是一种误解。如果你的服务端只需要同时处理几十个连接而且每个连接都有持续的请求往来那用阻塞IO加线程池代码最简单、维护成本最低线程数量可控性能完全够。一上来就写事件驱动反而是过度设计。真正需要上多路复用的是两类场景一类是连接数很大比如网关、推送服务、长连接服务几万到几十万的连接数线程不可能按连接数去开另一类是大量连接处于空闲状态比如物联网设备接入设备大部分时间都在休眠如果用阻塞IO每个连接都得占一个线程线程资源的浪费就太明显了。用一张表快速帮你判断场景特征推荐模型理由连接数少几十到几百请求频繁阻塞IO 线程池实现简单开发效率高性能足够连接数大大部分空闲epollLT优先线程占用少事件驱动能及时响应活跃连接连接数大且大部分活跃度高epoll可尝试ET减少重复通知但要严格设计读取循环大量文件读写、追求极致IO吞吐io_uring批量提交、异步完成有效降低系统调用开销5.2 边缘触发模式下“读不干净”导致饿死这是我最想强调的一个坑。我见过一个同事实现的边缘触发读取逻辑循环里每次只读1024字节读了两次就退出循环理由是“怕多读了占用CPU”。这个逻辑在高并发下直接造成一个隐蔽问题数据包稍微大一点就会被拆成多次读而ET模式只在状态变化时通知一次下一次epoll_wait可能迟迟不触发连接上的数据就积累了严重的直接表现是发送端看到接收窗口一直变小性能急剧下降。ET模式下正确的读取方式是在收到可读事件后循环read()直到返回EAGAIN。注意这里的细节要用非阻塞fd否则最后几次read()可能因为缓冲区已经空了而阻塞循环要有退出条件避免在极端情况下陷入死循环。把这两条做到ET模式才值得用。如果你没有十足的把握直接用LT模式数据每次没读完还能再次触发通知性能差距在绝大多数业务场景下并不明显。5.3 别忽略文件描述符数量上限和内核参数不知道你有没有遇到过这种问题本地压测到一万连接就报“Too many open files”代码逻辑完全没问题查了半天才发现是进程的文件描述符上限没调。Linux默认的ulimit -n通常只有1024这在处理几万连接时直接就是瓶颈。生产环境要把这个限制调大同时注意查看系统级的/proc/sys/fs/file-max是否足够。还有几个参数值得关注tcp_max_syn_backlog影响半连接队列大小somaxconn影响accept队列长度net.core.rmem_default等影响接收缓冲区大小。这些参数不会日常修改但在你做高并发压测、排查连接建立失败或数据吞吐异常时它们往往就是幕后黑手。5.4 关于AIo和io_uring的一些大实话聊到异步IO很多人容易把它想成“性能翻倍神器”。从实际经验来看io_uring在存储IO和一些高吞吐定制场景下确实表现亮眼但它不是无脑替换方案。这套机制需要预先分配固定的缓冲区、管理用户态和内核态共享的环形队列代码复杂度明显上升而且不同内核版本对io_uring特性的支持进度也不一样。如果你的业务里网络事件驱动已经够用整体链路没有明显的系统调用瓶颈那盲目上io_uring可能只会徒增维护成本。我的建议是先把epoll这套玩透搞清楚阻塞、非阻塞、同步、异步的本质区别再按需去了解io_uring的进阶用法。底层机制相通新工具终究是旧问题的更优解而不是另起炉灶。6. 结尾这篇文章从一次read()的完整旅程讲起梳理了阻塞、非阻塞、多路复用、信号驱动和异步IO五种模型又重点拆解了select、poll、epoll的演进逻辑和LT/ET的实践差别。内容不算新潮但这些都是我踩过很多坑之后才真正想明白的东西。最后分享两个小技巧。排查线上IO模型相关问题时先看线程数和fd数再下结论写网络服务时默认用LT模式把“必须用ET”的理由写清楚再换这样能让整个团队少踩很多莫名其妙的雷。希望这些经验对你也有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑