
钓鱼的人都知道看浮漂最熬人。如果把这个过程搬进操作系统里程序读文件、读网络包、写磁盘本质上都是同一种等待数据还没准备好我得在这儿耗着。这套“等待 拿数据”的方式在计算机网络和操作系统里被归纳成非常经典的五种IO模型。我不打算摆一堆内核源码就用一个钓鱼佬的视角把阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO挨个讲透。只要你有过写网络程序的经历哪怕你只是钓过鱼都能顺着这条线把模型记牢。理解了它们你再去看Nginx、Redis、Netty这些高性能框架会发现所有套路都绕不过这张清单。1. 为什么非要拿钓鱼来讲 IO 模型1.1 IO 的本质一次完整的钓鱼过程程序里做一次 IO从上往下看其实要经历两个阶段。第一阶段是等待数据准备好比如 TCP 数据包到达网卡被内核收进缓冲区或者文件数据从磁盘读进了内核的页缓存第二阶段是把数据从内核缓冲区拷贝到用户空间缓冲区然后让你的应用程序真正拿到这些字节。在 Linux 上这两个阶段经常被误解成函数调用那一瞬间的事实际绝大多数时间都花在“等”上。钓鱼恰好也是这个节奏。你放饵、抛竿这是发起请求鱼在水底转悠、试探、咬钩这是数据到达浮漂一沉这是内核告诉你“数据就绪了”你提竿、收线、摘鱼这是把数据从水里带出来最后放进鱼护这是你的业务处理。一个完整的 read 调用本质上就是“坐在岸边等浮漂 用力收线”两条动作的组合。区别只在于这个过程中的“等你”和“拉拽”到底是线程自己在干还是交给了别人。很多刚学网络编程的人看到五种模型的名字就头疼其实它们都是在回答同一个问题程序发起 IO 之后在数据真正从内核挪到用户空间之前我这线程到底是睡死过去还是能顺便干点别的如果你能把这个“等”字拆开来看后面五个钓鱼姿势就一点都不神秘了。1.2 阻塞、非阻塞、同步、异步先讲清楚两个维度在进入五种模型之前得先把两组容易吵架的词说清楚阻塞与非阻塞同步与异步。我在很多项目组里见过同事争论“epoll 到底是阻塞还是非阻塞”其实就是因为这两个维度的坐标没对齐。阻塞和非阻塞问的是“我等数据时能不能离开钓位”。你抛竿后死死盯着浮漂连水都不敢喝这是阻塞你抛竿后去旁边收拾钓具隔几分钟回来瞄一眼浮漂这是非阻塞。程序里对应的是调用 recv 时如果数据没到线程是睡过去等唤醒还是立刻返回一个“没数据”的错误码。同步和异步问的是“这条鱼从头到尾是不是我自己弄上来的”。自己提竿、自己收线、自己摘鱼哪怕中间非常高效也是同步全部交给代钓师傅他提竿、他遛鱼、他把鱼装好送到你手里这是异步。程序里最关键的分界线在第二阶段数据从内核空间拷贝到用户空间这件事是用户进程亲手做的还是内核彻底做完了再通知你。经典分类里前四种模型都是同步IO因为最终把数据从内核缓冲区搬到用户缓冲区的那一下仍然是你的进程在做。只有第五种异步IO连拷贝都由内核包了。记住这个点面试时被问“为什么说 epoll 不是异步IO”就不会卡壳了。2. 五种 IO 模型五种钓鱼姿势2.1 阻塞IO守住一根竿鱼不来我哪也不去最原始的钓鱼方式是找好钓位上饵抛竿然后整个人焊在钓位上。浮漂没动你就不动浮漂动了你立刻提竿收线。在整个等待期间你这个人除了等之外做不了任何事。这个姿势放到代码里就是传统的 read / recv 调用。假设有一个 socket 连接你调read(fd, buf, sizeof(buf))内核发现接收缓冲区里没有数据就把当前线程挂到一个等待队列上线程进入睡眠状态。直到数据包到达网卡触发中断内核把数据放进缓冲区然后唤醒你再执行拷贝最后返回字节数。整个过程里你的线程什么都没有做只是在睡。阻塞IO的优点只有一条简单。你写业务逻辑的时候就是“读、处理、回包”的线性结构不会出现“未完成状态”的检查。但它的问题在高并发下非常致命一个连接至少需要一个线程线程数量上去以后大量内存花在栈空间上CPU 大量时间耗在线程调度和上下文切换里真正干活的时间反而不多。我自己早年写过一个每连接一线程的网关连接数到几千时拓扑图已经乱成一团监控里全是进程切换。所以阻塞IO更适合脚本、调试工具、连接数很少的管理端程序而不是核心服务入口。2.2 非阻塞IO动不动就提竿看一眼没鱼就走有一种钓友坐不住抛竿之后不盯浮漂而是每隔一会儿就提竿看看鱼饵有没有被咬掉。没咬就重新抛下去干点别的咬了再认真收线。这个动作对应的就是非阻塞IO。给 fd 设置O_NONBLOCK之后你调 recv 时如果内核缓冲区没有数据它不会让你睡觉而是立刻返回 -1同时把errno设置成EAGAIN或者EWOULDBLOCK。你的循环一看“没鱼”转头继续做别的事情过一阵再来问一次。这种“提竿检查”从人的角度看很灵活但放到程序里有一个巨大的隐患轮询不是免费的。每次 recv 都是一次系统调用从用户态切到内核态再切回来。如果一个连接每秒轮询一万次一万个连接就是一亿次系统调用CPU 直接烧给空转。所以单独使用非阻塞IO只适合“并发低、等待少”的场景或者当你实在不想让线程睡死又不想引入复杂事件机制时做个临时方案。它更大的价值是作为多路复用的基础设施当 epoll 告诉你某个 fd 可以读你还需要这个 fd 是非阻塞的否则读数据时可能把一个事件循环卡死。这个话题等会单独说。2.3 IO多路复用一排钓竿一双眼睛谁动处理谁真正的高手不会只守一根竿。他们会把一排钓竿插在架子上每个竿都有浮漂自己坐在中间眼睛来回扫视这一排浮漂哪个动了就放下手里的茶杯过去提那根竿。一个人看二十根竿线程不再是一连接一线程而是一线程管一片连接。对应到系统调用这就是 select / poll / epoll 的世界。你把一堆 fd 注册给一个 epoll 实例然后调用epoll_wait睡在那边。内核负责盯着这些连接一旦某个连接上有数据到达就把它放进就绪队列epoll_wait立刻返回把有事件的 fd 列表给你。你只需要遍历这个列表逐个 read、处理、再继续等待。这里最大的认知误区是“多路复用就是异步”。其实不是。epoll 只是解决了“我到底该提哪根竿”的问题它帮你判断哪根竿的浮漂动了但提竿收线这件事也就是从内核缓冲区拷贝数据到用户空间依然要你的进程去做。所以它仍然属于同步IO模型。但因为它把“等待多个 fd”的开销降得很低实际能支撑几十万连接所以现代高性能服务几乎都走这条路。Nginx 的事件循环、Redis 的单线程模型底层都是这套逻辑。实际选型中Linux 上直接 epollmacOS 上用 kqueueWindows 上则通常走 IOCP 异步模型。2.4 信号驱动IO挂个铃铛响了我再去有些钓点竿太长浮漂远到看不清钓友会在竿梢夹一个小铃铛。鱼一咬钩竿梢抖动铃铛哗啦响你听到声音再站起来走过去提竿。这跟非阻塞轮询的“不定时提竿检查”完全不同——不用反复打扰水面等通知就行。信号驱动IO的程序形态是这样你用fcntl设置当前进程为 fd 的属主再打开O_ASYNC让文件描述符在数据就绪时向进程发送SIGIO信号。进程提前注册一个信号处理函数收到信号后知道“鱼咬钩了”于是再进行 read 操作把数据读到用户空间。这个模型看着很优雅等待期间进程可以干别的也不用像非阻塞那样反复轮询。但它在工程上并不讨喜。信号本身有限制普通信号无法排队高并发下信号还会丢失在信号处理函数里做太多事情又容易引发可重入问题。更麻烦的是Linux 上 SIGIO 对不同 socket 类型的支持不一致在 TCP 上的表现尤其飘忽。所以这更像教科书里完整的逻辑推演实际高并发服务很少有人拿它当作主力模型最多在个别简单的嵌入式场景里见到它的影子。2.5 异步IO请个代钓鱼的全套服务到位最后一个玩法属于“氪金玩家”。你不亲自守竿也不亲自提竿而是请一个专业代钓师傅把全套流程外包出去。他负责盯浮漂、提竿、遛鱼、摘钩、入护最后把一条干净鱼递到你手上。你从头到尾没有碰过鱼竿拿到的已经是处理好的结果。这才是真正的异步IO。程序提交一个异步读写请求比如aio_read然后立刻返回继续执行后面的逻辑。内核自己完成“等待数据就绪”和“从内核缓冲区拷贝到用户空间”两个阶段等到全部完成之后通过信号、回调或者 eventfd 告诉你缓冲区里已经是能用的数据了。前面四种模型里无论等待阶段怎么变花样用户进程都会参与拷贝数据异步IO把这一步也彻底交出去所以它和前四种有本质区别。异步IO的性能上限非常高但工程复杂度也是五者里最高的。传统 POSIX aio 在 Linux 上的实现并不总是尽如人意很多底层还是用线程池模拟真正把异步IO推向极致的是后来的 io_uring它通过共享内存的环形队列让用户态和内核态高效交换请求与结果。Windows 上的 IOCP 也属于这个分类。如果你的目标是把网络服务压到极限或者做高性能本地文件IO异步IO是值得投入的方向如果只是常规的 HTTP 接口服务epoll 已经足够未必需要强行上 io_uring。3. 把钓鱼姿势落到代码里实操与参数解读3.1 阻塞IOread/recv 一行代码的等待哲学先看最简单的阻塞读。以下代码是网络程序最原始的形态int fd socket(AF_INET, SOCK_STREAM, 0); // connect / bind / listen 等过程省略 char buf[1024]; int n read(fd, buf, sizeof(buf)); if (n 0) { // 处理数据 } else if (n 0) { // 对端关闭 } else { // 出错通过 errno 判断原因 }这里的关键是 read 没有设置任何非阻塞标志所以如果内核缓冲区没有数据当前线程会一直睡着。很多人低估了这个“睡着”的杀伤力在单线程程序里一个阻塞 read 会让整个进程停摆在多线程程序里每个连接占一个线程系统资源很快耗尽。我见过一个同事把数据库连接池配成每连接一线程结果连接数涨到两千后机器 load 飙到几十排查下来发现大部分线程都在 futex 等待里排队。阻塞IO不是不能用但选它之前先想清楚自己的并发边界。3.2 非阻塞IOO_NONBLOCK 与 EAGAIN 的轮询现场非阻塞IO的代码形态比较直观先给 fd 设置O_NONBLOCK然后循环不读。int flags fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags | O_NONBLOCK); char buf[1024]; while (1) { int n read(fd, buf, sizeof(buf)); if (n -1 (errno EAGAIN || errno EWOULDBLOCK)) { // 数据还没到先干点别的事过会再来 sleep(1); continue; } if (n 0) { // 处理数据 break; } }代码里的EAGAIN是核心信号它含义是“我不阻塞你但现在确实没有数据”。很多新手第一次写非阻塞读会把EAGAIN当错误打在日志里结果日志文件一分钟就爆掉。正确做法是把这一支当成“没鱼”的正常分支。这里还要提醒一点sleep(1)只是演示生产环境几乎不会这么干。你如果不想用一个事件机制又想减少空转可以加一段退避时间但延迟和 CPU 之间必须做取舍。非阻塞IO单独使用的性价比很低它最合理的身份是“多路复用的辅助工具”。3.3 IO多路复用用 epoll 管理二十根竿这里给一个 epoll 的实用骨架对应“一个人盯二十根竿”的场景int epfd epoll_create(1024); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[128]; while (1) { int n epoll_wait(epfd, events, 128, -1); for (int i 0; i n; i) { int ready_fd events[i].data.fd; if (ready_fd listen_fd) { // accept 新连接然后设置成非阻塞并加入 epoll } else { char buf[1024]; int nread read(ready_fd, buf, sizeof(buf)); // 处理 data } } }epoll_wait返回的n是有多少根竿有鱼你只需要处理这批 fd这在连接数上万时节省了大量遍历成本。相比 select 每次要把几千个 fd 从用户态拷到内核态、内核再挨个查状态epoll 用一棵红黑树维护注册信息用就绪队列直接给你答案复杂度从 O(n) 降到 O(1)。实际工程里还要考虑EPOLLIN、EPOLLOUT、EPOLLET等事件组合以及 accept 之后的新连接是否一定要注册进同一个 epfd。我最早写 epoll 时漏掉对新连接的EPOLL_CTL_ADD导致客户端连接后迟迟没有事件整整排查了几个小时。3.4 信号驱动IOSIGIO 与 fcntl 的组合配置信号驱动的代码不长但每一步都很讲究。首先要设置信号的属主然后打开异步通知开关signal(SIGIO, io_handler); fcntl(fd, F_SETOWN, getpid()); int flags fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags | O_ASYNC);再看看信号处理函数void io_handler(int signo) { char buf[1024]; int n read(fd, buf, sizeof(buf)); // 信号处理函数里只做最紧急的事情 // 更好的做法是置一个标志位回到主循环再读数据。 }这种模式把一个连接的数据就绪事件变成了“铃铛响”但在实际使用中SIGIO 在 Linux 上对 TCP socket 的支持并不像文档描述的那样完美。信号处理函数还会打断正常的执行流如果它里面调用了不安全函数可能造成死锁或者数据错乱。所以我的建议是理解它是为了打通“信号驱动IO”这个知识点但别轻易在生产环境里大面积使用除非你对目标平台的实现做过充分测试。3.5 异步IOaio_read一次完整的“外包”提交最后看异步IO的简化用法以 POSIX aio 为例说明“提交”和“等待结果”是分离的#include aio.h struct aiocb cb; memset(cb, 0, sizeof(cb)); cb.aio_fildes fd; cb.aio_buf buf; cb.aio_nbytes sizeof(buf); aio_read(cb); // 这里不阻塞可以继续做其他事情 // 稍后检查状态 while (aio_error(cb) EINPROGRESS) { // 忙等或者睡眠也可以使用回调方式 } int ret aio_return(cb);这段代码是异步IO的“入门版”。真正追求性能的工程会使用 io_uring 这类更底层的方案它通过内核与用户态共享 ring buffer 来提交请求和收割完成事件吞吐能力甩传统模型几条街。不过异步IO的思维模式不是“读数据、等结果”而是“提交一个任务、倒时候拿完成通知”。如果你之前习惯同步编程刚开始会很不适应需要处理很多“请求没有立刻完成”的状态。我自己的体会是先踏实把阻塞IO和 epoll 练熟再涉足 io_uring否则很容易被完成回调、异步通知、内存复用这些概念绕晕。4. 实战中的坑排查记录与速查表4.1 非阻塞轮询为什么把 CPU 烧满这里分享一个真实场景。有一段时间我接手过一个网关模块代码里用了非阻塞 socket主循环里对每个连接反复 read没有数据就继续下一轮。刚开始连接只有几百个CPU 还在可接受范围等到连接数涨到五千机器 CPU 直接 100%。监控里看大部分线程都在用户态忙转没有任何一个线程在睡眠。原因就是忙轮询。非阻塞IO不会睡你的循环会以最大速度一遍又一遍地发起系统调用。系统调用本身有开销次数一多CPU 自然被耗尽。解决方法是让循环从“主动问”变成“等通知”用 epoll 挂起整批 fd让内核在有数据时才唤醒你。如果一定要自己轮询也需要加入动态退避比如连续几次发现没有数据就把轮询周期拉长避免空转。这个坑让我彻底记住了“非阻塞”不等于“高性能”它只是把“阻塞在等待队列”换成“阻塞在事件循环里”。4.2 多路复用里的 fd 为什么不设成阻塞就出事另一个高频坑出现在 epoll 阻塞 fd 的组合里。设想一个场景epoll_wait 返回了一个可读事件你开始 read想要一次读完逻辑上的完整消息于是又调了一次 read 等待剩余的数据。如果数据没有到齐阻塞 read 就会把线程挂起整个事件循环被卡住其他几千个连接全部得不到处理。所以标准做法是所有注册进 epoll 的 client fd 都必须设置成非阻塞。这样 epoll 告诉你“可读”之后你去 read能读多少读多少如果读到EAGAIN说明当前缓冲区已经读完就回到 epoll_wait 继续等下一轮。这才不会让一根竿上的意外卡死整排竿。还要注意水平触发和边缘触发的差别水平触发模式下只要缓冲区还有数据epoll 就会反复通知处理起来相对宽容边缘触发则只在状态变化时通知一次要求你必须循环读取到EAGAIN否则数据会留在内核缓冲区里白白错过通知。选边缘触发想省一点系统调用就必须承担更高的编程复杂度新手建议先用水平触发。4.3 信号驱动IO为什么在高并发里几乎没人用我曾经在一本老书上看到信号驱动IO的示例觉得很高级于是想引入到一个 UDP 小服务里。结果测试时发现并发请求一上来信号偶尔丢失程序收到通知的次数远少于实际到来的数据包。这是因为普通信号没有排队能力要是同时来十个事件最终可能只发一两个信号。实时信号可以排一部分队但个数也有上限。更麻烦的是信号处理函数运行在进程任意上下文中你没办法在里面安全地做加锁、操作复杂数据结构否则很容易死锁。多线程程序里信号定向也是一团乱麻。相比之下epoll 的事件通知模型更干净它是数据驱动的就绪队列没有信号丢失问题也没有处理函数打断主流程的问题。所以我自己最终把所有新代码都迁到了 epoll 上信号驱动只保留了教科书意义。4.4 五种模型的速查表与选型建议把五种模型放一张表里方便你面试前或做架构选型时快速过一遍IO模型钓鱼姿势等待阶段是否占用进程数据拷贝由谁做复杂度典型场景阻塞IO死盯一根竿阻塞用户进程低脚本、简单客户端、连接数少的服务非阻塞IO反复提竿检查非阻塞但忙轮询用户进程中配合多路复用少数对延迟敏感的场景IO多路复用一排竿一个监看非阻塞用户进程中高Linux 高并发网络服务epoll/kqueue信号驱动IO铃铛响了再去非阻塞用户进程中特定协议、嵌入式生产使用少见异步IO请代钓全包非阻塞内核高高吞吐存储、极致网络性能io_uring/IOCP选型建议其实不难。如果你的服务是内部的、并发很温和直接用阻塞IO省事是最大的优点。如果连接数高而你对强度没有极致追求Linux 上就选 epoll熟练用好多路复用已经能撑起绝大部分业务。如果未来要考虑文件IO和网络IO混合、追求极限吞吐再去看 io_uring。别一上来就异步IO很多团队最后死在了“过度异步”导致的排查难度上。最后说一点个人经验。我在最开始学网络编程时一直背不住五种模型因为总是拿“阻塞/非阻塞”和“同步/异步”两套词硬套套到 IO 多路复用就蒙了。后来真的在水库边看着一排鱼竿才一下想通不要管名词怎么叫就问两件事——我在等数据时能不能干别的数据从内核到用户空间这一下到底谁在用力只要把这两个问题答清楚任何模型都能对号入座。你现在再去读 epoll 或 io_uring 的源码会发现它们的本质不过就是“把鱼护递到你手里的方式不一样”。