上半年我们的API网关突然开始间歇性超时,CPU占用率却只有20%——这不科学。当时用的就是传统的阻塞IO模型,每个请求一个线程,线程池开到500,请求量上来后,线程调度时间超过了实际业务处理时间。这个血案让我重新审视了那个老生常谈的话题:阻塞与非阻塞。
用户态与内核态的真实博弈
不谈内核,说阻塞非阻塞就是耍流氓。以recvfrom调用为例,当数据未就绪时,阻塞IO会让进程挂起,放入等待队列,CPU去忙别的事。非阻塞IO则立即返回一个错误码——EWOULDBLOCK,意思是“现在没货,请稍后再来”。应用层的套路就是轮询:
while (recv(fd, buf, len, MSG_DONTWAIT) == -1 && errno == EWOULDBLOCK) {}
得了吧,这代码就是在烧CPU,生产环境根本不敢用。所以非阻塞IO必须搭配IO多路复用才有意义。Select、poll、epoll这一系,本质上是在内核层面帮你高效地等待,把“轮询”的脏活交给内核,而不是用户态循环。

epoll的精妙在于,它用红黑树维护fd集合,用就绪链表挂接活跃fd,并通过回调函数自动将就绪事件注入链表。进程调用epoll_wait时,只需检查链表是否有内容,有则立刻返回,没有则挂起。当数据到达网卡,经过硬中断、软中断,最终触发socket的回调,该回调会把fd挪入就绪链表,并唤醒等待的进程。整个过程O(1)复杂度,完全避免了轮询的CPU空转。说实话,第一次读epoll源码时,我被这种精巧的工程美感震撼到了——用少量代码就解决了高并发下的性能瓶颈,这就是为什么Nginx几十万连接也能扛住。别以为epoll是银弹,它的边缘触发模式稍有不慎就会导致惨案,这个后面再说。
数据不说谎:压测下的真金白银
我在同样硬件环境下(Xeon E5-2680 v4,内存64G,网卡万兆),用wrk2做了一次对比压测。服务端实现四种模型:阻塞多线程、select、epoll水平触发、epoll边缘触发。场景是10K长连接,每个连接每30ms发一个512字节的小包,模拟物联网设备心跳。每个模型预热10分钟后,压测5分钟。结果令人咋舌:
阻塞模型:吞吐量约1800 req/s,p99延迟突破2200ms。线程栈内存占用4.7GB,上下文切换每秒12万次,CPU用户态只有15%,内核态却占了65%——都在忙着调度。
select模型:吞吐量2200 req/s,p99延迟1500ms,但CPU sys消耗高达80%,因为select需要遍历所有fd,1024个fd每次都要扫一遍,更糟的是默认最多支持1024个连接。
epoll水平触发:吞吐量4500 req/s,p99延迟80ms,CPU总体占用35%,内存仅需几十兆,这才像话。
epoll边缘触发:吞吐量5100 req/s,p99延迟45ms,CPU占用更低,但代码复杂度剧增,稍有不慎就是掉坑。

注意一个细节:边缘触发在高压下能减少一半以上的epoll_wait调用,因为只在状态变化时才通知,避免了水平触发的重复通知冗余。不过话说回来,水平触发代码更稳健,适合大部分场景。盲目追求边缘触发,掉坑率极高。
落地时的三个大坑,血泪换来的教训
坑一:边缘触发+非阻塞读写,没循环到EAGAIN。 很多开发者以为收到EPOLLIN事件就读一次,结果只读了一个包,而socket缓冲区还有数据,之后永远不会再触发EPOLLIN——条件没变,“有数据可读”的状态没有重置。正确的做法是循环读直到返回EAGAIN。写操作同理。这个问题让我在线上丢了无数消息,直到深夜看着strace输出才恍然大悟,气得我差点砸键盘。
坑二:回调地狱与状态机噩梦。 纯非阻塞异步代码,就是用回调层层嵌套,俗称“callback hell”。业务逻辑一复杂,那代码就成了一坨不可维护的意大利面条。怎么办?上协程。Go语言的goroutine就是天然的非阻塞实现,底层把epoll藏起来,上层写同步代码。C++20的协程或无栈协程库也类似。它们让程序员用同步的思维写异步逻辑,代码清晰,错误处理简单。我曾把一个5层回调的协议解析重构为协程,代码行数减少60%,bug数量归零。不夸张地说,这是解放生产力的关键一跃。
坑三:内核参数没调优,高性能就是纸上谈兵。 代码写得再漂亮,/proc/sys/net/core/somaxconn默认128,高并发下连接队列溢出,直接丢弃SYN包。net.core.netdev_max_backlog也要增大,避免网卡收包队列溢出。还有tcp_max_syn_backlog、tcp_tw_reuse等。不调这些参数,高并发一来,内核先跪,你代码性能再高都是白搭。记得一次大促前,我以为万事俱备,结果压测时扛不住5万连接,查了半天,就是somaxconn才128。火速调到32768后,世界安静了。
从epoll到io_uring:美无止境
非阻塞IO的进化没有停止。io_uring作为新秀,通过两个环形缓冲区实现零系统调用开销——用户态和内核态共享队列,把事件提交和完成变成内存写入。理论上比epoll更优,尤其适合大量小IO的场景。虽然生态还不算成熟,但已经让我看到了下一代高性能服务的雏形。

写到这里,想起那个凌晨四点改bug的夜晚,还是觉得值得。技术的美,往往就藏在这些看似不起眼的细节里。一个数据结构的选择,一个参数的无心设定,都可能引发蝴蝶效应。理解阻塞与非阻塞,不仅是学API,更是一场与内核的深度对话。而这场对话,还远未结束。