前言
本篇也是过渡篇,主要补充NodeJS的重点和难点。
嘛,废话不多说,正文开始。
NodeJS
首先,理解 NodeJS 有一个很重要的前提,就是知道它究竟是 单线程的,还是 多线程 的?
还是如以往一样,越是简单的问题答案就每个人的答案就越是令人迷惑;
实践是检验真理的唯一标准,最后看看 node线程数就知道了——7个,所以它是多线程的。
但是为何有人说它是 单线程 呢?
单线程这个说法应该被严重曲解的,导致出现了一些误导性; 完整说法应该是 NodeJS运行Javascript代码只有一个线程, 这个特征与浏览器如出一辙,浏览器运行Javascript也是单线程的。然而却不能说浏览器就是单线程,同样的NodeJS也是这个道理。
所以从根本上来说,NodeJS只有一个运行JS的主线程,它也是负责事件循环的线程; 与其他异步模型一般,它永远不会阻塞。 Node官网的事件循环,太过简洁了在细节方面做得不够详尽,因此只能参考实现它的组件——libuv(但这个细节也太多了)。
NodeJS 的事件循环其实是由 libuv 实现的,所以只有理解了 libuv 的事件循环才能算彻底理解了 nodejs 的事件循环。libuv 实现机制要细说三天三夜也讲不完, 所以长话短说——
- libuv 负责收集所有事件(它也可能会来自外部、也包括内核的)
- 针对这些事件注册回调函数
- 事件发生后被执行回调函数。
其实原理与浏览器环境大同小异,都是采用消息传递的异步通信方式,并且都是基于事件循环来执行代码的。不过要注意的是Node 程序处理最多的不是 页面渲染, 而是 输入和输出,统称作IO, 例如文件读写、网络请求等等,这类操作统一特点都是阻塞(响应时间超长)的,传统的读写事件都是同步的,也就是说在获取文件全部内容之前,根本无法做任何事情,自然也效率低下,我们的任务就是狠狠的压榨CPU性能。
libuv便是上面问题的一个解决方案,基本思路是将一些阻塞任务(主要是IO,网络请求.etc)这类阻塞任务分配到工作线程中等待稍后执行,这些工作线程会统一保存在线程池中;然后开启事件循环一一处理。 至于一次能处理多少事件(可能一次事件循环爆发式接收到500个请求),取决于计算机硬件。
所以,理解 nodejs 事件循环, 还是要从 libuv事件循环开始。
下面是官网给出的事件循环示意图:
libuv 概览图:

理解事件循环难点:
- 在主模块代码结束后,事件注册才会完成。一旦有注册的事件,就必然会产生一个
handler,它可以标识任务的上下文(术语:事件句柄、文件描述符)但这时还没有正式开启事件循环。 - 当有
handler存在,初始化线程池。然后将已经注册事件的handler分配到线程池的对应workthread中。例如setTimeout的handlers(Timer)会分配到Timer,setImmediate的handler(CheckHandler)分配到Check, 其他事件分配到poll中统一处理。 - 开启事件循环,然后更新事件循环的
handler计数 ; 现在能够看出,事件循环的每个phase其实都保存在了线程池中。 - 也正因为如此,
phase并不是严守顺序的;或是说它是跳跃性的。例如当第一次进行事件循环时,Timer没有到期,所以不会执行回调。但反过来说一旦到期就一定会执行回调。由于没有需要异步完成的IO Callback,所以不会进入pending callback。 - 因此,首次事件循环会直接跳跃到
poll阶段完成IO Callback。poll阶段极其特殊,它有一个阻塞时长,如果Callback在阻塞时长内没有完成,相应的io callback将异步完成,即在下一个事件循环的pending callback处理完所有剩余的callback。同样的道理,如果在阻塞时长内存在setImmediate的回调,那么就会立即进入到check阶段(不用在意几回合事件循环)。 - 每个事件循环跳跃到下一个事件循环前,都会有一个Close Handler过程,它会清理掉完成状态(
done=true)的handler,更新事件循环的引用计数等等。
事件循环的核心,poll阶段的总结就是(有点都合主义的味道):
- 计算
Poll阶段的阻塞时间(即Timeout) - 执行已发生事件的回调函数,但还有:
- 是否有
nextTick和microtasks? 如果有则执行,否则继续 - 检查是否有
setImmediate回调,如果有执行回调然后进入Check阶段。 - 若无,继续。
- 是否有
- 在阻塞时间内等待事件发生,如果存在
Timer,那么阻塞时间是最近的Timer到期时间,否则不限制阻塞时间:- 若有事件发生,立即执行相应的回调函数,此步骤完全等价于
(2) - 若无则继续等待下去(直至node 停止监听、或没有活跃的
handler)。 - 如果在阻塞时间内仍有未回调完成的任务(
handler),挂起在下一个事件循环的pending callback阶段排队处理。
- 若有事件发生,立即执行相应的回调函数,此步骤完全等价于
- 超过阻塞时间后,进入下一个事件循环,执行到期
Timer。- 此时不再有可用
setImmediate回调,因此前移。 - 阻塞时间是最近的
Timer到期时间
- 此时不再有可用
特别注意:
(2)实际上是(3)其中的一个步骤,但是分离出来更容易理解一些。- 虽然官网说是绕回,但是我觉得这里应该是前移到下一个事件循环中更准确一点。这是和nodejs官网矛盾点。
想要了解libuv事件循环所有细节的,可以直接跳到后面。
setTimeout VS setImmediate
这是很令人兴奋的话题,它们到底谁更优先? 事实上它们并没有优先级关系,因为事件循环没办法用顺序去理解,因此不要用谁优先谁就执行原则,而是谁触发谁就必须执行原则。
注意两点:
setTimeout是到期立即执行setImmediate是进入到check阶段后执行。
也就是说,Timer与CheckHandler是没有逻辑上的必然关系的。
例如:
setTimeout(()=>{
var start = Date.now();
setTimeout(()=>{
console.log('------------------');
console.log('timeout 1 = '+(Date.now()-start)+'ms');
})
setTimeout(()=>{
console.log('------------------');
console.log('timeout 2 = '+(Date.now()-start)+'ms');
})
setImmediate(()=>{
console.log('------------------');
console.log('check 1 = '+(Date.now()-start)+'ms');
})
setImmediate(()=>{
console.log('------------------');
console.log('check 2 = '+(Date.now()-start)+'ms');
})
})
输出结果:
------------------
check 1 = 9ms
------------------
check 2 = 10ms
------------------
timeout 1 = 11ms
------------------
timeout 2 = 11ms
从结果上看,setImmediate的回调永远先于setTimeout。

