1. IO模型到底是什么?先分清这几个基础概念
Unix/Linux下的IO,很多人一看到就说"异步、同步、阻塞、非阻塞"四个词,但真到了写代码或者排查性能问题的时候,就发现完全对不上号。我自己带过不少刚入门的同学,最典型的状态是:背概念时头头是道,一问他"select和epoll的差别本质在哪",或者"为什么Nginx能扛住十万并发而Apache不行",立刻卡壳。
先说清楚一个最基本的模型。一次完整的网络IO,在操作系统层面其实分成了两个阶段:
- 第一阶段:等待数据准备完成,也就是数据从网卡/磁盘到内核缓冲区。
- 第二阶段:把数据从内核缓冲区拷贝到用户态缓冲区。我们写的代码能直接使用的,是用户态这块内存。
这两个阶段里,各个模型的差异全部体现在"怎么等"以及"谁来拷"上。五个经典的IO模型,就是围绕这两个阶段做的不同处理策略:阻塞式IO、非阻塞式IO、IO多路复用、信号驱动IO、异步IO。
我在实际开发里最常遇到的情况是:业务同学写完网络框架,吞吐上不去,一查发现要么是线程模型不匹配,要么是IO模型选错了。比如一个在线聊天系统,用户量上万,如果每个连接一个线程走阻塞式IO,你用多少内存都不够。反过来,如果一个超低并发的管理后台,非要用异步IO + 事件驱动,复杂度上去了,收益微乎其微。这中间的权衡,就是我写这篇文章想跟你聊清楚的核心。
这篇内容除了讲五个模型的原理,还会把非阻塞IO单独拎出来,把它和阻塞IO、多路复用放在一起做对比,最后给出实际场景里的选型建议。适合刚接触网络编程的开发者,也适合已经写了几年业务代码但一直没有系统梳理过IO模型的同学。
2. 逐个拆解五种IO模型的行为差异
2.1 阻塞式IO:最直观但最浪费资源的模型
阻塞式IO是教科书默认模型。进程发起recvfrom调用之后,如果内核缓冲区里没有数据,进程直接进入睡眠状态,直到数据到达并且被拷贝到用户态缓冲区,调用才返回。整个过程里,这个线程什么事情都干不了,完全被这个IO操作独占。
从资源角度看,这种方式最省CPU,因为线程睡着的时候不占CPU。但从业务并发角度看,代价非常明显:一个连接就要占用一个线程,而线程本身的栈空间、调度开销都是实打实的成本。默认线程栈通常是8MB左右,即使只开了几百个线程,虚拟内存的开销就非常可观。
用生活场景来类比,阻塞式IO就像你去餐厅吃饭,一个人坐在位置上干等服务员端菜上来,等菜期间你什么都干不了,电话不能打,文件不能看。一对一服务时这没问题,但如果同时来了几百个客人,就得雇几百个服务员,每个服务员只管一个客人,这成本没有人扛得住。
阻塞式IO适合的场景其实不多。早期Java的BIO(Blocking IO)就是这种模式,在连接数很少、每个连接都要长期占用的时候,它反而简单可靠。比如内网里的数据库客户端连接,连接池保持少量长连接,每个连接由独立线程处理,这种场景下阻塞式IO完全够用,代码清晰,也容易调试。
但一旦连接数上千,阻塞式IO的线程模型立刻成为瓶颈。线程切换的开销、内存栈的消耗、上下文切换时CPU缓存失效,这些问题会一起爆发。这也是为什么后来有了线程池优化,但线程池只能限制线程数量,并没有解决"一个线程处理一个连接"的本质限制。
2.2 非阻塞式IO:轮询带来的转机与陷阱
非阻塞式IO和阻塞式IO最大的区别在于:当内核缓冲区没有数据时,recvfrom调用不会让进程睡眠,而是立刻返回一个EWOULDBLOCK或者EAGAIN错误。这个返回值的意思是:"现在没有数据,你等会儿再来问"。
由于调用不阻塞了,我们就可以用一个线程去管理多个连接,用循环轮询的方式反复调用recvfrom检查每个连接是否有数据。这就是传说中的"轮询",也就是很多教程里说的busy-loop。这种方式确实让一个线程能服务多个连接,代价是CPU一直都在空转,因为大部分轮询是没有数据到达的。
回到餐厅的例子,非阻塞式IO相当于你把菜单放桌上,每隔几秒就去问一次服务员"菜好了吗"。心眼是好的,能同时盯好几桌,但是你得一直来回跑,大部分时候得到的答案是"还没好"。你确实没干等,但你也没干正事,CPU全消耗在反复询问上了。
实际编码中,非阻塞IO有非常典型的陷阱。一个是我之前带人写客户端程序时经常看到的:忘记处理EAGAIN错误,把正常情况当成异常去处理,日志里疯狂报错。另一个问题是窗口大小和发送缓冲区的处理,非阻塞发送时如果对方接收缓慢,write调用也可能返回部分写入,甚至同样返回EAGAIN,你需要自己维护发送队列,稍不注意就会丢数据。
非阻塞IO单独使用的价值其实有限,但它作为IO多路复用的基础,价值非常大。select、poll、epoll这些多路复用机制,本质上就是把"检查多个连接有没有数据"这件事交给了内核,让内核帮我们从轮询中解脱出来。而epoll内部对于未就绪的文件描述符,采用的就是非阻塞模式,这个结合才是真正的黄金组合。
2.3 IO多路复用:一个线程盯住成千上万个连接
IO多路复用的核心思路是:把多个连接的等待操作统一交给内核去监听,内核同时盯着这些连接,一旦某个连接有数据到达,内核就通知应用程序去处理。
实现方式上,select和poll都是每次调用时把完整的文件描述符集合传给内核,内核线性扫描这些描述符,检查哪些已经就绪。这个过程有一个明确的性能特征:文件描述符越多,扫描时间越长,而且不管有没有事件发生,整个描述符集合都要完整拷贝一遍,效率会随着连接数增长线性下降。
epoll则完全不同。它通过在内核里维护一个事件表,应用程序只需要把感兴趣的描述符注册一次,后续等待时无须重复传递。当某个描述符就绪时,内核会把它放到一个就绪列表里,应用程序只需要处理就绪的那一小撮描述符即可。这就是epoll在高并发下远胜select/poll的根本原因。
用生活场景来理解,select/poll就像是你在自习室门口等同学,每次都要报一遍所有同学的名字,管理员帮你进去挨个看谁出来了,人越多查得越慢。epoll则是你在管理员那里登记"只要张三、李四、王五出来就通知我",然后你安心看书,有人出来时管理员主动喊你,你只处理喊到的那些人。
IO多路复用虽然解决了"一个线程盯着海量连接"的难题,但它本身依然是同步IO。因为通知你"有数据可读"之后,真正执行read操作时如果数据没有准备好,你依然可能被阻塞。所以使用多路复用时,每个被管理的socket都必须设置成非阻塞模式,配合使用才是正确的姿势。
我在实际开发中常用的组合是这样的:
- 用epoll监听服务端socket和所有客户端socket的可读/可写事件
- 每个socket注册时设置为非阻塞
- 事件循环里,只处理就绪的socket,读取数据、解析协议、响应业务
这套组合是所有主流高并发网络框架的地基。Redis的单线程事件循环、Nginx的worker进程事件模型,底层都是这个思路。
2.4 信号驱动IO:把主动询问变成被动通知
信号驱动IO在五个模型里存在感比较低,但在特定场景下非常有用。它的做法是:进程先跟内核说好,某个socket有数据可读的时候,发一个SIGIO信号给我。然后进程可以继续做自己的事,等信号来了再回头处理数据。
这个"被通知"的过程,比非阻塞IO的轮询高效得多,因为它不需要反复检查,数据就绪时内核主动来找你。但信号驱动IO有个先天不足:要处理信号、要在信号处理器里做逻辑、而且像UDP这种协议下还行,TCP下的信号驱动因为连接状态复杂,实际用起来远没有理论那么美好。
简单类比一下,信号驱动IO相当于你在餐厅点了菜,然后扫了桌上的二维码,菜好了手机会推送消息给你。对比非阻塞IO那种每次跑去问的服务方式,体验上确实是升级。但信号机制容易受到系统各种条件影响,信号也可能被其他信号打断,处理起来很麻烦。
信号驱动IO实际在工程里用得少,一方面是因为信号处理的事件模型不够统一,另一方面是它和epoll这类多路复用相比没有明显优势。不过这个概念是理解异步IO的好铺垫,因为异步IO也遵循"内核主动通知"的思路,只是通知的粒度更彻底。
2.5 异步IO:数据准备好了连同拷贝一起做完
异步IO和前四个模型的本质区别,体现在数据拷贝阶段。前四个模型里,即使数据到达内核缓冲区,从内核到用户态缓冲区的拷贝操作依然需要应用程序发起read/recvfrom调用来完成,这个过程是同步的。异步IO则不同:应用发起aio_read之后,内核负责等待数据到达,并且主动把数据从内核缓冲区拷贝到用户缓冲区,拷贝完成后才通知应用程序。
这时候应用收到通知时,数据已经在用户态缓冲区里躺着,可以直接用,不需要再发起一次read调用。
这在餐厅比喻里相当于:你不仅不需要等菜,连菜端上桌这个过程都由服务员包办了,你只需要接到"菜已上齐"的通知,直接开吃就行。
理论上,异步IO是性能上限最高的模型,因为进程在等待数据和拷贝的过程中完全不会被阻塞。但现实是,Linux下异步IO的支持长期以来都不够完善。早期Linux的AIO(Async IO)主要针对磁盘IO,在文件描述符上的支持度有限。后来io_uring的出现算是把Linux异步IO补了一大块,但它对编程模型的要求更高,使用门槛也明显高于epoll。
Windows平台上的IOCP是另外一个异步IO实现,它的完成端口模型在Windows生态里使用很广泛,很多高性能网络服务在Windows上都会选择IOCP。跨平台做网络框架时,这块的差异需要额外注意。
对于大部分应用场景来说,epoll同步非阻塞这套组合已经是"够用且优秀"的状态,异步IO多用于极端性能和特殊场景。在不理解透彻的情况下盲目追新,往往会把复杂度引入业务代码里,得不偿失。
3. 非阻塞IO单独拿出来深究:到底怎么用才对
3.1 非阻塞IO和阻塞IO的切换方式
在Linux下,把一个socket设置成非阻塞模式有两种常用方式。一种是socket创建时传入SOCK_NONBLOCK标志,另一种是创建后用fcntl系统调用设置O_NONBLOCK标志。
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);这种创建时直接指定非阻塞的方式最简洁。如果用fcntl,代码如下:
int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);对于已经创建好的socket,比如accept调用返回的客户端连接fd,通常需要用fcntl来设置。因为accept新返回的fd默认继承监听socket的阻塞属性,服务端一般会把监听socket设成非阻塞,所以accept返回的客户端fd自然也是非阻塞的。这里有一个比较容易踩的坑:如果监听socket是阻塞的,accept在无新连接时会让整个进程卡住,这种问题在普通开发中很常见。
3.2 非阻塞模式下的read和write行为差异
非阻塞模式下,读取数据的正确姿势是循环读取,直到read返回0表示对端关闭,或者返回-1且errno为EAGAIN/EWOULDBLOCK表示本次数据已经读完。
这里有一个重要的点:非阻塞模式下,read只要读到一点数据就会立刻返回,而不是像阻塞模式那样等满你要的长度。如果我们的业务协议要求读取完整的数据包,就必须自己维护缓冲区,把每次read到的数据拼接起来,然后依据协议头判断是否收够一个完整包。
while (1) { ssize_t n = read(fd, buf + offset, sizeof(buf) - offset); if (n > 0) { offset += n; // 在这里判断是否已经收齐一个完整协议包 if (is_packet_complete(buf, offset)) { process_packet(buf, offset); offset = 0; } } else if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { break; // 当前没有更多数据可读,跳出循环等待下一次事件通知 } else if (n == 0) { // 对端关闭连接 close(fd); break; } else { // 其他错误 handle_error(errno); break; } }写数据的时候,非阻塞模式更要小心。因为TCP发送缓冲区可能已满,write可能只写入一部分数据就返回,甚至直接返回EAGAIN。如果此时简单地把剩余数据丢弃,就会出现数据丢失。正确做法是维护一个应用层发送队列,写入遇到EAGAIN时,把剩余数据放入发送队列,等下一次可写事件触发时继续发送。
服务端的发送队列如果一直积压,通常意味着客户端消费速度太慢。这种情况下可以考虑关闭连接或者走慢客户端保护逻辑,而不是无限缓存下去。
3.3 非阻塞IO的正确搭档:多路复用事件循环
单独使用非阻塞IO时,我们需要自己去循环每个socket,反复调用read查看是否有数据。这样CPU空转率高,代码还非常难看。把非阻塞IO和多路复用结合起来是实践中百试不爽的配套方案。
基本流程是这样的:
- 创建监听socket,设置为非阻塞
- 调用epoll_create创建epoll实例
- 将监听socket注册到epoll,监听EPOLLIN事件(可读事件)
- 进入事件循环,调用epoll_wait阻塞等待事件
- epoll_wait返回后,遍历就绪列表:
- 如果是监听socket可读,调用accept获取新连接,把新连接设为非阻塞并注册到epoll
- 如果是普通socket可读,执行上面说的循环读取逻辑
- 如果是可写事件,处理发送队列中的剩余数据
- 循环往复
这套流程写出来其实并不长,但每一环节都有讲究。比如accept之后的新连接至少要设置TCP_NODELAY来关闭Nagle算法,降低小包延迟;又比如epoll_wait返回的事件数量很大时,要循环处理完所有事件而不是只处理前几个。
我见过不少同事把epoll_wait的返回值当作可处理的事件数,然后只遍历前N个事件。这个理解是对的,但如果就绪事件非常多,一次epoll_wait返回了几百个事件,只处理前几十个就把剩余事件丢弃,会造成事件丢失。正确做法是遍历全部返回的event数组,因为epoll_wait返回的事件数量是实际就绪的事件数量,而不是一个上限值。
3.4 非阻塞IO在业务代码里的实际案例
假设我们做一个简单的IM消息推送服务,客户端通过TCP长连接连接到服务器,服务器需要支持大量连接同时在线,并且能够把消息推送给指定用户。
这个场景如果采用阻塞式IO,撑死几百个连接,而且不可能做全局广播。使用非阻塞IO + epoll后,单机支持几万连接是非常轻松的事情。因为每个连接只占用一个fd,事件循环里真正活跃的连接才做IO操作,大多数空闲连接的消耗几乎可以忽略。
在业务代码层面,每个连接需要维护的数据包括:
- 客户端的用户ID
- 连接建立时间、最后活跃时间
- 接收缓冲区和发送队列
- 当前认证状态等业务信息
这些数据在accept之后通过一个map把fd映射到对应的连接对象上。事件循环里拿到就绪fd后,通过map找到对应的连接对象,执行相应的业务逻辑。消息推送时,往目标用户对应的fd的发送队列里写入数据,同时注册EPOLLOUT事件,等待可写时触发发送。
这种结构非常清晰,也是大部分C/C++网络服务的基本架构。如果你用的是Java,Netty已经把event loop、ChannelPipeline这些概念做了很好的抽象;如果你用的是Go,goroutine配合非阻塞的网络轮询也在运行时层面处理掉了,语言层面的体验会友好很多。
4. 关键问题与排查经验实录
4.1 服务端连接数一高就频繁出现Connection Reset
这个问题我在早期做长连接服务时踩过。客户端报错Connection reset by peer,服务器日志里则出现大量send失败。
排查的时候先确认了对端是不是真的关闭了连接。用tcpdump抓包看,发现客户端已经发送了FIN包,但服务端没有及时read到对端的FIN,反而继续往这个连接上写数据。TCP协议栈在收到FIN之后如果继续收到数据,会回复RST,RST一回来,后续再write自然就报错。
背后的原因是业务进程处理能力跟不上,事件循环里有大量逻辑耗时过高,导致即使socket有数据或者关闭事件,也没有及时读到。这里的解法是:一是减少事件循环里的耗时操作,比如把日志、DB操作挪到单独线程池;二是在write返回EPIPE错误时,主动关闭连接并清理fd。
EPIPE这个信号如果被忽略,进程会被直接终止,所以在写socket的代码里,务必对SIGPIPE信号做处理,通常的处理方式是signal(SIGPIPE, SIG_IGN),让write返回EPIPE错误码而不是杀掉进程。
4.2 accept之后立刻read,却一直EAGAIN
有些同学在accept返回一个新连接之后,立刻就在循环里调用read,发现几乎每次都是EAGAIN。这其实是正常的。因为客户端数据到达还需要一个网络往返时间,刚accept完数据可能还没到,读到EAGAIN说明当前无数据可读,不代表任何异常。
正确的做法是把新连接注册到epoll里等可读事件,而不是accept之后立刻同步读。如果你真的有必须在accept后立刻读取的场景,那一般只适用于特殊协议,且要处理好EAGAIN。
4.3 epoll_wait返回的fd在我自己的连接表里找不到
这个问题多出现在连接初始化和关闭的竞争场景。可能这个fd已经被close,但事件队列里还有残留的事件没有处理完。还有一种可能是fd被复用,新连接恰好分配了同一个fd号,旧事件就错误地和新连接关联起来了。
如果是多线程同时处理epoll事件,这种问题就会更复杂。我建议:
- 事件循环单线程化,或者使用多线程时确保同一个fd始终由同一个线程处理
- close fd之前先从epoll中移除,再执行close
- 维护连接状态标记,处理事件前先校验连接的当前状态
4.4 疑似内存泄漏,连接数持续增长但是业务并发没涨
出现这种情况先别急着怀疑代码里有分配没有释放。用ss或者netstat查看当前连接状态,如果存在大量TIME_WAIT连接,这是正常的:主动关闭连接的一方会进入TIME_WAIT状态,等待2MSL时间确保所有包都已经处理完毕。
如果大量连接处于ESTABLISHED状态,说明连接根本没有被关闭,很可能是业务侧忘记调用close,或者读到了EOF但是没走关闭分支。还有一种情况是keepalive配置的保活探测周期太长,死链要很久才会被系统回收。
这种问题调试时,配合lsof、strace,加上系统日志逐层排查,定位速度会快很多。很多新手有个误区,一上来就怀疑内核参数,其实大多数情况下都是业务代码没有正确管理连接生命周期。
4.5 非阻塞write总丢数据的排查思路
如果你发现通过非阻塞socket发送的数据接收方总是少包,优先考虑应用层发送队列是否实现了。非阻塞模式下write返回值可能小于你要发送的长度,而这个"部分写入"是非常常见的,尤其是在发送窗口变小时。
处理的原则是:定义write_complete函数,内部循环write,并且处理两种情况:
- 写完之后还有剩余,则把剩余数据挂到发送队列,等待可写事件
- 可写事件触发时,继续把队列里的数据发送出去
如果服务端需要给单个客户端发送大数据块,而客户端读取速度固定,那发送缓冲区极容易积压。此时需要限速或者断连,避免缓冲无限增长导致内存耗尽。
5. 工具选型与实战配套建议
5.1 Linux平台:从epoll到io_uring的进阶路线
Linux上最常见的多路复用工具是epoll,它已经足够高效、稳定。除非你明确需要极致性能,否则不必轻易上io_uring。如果你要构建消息网关、缓存服务这类高并发低延迟的系统,可以了解io_uring,它在异步化方面做得更彻底,但对使用者的要求也高一些。我自己的经验是:先用epoll把业务做对,再谈io_uring优化不迟。
5.2 跨平台方案:Netty和libevent这类库的价值
Java生态里Netty已经是事实标准,它对业务程序员屏蔽了底层差异。用Netty写聊天服务、网关服务都很方便,而且它的线程模型封装得成熟,扩展性好。C/C++生态里libevent和libuv也可以跨平台,libuv是Node.js的底层事件库,稳定性有保障。
这里多说一句,现在很多新项目直接用Go或者Rust这类语言,它们在网络并发的处理上做了大量运行时优化,底层屏蔽了非阻塞IO和事件循环的复杂性。Go的netpoller、Rust的tokio,本质上都是非阻塞IO + 多路复用的封装,理解底层模型后,用这些框架会有一种"原来如此"的豁然开朗感。
5.3 推荐的两个排查利器:strace和tcpdump
排查IO问题的时候,strace可以观察进程的系统调用行为,确认是否发生了阻塞,以及阻塞在哪里。启动一个简单的命令用strace跟踪其系统调用,序列非常直观:
strace -f -e trace=network,read,write -p <pid>tcpdump则可以看网络包层面的交互过程,确认握手、挥手、重传等细节:
tcpdump -i eth0 tcp port 8080 -nn -A这两个工具再加上ss、netstat、lsof这些常规命令,基本覆盖了常见的IO排查需求。
6. 多路复用的底层机制与性能边界
6.1 select和poll到底慢在哪
select的核心问题在于描述符集合是使用bitmap表示的,有FD_SETSIZE限制,默认1024,超出就不可用。poll去掉了1024限制,采用pollfd数组存储描述符,但依然需要每次调用时把全部描述符传给内核,内核在做完监听后要么修改传入的数组,要么重新填充。无论select还是poll,内核都要做一次全量扫描,这个O(n)复杂度在连接数增长后成为瓶颈。
这就像一家小餐馆,客人到店后每来一波都要重新登记一遍所有人的姓名,服务员再去逐个查看谁点的菜好了,客人多了之后登记和查询的时间就会拖垮整体效率。
6.2 epoll的高效体现在哪
epoll的核心是三个操作:epoll_ctl向内核事件表注册描述符、epoll_wait阻塞等待事件、事件就绪后从就绪列表取出事件。epoll_wait的时候不需要拷贝全部描述符集合到内核,只需要传递一个就绪事件数组。内核通过回调机制,在描述符就绪时直接加入到就绪链表里,epoll_wait只需要把就绪链表复制到用户态即可。
这个机制的效率是O(k),k是就绪事件的数量,而不是连接总数。所以哪怕有一万个连接,只有两个连接有数据,epoll只会在那一次epoll_wait返回里告诉你这两个连接有数据,不会去遍历剩下9998个没数据的连接。
6.3 水平触发和边缘触发
epoll支持两种触发模式:水平触发(LT)和边缘触发(ET)。水平触发下,只要缓冲区里还有数据,epoll就会持续通知你,所以即使你只读了一部分数据,下次epoll_wait还会继续通知你,不容易漏事件,编程难度低。
边缘触发下,只有状态发生变化时才通知一次。如果你没有把数据全部读完,剩下来的数据不会再有通知,必须自己保证把缓冲区读干净。ET模式效率更高,但漏读风险极大,需要配合非阻塞IO循环读取,把数据读至EAGAIN为止。
我个人在做高并发服务时的习惯:默认使用LT模式,代码清晰、不容易出bug;如果追求极致吞吐量,再考虑ET模式,并且严格测试读写的循环逻辑。
6.4 事件驱动的性能边界
事件驱动模型本身是单线程的,它所受益的是没有线程切换开销、没有锁竞争,但代价是一个事件循环里的CPU密集计算会阻塞所有连接的IO处理。所以如果某个业务逻辑要做复杂计算,必须把它丢到线程池/协程池里执行,事件循环只负责IO和轻量业务。
Redis之所以能用单线程扛非常高并发,深度原因正是它的事件循环里只做内存操作和IO,没有阻塞点。而我们在业务代码里如果不小心加入了一个耗时的DB查询,整个事件循环都会被拖住,所有连接都会感受到延迟。这一点在设计架构时就要有意识地去规避。
7. 按场景快速选型参考
为了节省大家的时间,我把常见场景下的IO模型选择整理成一个速查表。这个表并不是绝对标准,但可以作为起步阶段的参考:
| 场景 | 推荐模型 | 理由 |
|---|---|---|
| 单客户端文件读写/串口通信 | 阻塞IO | 简单可靠,调试容易 |
| 少量连接的长连接服务 | 阻塞IO + 线程池 | 代码清晰,连接数可控时线程开销可以接受 |
| 高并发短连接服务(如HTTP) | 非阻塞IO + epoll | 减少线程开销,支撑海量连接 |
| 大规模长连接推送系统 | 非阻塞IO + epoll(LT/ET) | 单机支持数万连接,事件驱动高效 |
| 管道/流式处理系统 | 非阻塞IO或异步IO | 避免阻塞等待,提升吞吐 |
| 超高性能磁盘IO/NVMe场景 | io_uring异步IO | 彻底异步化,降低系统调用开销 |
推荐的原则只有一条:优先选择你团队能驾驭的模型,而不是纸面上性能最高的模型。错误使用异步IO的代价,远比正确使用阻塞IO要高得多。
8. 我踩过的坑和给后来者的建议
第一,不要试图用非阻塞IO模拟异步IO的效果。非阻塞IO本质是同步的,它只是不等待数据,而异步IO是内核完全帮你做完输入输出。如果非要从非阻塞IO里压榨出异步的效果,往往会引入非常复杂的回调逻辑,代码的可维护性会急剧下降。
第二,使用epoll的时候,务必确认accept返回的每个连接都设置了TCP_NODELAY。Nagle算法默认开启时,小包会被延迟合并,这对实时性要求高的协议(比如游戏、IM)极不友好。很多延迟问题排查到最后,就是这一个参数的问题。
第三,事件循环里尽可能不要做任何可能被阻塞的系统调用。比如磁盘IO、DB同步操作,最好都放到其他线程去处理,事件循环保持"非阻塞 + 快速返回"的节奏。这样才能发挥事件驱动模型的全部优势。
第四,遇到莫名奇妙的丢包、延迟问题,先上tcpdump抓包看TCP层的表现,再看应用层的协议解析逻辑。很多时候问题不在IO模型本身,而在半包、粘包的处理逻辑上。
我个人做了几年网络相关的开发之后,最大的体会是:IO模型没有银弹。每个模型的适用边界都很清晰,关键是对自己的业务场景有准确的评估。刚开始做高并发服务的时候,我也是把epoll、非阻塞IO、事件循环这些概念背得滚瓜烂熟,但直到真的写出一个单机扛下几万连接的推送服务,实际运行稳定上线之后,才真正理解内核和业务之间这些微妙的关系。如果你正在走这条路,建议先挑一个小型项目,把非阻塞IO和epoll的组合完整写一遍,在真实的数据量和连接数下观察它的表现。这个过程,比看十篇理论文章都管用。