1. 传统JS事件循环
1. 执行环境、执行栈
当我们调用一个方法时,会生成一个与该方法对应的执行环境(context),又叫执行上下文。执行环境中存储方法的私有作用域、上层作用域的指向、方法参数、方法作用域中定义的变量、方法作用域的this对象等。
由于js是单线程的,同一时间只能执行一个方法,因此当一系列方法被依次调用的时候,js就将这些方法依次存放在一个单独的地方------执行栈。执行一段仅包含同步代码的js脚本时,js引擎会按照执行顺序将任务加入执行栈中,加入后的任务立即执行,执行完毕出栈,接着进行下一段代码的执行。
当执行一个方法时,js会向执行栈中添加方法的执行环境,然后进入执行环境继续执行其中的任务,当执行环境中的任务执行完毕并返回结果后,js会退出执行环境并把这个执行环境销毁(方法执行结束)。接着回到执行栈中继续执行剩余的任务。在一个方法的执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以无限进行下去,直到栈溢出为止(即超过了所能使用内存的最大值)。
上述过程反复进行,直到所有代码都执行完毕,一段代码就运行完成了。(栈的数据结构就可以直观的体现上述的过程)
2. 事件队列
只有同步代码的情况下,整个过程并不复杂,那么当代码中不仅有同步代码还有异步代码时该如何处理呢?前文提过,js的另一大特点是非阻塞,实现这一点的关键就在于下文要介绍一项机制------事件队列。
遇到一个异步方法时主线程并不会一直等待其返回结果,而是会将其挂起(异步方法本身的任务交由辅助线程处理,如定时器线程、http请求线程等,辅助线程执行完毕后返回),继续执行执行栈中的其他任务。当该异步方法返回结果后,js会将这个方法的回调事件放入一个先入先出(FIFO)队列中,这个队列就称为事件队列。
值得注意的是,js引擎不会立刻执行事件队列中的回调事件,而是会等待执行栈中的所有执行环境都执行完毕、主线程处于闲置状态时才去执行。此时主线程会查找事件队列中是否有回调事件,如果有就从中取出排在第一位的回调事件,并把其对应的执行环境放入执行栈中,主线程继续遵循先同步后异步回调的原则处理执行环境中的任务;如果没有就持续查找。如此反复就形成了一个可以无限进行的循环,因此这个过程被称为事件循环(Event Loop)。
根据上文所述,一般js代码执行的大致流程图如下所示:

3. 宏任务、微任务
上述的事件循环过程是一个宏观的表述,实际上异步任务的执行优先级也有区别。不同的异步任务被分为两类:宏任务(macro task)和微任务(micro task)。宏任务由宿主(浏览器、Node)发起,包括script (可以理解为每次执行的代码,因此这种类型的宏任务可能会包含可执行的微任务)、setTimeout / setInterval、UI rendering / UI事件、I/O(NodeJS)等;微任务由JS引擎发起,包括Promise、async / await 等。
在上文的介绍中,我们提到过异步事件的回调事件会被放到事件队列中,但实际上事件队列不止有一个,根据异步任务的类型,事件队列也分为宏任务队列和微任务队列,不同类型异步任务对应的回调事件也会被放入相应的宏任务队列或者微任务队列中。因此一轮事件循环的实际过程大致为:
(1)执行一个宏任务(栈中没有就从事件队列中获取)
(2)执行过程中如果遇到微任务,将其添加到微任务队列中
(3)宏任务执行完毕后:
① 若微任务队列中存在微任务,立即执行队列中的所有微任务(依次执行)
② 若微任务队列中没有微任务,开始下一个宏任务(从事件队列中获取)

4. 传统js事件循环流程
综上所述,传统js中的事件循环流程图可以细化为如下所示:

