做 Web 服务这些年,我越来越确认一件事:I/O 模型选错了,高并发下再好的业务代码也扛不住。几年前有个项目,平时接口响应二三十毫秒,一到活动流量上来就飙到一两秒,数据库不忙、内存不紧、CPU 也没打满,排查到最后才发现,瓶颈不在业务逻辑,而在服务底层的 I/O 等待。这篇文章是我近几年围绕 Web 服务与 I/O 模型做的系统总结,从阻塞、非阻塞讲起,一路到 select、epoll、事件循环和协程,最后附上几个真实项目的压测调优记录。想搞清楚"高并发到底该怎么选模型"的朋友,可以放心往下看。
1. 一个慢接口引发的排查:先搞清楚 Web 服务到底在等什么
1.1 压测时最先暴露的往往不是 CPU,而是 I/O 等待
那次排查的过程说起来也简单:我先用系统监控工具看了 CPU、内存和磁盘指标,CPU 使用率只有两成,内存占用不到一半,磁盘也没有持续的读写压力,但接口就是慢。后来用火焰图一看,绝大多数时间都耗在系统调用上,再深入看线程状态,发现线程几乎全部阻塞在内核的等待队列里。
这就是 I/O 模型问题的典型现场。一个 Web 请求从进来到返回,做的事情无非是三段:从 socket 读入请求数据,执行业务逻辑(查数据库、读文件、调上游),再把结果写回 socket。其中真正属于"计算"的时间通常少得可怜,绝大部分时间都花在等待上——等网络数据到达、等磁盘寻道、等数据库查询返回。
很多初学者会以为服务慢就是 CPU 不够,实际上内存访问是几十纳秒级别,磁盘一次访问是几毫秒,一次网络来回可能要几十毫秒甚至更久,中间的差距是三个到六个数量级。当你能理解"等待"才是 Web 服务的主要开销时,就会明白为什么 I/O 模型的选择会直接决定服务的高并发天花板。
1.2 阻塞 I/O 的代价,本质上是一笔线程资源的账
阻塞式 I/O 是最直观的做法:一个线程处理一个请求,读数据时读不到就挂起等待。这种方式写代码非常舒服,业务逻辑从上到下线性执行,调试也容易。问题在于线程挂起虽然不消耗 CPU,但它占着资源不放。
每个线程都有独立的栈,默认配置下光栈空间就是 1MB 左右,加上线程控制块和内核栈,一千个线程就占掉不小的内存。线程多了以后,上下文切换的开销会直线上升,CPU 大量时间花在"保存现场、恢复现场"上,而不是真正在处理请求。更尴尬的是,这些线程里绝大多数不是在干活,而是在等一个数据库查询、等一个上游接口返回、等一次磁盘读取。
所以理解 I/O 模型,本质上就是在理解一个问题:当线程在等待数据的时候,这个线程到底是应该继续占着资源干等,还是应该腾出手去做别的事情?
2. 五种 I/O 模型的底层差异:关键看谁在等、等的时候干什么
2.1 一张表看清五种模型
I/O 模型这个说法经常把人绕晕,因为术语太多。其实剥开来讲,操作系统层面的 I/O 模型就五种:阻塞式、非阻塞式、I/O 多路复用、信号驱动、异步 I/O。我用一张表把它们的核心差异列出来。
| 模型 | 数据未就绪时线程状态 | 谁把数据从内核拷贝到用户态 | 适用场景 |
|---|---|---|---|
| 阻塞式 I/O | 线程挂起等待 | 内核拷贝,线程全程等 | 并发低、逻辑简单 |
| 非阻塞式 I/O | 线程反复查询,不挂起但空转 | 内核拷贝,线程等拷贝结束 | 极少单独使用 |
| I/O 多路复用 | 线程阻塞在内核的监视调用上 | 内核拷贝,线程等拷贝结束 | 高并发连接,事件驱动服务 |
| 信号驱动 I/O | 线程可以做别的事,收到信号再回来 | 内核拷贝,线程等拷贝结束 | 场景较窄,被多路复用取代 |
| 异步 I/O | 线程完全不等,内核完成后通知 | 内核全程负责拷贝 | 大量磁盘/网络 I/O 的高并发服务 |
2.2 阻塞与非阻塞:差别不在"有没有等待",而在"怎么等待"
非阻塞 I/O 的系统调用不会因为数据未就绪就把线程挂起,而是立刻返回一个状态,比如"还没好"。线程拿到这个状态后可以做别的事,然后隔一段时间再回来问一次。问题在于这种"反复问"的轮询方式特别浪费 CPU,因为大多数时候数据都没准备好,大量循环是在做无用功。
所以非阻塞 I/O 单独用并不划算,它只解决了"线程不再干等"的问题,却没有解决"谁去注意数据什么时候才来"的问题。于是就有了多路复用。多路复用的思路是:既然线程不能干等,那就把"等"这件事集中交给内核。内核同时盯着成千上万个连接,哪个有数据了通知你,你再去对那个连接做读写。注意,到了这一步,数据从内核空间拷贝到用户内存这个动作,仍然是由线程自己完成的,拷贝过程中线程还可能被阻塞。
2.3 多路复用不算真正的异步,异步 I/O 才算
很多人把多路复用叫作"异步",这是不对的。多路复用在数据拷贝阶段还是同步的,它只是把"等待事件"这个环节集中化了。异步 I/O 则是更进一步:你发一个读请求给内核,然后转身就走,内核负责等数据、负责把数据从内核空间拷到你指定的内存,全部完成之后再通知你。整个过程发起方没有被阻塞过任何一个瞬间。
也可以这样理解:多路复用是"一个服务员同时盯十张桌子,哪桌有人招手就过去",异步 I/O 是"你点了菜就回座位,厨师做好直接端到你面前,全程不用你操心"。
2.4 食堂打饭的四种姿势,说透所有 I/O 模型
阻塞式:站在窗口前面死等,厨师做好一份你拿走一份。你不干别的,窗口前队伍越来越长。非阻塞式:隔五秒问一次"好了没",好了就取,没好就溜达,但来回问很累,窗口被你烦得不行。多路复用:取一个叫号器,坐在大厅慢慢等,哪个窗口叫号你再去取,一个人能同时挂着几十个号的单。异步 I/O:直接在座位上点餐,厨师做好直接送到你桌上,你全程不用离开座位。
这个类比不完美,但足够说明核心差异:等的方式不同、等的人不同、等的时候能不能干别的事也不同。把这几个问题想清楚,后面的内核机制和架构选型就都好理解了。
3. 从 select 到 epoll:内核多路复用能力是怎么一步步变强的
3.1 select 的三个硬伤,决定了它只能停留在教学场景
select 是最早出现在大量教科书里的多路复用接口,但它在高并发场景下有三个致命问题。第一个是监听数量受限:它用 fd_set 位图存放文件描述符,默认上限通常是 1024,虽然可以改,但很多环境改起来很麻烦。第二个是每次调用都要把整个 fd 集合从用户态拷贝到内核态,返回后还要遍历整个集合才能知道哪些 fd 就绪,这个操作是 O(n) 的,连接数越多单次开销越大。第三个是内核只告诉你"有事件发生了",具体是哪个 fd 就绪、发生了什么事件,还得你自己逐个检查。
所以 select 适合教学环境和连接数极少的场景,一旦连接规模上来,它本身就是瓶颈。
3.2 poll 修掉了一半坑,却没修掉复杂度
poll 把 fd 集合从位图换成了链表结构,数量上限没有了,这是一大进步。但它每次调用依然要把全量 fd 集合传给内核,内核也依然要全量扫描一遍才能确定哪些 fd 就绪。换句话说,poll 只是解决了"数量上限"的问题,并没有解决"全量扫描"的问题,时间复杂度依然是 O(n)。
3.3 epoll 为什么快:红黑树加就绪链表,还有一个关键的回调机制
epoll 是 Linux 内核提供的多路复用接口,它引入了三个系统调用:epoll_create 创建实例,epoll_ctl 添加、修改、删除监听对象,epoll_wait 等待事件。在你监听的 fd 数量很多时,epoll 的性能优势非常明显,它的核心设计是:所有被监听的 fd 挂在一棵红黑树上,增删改查是 O(log n);每个 fd 在数据就绪时,通过内核回调机制把自己挂到一条"就绪链表"上;epoll_wait 返回时,内核只需要把就绪链表里的内容拷贝给用户态。
也就是说,epoll_wait 返回给你的,是一份"已经就绪"的 fd 列表,你不需要再遍历成千上万个 fd 去确认谁有数据。整体复杂度从 O(n) 变成了接近 O(就绪数量),这就是 epoll 能支撑几十万并发连接的关键。
epfd = epoll_create(1024); epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); while (1) { n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (i = 0; i < n; i++) { handle_event(events[i]); } }上面这段是 epoll 最典型的使用骨架,理解它比背 API 更重要:epoll_wait 负责"等",返回的事件列表负责"告诉你去处理谁"。
3.4 水平触发与边缘触发:一个必须搞清楚的差异,否则会丢数据
epoll 有两种触发模式。水平触发(LT)是只要 fd 还有数据可读,epoll_wait 就会一直返回;边缘触发(ET)是只有 fd 从"没有数据"变成"有数据"这个状态变化时,才通知一次。ET 模式下你必须一口气把数据读完,一直读到 read 返回 EAGAIN 为止,否则剩下的数据不会再次触发通知。
ET 的效率更高,因为内核减少了重复上报,但编程要求也更高:你要处理半包、粘包、部分读,还要处理对端连接被重置的情况。我在项目里见过因为没有处理 EAGAIN 而丢数据的 bug,也见过为了用 ET 把代码复杂度抬高一截的团队。不同操作系统也有类似的机制,比如 Windows 系统的 IOCP、新内核里的 io_uring,思路都是把"等待"下沉到内核,谁更高效谁就能支撑更大规模的连接。
提示:如果团队对网络编程不够熟悉,默认用水平触发模式。性能差异对大多数业务没那么敏感,先保证正确,再谈优化。
4. 主流 Web 架构的两条实现路线:线程模型与事件循环的取舍
4.1 同步阻塞加线程池:简单可靠,但伸缩性有天花板
最传统的方式是每连接一个线程,或者用线程池限制并发。这种模型的好处是代码好写,业务逻辑线性执行,出了问题好定位。坏处是资源消耗大:线程数越多,调度成本越高,单线程栈占的内存也不可忽略。更麻烦的是,如果业务里有一个上游调用特别慢,线程池里的线程会被占满,后面的请求只能在队列里排队,表现出来就是"服务还活着,但就是不出活"。
线程池模型并不是不能用。对于内部管理端、低频接口、并发连接数稳定在几百上千的场景,它是最省心的选择。很多所谓的高并发问题并不是这种模型不能跑,而是连接规模到了一定程度之后,它的成本结构不划算了。C10K 问题之所以成为经典,正是因为它准确刻画了这个临界点。
4.2 事件循环加非阻塞 I/O:单线程也能扛高并发
另一条路线是事件驱动,核心结构是一个事件循环:主线程阻塞在 epoll_wait 上等事件,事件到达后根据 fd 找到对应的处理函数执行。因为处理过程中不存在阻塞等待,单线程就能同时管理几万个连接。很多高并发 Web 服务器和 JavaScript 运行时都采用这种结构。
原理上可以这样理解:服务端把每个连接对应的一小段处理逻辑注册成回调,数据到了、写完了、出错了,都是事件。整个程序变成"来事件就处理,处理完继续等下一个事件"。难点也在这里:回调里不能出现阻塞操作。一旦回调里有一次同步磁盘读,或者一次慢的三方调用,整个事件循环就卡住了,所有连接都会跟着变慢。这也是事件驱动型架构反复强调"不要在事件循环里干重活"的原因,重活要么拆到线程池,要么用异步接口。
4.3 协程:把异步重新写成同步的样子
协程是第三条路线,也是我目前认为最适合工程落地的路线。它的本质是让编译器或运行时帮你管理状态:你写的代码看起来是同步的,但每次遇到真正的 I/O 等待,协程就自动让出执行权,底层用非阻塞 I/O 加多路复用继续监听,等数据就绪再恢复执行。
这样做的好处是既保留了线程模型那种线性的代码结构,又获得了事件驱动的高并发能力。协程可以理解成"用户态线程",它的切换不需要内核参与,成本比线程切换低一个量级,所以一个进程里挂几十万个协程并不稀奇。现在不少语言运行时和网络框架都在走这条路。协程也不是银弹,它要求底层 I/O 确实是非阻塞的,否则遇到一次真正的阻塞调用,整个调度器照样会被卡住。
注意:选型时别被"协程比线程高级"这种话带跑。如果你连当前服务的连接数、请求特征都没摸清楚,换任何模型都救不了架构。
5. 我在真实项目里的 I/O 选型与调优记录:三个值得复盘的案例
5.1 案例一:批量文件导出接口从 200ms 降到 20ms
有段时间我给某内部系统做批量导出功能,接口逻辑很简单:查数据库拿到一批文件路径,然后循环读文件,拼成压缩包返回。单测时一切正常,数据量一大就慢得离谱。用监控工具一看,线程全部停在磁盘等待状态。问题本质是:一次同步 read 要等磁盘寻道和数据返回,几百个文件逐个顺序读,每次等待都在累加。
我做了两处改动:一是把文件读取改成异步接口,让事件循环不再阻塞在读文件上;二是加了一层并发上限控制,避免一次性把几百个读请求全部打入磁盘,让磁盘队列过度堆积。改造后接口从 200ms 附近降到了 20ms 左右。这个案例特别能说明一个道理:慢的原因未必是算法,而是你在用一个阻塞的方式,等待一个本可以不用等的 I/O。
5.2 案例二:长连接网关从一万连接扩到二十万
另一个项目是长连接网关,最初用的是每连接一个线程。测试到一万连接时,线程数已经超过了机器核数的十倍,上下文切换极其频繁,CPU 大量花在调度上。我们做了一次架构调整:网络层改成事件驱动加多路复用,业务处理保留少量线程池来跑 CPU 密集任务。改造完成后,单机连接数从一万级提升到二十万级,CPU 使用率反而比之前低了。
这里有个容易被忽略的细节:应用层支持二十万连接,不等于系统就扛得住。文件描述符上限默认只有 1024,不调大就等着报 Too many open files;内核网络参数,比如 TCP 长连接超时、backlog 队列长度、端口范围这些,压测之前都应该先确认一遍。I/O 模型解决的是应用层怎么管理连接,系统和内核层的坑一个都少不了。
5.3 案例三:边缘触发导致的数据丢失,修复只差一个循环
另一个团队在尝试边缘触发模式时踩了坑。他们的读逻辑是收到事件后读一次,没读完就等下一次事件,结果在数据量稍大或包粘连时就出现间歇性的数据丢失。原因很简单:边缘触发只在状态变化时上报一次,如果一次没把 socket 缓冲区读完,内核认为状态没变化,就不会再次上报。
修复方式也简单:循环读,直到返回 EAGAIN,再回去等事件;同时要处理连接被对端重置的情况。这个坑几乎每个刚接触边缘触发的人都会踩一遍。我的建议是:默认用水平触发,除非你已经确认性能瓶颈确实在事件通知次数上,再考虑切换到边缘触发。
5.4 我怎么判断该选哪种模型
这些年我给自己整理了一套判断标准,分享出来供参考:
- 并发连接数少、请求频率高:线程池加阻塞 I/O 足够,维护成本最低
- 海量长连接、空闲连接多:选事件驱动加多路复用,连接本身不占线程
- 业务逻辑重、大量第三方调用:选协程模型,写起来像同步,扛得住大量等待
- 团队水平参差、要快速交付:先用线程池,压测验证瓶颈之后再考虑迁移
选型从来不应该是"哪个名词听起来更高级就选哪个",而是看你的连接特征和团队能接受的维护成本。模型解决的是问题,不是用来炫耀的。
6. 选型之外容易被忽略的底层细节:描述符上限、网络参数与常见误区
6.1 文件描述符上限是第一道坎,压测前先确认
不管用哪种 I/O 模型,高并发都意味着大量打开的 socket。开发环境没感觉,生产环境一上来就容易报打开文件过多。压测之前先执行ulimit -n看当前上限,不够就调大。除此之外,内核网络参数也要过一遍,比如端口范围、半连接队列长度、TCP 连接复用相关的选项。很多团队的压测数据难看,不是应用代码的问题,而是这些底层参数压根没调过。
6.2 事件循环里不能碰的几个雷
在事件驱动架构里,同步磁盘读写、锁竞争、CPU 密集操作、无界缓冲区都是要避开的雷。特别是内存里堆积未发送的数据:如果生产数据的速度大于消费速度,队列会越积越多,最后要么内存暴涨,要么延迟持续飙升。这种问题 I/O 模型本身解决不了,需要做背压控制,也就是当消费不过来的时候,主动限制生产速度或者丢弃一部分数据。
6.3 四个最常见的认识误区
第一个误区是"非阻塞等于异步"。实际上多路复用仍然是同步的,数据从内核拷贝到用户态这一步还是要等的。第二个误区是"事件驱动就是不用线程"。它只是把网络 I/O 的线程省掉了,CPU 密集任务和阻塞调用仍然要交给线程池处理。第三个误区是"线程池越大越好"。线程数超过核数一定比例后,上下文切换成本会吃掉收益,吞吐反而下降。第四个误区是"只看平均响应时间"。高并发下要看 P99 甚至 P999,尾延迟才是用户体感的真实来源,平均数是会骗人的。
6.4 最后分享一点个人体会
这几年看下来,I/O 模型本质上就是关于"等待"的管理:谁在等、等多久、等的时候能不能干别的事。想清楚这三个问题,再多的名词都不会把人绕晕。我越来越觉得选型不是选最先进的,而是选最匹配的:先量化连接规模、请求特征、团队维护成本,再决定用线程池、事件循环还是协程。模型没有绝对的好坏,只有合不合适。拿一套自己验证过的压测数据去说服别人,比引用任何高深的概念都有用得多。