简而言之, 在Timer到期之前就已经触发了CheckHandler,因此会跳跃到Check。执行完毕后Timer必然到期,因此执行。
但是为什么在主模块setTimeout和setImmediate这两个函数是随机呢?
var start = Date.now()
setImmediate(()=>{
console.log('------------------');
console.log('check = '+(Date.now()-start)+'ms');
})
setTimeout(()=>{
console.log('------------------');
console.log('timeout = '+(Date.now()-start)+'ms');
})
输出:
timeout = 10ms
------------------
check = 12ms
------------------
check = 8ms
------------------
timeout = 9ms
说明:
- 开启事件循环后,更新
now时间,如果Timer在now时间到期,那么就会直接运行。否则不会运行。简而言之,如果能够尽快进入事件循环,就不会执行Timer反之就会执行。
这个解释是在github看到的,感觉很有道理的样子。
nextTick VS Promise
在NodeJS中,也有微任务microtasks,不过它分为两种微任务:
nextTick: 即nextTickotherMicrotasks:Promise。
其中nextTick总是会先于Promise执行。无论哪种微任务,它们都能在跳跃到其他phase前执行完毕。
例如:
console.log('start');
Promise.resolve('promise 1').then(console.log)
Promise.resolve('promise 2').then(console.log)
process.nextTick(()=>{
console.log('ha,ha,ha,ha');
console.log('im tickObject!!');
})
console.log('end');
验证输出:
start
end
ha,ha,ha,ha
im tickObject!!
promise 1
promise 2
然后四个一起,综合来PK一下(正常版本):
const fs = require('fs')
const start = Date.now()
fs.readFile(__filename,()=>{
console.log('start');
setImmediate(()=>{
console.log('checkhandler 1');
Promise.resolve('promise 1').then(console.log)
console.log('-----------------------------------');
})
setTimeout(()=>{
console.log('timeout 1');
})
setImmediate(()=>{
console.log('checkhandler 2');
Promise.resolve('promise 2').then(console.log)
process.nextTick(()=>{console.log('nextTick 2');
})
console.log('-----------------------------------');
})
process.nextTick(()=>{
console.log('nextTick 1');
})
console.log('end');
})
输出:
start
end
nextTick 1
checkhandler 1
-----------------------------------
promise 1
checkhandler 2
-----------------------------------
nextTick 2
promise 2
timeout 1
上面的过程如下:
poll阶段:调用readFile,开始文件读写。poll / pending_callback阶段: 文件读写完成。开始调用回调函数:
- 输出
start- 调用
setImmediate;checkhandler1活跃状态- 调用
setTimeouttimeout 1被初始化,等待到期。- 调用
setImmediatecheckhandler2活跃状态- 调用
nextTick, 产生一个TickQueue- 输出
end- 执行
TickQueue中的所有任务,输出nextTick 1
存在checkhandlers, 进入check阶段check阶段:处理所有的checkhandlers的回调:
- 执行
checkhander1的回调:
- 输出
checkhandler1Promise.resolve().then, 产生一个promiseList- 输出横线
- 处理
promiseList中所有的任务,输出promise 1- 执行
checkhander2的回调:
- 输出
checkhandler2Promise.resolve().then, 产生一个promiseListnextTick, 产生一个TickQueue- 输出横线
- 处理
TickQueue所有任务,输出nextTick 2- 处理
promiseList所有任务,输出promise 2
Timeout到期,跳跃到TimerTimer阶段: 处理Timeout回调,输出timeout 1
注意:
- 每处理完一个回调,就会调用一次
nextTick或otherMicrotask io callback究竟是在哪个阶段完成的,是未确定的。
实际上在其他模块,也会出现随机顺序问题,例如:
const fs = require('fs')
setTimeout(()=>{
fs.readFile(__filename,()=>{
console.log('file one');
setTimeout(()=>{
console.log('timeout one');
})
})
fs.readFile('./file.md', ()=>{
console.log('file two');
setTimeout(()=>{
console.log('timeout two');
})
})
})
然后出现以下三种结果(更离谱的代码就没写):
这也侧面证明了IO Callback的随机性,它的完成与文件大小也是息息相关的。左右两侧的结果是比较容易理解的,中间的是为什么?
大概率是因为file two是在pending callback阶段完成的。
- 在
poll阶段,fileone回调完成,添加一个定时器。 - 在
poll阶段,但是filetwo回调未完成, 定时器到期,立即回到Timer阶段 - 执行
Timer - 在
pending callback,回调完成,添加一个定时器。 - …
下面是小生看了一遍源代码得出的结论,大致上补充了点细节。不过因为linux编程这一块遗忘了不少,可能未必是正解,这方面还请多提宝贵意见。
同步转发:
https://juejin.im/post/5ed36676e51d457b3a67ccef
附注:libuv事件循环所有细节
首先要知道几个术语:
- handler来标识一个任务,事实上它就是句柄(文件描述符),总之通过它可以引用任务的一切信息:任务的回调函数、回调状态等等。
- 一个handler普遍有三种状态:初始化 、 挂起(即暂停) 、 活跃 。常用后面的两种。
开始吧:
Bootstrap/Dispatcher:
- 初始化Node环境
- 执行同步JS代码并进行事件分发(注册)。
- 初始化线程池
然后正式进入到事件循环中:
UpdateLoop:
- 事件循环是否是活跃的(主要检查是否有活跃的
handler)- 如果是,更新当前时间(
now),正式进入Phase I- 否则终止事件循环,关闭。
Phase I: Timer
- 检查 Timer 是否到期
- 如果到期,立即触发事件然后继续执行回调。
- 上面是最简单的,就是
setTimeout/setInterval这类调用。
Phase II:Pending (IO) Callbacks
- 检查
Pending_Queue是否有handler- 如果有,立即从
Pending_Queue取出handler进行回调操作。- 如果
handler中回调函数代码中存在CheckHandlers(即setImmediate(...)),那么处理完成后跳转到Phase V- 否则继续。
附注:
Pending_Queue用于放置未回调完成的handler的队列。- 一般IO Events 会在这个阶段完成处理。例如:文件操作时抛出异常、读取完成然后执行回调等等。
CheckHandlers是极其特殊的handler,简单来说就是setImmediate(...)的回调。不只会在Phase II导致迭代前移(直接跳转到Phase V),甚至在其他Phases也会如此。
Phase III.a: Idle/Idling
- 进入空转监听阶段
- 它会不断地监听
handler是否发生事件(此时handler是活跃的),如果是进入Phase III.b- …(记录日志等,与我们无关了……)
- 达到事先规定好的循环次数后,也未曾监听到新事件 那么
Idling会被终止;事件循环也会被终止(因为一直监听不到事件发生)。
附注:
Idling阶段永远不会停止;也就是说即使事件发生后转入到Phase III.b时,Idling也是一直在空转监听。- 也正因为如此,我们才能任何时刻监听到事件。
- 虽说如此,
Idling会有一个极限循环次数,这个次数由硬件决定的。过了这个次数,它就结束了。
Phase III.b: Prepare
- 为活跃的
handler执行回调前做些准备- ……(准备上下文……等)
- 进入
Phase IV,正式处理回调。
附注:
- 事实上,了解
Phase III没什么大用,因为不可能编写一定处于Phase III阶段的代码。 - 它是libuv执行的。
- 只是补充了一点细节
Phase IV Poll:
- 计算超时时间(
Timeout)- 在超时时间内(
Timeout):
- 如果
checkhandler存在,执行相应回调- 如果发现
IO Event,立即执行回调。- 如果无事件发生,检查是否超时
- 如果超时,立即退出
Phase IV。否则回到(2.1)- 超过超时时间后,会有
Timer到期,直接前移到下一个事件循环。- 会存在未回调的
handler,此时它们会在下一个事件循环的pending callbacks完成处理。
未完待续。
附注:
Timeout计算规则:
- 在以下情形,
Timeout=0:- 事件循环将被终止(由
IdleHandler设置) - 没有活跃的
handler。 - Idle不再监听(即
IdleHandler终止) Pending_Queue不为空.
- 事件循环将被终止(由
- 除却以上情形, 如果有待处理的
Timer,那么Timeout是最近的Timer到期时间。 - 如果没有待处理的
Timer, 那么Timeout为-1,可以理解为poll阻塞时间为 无穷大(这块我忘了,应该是阻塞时间不确定)。
Phase V : Check
- 检查
CheckHandlers是否存在(即setImmediate的消息序列)- 若有则处理所有
CheckHandlers- 完成, 进入
Phase VI
Phase VI : Close handlers
- 对处理完的
handler进行Close操作,例如send()/close()/destory()等等。- 调用
GC,进行垃圾回收- 转至
UpdateLoop。
本文详细探讨了NodeJS的事件循环机制,指出NodeJS是基于单线程运行JS代码,但利用libuv库实现异步IO处理。libuv通过线程池处理阻塞任务,确保事件循环的高效运行。文章对比了setTimeout和setImmediate,以及nextTick和Promise的执行顺序,并介绍了事件循环的六个阶段,强调了它们在不同场景下的行为特性。通过对libuv事件循环的理解,读者可以更好地掌握NodeJS的异步模型。

被折叠的 条评论
为什么被折叠?