需要注意的是,这里容易产生一个误解,就是*微任务先于宏任务执行 *(错误的!!!)。由于存在script类型的宏任务,一个js脚本本身就是一个大的宏任务,因此实际的过程是:一个宏任务,所有微任务;一个宏任务,所有微任务...如此循环。这里举一个比较形象的例子模拟这个过程:
银行柜台前排着一条队伍,都是存钱的人(这条队伍就是宏任务队列,存钱是宏任务,其他业务都是微任务)。这时一个"宏大爷"被叫号了,他就上前去办理存钱业务,办理时"宏大爷"突然想改个密码,那么银行职员就将改密码列为待办事项(微任务进入微任务队列),当存钱宏任务完成后就可以处理衍生出来的改密码微任务,大爷不用为了改密码而再次排队。改密码时大爷又说办个信用卡,那就再加到微任务队列里。但要是"宏大爷"说他老伴也要存钱(这也是宏任务),那么不好意思,需要再次取号到后面排队(宏任务进入宏任务队列)。
NodeJS事件循环
Libuv
CPU 密集型& IO 密集型
在软件开发和系统设计中,了解 CPU 密集型和 IO 密集型的任务对于优化应用程序和选择合适的技术栈极为重要。这些概念主要与应用程序的性能瓶颈有关,可以帮助开发者更好地理解如何设计高效的多线程和异步程序。
首先,我们的电脑可以简单地抽象为这么一种模式:
Plain输入(键盘) -> 计算(CPU) -> 输出 (显示器) 输入和输出是 IO 的范畴,计算是 CPU其次,单机程序是由多个方法或者函数串并行组合而成,可以抽象为:
Plain入参 -> 计算 -> 返回值最后,分布式服务是由多个,单机服务(集群)串并行组合而成,服务间交互可抽象为:
Plain网络请求(入参) -> 计算 -> 网络响应(返回值) 请求和响应是 IO 的范畴,计算是 CPU综上所述,无论从硬件还是从软件层面来看,它们都是由 I/O 和计算 CPU 来组成的。
CPU 密集型任务(CPU-bound)
CPU 密集型任务是指那些主要受中央处理器(CPU)速度限制的任务。这类任务需要大量的计算,消耗的时间主要在 CPU 上,而不是等待外部资源(如磁盘输入输出或网络通信)。
这些任务的主要有以下几个特点:
- 高计算需求:这类任务通常涉及复杂的数学计算,如视频编码解码、图像处理、科学计算等。
- 多线程优势:在多核 CPU 上,通过并行处理可以显著提高 CPU 密集型任务的执行效率。例如,使用多线程分解任 务到不同的核心上执行。
- 资源消耗:CPU 密集型任务在运行时会导致 CPU 使用率接近 100%。
一般的示例有如下内容:
- 数据分析和大规模数值计算。
- 图形渲染或视频处理软件。
- 加密货币挖矿。
你的笔记本电脑响的时候基本就都是这些类型了
对于 CPU 密集型,有如下几个优化的方向:
- 并行化:利用多核处理器通过并行计算来提升性能。
- 优化算法:改进算法可以减少必要的计算量。
- 编译优化:使用适合高性能计算的编译器和编译优化技巧。
I/O 密集型(I/O-bound)
IO 密集型任务是指那些主要受输入/输出(I/O),包括磁盘 I/O 和网络通信,限制的任务。这类任务的瓶颈主要在于等待 IO 操作的完成,而 CPU 计算量相对较小。
这些任务的主要有以下几个特点:
- 高 I/O 需求:这类任务可能需要频繁地读写文件系统,或者有大量的网络请求需要处理。
- 并发优势:IO 密集型任务适合使用事件驱动和异步编程模型来提高效率,如 Node.js 的非阻塞 I/O。
- CPU 利用率低:由于大部分时间花在等待外部操作上,CPU 的利用率通常不高。
它们的主要使用场景有如下示例:
- 网页服务器和数据库服务器,这些应用需要处理大量的网络请求。
- 文件服务器,需要频繁地读写磁盘。
- 客户端应用,如电子邮件客户端或社交媒体应用,它们需要频繁地发送网络请求并接收数据。
对于 I/O 密集型,有如下几个优化的方向:
- 增加缓存:使用内存缓存来减少对磁盘 I/O 的需求。
- 异步编程:利用异步 I/O 操作来避免阻塞,提高程序整体的响应能力和吞吐量。
- 优化资源管理:合理安排 I/O 操作,避免不必要的读写。
什么是非阻塞 I/O?
非阻塞 I/O(Non-blocking I/O)是指在执行输入输出操作时,不会让程序完全等待 I/O 操作完成。这种方式允许程序在等待 I/O 操作完成的同时,还能继续执行其他任务。
Node.js 中的非阻塞 I/O 如何工作?
Node.js 使用 JavaScript 运行在 V8 引擎上,并利用 libuv 库实现非阻塞 I/O 和异步编程。NodeJs 之所以能实现非阻塞 I/O,主要由以下这几个核心来实现:
- 事件循环:事件循环是 Node.js 实现非阻塞 I/O 的核心机制。它允许 Node.js 执行非阻塞操作,如网络通信、文件 I/O,同时还能处理用户接口或定时事件。当 NodeJs 启动时,它会初始化事件循环,处理任何提供的输入脚本(这可能会触发异步操作),然后进入事件循环。
- 调用堆栈:所有同步任务(即阻塞操作,如计算或直接数据处理)都在调用堆栈中执行。如果堆栈中的操作耗时较长,会导致程序阻塞,即常说的"堵塞主线程"。
- 回调队列:异步操作完成时,其回调函数被放入回调队列等待执行。事件循环按照顺序检查这些队列,并将可运行的回调函数返回到调用堆栈中执行。
- 非阻塞操作:对于文件系统操作 ,Node.js 调用 libuv 库实现非阻塞功能,这涉及到使用底层非阻塞的 POSIX API。对于网络请求,Node.js 使用非阻塞的网络 I/O。
非阻塞文件系统操作
当 Node.js 执行文件系统操作(如读取文件)时,它使用 libuv 来进行这些操作,而不是直接使用 POSIX 文件系统 API。libuv 决定使用最佳的方法来执行这些操作,以便它们不会阻塞事件循环。
libuv 维护了一个固定大小的线程池(默认为四个线程),用于执行操作系统级别的阻塞 I/O 调用。这意味着文件 I/O 操作实际上是在这些线程上异步执行的,因此不会阻塞主事件循环线程。
Node.js 的文件操作支持同步调用和异步调用,根据 Libuv 官网的介绍,我们知道它没有跨平台的异步文件 IO 可以使用,所以它的异步文件 IO 是通过在线程池中执行同步文件 IO 实现的。那具体是怎么实现的呢?答案就是生产者消费者模型。Libuv 的线程包括 2 部分,一个是主线程,一个是线程池。主线程的一部分工作是描述任务并将其提交给线程池,线程池进行处理。拿异步文件操作为例,主线程生成一个描述文件操作的对象,将其提交到任务队列;线程池从任务队列获取该对象进行处理。其中主线程是生产者,线程池中的线程是消费者,任务队列是生产者和消费者之间的桥梁,下面是一个简单的示意图:

