
1. 重新认识IO应用到底在等什么聊非阻塞IO之前得先把一个最基础的问题掰开揉碎讲清楚一次read()调用操作系统里到底发生了什么很多人写了好几年业务代码天天跟数据库、Redis、HTTP接口打交道但对“IO”这两个字母的理解还停留在“读文件、收发网络包”的层面。实际上IO模型的差异本质上就是应用程序在“等待数据”这件事上愿意付出多少CPU代价和线程代价的问题。我习惯用一个“点外卖”的类比来解释。你去餐厅点了一份菜从下单到吃到嘴中间有两个阶段后厨做菜数据从网卡/磁盘到达内核缓冲区服务员把菜端到你桌上数据从内核缓冲区拷贝到用户空间缓冲区。注意这两个阶段是可以拆开的。大多数IO模型的分野就发生在“后厨做菜”这个阶段——你到底是傻等、隔一会儿问一次、还是干脆等菜好了别人再叫你。把IO等待拆成两个子阶段是理解五种模型的关键。阻塞IO是“后厨做菜”和“服务员端菜”都让你死等非阻塞IO是“后厨做菜”阶段你反复去问做好没做好了再等端菜多路复用是你请了个大堂经理帮你盯着所有桌子的菜哪桌好了通知你去端信号驱动是后厨做好菜直接喊你异步IO是后厨做好菜、服务员端到你桌前放好才喊你“可以吃了”——你从头到尾什么都没管。这个认知一旦建立任何IO模型摆在面前你都能一眼看穿它到底把等待放在哪个阶段。既然是聊服务端开发我们默认聚焦网络IOsocket因为网络IO的不可控性最强、对模型的选择最敏感。磁盘IO虽然也符合这套理论但磁盘的“就绪”速度相对稳定不像网络那样充满不确定性所以网络场景更能体现五种模型的差异。2. 五种IO模型逐一拆解对比2.1 阻塞IOBIO最朴素也最直观的模型阻塞IO是绝大多数程序员接触的第一个IO模型。调用read()之后如果数据没到线程就挂起内核帮你把CPU让给其他线程。数据到了read()返回线程继续往下跑。这种模型的编程模型极其简单一个连接来分配一个线程去处理处理完了回收线程。业务逻辑写起来跟写同步代码一模一样没有回调、没有状态机、没有事件循环。Java的Socket、Python的socket默认都是阻塞模式很多初学网络编程的人写出来的第一个TCP服务就是这种。阻塞IO的问题不用多讲如果同一时刻有1万个连接你就要养1万个线程。线程不是免费的每个线程默认栈空间就好几MBJVM里默认1MB内存先扛不住线程切换的上下文开销也会把CPU拖垮。这就是经典的C10K问题产生的根源——不是硬件不行是模型不行。但你说阻塞IO完全没用吗也未必。在连接数少几十、几百、每个连接通信量大的场景下阻塞IO反而是最稳的选择。写起来不容易出bug排查问题也直观线程栈上直接能看到当前阻塞在哪个socket上。很多传统企业应用的内部服务至今还在用BIO因为并发量根本不需要上多路复用而且团队维护成本低。2.2 非阻塞IONIO轮询带来的转机非阻塞IO要解决的问题很明确既想避免线程被白白挂着等数据又不想引入复杂的回调机制。核心动作是把fd文件描述符设置为非阻塞模式O_NONBLOCK之后每次调用read()都会立即返回。数据没准备好返回-1并设置errno为EAGAIN或EWOULDBLOCK告诉你“现在还没数据你回头再来”数据准备好了正常返回读取的字节数。这就意味着应用程序可以“轮流巡视”多个连接了先看连接1有没有数据没有就去看连接2再看连接3……如果某个连接有数据了就处理它。这样一个线程就能照看大量连接线程数量的问题解决了。但代价也很明显你主动去“问”的过程是纯CPU操作而且是忙等。假设1000个连接大多数时候只有几个有数据但你每次循环都要挨个问一遍。更重要的是一旦调用read()时数据只到了一半比如一个完整的数据包还没到齐你这次read读出的数据是不完整的业务层必须自己处理“半包”和“粘包”问题把数据缓存、拼接。非阻塞IO在工程上很少单独出现因为它“主动轮询”的效率太低。但它为后续模型奠定了一个基础非阻塞的socket加上某种“通知机制”就是大名鼎鼎的多路复用。严格来说Java的NIONew IO和这里说的非阻塞IO不是一回事。Java NIO的底层核心虽然是多路复用Selector但它的名字经常会让人混淆面试聊起这个话题时建议说清楚避免表述歧义。2.3 IO多路复用一个线程盯一堆连接这是目前使用最广泛的模型Redis、Nginx、Netty、Node.js的底层都离不开它。多路复用的思路是我不挨个去问每个连接“你有数据了吗”而是把这些连接统一交给内核由内核帮我盯着。我调用select()、poll()或epoll_wait()传入我关心的所有fd列表然后阻塞在这个调用上。内核一旦发现有任何一个fd上出现了“可读”或“可写”事件就唤醒我我再去处理那些真正有事件发生的连接。注意这里的关键词是“可读/可写事件”不是“数据”。内核只负责告诉你“这个fd可以读了不会阻塞”具体能读多少字节、数据什么格式依然要你亲自去read()。这一点和异步IO有本质区别很多人容易混淆后面我会专门对比。多路复用又分三代的演进select、poll、epollLinux。select最早出现它有一个致命限制——fd数量上限是1024而且每次调用都要把全部fd从用户态拷贝到内核态内核还要线性扫描所有fd效率随fd数量增长而线性下降。poll解决了1024上限的问题但依然存在全量拷贝和线性扫描。epoll则彻底改变了局面它用红黑树管理fd用就绪链表记录有事件的fd每次调用只返回有事件的fd集合不需要全量扫描也不需要每次重新注册fd——这就是“事件驱动”的威力。以Redis为例它之所以能用单线程扛住每秒十万级甚至百万级的QPS靠的正是epoll的事件驱动机制。Redis主循环只需要阻塞在epoll_wait上每当有连接事件到来就绪列表会精确告诉我们“哪几个客户端发数据了”处理完这几个再回去继续等。整个过程没有线程切换开销没有锁竞争单线程反而成为优势。从工程角度看多路复用是一个“高性价比”的模型它用相对简单的编程模型虽然有状态机但比异步IO还是简单得多换来了极高的并发能力适合绝大多数服务端场景。2.4 信号驱动IO让内核来“喊”你信号驱动IO可能知道的人最少因为它实际应用非常少但面试时偶尔会被提及。既然聊五种模型还是得讲清楚。它的流程是预先给fd注册一个SIGIO信号处理函数然后继续做别的事。当数据到达内核缓冲区时内核会发一个SIGIO信号给你的进程信号处理函数被触发你就可以在函数里调用read()读取数据了。听起来是不是很像异步其实不是。信号驱动只是告诉你“数据准备好了”你自己还是得去read()把数据从内核拷出来这个拷贝过程依然是同步阻塞的。所以它本质上属于“非阻塞的变体”只是把“轮询”换成了“内核主动通知”省去了反复询问的CPU开销。它之所以没流行起来有几方面原因。一是信号处理的编程复杂度比较高信号处理函数中只能调用异步信号安全的函数很多常规操作都不能做二是多个fd的信号是共用一个处理函数的你得自己去区分是哪个fd触发了信号连接一多就非常麻烦三是它本身的性能优势在epoll面前并不明显而工程上用epoll已经能优雅解决绝大多数问题。所以信号驱动IO更适合看作“理论上的存在”真正的生产环境很少看到它的身影。2.5 异步IOAIO从头到尾不用你管异步IO才是真正意义上的“省心”。你调用aio_read()之后把数据缓冲区地址告诉内核就直接去干别的事了。内核负责等待数据就绪、从内核缓冲区把数据拷贝到用户缓冲区全部搞定之后才通知你“数据已经在你的缓冲区里了你直接用吧”。整个过程你的线程一次都没有被阻塞甚至不需要参与数据拷贝。这才是“异步”的本意。相比之下多路复用虽然等待是异步的但read()那一刻依然是同步拷贝数据所以严格来说只能算“异步等待同步读取”的混合体。异步IO在Windows上IOCP用得比较成熟Linux的AIO实现io_uring出现之前则一直饱受诟病。Linux原生AIO对普通文件的支持还行但网络socket的支持一度很不完善接口难用、回调复杂所以Linux生态走的是另一条路——用epoll这种事件驱动的方式把“异步”在人肉层面实现。近年来以io_uring为代表的新一代异步框架开始成熟但覆盖范围、稳定性能不能全面替代epoll生态还需要时间验证。对于大部分应用开发者来说AIO目前还处于“知道有这么个东西但生产落地不多”的状态。模型等待数据阶段数据拷贝阶段主动轮询阻塞等待代表应用阻塞IO阻塞阻塞无全程传统BIO服务非阻塞IO轮询阻塞是仅拷贝阶段极少数自研框架IO多路复用阻塞但等待多fd阻塞无内核监听仅等待事件Nginx、Redis、Netty信号驱动IO信号通知阻塞无仅拷贝阶段理论场景异步IO内核处理内核处理无全程无Windows IOCP、io_uring3. 非阻塞IO的工程实现从理论到代码聊完了五种模型的对比现在聚焦到本文的另一个核心词非阻塞IO。前面说了它很少单独出现但是它是多路复用和事件驱动的基础掌握它的细节你才能真正理解select/epoll背后的逻辑。3.1 把socket设为非阻塞fcntl与O_NONBLOCK在Linux下把一个fd变成非阻塞模式的方式很简单int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);两行代码解释一下每一步。F_GETFL获取当前文件描述符的状态标志F_SETFL把新的标志写回去其中O_NONBLOCK就是非阻塞模式的位置。为什么不直接set一个O_NONBLOCK因为F_SETFL的语义是覆盖设置你直接写O_NONBLOCK会把其他已有的状态标志比如O_APPEND、O_SYNC清掉导致不可预知的副作用。先读后写的顺序在工程上是必须的。对应到高级语言Java可以用SocketChannel.configureBlocking(false)Python可以用socket.setblocking(False)C#的Socket则是在创建时指定SocketType底层都是同一个东西——把fd标记为O_NONBLOCK。3.2 非阻塞read/write的返回值EAGAIN与EWOULDBLOCK设成非阻塞模式之后一切就开始变得微妙了。每次调用read()返回值可能有三种情况大于0读到了数据返回字节数。等于0对端关闭了连接你可以关闭这个fd了。小于0出错。此时要检查errno如果是EAGAIN或EWOULDBLOCK说明当前没有数据可读如果是EINTR说明被信号中断重试即可其他错误码才是真的异常。write()也有类似的逻辑但稍微不同非阻塞模式下如果内核发送缓冲区满了write()会直接返回EAGAIN告诉你“现在写不进去你等会儿再写”。你可能要问了什么时候发送缓冲区会满高并发场景下非常常见——网络拥塞时对端接收得慢本地发送缓冲区的积压数据就会越来越多最终写满。很多新手踩坑的地方就在这里明明监听了“可读”事件read()却返回了EAGAIN明明监听了“可写”事件write()却也返回了EAGAIN。这是因为epoll事件是“边缘触发”和“水平触发”两种模式处理逻辑大不相同后面我会单独展开。3.3 一个最小可运行的非阻塞轮询示例为了能把非阻塞IO的执行过程看明白下面给一个极简的服务端示例仅用于演示原理。它做的事情是监听两个连接轮流检查哪个有数据读出来打印#include stdio.h #include sys/socket.h #include sys/types.h #include netinet/in.h #include fcntl.h #include unistd.h #include errno.h #include string.h int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.s_addr htonl(INADDR_ANY); int reuse 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); bind(listen_fd, (struct sockaddr*)addr, sizeof(addr)); listen(listen_fd, 1024); set_nonblock(listen_fd); int conn_fds[1024]; for (int i 0; i 1024; i) conn_fds[i] -1; while (1) { int cfd accept(listen_fd, NULL, NULL); if (cfd 0) { set_nonblock(cfd); for (int i 0; i 1024; i) { if (conn_fds[i] -1) { conn_fds[i] cfd; break; } } printf(new connection: %d\n, cfd); } for (int i 0; i 1024; i) { if (conn_fds[i] -1) continue; char buf[1024]; ssize_t n read(conn_fds[i], buf, sizeof(buf)); if (n 0) { printf(socket %d says: %.*s\n, conn_fds[i], (int)n, buf); } else if (n 0) { close(conn_fds[i]); conn_fds[i] -1; printf(socket %d closed\n, conn_fds[i]); } else { if (errno ! EAGAIN errno ! EWOULDBLOCK errno ! EINTR) { close(conn_fds[i]); conn_fds[i] -1; } } } usleep(1000); } return 0; }这个例子有一个致命问题但它恰恰是最好的教学素材主循环每次都要遍历1024个连接还要sleep 1毫秒来“降速”否则轮询本身会吃掉整个CPU。如果连接里只有极少数有数据这个效率就很着急了。所以非阻塞IO单独使用的价值并不高真正的价值在于它是最底层的行为模式一切事件驱动框架都必须依赖“非阻塞就绪通知”的组合。没有非阻塞就没有epoll的“可读/可写事件”概念因为阻塞socket在没数据时是直接睡着的内核没法给你触发“可读了”的事件——它压根不会去读。4. 生产环境选型什么场景选什么模型4.1 从C10K到C10M并发规模决定了模型上限现在你理解了五种模型自然就会问我写服务的时候到底用哪种答案不能脱离并发规模和业务形态。我给一个非常朴素的判断标准连接数小于几百BIO线程池完全够了几千到几万多路复用epoll/select是正路几十万以上就要上多线程事件驱动、多级分发架构还要考虑内核参数调优单纯靠模型已经不够了。另外还要看业务的IO密度。如果是计算密集型服务数据都在内存里算IO模型的影响力相对有限如果是IO密集型频繁读数据库、调外部API、转发上游数据IO模型的效率直接决定服务吞吐。判断方法很简单压测时观察线程的阻塞比例——如果大量线程长期处于WAITING状态说明IO模型拖了后腿。4.2 单线程事件循环为什么能扛住高并发很多人初次听说Redis单线程能扛10万QPS第一反应是“骗人的吧”。亲身去查一下源码或者用strace看一下运行过程你就明白了。Redis的主循环大致是这样的调用epoll_wait阻塞等待事件有事件来了就逐个处理。处理一个命令的时间是纳秒到微秒级别然后立刻回到epoll_wait继续等。因为所有命令的执行都在一个线程里顺序发生不存在锁竞争也没必要做线程切换单核CPU的利用率反而比多线程方案高得多。Nginx比Redis走得更远它是多进程事件驱动模型每个worker进程里跑着自己独立的事件循环通过共享内存和原子操作协调进程间的负载分配。这种模型在多核机器上既有事件驱动的高并发能力又能充分利用多核CPU。从这两个案例能看出事件驱动模型的核心设计目标是让CPU永远在处理“有用的事”尽量不要在无谓的等待和上下文切换上浪费指令周期。多路复用是这个目标落地的基石。4.3 线程池阻塞IO为什么仍然广泛存在聊了这么多事件驱动还是要泼一盆冷水线程池阻塞IO的架构远没有退出历史舞台。Spring Boot里默认的Tomcat绝大多数连接走的就是BIO模式配合线程池。数据库连接池一般也采用阻塞IO模型。原因是很多业务逻辑本身就是阻塞性的——你需要等待数据库返回、等待下游HTTP接口返回。哪怕你用了非阻塞模式业务代码里一个同步的SQL查询照样会把线程卡住整体收益大大缩水。这种情况下把一条流水线上一个环节改成非阻塞对整个吞吐量的改善非常有限。如果在业务代码里强行引入异步回调反而经常导致灾难回调嵌套把代码变成“回调地狱”排查问题可能要跨越好几个回调函数来回跳跃异常处理也异常艰难。这不是技术帝的问题是代码可维护性的问题。所以我的建议很直接连接量没到瓶颈、团队没有掌握事件驱动编程的经验就先老实使用阻塞IO超时控制别为了“技术听起来高级”盲目杨帆。等并发真的涨上来了先想清楚瓶颈在哪里再考虑迁移多路复用模型。5. 常见问题与排查技巧实录5.1 非阻塞IO模式下的CPU空转问题这是新手最常遇到的坑把socket设成非阻塞后写了一个循环不断read结果CPU占用率直接飙到100%。原因很简单——你的循环在没有数据时依然在疯狂轮询。排查方法gdb进到进程里看CPU在哪个逻辑路径上打转或者perf top看热点函数。解决办法也不难如果只是单独用非阻塞IO加上usleep让出CPU或者增加等待时间更好的做法是切换成多路复用让内核在数据就绪后才唤醒你的进程。这里有几个参数供参考Linux下select等待时间设为0pure轮询设成微秒级则退化为“固定频次的轮询”。但不管怎么调单独使用非阻塞IO在连接数增长后CPU占比都会指数上升遇到类似场景考虑直接改造模型可能更划算。5.2 边缘触发与水平触发读不完数据的事件陷阱epoll有ET和LT两种事件触发模式这个坑在非阻塞IO和多路复用结合时尤其明显。水平触发LT是默认模式如果fd上有数据没读完每次epoll_wait都会再返回这个fd的事件。这样即使你这次没把数据读干净下次还会提醒你继续读逻辑不容易出错。边缘触发ET只在状态变化时通知一次如果数据没读完内核不会再次通知你直到有新的数据到达才会重新触发。这意味着ET模式下你必须在一次read中把数据全部读完——不断循环调用read直到返回EAGAIN才算处理干净。在阻塞IO模式下ET无法使用因为阻塞的read会在没有数据时sleep你不知道是等下一批数据还是彻底清空。ET模式效率更高减少了用户态和内核态的上下文切换但几乎和“容易出错”绑定在一起。Nginx、Netty的高性能模式都倾向用ET代价是需要写非常严谨的读取循环。我在实际生产里如果不是特别极端的性能需求都建议先用LT模式。它虽然每次会多返回一些事件但正确性容易保障调优空间也大。5.3 误用异步IO导致的内存安全与悬挂指针问题异步IO中“你传一个缓冲区给内核内核用完再还给你”的工作方式有一个隐性风险如果内核拷贝数据期间这个缓冲区被释放了就是经典的use-after-free问题。具体场景是调用aio_read时传入了一块临时缓冲区函数立即返回业务线程继续往下执行把这块缓冲区栈上变量销毁了甚至重新分配给了其他用途。稍后内核把数据拷贝进这个地址写入了完全不该写的地方——轻则数据错乱重则直接段错误崩溃。这是异步IO最核心的编程难点它在本质上要求你管理好缓冲区的生命周期确保在IO完成之前内存一直有效。工程上这往往意味着缓冲区要预先分配、标记引用计数、在完成回调里释放。复杂度比同步代码高一整个量级。这也是为什么我在前文建议不是并发量到了非用不可的程度尽量不要主动选择AIO。5.4 非阻塞IO导致的“丢数据”假象用非阻塞模式做日志写入或者消息发送时write()返回的字节数可能小于你传入的数据长度甚至直接返回EAGAIN。如果不做处理数据就悄悄丢了。而阻塞IO模式下write会一直等到数据全部写入内核缓冲区后才返回不存在这种“部分写入”的问题。解决思路有两种短写重试如果write返回n小于len将剩余部分继续写入直到全部完成或遇到EAGAIN。更稳妥的方案在遇到EAGAIN时把剩余数据挂到该连接的发送队列里等下一次“可写”事件到来时再发送。第二个方案在多路复用模型中是标准做法也是很多高性能框架发送逻辑的核心并不是“能发就发、发不完就报错”而是把写不出去的数据缓存起来通过事件循环伺机发送。5.5 面试常问的几个问题速查既然这个话题经常出现在面试中顺手整理一下高频考点阻塞IO和非阻塞IO的本质区别是什么答发起系统调用时若无数据就绪是否立即返回。立即返回是非阻塞否则是阻塞。同步IO和异步IO的区别答看数据从内核拷贝到用户空间的过程是否由当前线程参与。线程亲自拷贝是同步内核全程代劳是异步。多路复用是异步IO吗答不是。多路复用在“等待事件”阶段不阻塞但数据拷贝阶段线程仍然参与是同步的。epoll和select的区别答epoll用红黑树管理fd、就绪链表返回结果、不需要每次都全量拷贝select有数量上限且遍历全部fd。Java NIO的N是什么答New不是Non-blocking。虽然它基于非阻塞多路复用实现但作为一个完整框架它里面包含的东西远不止非阻塞IO。6. 结尾的几句实在话做了这么多年后端看着各种框架、中间件、云原生技术层出不穷但IO模型这个基础话题始终没有被淘汰。不管是写RPC框架、自研网关还是排查线上性能问题最终都要回到“你的线程到底在等什么”这个问题上来。我个人在实际工作中的体会是模型选型从来不是技术高低的对决而是业务需求、团队熟练度、运维成本之间的均衡。能用阻塞IO解决的事情别为了“高性能”硬上事件驱动但一旦并发量上来了、线程数膨胀了、CPU全消耗在线程切换上了该换多路复用的时候也别犹豫。如果这篇文章让你对IO模型有了一个更清晰的框架性认知建议抽时间用strace跟踪一下redis-server或nginx的epoll_wait调用亲手看看事件驱动到底是怎么运作的比读十篇博客都管用。