钓鱼的人都知道,看浮漂最熬人。如果把这个过程搬进操作系统里,程序读文件、读网络包、写磁盘,本质上都是同一种等待:数据还没准备好,我得在这儿耗着。这套“等待 + 拿数据”的方式,在计算机网络和操作系统里被归纳成非常经典的五种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 上直接 epoll,macOS 上用 kqueue,Windows 上则通常走 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 阻塞IO:read/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 非阻塞IO:O_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 信号驱动IO:SIGIO 与 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 异步IO:aio_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 的源码,会发现它们的本质,不过就是“把鱼护递到你手里的方式不一样”。