Libuv 在生产者消费者模型中多加了一步,线程池执行完任务后,将结果交给主线程,主线程拿到结果后,如果发现有回调函数需要执行,就执行。所以 Libuv 的线程模型如下:

通过上面对生产者消费者模型的介绍,该代码大致分为 4 部分:任务队列 、主线程提交任务到任务队列(提交任务 )、线程池从任务队列获取任务并执行(消费任务 )、线程池执行完任务通知主线程执行回调函数(回调处理)。
一旦线程池中的线程完成了任务(例如,文件读取操作),它将任务的回调函数放入事件循环待处理的队列中。当事件循环轮到这个回调函数时,主线程将执行它,完成整个异步任务的周期。这确保了即使在进行重量级 I/O 操作时,主线程也能保持轻量和响应用户交互。
在 JavaScript 的所有宿主环境中,无论是浏览器还是 Node.js,事件循环机制都不是 ECMAScript 的语言规范定义的。浏览器中的事件循环是根据 HTML 标准 实现的,而 Node.js 中的事件循环则是基于 libuv 实现的。
libuv 是一个用 C 语言实现的高性能解决单线程非阻塞异步 I/O 的开源库,本质上它是对常见操作系统****底层异步 I/O 操作的封装。在 nodejs 底层,Node API 的实现其实就是调用的它。
我们知道浏览器事件循环中执行异步任务的其他线程是由浏览器本身提供的,多线程调度是由渲染主线程完成的。而在 nodejs 中,这都是 libuv 完成的。
几乎每个 Node API 都有异步执行版本 ,libuv 直接负责它们的执行,libuv 会开启一个线程池,主线程执行到异步操作后,libuv 就会在线程池 中调度空闲线程去执行,可以说 libuv 为 nodejs 提供了整个事件循环功能。

