事件循环不是银弹,但大多数时候是
说实话,第一次看到Node.js那个单线程跑出高并发的demo时,我是不信的。一个线程,怎么可能?但当我打日志发现它在一个循环里傻乎乎地检查任务,事情变得有趣起来。这就是事件循环——一个看似简单、实则精巧的调度器。它背后是操作系统层面的I/O多路复用,是libuv在epoll/kqueue/IOCP上的暴力封装。无非是:等事件,处理事件,再等。但为了让它不阻塞,V8搭配了一个宏任务队列和一个微任务队列,两个队列的清空时机——嘿,你调试过浏览器渲染卡顿没?十有八九是微任务队列里塞了太多Promise.then。
我举个例子吧。浏览器中,每执行完一个宏任务(比如setTimeout),就会清空整个微任务队列,然后再渲染。如果你在一个微任务里又生成一个微任务,就无限循环,渲染永远排不上队。这就是老生常谈的掉帧。Node.js里更明显,libuv的每个阶段之间,会额外检查process.nextTick和Promise回调,一旦递归nextTick,进程直接变成算力黑洞。所以我说,事件循环像一台精密的变速箱,挂错档就崩。

但为什么需要事件循环?因为线程上下文切换代价太高。传统多线程模型,一个连接一个线程,C10K问题直接教你做人。每个线程都要独立的栈空间,CPU时间片切换时寄存器状态保存恢复,内存一上来就爆。事件循环呢?单线程循环处理回调,内核通过事件通知机制告知可读可写,零上下文切换。这正是它的工程美学:用极少的资源做极多的事,像极了餐厅里那个眼观六路的服务员——你点单、上菜、结账全是他,但只要你不出声,他就在loop里发呆。不过老实讲,这种模型也不是没有代价,一个烂回调堵住,整个餐厅瘫痪。
翻源码看到libuv的uv_run()里有一个while循环,调用uv__io_poll(),内部就是epoll_wait或者kqueue。它阻塞等待,直到有事件或timer到期。返回后检查各个阶段。这种设计哲学,是从nginx那里一脉相承的,说白了就是:别让CPU闲着,但也别让一个客户霸占CPU。很公平。

一次压测,两行泪
不吹不黑,我自己搭过环境对比。一台4核8G的云主机,跑Apache bench压测。Node.js单进程,用http模块裸写,Keep-Alive开。另一个Apache,默认mpm_prefork,开200进程。同样并发1000,Node.js内存稳在60MB,CPU 30%;Apache内存直接飙到1.2G,CPU 90%,请求超时一堆。数据说话:

吞吐量呢?Node.js平均8000 req/s,Apache 1500 req/s,差了5倍多。这不是Node.js多牛,是事件循环模型天然适合I/O密集型。计算密集型?对不起,另说。所以别神化它,得看场景。不过话说回来,现在谁还没点计算?所以后面又会遇到陷阱。
踩坑记:三个让你半夜惊醒的陷阱

陷阱一:微任务的饕餮陷阱。你写了个递归的process.nextTick或queueMicrotask,想着实现个异步流转——砰,爆栈。因为微任务队列会在每个宏任务之后一口气清空,如果是递归生成微任务,就无限循环,事件循环被卡死,I/O完全得不到响应。我在一次实时日志分析中见过,CPU直接100%,啥请求都进不来。解决?别用微任务做递归调度,改用setImmediate(Node)或setTimeout,它们属于宏任务,每一轮只执行一个回调,给事件循环喘息机会。或者用worker线程分摊。踩过这坑后,我现在对nextTick有种生理性恐惧——写之前都得问自己三遍:这会不会炸?
陷阱二:Timer的精度错觉。setTimeout(fn,0)不是0,最小延迟在浏览器里大约是4ms,在Node里受libuv控制,大概是1ms。你以为的“尽快执行”其实排队排到后面去了。更坑的是,当事件循环被某个长任务阻塞,定时器到期了也不会执行,必须等当前任务结束。所以高精度计时?用process.hrtime或浏览器performance.now,定时循环?别依赖它做游戏帧同步,你会后悔的。真要用,结合requestAnimationFrame,那是另一个循环。曾经有个同事用setTimeout做动画,在Chrome上跳帧严重,他一怒之下换成RAF,世界清净了。
陷阱三:未捕获的异步异常雪崩。事件循环里,一个回调崩了,如果不捕获,进程直接挂。但在Node里,uncaughtException处理器能防退出,但会导致应用处于不确定状态,内存泄漏、连接泄露随之而来。我团队曾因一个数据库连接池的回调没try catch,导致整个服务时不时僵死,查了三天。最佳实践:所有异步回调,特别是第三方库的,一律用.catch()或域(domains已废就别用了),或用async/await包上try。另外,用Promise.allSettled代替all,别让一个失败连坐全员。这些都是血泪教训。说实话,我现在code review一看没有catch的promise,血压就上来。
事件循环就像操作系统的调度器一样,精巧但也容易误用。理解了它的“单线程+非阻塞I/O+事件通知”的哲学,你才能写出真正高性能的Node应用。否则,你还是在用战术上的勤奋掩盖战略上的懒惰——明明用事件循环,却写了一堆同步死循环。何必呢。