在网络编程这块摸爬滚打几年的人,迟早都会碰到“阻塞和非阻塞模型”这三个词。我第一次真正被它按在地上摩擦,是因为一个网关服务在连接数涨到几千的时候吞吐量直线下跌,当时满脑子都是“不就是等一个IO结果吗,怎么会把CPU耗成这样”。后来把调用栈一拉,发现大量线程全部挂在一个读操作上,背着锁、占着栈,却什么活都没干。从那以后我才意识到,阻塞和非阻塞根本不是“等不等”的差别,而是看待IO、看待线程、看待资源的方式发生了根本性的变化。
这篇内容适合谁?后端开发、中间件维护者、做网关或消息推送的同行,以及那些正准备啃框架源码的人。它解决什么问题?把阻塞、非阻塞、多路复用、同步异步这些概念摆到一起讲清楚,告诉你它们真实运行时的表现、选型依据以及最容易踩的坑。不堆理论,我尽量用实际遇到的现象和代码级细节来说话。
1. 阻塞和非阻塞到底在阻塞什么
1.1 一次最简单的数据读取背后发生了什么
很多人把阻塞理解成“卡住了”,这个说法不算错,但不准确。阻塞的真实含义是:调用发起后,当前执行线程在等待某个外部条件满足之前,被系统挂起,不再参与调度。
拿一个最普通的read操作来说,不管是读文件还是读网络数据,底层最终都会走到系统调用。当你对一块磁盘发起read,而数据还没有从磁盘介质上读入内存时,你的线程就处于不可中断的睡眠状态。这时候CPU不会给它分时间片,它自己也没法主动做任何事,整个栈上的变量、锁的状态、用户态上下文全部被冻结在那里。直到磁盘中断通知内核数据已经拷贝到缓冲区,内核才会把这个线程重新放入就绪队列。
如果只是单线程读一个文件,这个等待没有问题。但问题一旦放到服务端,事情就失控了。
我见过不少新人写的业务代码,看起来完全正常:一个线程池,每来一个连接就交给一个线程,线程里就是阻塞地读请求、处理、写响应。表面上看线程池大小合理,业务处理时间也不长。但你只要用压测工具模拟几百个并发连接,立刻就会发现线程数量被撑爆,内存涨得飞快,频繁出现连接没被处理的情况。
原因很简单:网络IO的阻塞和磁盘IO的阻塞还不太一样。磁盘IO只要发起,数据早晚会到;网络IO则完全取决于对端,对端不给你发数据,你的线程就永远挂在读上。也就是说,一个线程同时只能等一个连接。一千个连接如果要同时处理,你至少需要一千个线程。
1.2 传统的“一连接一线程”是怎么被慢慢拖垮的
传统阻塞模型处理网络请求,代码大概长这样:
ServerSocket serverSocket = new ServerSocket(8080); while (true) { Socket socket = serverSocket.accept(); // 阻塞等待新连接 new Thread(() -> { byte[] buf = new byte[1024]; int len = socket.getInputStream().read(buf); // 阻塞等待数据 socket.getOutputStream().write(...); socket.close(); }).start(); }这种模型的好处是简单,业务逻辑就是从上往下写,天然的顺序执行,不容易出错。但代价就是上面说的:一个连接一个线程,线程成了最稀缺的资源。
我记得有次线上OOM,排查了半天,发现就是线程创建上限没卡住。每个线程默认栈大小1MB,业务线程也动不动分配堆内存,连接稍微多起来,进程内存瞬间吃满。而且线程多了以后,CPU大量时间花在线程上下文切换上,真正的业务代码反而拿不到执行时间。你用top一看,us不高,sy很高,但整个服务就是很慢。
这也是为什么阻塞模型在连接数少的场景下依然好用,连接数一旦上来就立刻露馅。它在本质上的问题不是“等待”,而是“用线程去等”。每个连接都要占一个线程,每个线程又有自己的栈和调度代价,这种一对一关系决定了它在高连接场景下很难撑住。
有人会说,那我用线程池不就限制线程数量了吗?线程池确实能限制数量,但它解决不了另一个问题:如果一个线程正在阻塞地读某个连接,而这个连接一直没有数据,线程池里的其他线程就越来越少。当所有线程都挂在读上时,新的连接进来也没人处理,依然等于卡死。所以线程池只是把“线程爆掉”的时间往后延了,没解决本质问题。
2. 非阻塞模型:从轮询到事件通知
2.1 非阻塞不是“不等待”,而是“不等出结果先返回”
非阻塞IO解决的是“用线程去等”的问题。它的思路是:把socket设置为非阻塞模式后,你调读写接口时,如果数据没准备好,内核不会把线程挂起,而是直接返回一个错误码,表示“现在没数据,你过会儿再来”。
在Linux下,对应的错误码是EAGAIN或者EWOULDBLOCK。听起来很简单,但第一次写非阻塞代码的人很容易掉进一个坑:既然调read不阻塞,那我一直循环调read不就能把数据拿回来了吗?这种想法在低并发时确实很灵,因为你只有一个连接,CPU一直轮询这个连接,数据一到就能处理。
但你想一下,如果有一万个连接,你的线程在一个循环里把一万个socket全轮询一遍,每次都是“没数据就继续下一个”,那对CPU的浪费会比阻塞模型更严重。而且很多连接大部分时间都是空闲的,你却在拼命问内核“有数据了吗?有数据了吗?”,这种忙轮询在连接数变大后基本是灾难。
所以非阻塞本身不是目的,它只是给了你一种“不被挂起”的能力。问题在于,你要怎么知道哪个socket有数据,而不是傻乎乎地一个一个去问。
2.2 多路复用:把“谁来通知”这件事交给内核
select是第一个大规模使用的多路复用方案。它把一批fd(文件描述符)交给内核,内核帮你去检查这些fd中哪些可读、哪些可写,你再去处理有事件的fd。它比忙轮询高效,因为内核能给出具体哪些fd就绪了,不需要用户态一个个试。
但select有个硬伤,fd数量上限低,而且每次调用都要把整个fd数组从用户态拷贝到内核态,再拷回来。连接越多,这个拷贝成本越大。poll解决了上限问题,但每次依然要遍历全量fd。
epoll把这件事做彻底了。它的逻辑是:你先把关心的fd注册到内核里,内核在fd就绪时主动回调你提前挂进去的等待队列。所以epoll_wait返回时,你拿到的就是“已经就绪”的那一批,不用遍历所有fd。
int epoll_fd = epoll_create(1024); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev); while (1) { struct epoll_event events[64]; int n = epoll_wait(epoll_fd, events, 64, -1); for (int i = 0; i < n; i++) { // 只处理就绪的fd } }我这里省略了边缘触发和水平触发的细节处理,后面会单独讲。但这套机制的思想已经和阻塞模型彻底不同了:一个线程可以同时管理上万个fd,它不会再被任何一个连接卡死。线程不再和连接一一对应,而是“一个线程管一堆连接,谁有数据就处理谁”。
这种“事件驱动”的思路,就是后来很多高性能组件的地基。反向代理、网关、消息队列的接入层,基本都是这个套路。
2.3 边缘触发和水平触发,这个坑躲不过去
用epoll的人,多多少少都要面对LT和ET这对概念。水平触发(Level Triggered,LT)是epoll的默认行为,只要内核缓冲区里还有没读完的数据,每次epoll_wait都会返回这个fd。边缘触发(Edge Triggered,ET)则不太一样,它只在状态变化的时候通知一次,也就是从“没数据”变成“有数据”的那一刻,它会通知你一次。如果你这次没有把数据全部读干净,那下次它不会再通知你,除非又来了一批新数据。
LT的好处是简单,你不用担心中途没读完怎么办,反正下次还会通知你。坏处是如果你处理慢,它会反复唤醒你,造成不必要的开销。ET的好处是通知次数少,能榨干系统性能,但代价是你要在收到通知后一次性把数据读完,或者自己维护好缓冲区。
我第一次写ET模式时栽过跟头:一个连接的数据包还没到齐,我就读了前面一部分,以为读完了,结果剩下的数据就再也没被通知到。对面客户端一直等我回包,我这边一直等通知,最后只能靠超时把连接断开。后来学乖了,在ET模式下收到可读事件,必须循环read,直到返回EAGAIN才算读完。
怎么选?我个人的习惯是:如果你自己写底层网络库,追求极限性能,用ET没毛病;如果只是想用epoll来支撑业务,用LT更稳妥,代码也更好写。很多框架默认用LT并不是因为它快,而是因为它不容易出bug。
> 注意:ET模式下,读事件的循环一定不要把阻塞模式的fd混进来,否则最后一次read会把线程卡住。ET和阻塞模式不是一个搭配组合。3. 阻塞非阻塞与同步异步,别再用“四象限”绕晕自己
3.1 两套维度,四个组合
经常有人问我“异步不就是非阻塞吗”,其实它们不是一回事。阻塞与非阻塞,说的是调用者在发起操作后,能不能立刻拿到结果或者立刻返回。同步与异步,说的则是结果到达时,是谁来通知调用者。
用生活类比讲得最顺。同步阻塞,就像你去银行柜台排队办业务,轮到你之前你哪儿也不能去,一直站着等。同步非阻塞,就像你取了个号,但你会每隔一分钟跑去窗口问一次“到我没”,问的次数多了周围人以为你是个机器人。异步则是你取完号,留了手机号,叫号系统或者柜台工作人员在轮到你的那一刻主动打电话通知你。
阻塞和非阻塞描述的是“你去问”时的感受,同步和异步描述的是“结果怎么给你”的方式。所以异步一定是非阻塞的,但非阻塞不一定是异步的,光靠忙轮询,那还是同步的。
3.2 一个典型的非阻塞同步调用长什么样
你可能会想,为什么非阻塞也要分同步异步?看下面这个伪代码:
while (1) { int ret = recv(fd, buf, size, MSG_DONTWAIT); if (ret == EAGAIN) { // 数据还没到,继续问 continue; } // 处理数据 }这个循环就是在同步非阻塞。系统调用本身不挂起线程,但调用方依然要通过不断发起调用来确认结果。从这个定义看,epoll本身提供的也只是一个“同步非阻塞”机制:你自己调epoll_wait,内核把就绪事件告诉你,然后你自己去处理。没有东西帮你把业务逻辑回调到某个特定的线程上去执行。
所以很多老手会说,epoll只是非阻塞的基石,真正要实现“异步”,还需要一个独立的角色来驱动事件分发。这个角色可能是一个事件循环线程,也可能是一组负责读数据、解析协议、执行业务回调的工作线程。框架替你把“主动去问”这件事包掉了,暴露给你的是“数据到了,你来处理”这种回调风格,这看起来才像异步。
3.3 为什么纯异步模型写起来容易翻车
异步给你带来的收益很直接:线程利用率高,吞吐量大。但代价是把程序的控制流切碎了。原来的代码是一行一行写完的,数据到了、处理完了、返回响应,逻辑是线性的。到了异步模型里,你发起一个请求后就得立刻返回,等结果的时候别的任务会插进来,数据到达后的处理逻辑只能放到回调函数里。
这种“控制流翻转”是很多业务开发不适应异步的根本原因。一个稍微复杂的流程,如果中间要调两三个外部服务,回调套回调,代码就像洋葱一样一层包一层。出问题的时候,你打印一条日志,想还原整个请求链路,全靠手动透传一个traceId,稍有不慎就丢了上下文。
我见过有的团队为了绕开这些问题,在异步框架里硬写同步代码:发起一个异步调用后,用一个阻塞队列去等结果。本质上就是把异步API用成了同步,事件循环线程也被卡住,整个服务的并发能力瞬间跌回阻塞模型。这种用法不是不行,但你要知道自己在干什么,不要在异步框架里做“伪同步”。
4. 三个实践模型的选型参考
4.1 线程池加阻塞IO,适合什么场景
不是说阻塞模型一无是处。事实上,在连接数极少、单次处理时间较长的场景里,阻塞模型是最省事、最不容易出错的选择。
典型的例子是数据库访问、内部管理系统、离线任务调度。这类场景并发量不大,请求数每秒几十到几百,业务逻辑重,大多在等数据库或者计算资源。你用非阻塞模型去重写一遍,收益很小,复杂度却会上升好几个量级,完全不划算。
另外,阻塞模型在代码可读性上有天然优势。“读请求、算逻辑、写响应”这三步写在一个方法里,后面接手的同事一眼就能看明白。出了bug也好定位,整个执行路径就是一条直线。高并发不是所有业务的默认标签,如果你的业务根本不会有几千个同时在线连接,就不要为了赶时髦去上异步大礼包。
当然,用线程池的时候还是要做几件事:给线程池命名,便于排查;设置队列长度上限,防止请求堆积把内存吃光;对每线程的栈大小做设置,有些业务根本用不到1MB,可以调小一点,能省不少内存。
4.2 事件驱动加非阻塞IO,适合什么场景
如果你的场景是高并发长连接、即时通信、推送网关、物联网设备接入,那事件驱动基本是逃不开的。这类场景的特点是:连接数可能上万,但每个连接大部分时间都在发呆,真正收发数据的时间占比很小。
这时候如果还用“一连接一线程”,线程资源会浪费到让人心疼。事件驱动模型下,上万连接只需要几十个线程就能全部覆盖,CPU时间几乎都花在真正有数据的fd上。
用这种模型,有几个细节要注意。第一个是业务处理不能占用事件循环线程太久。事件循环线程既要管新连接,又要读数据,还要派发事件。你在里面执行一个耗时的数据库查询,整个循环就卡住了,所有连接都会跟着等。正确做法是,事件循环线程只做网路IO和协议解析,拿到完整的业务数据后,丢给后面的工作线程池去处理。
第二个细节是内存缓冲区的管理。阻塞模型下,一个线程一个连接,一个连接一个缓冲区,不存在竞争。非阻塞模型下,同一块缓冲区可能在多个事件循环之间流转,内存复用和并发控制要提前设计好。不然后端学院派说的“内存零拷贝”,你一个都挨不上,全耗在内存分配和拷贝上了。
第三个细节是慢客户端问题。垃圾网络环境下,一个客户端可能连接半天都不发数据,或者发一半就不发了。你在前面做了读事件缓存,但要给每个连接设置空闲超时和半包超时,否则一堆僵尸连接会慢慢堆死你。这个我在第5节还会展开讲。
4.3 协程模型,为什么说它是“伪非阻塞”
协程是另一种值得说的模型。它从用户视角看,写的还是同步代码:你调一个read,数据没到,函数好像停住了,等数据到了再继续往下走。这个体验和阻塞模型一模一样,但协程内部并不会占用一个系统线程。
协程的做法的确很像“轻量级线程”。每个协程有自己的栈上下文,切换只在用户态进行,不需要内核介入。当协程调用一个需要等待的操作时,运行时库会把它挂起,然后调度到下一个就绪协程。等IO事件来的时候,再由调度器把那个协程唤醒。
其实很多协程框架底层的实现就是epoll加N个逻辑调度线程。你可以理解为:把阻塞模型的代码体验,嫁接到了事件驱动的资源使用方式上。这就是为什么Go、Erlang这些语言能让你用同步风格写出极高并发服务的原因。它的底层已经帮你把“谁来通知”这件事做完了,你写业务时不用关心。
选择协程模型,最大的收益是开发效率。同样一个高并发网关,用回调式异步写的话,每个人的写法都不太一样,代码风格参差不齐;用协程写,大家看起来都像同步代码,心智负担小很多。但协程也有代价,协程切换虽然比线程切换轻,可大量协程挂起时,内存占用也不小,而且调试器对协程转储的支持往往不如线程序,排查问题时要习惯这种状态跳来跳去的方式。
5. 常见问题与排查技巧实录
5.1 EAGAIN出现太频繁,是瓶颈还是正常现象
很多人第一次写非阻塞代码时,看到read返回EAGAIN就慌了,以为出错了。其实EAGAIN不是错误,它只是内核在说“数据还没准备好”。在非阻塞模式中,它就是你业务逻辑的一部分,不是一个异常分支。
但如果你的服务经常返回大量的EAGAIN,反而要注意一下“忙轮询”倾向。比如你在事件循环里写了一个死循环去等一个socket的可读状态,别的连接全都被晾在一边,系统吞吐自然不会好看。排查方法是查看你的代码中,有没有在高频路径上反复调用非阻塞read、write,或者反复调用epoll_wait时设置极短的超时时间。
epoll_wait的timeout参数,如果设成0,基本上就是个纯轮询,CPU会被打满。即使没有事件到来,你也要立刻返回再去查一次。这种写法在高连接场景下跟忙轮询没区别。除非你有很特殊的需求,否则timeout不要设成0或者太小,让它正常阻塞,靠内核把你唤醒。
5.2 多路复用里的耗时代码,会把所有连接拖下水
之前讲事件循环不能跑耗时任务,项目里多次遇到有人在读事件回调里直接调远程接口,一次调用平均耗时200ms。这个量级如果偶尔出现,事件循环能被拖住200ms,期间所有连接的数据收发都停摆。客户端的表现就是“时不时的请求超时”,而且超级随机,没有规律。
排查这个问题最直接的方式,是在压测时关注事件循环线程的响应时间曲线。如果出现“平时很快,偶尔冒出一个大尖峰”的形态,基本可以断定有耗时代码混进来了。查看调用栈,把队列里耗时的部分移出去。
数据库查询、外部HTTP调用、慢日志写入、加锁操作,这些都不该出现在事件循环里。事件循环只接收事件、解析协议、把任务分配给工作线程,然后回去继续接收事件。
5.3 慢客户端、半包和粘包,非阻塞下面更隐蔽
非阻塞和事件驱动解决了高并发,但把协议处理的问题凸显了出来。阻塞模型下,你可以放心地一次性read到足够的数据,因为线程挂起等数据;非阻塞模型下,一个完整的业务包可能被拆成多次读事件,每次只来一部分。
我第一次处理这个是在一个旧的推送网关里,客户端发了一个很大的消息,服务端第一次读到三分之一就开始解析,结果报错。后来加上了一个简单的状态机:每次read后把数据追加进缓冲区,然后尝试解析完整包,解析成功就处理业务,解析失败就继续等剩下的数据。这个方案很朴素,但足够解决大部分半包、粘包问题。
慢客户端更麻烦。有的客户端网络很差,一个包发了几秒才发完。你如果给每个连接分配大块缓冲区,上万连接就会占掉极大的内存。所以还要配合超时上限:连接空闲超过一定时间,直接断开,把缓冲释放掉。有时候为了性能,可以给每个连接一开始分配一个小缓冲区,不够再扩展,不要直接预分配几MB。
5.4 压测总是上不去,先看这几个地方
压测时并发上不去,大多数时候不是阻塞或非阻塞模型的问题,而是模型用错了方向。
先用上限估算公式体会一下:线程模型下,单机并发上限大致等于线程数乘单线程并发能力;事件驱动模型下,单机并发上限主要受限于fd数量、内存和内核事件分发效率。如果你的服务用的是线程阻塞模型,却去追求几十万的并发,那就是模型选错了,怎么调优都白搭。
其次要看你的压测工具本身。很多压测工具默认建立的连接本身就是阻塞socket,客户端连接过多时,压测机自己先卡死。这会让服务端看起来性能极差,但其实请求根本没打到服务端。建议压测时单独关注服务端的连接数和吞吐指标,而不是只看压测机的成功率。
然后检查系统参数。高并发连接数在中,单个进程的fd上限、网络内核缓冲区大小、TCP端口范围、time_wait的数量,这些都会成为瓶颈。我在一台内存很大的机器上调过一个大并发服务,明明配置很高,连接数就是到不了预期。后来一查,系统的硬限制只有1024个fd,所有努力都白费在这个地方了。改完sysctl参数后,连接数立刻上去了。
还有一点,压测时面提到“空连接”的情况:大量连接建立后不发送任何数据。这种情况下,阻塞模型会消耗大量线程挂机等数据,事件驱动模型则只消耗极少的资源。这也是验证模型优劣的一个好办法。
写到最后,还想多说两句
我自己在后端摸爬滚打的体会是,阻塞和非阻塞模型不是一条二选一的非此即彼,不同业务阶段、不同团队水平,应该用不同的模型。不要看见异步就去跟风重构,也不必觉得阻塞模型就是“土办法”。你在正确的场景下,用一个足够简单的方案,把它做到极致,比套用一个复杂但管不住的方案要有效得多。
如果刚开始接触这些概念,最好的方式其实不是读很多理论,而是自己手写一个小型收发服务:先用阻塞模型实现,再把socket改成非阻塞,最后加上epoll或者类似的封装。你亲手把EAGAIN处理过一遍,把事件循环卡死过一次,你的理解就会比看一百篇博客都扎实。
另外,如果真的要在下一步踩这个方向,我建议你多关注“背压”的概念:当业务处理不过来的时候,你是丢弃请求,还是限制上游并发,还是在事件循环里排队等待?这三种应对方式分别对应三种不同的系统行为,也是阻塞和非阻塞模型真正影响到你实际业务的深层原因。这不是一个简单的“谁的吞吐大”的问题,而是“你希望系统在压力下呈现什么行为”的设计决策。