Node.js 中的 Event Loop
与在浏览器中一样,在 nodejs 中 JS 最开始在主线程上执行,执行同步任务、发出异步请求、规划定时器生效时间、执行 process.nextTick 等,这时事件循环还没开始。
在上述过程中,如果没有异步操作,代码在执行完成后便直接退出 。如果有,libuv 会把不同的异步任务分配给不同的线程 ,形成事件循环。在同步代码执行完后,nodejs 便会进入事件循环,依次执行不同队列中的任务。libuv 会以异步的方式将任务的执行结果返回给 V8 引擎,V8 引擎再返回给用户。

Nodejs 事件循环中的消息队列共有 8 个,若引用之前宏队列、微队列的说法,具体可划分为:
- 宏队列
- timers (重要)
- pending callback
- 调用上一次事件循环没在
poll阶段立刻执行,而延迟的 I/O 回调函数
- 调用上一次事件循环没在
- idle prepare
- 仅供 nodejs 内部使用
- poll (重要)
- check (重要)
- close callbacks
- 执行所有注册
close事件的回调函数
- 执行所有注册
- 微队列
- nextTick
- Promise
我们先来说说宏队列中比较重要的 3 个:
📚 timers
timers,也就是计时器队列 ,负责处理setTimeout和setInterval定义的回调函数。
值得注意的是,不管在浏览器中还是 nodejs 中,所有的定时器回调函数都不能保证 到达时间后立即执行。一是因为从计算机硬件和底层操作系统来看,计时器的实现本身就是不精准的,二是因为 poll 阶段对 timers 阶段的深刻影响。因为在没有满足 poll 阶段的结束条件前,就无法进入下一次事件循环的 timers 阶段,即使 timers 队列中已经有计时器到期的回调函数。
📚 poll
poll 称为轮询队列 ,该阶段会处理除 timers 和 check 队列外的绝大多数 I/O回调任务,如文件读取、监听用户请求等。
事件循环到达该阶段时,它的运行方式为:
- 如果
poll队列中有回调任务,则依次执行回调直到清空队列。 - 如果
poll队列中没有回调任务- 若其他队列中后续可能会出现回调任务,则一直等待,等其他队列中后续的回调任务来临时,结束该阶段,开启下一次事件循环
- 若等待时间超过预设的时间限制,也会自动进入下一次事件循环
- 若其他队列中后续不可能再出现回调任务了,则立即结束该阶段,并在本轮事件循环完成后,退出 node 程序
poll阶段的超时时间在进入poll阶段之前计算。
💎 案例 1:不精准的计时器
javascript
const fs = require('fs');
const start = Date.now();
setTimeout(() => {
console.log('setTimeout exec', Date.now() - start);
}, 200)
fs.readFile('./index.js', 'utf-8', (err, data) => {
console.log('file read');
const start = Date.now();
while(Date.now() - start < 300) {};
})
// 输出结果:
// file read
// setTimeout exec 313ms
🔨 分析 1:
- 进入事件循环后,定时器还没到时间,
timers队列空,来到poll阶段 - 读取文件需要一定时间,
poll队列空,等待 - 文件读取完成,回调函数加入
poll队列,执行输出file read,开启循环,阻塞 300ms - 定时器到时间,回调函数加入
timers队列,由于poll阶段未结束,被阻塞,等待 poll中的循环结束,检测到timers中有任务,结束poll阶段,开始下一次事件循环- 执行
timers中的回调函数,输出setTimeout exec 313ms,计时器回调函数并没有在计时器到达时立即执行
setTimeout 并不是只在 timers 阶段才"检查是否到期" 到期判断发生在 libuv 的 timer 管理结构中 但"回调真正被取出并执行",只发生在 timers 阶段
📚 check
check 称为检查队列 ,负责处理setImmediate定义的回调函数。
setTimeout 和 setImmediate 的不同之处在于,每次执行到 timers 队列时,定时器观察者内部会去检查 代码中的定时器是否超过定时时间,而 setImmediate 则是直接 将回调任务加入 到 check 队列。
所以总的来说,setImmediate 的执行效率要远高于 setTimeout,于是也就出现了下面无法预测输出结果的情况:
javascript
setTimeout(() => {
console.log('setTimeout');
}, 0)
setImmediate(() => {
console.log('setImmediate');
})
// 上述代码是无法预测先输出那个的
// 因为即使 setTimeout(xxx, 0),在计算机运算慢的情况下也不能立刻加入 timers 队列
对于微队列的 nextTick 和 Promise,严格意义上讲也不属于事件循环。在事件循环中,每次打算进入下个阶段之前,必须要先依次反复清空 nextTick 和 promise 队列,直到两个队列完全没有即将要到来的任务的时候再进入下个阶段。
我们可以通过 process.nextTick() 将回调函数加入 nextTick 队列,和通过 Promise.resolve().then() 将回调函数加入 Promise 队列,且 nextTick 队列的优先级还要高于 Promise 队列,所以 process.nextTick 是 nodejs 中执行最快的异步操作。
💎 案例 2
javascript
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function () {
console.log("setTimeout0");
}, 0);
setTimeout(function () {
console.log("setTimeout3");
}, 3);
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
console.log("promise2");
}).then(function () {
console.log("promise3");
});
console.log("script end");
// 输出结果依次为:
// script start
// async1 start
// async2
// promise1
// promise2
// script end
// nextTick
// async1 end
// promise3
// 剩下的 setTimeout0、setTimeout3、setImmediate 顺序不定
// 唯一能确定的是 setTimeout0 在 setTimeout3 前输出
// 而 setImmediate 可能在 setTimeout0 前也可能在 setTimeout3 之后,也可能在两者中间
🔨 分析 2:
- 执行全局代码,输出
script start。 - 到达
setTimeout(0)和setTimeout(3),交给计时器线程开始计时,注意在线程计时完成前,两个回调任务console.log还未加入timers队列。 - 到达
setImmediate,立刻将console.log任务加入check队列。 - 到达
process.nextTick,立刻将console.log任务加入nextTick队列。 - 执行
async1,输出async1 start。await async2()立刻执行async2(),输出async2将后续console.log任务包装成Promise.then()加入Promise队列。 - 执行
new Promise(),输出promise1、promise2,这两步是同步代码。然后将.then()里的console.log任务扔进Promise队列。 - 执行最后的
console.log,输出script end。 - 至此同步代码全部执行完毕,消息队列中仍有任务,进入事件循环。
梳理一下此时各消息队列的状态:
已有的输出:
script start、async1 start、async2、promise1、promise2、script endnextTick队列:console.log("nextTick")Promise队列:console.log("async1 end")、console.log("promise3")timers队列:console.log("setTimeout0")、console.log("setTimeout3")check队列:console.log("setImmediate")
- 在进入
timers阶段前先清空微队列,先执行nextTick队列,输出nextTick。 - 执行
Promise队列,依次输出async1 end、promise3。 - 进入
timers阶段,由于不确定在到达这个阶段前,计时器线程有没有把完成对setTimeout(0)和setTimeout(3)中的一者或两者的时间检查,并将回调函数推入timers队列,故无法预测它们与check队列中的setImmediate谁先输出。
参考
Js,NodeJS事件循环
【Event Loop】浏览器与 Node.js 事件循环详解
探究 CPU 密集型与 IO 密集型任务:提升对 Node.js 中 libuv 库的理解
