写网络服务的人迟早会撞上一个问题:我的服务到底是怎么把数据等来的?不管是写HTTP服务、RPC框架还是消息队列,只要牵扯到网络收发,I/O模型就是绕不过去的一道坎。Linux下常见的五种I/O模型——阻塞I/O、非阻塞I/O、I/O多路复用、信号驱动I/O、异步I/O——说白了就是回答一件事:当数据还没准备好时,你的进程该怎么办。
这篇文章不打算堆教科书定义,我想从一个写代码的人的角度,把这五种模型拆开揉碎,讲清楚它们各自的等待方式、适用场景,以及实际开发里怎么选。无论你是刚接触网络编程的新手,还是已经被epoll折磨过的老手,应该都能从中找到点有用的东西。
1. 先搞清楚I/O模型到底在解决什么问题
1.1 一次网络读取,数据到底经历了什么
很多人一上来就背五种模型的名字,结果连“阻塞”和“非阻塞”到底卡在哪一步都没弄明白。我们先看最底层的场景:你调了一个read(fd, buf, len),想从TCP连接里读数据。这么简单的一个调用,内核实际上要干两件事。
第一件事是等数据。数据从网卡收到之后,要先经过协议栈处理,放到内核的接收缓冲区里。在你调用read的那一刻,数据可能还没到,可能到了一部分,也可能已经完整到达。第二件事是拷贝数据。内核把接收缓冲区里的数据,复制到你传入的用户态内存buf里,然后read才返回。
这两件事,就是理解所有I/O模型的钥匙。所谓阻塞、非阻塞、异步、多路复用,全是在“等数据”和“拷贝数据”这两个阶段上做文章。不同的模型,只是选择在不同的阶段让谁等待、谁来通知、谁来做拷贝。
注意:这里说的网络I/O,对文件的读写也基本适用,只是文件I/O的“等数据”阶段几乎瞬间完成,所以网络场景下这五种模型才显得尤其重要。
1.2 两个阶段:等待数据和拷贝数据
为什么要把这两个阶段分得这么清楚?因为阻塞、非阻塞,管的是“等待数据”这个阶段。异步,管的是“拷贝数据”这个阶段。这个区分非常关键,后面你会反复用到。
- 阻塞I/O:进程调用
read之后,如果内核缓冲区没数据,进程就睡觉,直到数据到了,内核把数据拷到用户态,read才返回。 - 非阻塞I/O:进程调用
read之后,如果内核缓冲区没数据,马上返回一个EAGAIN错误,进程不用睡觉,但得自己反复来问。 - I/O多路复用:进程先把一堆fd交给内核,内核说“其中任何一个有数据了我就告诉你”,然后进程再调用
read。 - 信号驱动I/O:进程先告诉内核“数据到了你发个信号给我”,然后进程该干嘛干嘛,信号来了再处理。
- 异步I/O:进程把
read请求完整交给内核,包括拷贝也交给内核,内核全部干完了再通知进程。
注意看,前四种模型里,“拷贝数据”这一步都是进程自己参与的,所以它们本质上都是同步I/O。只有第五种异步I/O,连拷贝都是内核代劳。这是很多人容易搞混的地方,后面我会再展开。
2. 阻塞I/O与非阻塞I/O:最基础的两极
2.1 阻塞I/O:简单但容易卡死
阻塞I/O是最传统的模型,也是新手最容易理解的。写一个简单的TCP服务端,accept之后直接recv,来一个连接就处理一个,处理完再接受下一个。代码写起来很顺手,逻辑也直观。
int client_fd = accept(listen_fd, ...); char buf[1024]; int n = read(client_fd, buf, sizeof(buf)); // 没有数据就卡在这里这段代码的问题,我在刚写服务端程序的时候就踩过:如果客户端连上来之后一直不发数据,那么对应的线程就永远卡在read上。一个连接占一个线程,1000个连接就是1000个线程,1万个连接就是1万个线程。线程多了之后,线程切换的开销、内存的占用都会把服务拖垮,这就是常说的C10K问题。
阻塞本身不是原罪。它的优点是程序逻辑简单,代码顺序执行,不容易出错。而且阻塞期间进程会让出CPU,对单连接的吞吐来说效率并不差。问题在于它“一个线程只能伺候一个连接”,扩缩容全靠线程数量硬扛。我曾经维护过一个老项目,连接数一上来,光线程栈内存就吃掉几个GB,机器动不动就报警。
所以阻塞I/O适合连接数不多、每个连接都有持续数据传输的场景,比如一个内网的小工具,几十个连接顶天了,用它最省事。
2.2 非阻塞I/O:轮询带来的新麻烦
非阻塞I/O就是把socket设置成O_NONBLOCK,然后read的时候,内核没数据就立刻返回,不让你睡觉。你拿到EAGAIN就知道现在没数据,可以先去干别的,过一会儿再来问。
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 == -1 && errno == EAGAIN) { // 没数据,先干别的,过会儿再问 do_something_else(); continue; } // 处理数据... }听上去不错,但实际用起来有个很尴尬的问题:你怎么知道该什么时候再来问?如果一直循环read,CPU就被白白烧掉了。我用一个非阻塞模型写过一个小客户端,没加延时,结果一个连接就把单核CPU跑到100%,数据还没收到几条。
更麻烦的是,如果你有几百个非阻塞fd,每个fd都要轮询一遍,这种“忙等”的效率比阻塞还差。所以非阻塞I/O通常不单独使用,而是作为I/O多路复用的基础。它在多路复用模型里扮演的角色是:当内核告诉你某个fd有数据时,你要用非阻塞方式去读,保证不会因为读一次没读完而卡住。这一点到后面讲边缘触发时还会再提到。
3. I/O多路复用:高并发服务端的基石
3.1 select和poll:早期方案的问题
I/O多路复用的核心思路是:与其让应用自己对每个fd轮询,不如把一批fd交给内核,让内核同时盯着它们,哪个有动静了就告诉应用。这样单个线程就能管理成千上万个连接。
最早的方案是select。你把fd集合传给内核,内核遍历一遍,发现有数据就标记,返回后你再遍历一遍找出可读的fd。说起来逻辑不复杂,但select有两个天生的毛病,写过的人应该都深有体会。
第一个是fd数量上限。select用fd_set位图管理fd,默认上限是1024,虽然可以改内核参数,但总归是个硬限制。第二个是效率问题。每次调用select都要把整个fd集合从用户态拷到内核态,返回后又要从内核态拷回来,而且内核是线性扫描所有fd,连接数越多,扫描越慢。我在一个模拟万人连接的测试里用过select,CPU占用高得吓人,大部分时间都花在拷贝和扫描上了。
poll解决了fd数量上限问题,因为它改用链表管理fd,不再受1024限制。但是线性扫描的毛病还在,fd多了照样慢。这两个模型的复杂度都是O(n),也就是说,不管你有多少个fd,每次都要全部过一遍。
3.2 epoll:Linux下的高效解法
epoll是Linux特有的,它就是为了解决select和poll的效率问题而生的。它的核心变化是:不再每次把全部fd传给内核,而是先通过epoll_ctl把要监视的fd注册进内核,内核维护一棵红黑树;有数据到达时,通过回调机制把就绪的fd加入一个就绪链表;你调用epoll_wait时,只需要从就绪链表里取出那些真正有事件的fd就行。
int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); struct epoll_event events[1024]; while (1) { int n = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i < n; i++) { handle_event(events[i].data.fd); } }这里要注意,epoll_wait返回的是已经就绪的fd数量,而不是全部fd数量。换句话说,连接数再多,内核也只把就绪的那几个告诉你。复杂度从O(n)降到了O(就绪fd数),这就是它能支撑高并发的根本原因。
epoll还提供两种触发模式:水平触发(LT)和边缘触发(ET)。LT模式是默认的,只要fd还有数据没读完,epoll_wait就会一直通知你。ET模式只在状态变化时通知一次,数据没读完就不再通知了。ET模式更高效,但要求你必须用非阻塞fd,并且一次把数据尽量读完,否则会漏数据。我见过不少人在ET模式下踩坑,核心就是没用非阻塞模式配合循环读取。
3.3 为什么多路复用能支撑高并发
很多人问我,是不是用了epoll就一定能扛住高并发?答案没那么简单。epoll解决的只是“同时等很多fd”的问题,数据到来之后怎么处理,还得靠你自己。
假如你有10万个连接,但同一时刻只有100个连接有数据到达,epoll能让你只处理这100个,这是它高效的地方。但如果这100个连接的数据都很大,处理起来都很耗时,那么单线程处理完这100个,后面的就得排队。这时候通常要搭配线程池,让epoll所在的线程只负责分发事件,真正耗时的业务逻辑扔给工作线程。
我从实际项目里的体会是:epoll本身不难用,难的是把它和业务逻辑、线程模型、内存管理结合起来。一个典型的服务端架构是这样的:主线程跑epoll_wait,有可读事件就accept或read,然后把任务丢给线程池处理;写操作一般先注册EPOLLOUT,等可写时再发送。这套模式我在多个项目里用过,稳定性和吞吐都说得过去。
4. 信号驱动I/O与异步I/O:少见的后两者
4.1 信号驱动I/O:SIGIO的故事
信号驱动I/O的思路很直接:你告诉内核,某个fd有数据了给我发个SIGIO信号,然后你的进程该干嘛干嘛,不用等着。信号来了之后,再在信号处理函数里去read数据。
signal(SIGIO, io_handler); fcntl(fd, F_SETOWN, getpid()); int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);听起来很美,但实际用的人很少。原因也简单:信号处理函数里能干的事太有限。read这种操作在信号处理函数里要非常小心,因为可能中断主程序的执行,产生各种不可重入的问题。你正读到一半,信号来了,处理函数又去读同一个fd,很容易把状态搞乱。
另外,信号本身有频率和开销的问题。如果数据包来得特别频繁,信号会被疯狂触发,系统疲于处理信号,性能反而变差。我在学习阶段写过一个信号驱动的小demo,数据量小的时候还行,稍微上点压测,进程就变得不稳定,日志乱得像踩踏现场。所以这个模型在实际工程里基本是“知道有这回事”的程度,真正用它做产品的很少。
4.2 异步I/O:真正的“读完再通知”
异步I/O和前四种模型有本质区别。前四种模型里,就算内核帮你等数据,最后“把数据从内核拷到用户态”这一步仍然是进程自己完成的,所以在数据真正可用之前,进程都得参与。异步I/O则是你发一个aio_read类似的请求,内核把等待、拷贝全部做完,然后再通知你“数据已经放在你给的buf里了”。
Linux下的异步I/O实现,早期有POSIX AIO,但一直不太让人满意,真正好用起来是后来io_uring的出现。io_uring通过内核和用户态共享环形队列来提交I/O请求和收割结果,性能很高,代码写起来也更复杂。很多高性能存储和数据库项目已经在用io_uring了。
这里必须强调一个区分:非阻塞I/O和异步I/O不是一回事。非阻塞是“我马上返回,但数据还没好,你得自己再问”;异步是“我全部干完再通知你,你不用再问”。拿点外卖类比,非阻塞就是你每隔几分钟问一次外卖到了没,异步就是外卖到了骑手打电话叫你下楼拿。我一直觉得这个类比比背概念管用得多。
4.3 五种模型对比:一张表看清差异
把五种模型放一起对比,很多东西就清楚了。我用一张表总结它们的核心特征:
| 模型 | 等待数据阶段 | 拷贝数据阶段 | 进程是否参与拷贝 | 典型应用 |
|---|---|---|---|---|
| 阻塞I/O | 进程睡眠等待 | 进程参与 | 是 | 简单服务、连接数少的场景 |
| 非阻塞I/O | 进程轮询询问 | 进程参与 | 是 | 配合多路复用使用 |
| I/O多路复用 | 内核统一监视 | 进程参与 | 是 | 高并发网络服务 |
| 信号驱动I/O | 内核发信号通知 | 进程参与 | 是 | 极少使用 |
| 异步I/O | 内核代劳 | 内核代劳 | 否 | 高性能存储、数据库 |
注意前四行,不管“等数据”是谁在等,最后拷贝都是进程干的,所以它们都是同步I/O。只有异步I/O是真正全程由内核代劳。很多面试题喜欢问epoll是不是异步的,答案就是“不是,epoll虽然有事件通知,但数据拷贝还得你自己来,它属于同步I/O多路复用”。
5. 实际开发中的选型与避坑经验
5.1 服务端编程:到底选epoll还是异步I/O
先说结论:绝大多数网络服务端程序,用epoll就对了。它成熟、稳定、坑少,网上资料也最多。我写了几年服务端,基本没遇到过必须上io_uring才能解决的业务场景。epoll加上线程池,配合合理的协议设计,撑住几万甚至几十万连接是完全可行的。
真正需要认真考虑异步I/O的场景,通常是磁盘I/O密集型的应用,比如数据库引擎、日志系统、对象存储服务。因为磁盘I/O的等待时间更长,而且文件I/O没有epoll这么顺手的多路复用方案(内核线程阻塞在磁盘I/O上的代价很高)。这种情况下io_uring才能体现出价值。
这里要提醒一句:如果你只是因为“异步听起来高级”,就跑过去用io_uring,大概率会把自己坑得很惨。它的API更底层,内存管理更繁琐,调试工具也更少。我在一个模拟项目中试过把简单的网络收发改用io_uring,光是处理用户态缓冲区的生命周期就费了不少劲,最后收益也不明显。选型要看你解决的到底是什么问题,而不是追新。
5.2 常见的理解误区与面试高频问题
第一个误区:epoll比阻塞I/O快。严格说,epoll在“连接数多但活跃连接少”的场景下优势明显,但如果是单个连接持续大流量传输,阻塞I/O配合独立线程并不差,甚至因为代码路径更短,性能还更好。epoll的优势是“能同时管很多连接”,不是“单连接更快”。
第二个误区:非阻塞I/O就是异步I/O。这个前面说过了,非阻塞只是不睡觉,但活还得你来干;异步是活都别人干完了再叫你。两者差着一个“拷贝数据”阶段。
第三个误区:select已经过时了,完全没用。Windows没有epoll,很多跨平台网络库在Unix-like系统上还是会用poll或者select来兜底,一些简单场景下它们反而够用。我在写一个小型调试工具时就用了select,几十个fd完全没压力,代码还简单。
面试里还有个高频题:为什么epoll比select高效?你得从三个层面回答:一是fd集合不用每次全量拷贝,而是通过内核维护的红黑树管理;二是内核不再线性扫描所有fd,而是靠回调让就绪fd进入链表;三是应用只需要遍历就绪fd,而不是全部fd。把这三个点讲清楚,基本就能过关。
5.3 我的实操建议
最后分享几个我自己的经验。
第一,先把阻塞I/O和多路复用吃透。这两个是基础中的基础,项目中八成的网络代码用的就是它们。非阻塞I/O、信号驱动I/O和异步I/O,可以理解原理,但别急着在生产环境里用。
第二,如果用epoll的ET模式,记得把socket设为非阻塞,并且用循环把数据读到EAGAIN为止。我发现不少同事踩的坑都是:用了ET,忘了设置非阻塞,结果数据没读完,后续事件不再通知,连接卡死。
第三,调试网络程序时多用strace看一下系统调用。strace -p 进程号能直接看到进程阻塞在哪个read上,是等待数据还是已经拿到了数据,一目了然。这个方法帮我排查过好几次线上连接异常的问题。
第四,架构设计上,不要把业务逻辑塞进事件处理循环里。一个常见的设计是:事件循环只负责收发数据,收到完整报文后丢给工作线程去处理。否则一个耗时的业务操作,会让整个epoll循环卡住,所有连接跟着遭殃。
我在实际项目里的体会是,I/O模型的学习,重点不是背下五种模型的名字,而是建立起“数据从网卡到用户态要经历什么”的完整画面。有了这个画面,再去看各种框架和中间件的源码,会发现它们的选择你都能理解,甚至能预判它们会在哪里遇到性能瓶颈。这个底子打好了,后面学什么网络框架都快。