引言
也许对于浏览器中的 EventLoop 大多数开发者已经耳熟能详了,掌握 EventLoop 它对于每一个前端开发者的重要性不言而言。
当然,现阶段无论是在前端面试中还是日常业务中,NodeJs 对于任何一个前端开发者的重要性都是毋庸置疑的。对于 EventLoop 的认识仅仅停留在浏览器环境下的执行流程的话是远远不够的。
但是对于 NodeJs 中的事件环你又有多少了解呢?或句话说,你了解浏览器中的 Event Loop 和 NodeJs 中有哪些哪些细微的差距吗。
文章会从以下方面:
- ✨ 并发模型
- ✨ 浏览器中的 EventLoop
- ✨ NodeJs 中的 EventLoop
- ✨ 浏览器和 NodeJs 中 EventLoop 的差距
本文会从以上四个方面带你探索不同运行环境下的 EventLoop 事件机制,一次真正掌握 EventLoop。
并发模型
在 JavaScript 中我们听到最多的词可能就是所谓的“单线程”,所以导致了在 JS 中所谓的异步并行模型和许多后台语言是不同的。
简而言之,虽然 JavaScript 是在单线程中去执行所有的操作,但它会基于事件循环(EventLoop)的过程去实现所谓的“异步”,从而给我们造成多线程的感觉。
在开始介绍它之前我们会稍微来讲讲一些简单概念。
栈
比如我们日常函数的执行,实质上基于栈去操作。JS 中会存在一个调用栈,它会负责跟踪所有待执行的操作。
每当一个函数执行完成时,它就会从栈的顶部弹出。比如下面这段代码:
代码语言:javascript复制const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
bar()
baz()
}
foo()
栈的执行过程如下图所示:
图片来自 NodeJs 官方文档。
其实这是一个非常简单的过程,代码中函数执行基于 stack 进行先进先出的一个过程。
调用栈 stack 负责跟踪要执行的所有操作。每当一个函数完成时,它就会从栈顶中弹出。
堆
对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
堆的概念其实对于我们理解 EventLoop 并不是特别重要,所以我们仅仅需要明白在 JavaScript 对于所有引用类型的变量,实际上我们是在堆中进行存储。
在 stack 中存储的只不过是对于堆中的指针而已。关于堆和栈的基础我相信大家都已经非常清楚,所以这里我就不在过于赘述了。
事件队列
上边我们讲到在 Javascript 中本质上是基于栈的形式去执行我们的代码,但是执行任务(比如上边的函数)是如何被推到栈中的呢。
这里我们就不得不提出事件队列的概念,所谓事件队列(Event Queue)正是负责将即将执行的函数发送到栈中进行处理,它循队列数据结构保证所有发送执行的正确顺序。
简单来说,当我们上述提到的栈中如果执行完毕时。此时 JavaScript 会继续进入事件队列(Event Queue)中查找是否存在需要执行的任务,如果存在那么会继续执行这个队列中的任务。(需要注意的是事件队列中的执行顺序是基于队列先进先出的顺序)。
需要额外注意的是,JS 中的执行过程是基于“执行到完成”这一过程。也就是说每当存在一个消息新的完整地执行后,其他消息才会被在 JS 线程中执行。
比如这样的一个场景,假如我的页面正在执行一段逻辑时。此时用户点击了拥有绑定事件的按钮。那么此时这个事件会立即加入队列中,但是它并不会被立即执行。
它仍然需要等待队列前的所有排队任务被执行完毕之后才会被执行。
串联流程
上边我们简单聊了聊栈和事件队列,这里我们以浏览器中的 SetTimeout 为例来举一个简单的例子来帮助你理解这一过程:
代码语言:javascript复制function fn() {
const a = '19Qingfeng'
console.log(a)
setTimeout(() => {
console.log('hello')
}, 0)
console.log(a 1)
}
fn()
比如我们的代码执行过程中,fn() 执行会被推入栈中。此时 JS 会在栈中调用这个函数,fn 首先会依次执行一行一行代码。
当在栈(fn)中处理 setTimeout 操作时,它会被发送到相应的定时器线程去处理,定时器线程等待指定时间满足后将该操作发送回处理。
需要注意的是时间满足后,定时器线程会将需要执行的 callback 函数发送到事件队列中,此时事件循环会检查当前栈中是否存在正在执行的函数。如果为空,则从事件队列中添加新函数推入栈中进行执行。如果不是,则继续处理当前栈中的函数调用。。
虽然 Javascript 本身是单线程的,但我们可以借助浏览器相关的其他线程以及事件队列的机制来实现异步。
因此,我们基于这样的事件循环模型就实现了达到了所谓的“异步”效果。
浏览器中的 EventLoop
其实浏览器中所谓的 EventLoop 网络上已经有非常多的优秀文章去描述这一过程,同时我也相信大家对于浏览器中的 EventLoop 这个话题都已经非常了解了,所以这里我并不会用太多篇幅来描述它。
图片来自修言的小册《前端性能优化原理与实践》
其实关于浏览器中的 EventLoop 这张图都已经足够代表一切了。
需要主要的是在浏览器中,所谓的 macro-task 代表:setTimerout、setInterval、script脚本、UI渲染等。
而所谓的 micor-task 代表则是:Promise、MutationObserver 等。
在图中我们可以清楚的看到,比如,当我们执行一个 macro-task 时(比如执行一个已经加载完毕的script脚本)。
- 首先首次页面执行时遇到 script 脚本(macro)会被推入栈中同步进行执行对应逻辑。
- 当执行时遇到所谓的 macro-task 时会将它交给对应的其他线程来处理。
- 当执行时遇到所谓的 micro-task 时同时他也会交给其他线程去处理。
- 当 script 脚本还在执行途中,上述代码中的 macro-task / micro-task 达到执行时间时,他们的 callback 处理函数会被依次推入它们各自的事件队列。
需要注意的是,是会推入事件队列并不代表会立即加入栈中进行执行。
- 当 script 脚本执行完毕时,也就意味着栈已经被清空了。那么此时,事件循环机制会检查到调用栈为空。
- 此时,会依次将 micaro-tasks 中的 task 推入调用栈执行(先进先出),清空所有 micro 队列中的任务。
- 接下来,正如图中所示。在清空 micaro-tasks 任务队列后 UI 线程会执行页面渲染。(当然这一步也可能没有,换句话说每次 EventLoop 并不代表一定会伴随着页面渲染。)
- 之后,会处理相关 Worker 中的任务。
其实这就是浏览器中完整的一次相关 EventLoop 的处理过程,它非常简单。我们仅仅需要牢记的就是每次 EventLoop 中会拿出一个 macro 来执行同时在 macro 执行完毕后会清空本次队列中中所有 micao 任务。
当然在 Worker 逻辑处理后,又会在此从 macro-tasks 中拿出排在队列第一位的 macro 进行执行重复这个过程。
当然,这里有一个小 tip ,我们可以看到在每次页面渲染之前是会清空所有的微任务(micro)。这也就意味着,我们对于 Dom 的操作如果放在微任务之中是会让 UI 线程进行一次少的绘制(更快的展示在用户视野中)。
Vue 中的 nextTick 异步更新原则首选方案是 Promise 其实就是基于这一行为去设计的,有兴趣的朋友可以自己私下去查阅。
Node 中的 EventLoop
上边我们简单描述了在浏览器中一次事件循环 EventLoop 的执行过程,接下来我们趁热打铁来看看在 NodeJs 中所谓的事件循环是如何执行的。
Node APi
这是 NodeJs 官方指南中对于事件循环的描述,在深入了解这张图之前我们先来看看 NodeJs 对于浏览器环境来说多了哪些 API 任务。
- Process.nextTick
所谓 Process.nextTick 方法是 NodeJs 事件环中一个非常重要的 API ,我们稍微回忆一下在浏览器中的时间环中 EventLoop 会清空当前 macro 下产生的所有 micaro 的 callback 。
所谓 Process.nextTick 的执行时机即是在同步任务执行完毕后,即将将 micro-task 推入栈中时优先会将 Process.nextTick 推入栈中进行执行。
换句话说,所谓的 Process.nextTick 简单来说就是表示当前调用栈清空后立即执行的逻辑,其实你完全可以将它理解成一个 micro (虽然官方并不将它认为是 EventLoop 中的一部分)。
它会在本次 EventLoop 中的所有 micro 之前执行进行优先执行。
- setImmediate
setImmediate 同样是 NodeJs 中的 API。它意为当要异步地(但要尽可能快)执行某些代码时,使 setImmediate()
函数。
它类似于 setTimeout(()=> {},0)
,所谓 setImmediate 同样是一个 macro 宏任务。关于它和 setTimeout 的执行时机我们会在稍后详细讨论。
- I/O 操作
我们都了解 NodeJs 是 JavaScript 脱离了浏览器 V8 的执行环境下的另一个 Runtime ,这也就意味着利用 NodeJS 我们可以进行 I/O 操作(比如从网络读取、访问数据库或文件系统)。
关于 I/O 操作,你可以将它产生的 callback 理解成为 macro 宏任务队列来处理。
当然在当前 Web 中也提供了FileReader API 提供文件读取操作。
Event Loop
此时我们再来回过头来看看这张图:
我们先来简单描述一下图中每一层所代表的含义,每一层你可以将它理解成为一个队列:
- timers 阶段。
在 timers 阶段会执行已经被 setTimeout()
和 setInterval()
的调度回调函数。
- pending callbacks 阶段。
上一次循环队列中,还未执行完毕的会在这个阶段进行执行。比如延迟到下一个 Loop 之中的 I/O 操作。
- idle, prepare
其实这一步我们不需要过多的关系,它仅仅是在 NodeJs 内部调用。我们无法进行操作这一步,所以我们仅仅了解存在 idle prepare 这一层即可。
- poll
这一阶段被称为轮询阶段,它主要会检测新的 I/O 相关的回调,需要注意的是这一阶段会存在阻塞(也就意味着这之后的阶段可能不会被执行)。
- check
check 阶段会检测 setImmediate()
回调函数在这个阶段进行执行。
- close callbacks
这个阶段会执行一系列关闭的回调函数,比如如:socket.on('close', ...)
。
其实 NodeJs 中的事件循环机制主要就是基于以上几个阶段,但是对于我们比较重要的来说仅仅只有 timers、poll 和 check 阶段,因为这三个阶段影响着我们代码书写的执行顺序。
至于 pending callbacks、idle, prepare 、close callbacks 其实对于我们代码的执行顺序并不存在什么强耦合,甚至有些时候我们完全不必在意他们。
接下来我们结合几个问题来探讨上图中描述 NodeJs 中的 EventLoop 流程:
process.nextTick
首先我们来看看这一段代码:
代码语言:javascript复制function tick() {
console.log('tick');
}
function timer() {
console.log('timer');
}
setTimeout(() => {
timer();
}, 0);
process.nextTick(() => {
tick();
});
// log: tick timer
上一步我们讲过,在当前调用栈清空之后会立即清空所有 nextTick ,之后才会进入所谓的事件循环 EventLoop 中。
也就是此时我们可以用简单的这张图来描述
上述代码的调用过程其实非常简单,当代码依次执行时遇到 process.nextTick 和 timer 时会分别将他们推入对应的 Queue 中。
当栈中的代码执行完毕时,会先检查 nextTick 中存在对应的 tick 函数,那么会拿出 tick 进入栈中进行执行。
当 nextTick 中的任务清空时,会进入所谓的 timers 阶段同样清空所有产生的 timer ,也就是会执行 timer 函数。
细心的朋友可能会发现,图中并没有将 process.nextTick 放在事件循环 EventLoop 之中。这也是 NodeJs 中明确表示的:
所谓 process.nextTick 虽然是异步 API 的一部分,但从技术上讲它并不是事件循环的一部分。
我们会在稍微的微任务中详细讲述 process.nextTick 的执行顺序,虽然它并不是 EventLoop 中的一部分。
但是我们完全可以将它理解成为 micro 。这两者在执行过程中是完全等价的,我们可以简单将 process.nextTick 理解成为拥有最高优先级的 micro (微任务)。
setTimerout & setImmediate 谁快?
相信上边的代码对于大家来说没有什么难度,紧接着我们来看这样一个代码:
代码语言:javascript复制function timer() {
console.log('timer');
}
function immediate() {
console.log('immediate');
}
setTimeout(() => {
timer();
}, 0);
setImmediate(() => {
immediate();
});
在公布执行结果之前,我们先来尝试分析这段代码。首先,我们说过当脚本执行完毕时(我们可以理解为同步代码执行完毕时),这段代码会在 timer 以及 check 阶段的队列中分别推入对应的 timer 函数和 immediate 函数。
按照我们的理解,当同步脚本执行完毕后:
- 首先会检查是否存在 process.nextTick ,显示代码中是不存在任何 nextTick 相关调用。所以会跳过它。
- 其次会进入所谓的 EventLoop 也就是 timer 阶段,因为我们代码中存在定时器函数 setTimerout(timer,0)。
所以到 Loop 到 timer 阶段时,因为定时器满足时间它应该被推入对应的 timers 队列中。当 EventLoop 执行到 timer 阶段时,会拿出这个 timer 的 callback 执行它。
所以不难想象,控制台会执行这个函数打印 timer 。
- 接下来会依次进入 pending callbacks ,显示上一次 EventLoop 中并不存在任何达到上限的操作。所以它是空的。
- 依次进入 idle prepare 阶段。
- 注意,此时我们会进入 poll 轮询阶段,此时 poll 阶段并不存在任何 IO 相关回调,返回在轮询阶段他会检测到我们代码中存在 setImmediate ,并且 setImmediate 的 callback 也已经被推入到了 check 阶段。
- 所以,在 poll 并不会产生所谓的阻塞效果。会进入 check 阶段,调用代码中 setImmediate 产生的回调函数 immediate ,所以控制台会输出 immediate 。
- 其次,check 阶段清完成 immediate 后,会进入 Loop 中最后的 close callbacks 中。
显示,根据我们的分析一切都显得那么美好,控制台应该先打印 timer ,其次打印 immediate 。
我们来看看是否正如我们期待的这样呢?
正如我们期待的那样对吧,可是如果你多次运行这段代码你就会发现有所不同。(甚至有可能你的运行结果现在就和我不同了)
当我在此运行这段相同的代码时,奇怪的事情发生了。
一段相同的代码造成的执行结果是完全不同的,这次竟然先执行了所谓的 immediate 之后才会输出 timer 。
任何看似没有规律的结果背后其实都隐藏着相通的逻辑。
首先请你相信我。按照我们之前分析的 EventLoop 执行结果是不存在任何问题的。
immediate 先执行
之所以会造成这样的输出结果,本质上还是 setTimeout 在捣鬼。
在 NodeJS 中所谓 setTimeout(cb,0) 实际上存在最小执行时间 1 ms,它是会被当作 setTimeout(cb,1) 来执行,具体你可以在➡️这里看到。
When
delay
is larger than2147483647
or less than1
, thedelay
will be set to1
. Non-integer delays are truncated to an integer.
也许有的同学会记得 setTimeout 最小间隔是 4ms ,当然 4ms 也是 setTimeout 的最小间隔。不过这是它在浏览器下最小间隔时间而非是在 Node 下。
相信说到这里,部分同学已经反应过来为什么执行结果会是随机的 timer 和 immedate 随机出现。
恰恰是因为 setTimeout 存在 1ms 的最小间接,如果我们的电脑性能足够好的话。
那么在上述的同步代码执行完毕,以及进入 EventLoop 中这一切发生在 1ms 之内,显然 timers 阶段由于代码中的 setTimeout 并没有达到对应的时间,换句话说它所对应的 callback 并没有被推入当前 timer 中。
自然,名为 timer 的函数也并不会被执行。会依次进入接下里的阶段。Loop 会依次向下进行检查。
当执行到 poll 阶段时,即使定时器对应的 timer 函数已经被推入 timers 中了。由于 poll 阶段检查到存在 setImmediate 所以会继续进入 check 阶段并不会掉头重新进入 timers 中。
所以在主调用栈中调用这两个 Api 时会根据 Timeout 以及代码执行时间,造成先输出 immediate ,之后在执行 timers。
setTimeout 先执行
此时在回头来分析 setTimeout 先执行的场景下就会显得非常简单。
假使你的电脑性能比较差劲,当栈中代码从执行到 setTimeout 定时器时到 EventLoop 进入对应的 timers 阶段超过 1ms 。
那么此时,毫无疑问因为在执行到 timers 阶段时由于定时器的时间已经满足所以它会被推入对应的 timers 中。
固然,执行顺序就会截然相反先输出 timer 其次才会输出 immediate 。
如果不相信的同学可以尝试执行下面的代码100次看看效果:
代码语言:javascript复制function timer() {
console.log('timer');
}
function immediate() {
console.log('immediate');
}
process.nextTick(() => {
for (let i = 0; i ; i < 3000) {
// do nothing
}
})
setTimeout(() => {
timer();
}, 0);
setImmediate(() => {
immediate();
});
一定是会先输出 timer 其次在输出 immediate 。
如何保证 setImmediate 比 setTimeout 快
其实在了解了 Node 中的 EventLoop 之后,关于如何保证 setImmediate 比 setTimeout 快对于我们来说是非常简单的。
之所以拿出来这个问题来说说,其实是笔者自己曾在某些面试过程中被问到过。
同学们可以仔细回忆一下最开始的 EventLoop 那张图,如果我们希望保证 setImmediate 快于 setTimeout 先执行,相当于保证在 EventLoop 中的 timers 阶段之后调用他们两个。
那么无论如何 setImmediate 都会比 setTimeout 快,比如:
代码语言:javascript复制const fs = require('fs');
const path = require('path');
// 在IO结束的callback执行 也就是 poll 阶段会执行该 fs.readFile callback
// IO回调中存在 setImmediate 那么EventLoop的下一个阶段一定会进入check阶段
// 进而一定会优先执行 setImmediate 的回调
fs.readFile(path.resolve(__dirname, 'package.json'), (err) => {
if (err) {
console.log(err, 'err');
}
setTimeout(() => {
console.log('timer');
});
setImmediate(() => {
console.log('immediate');
});
});
Micro 微任务
当然我们上述中描述的 EventLoop 中的 6个步骤都是相对于宏任务(Macro)的讲述的。
在 EventLoop 中必不可少的两个概念:Macro(宏任务)、Micro(微任务)。
接下来我们来看看在 NodeJs 的 EventLoop 中微任务是如何执行的:
代码语言:javascript复制setImmediate(() => {
console.log('immediate 开始')
Promise.resolve().then(console.log('immediate' 1));
Promise.resolve().then(console.log('immediate' 2));
console.log('immediate 结束');
});
setTimeout(() => {
console.log('timer 开始');
Promise.resolve().then(console.log('timer' 1));
Promise.resolve().then(console.log('timer' 2));
console.log('timer 结束');
}, 0);
上述的代码关于 Immediate 和 Timeout 究竟我们在之前讲过它们的原因,它并不是我们在这里讨论的重点。
重点是你可以清楚的看到,无论是 immediate 还是 timer 先执行,都会伴随着本次队列中产生的 Micro (微任务)一同执行完毕才会进入下一个 Macro 。
其实它的本质和浏览器中是类似的,虽然 NodeJs 下存在多个执行队列,但是每次执行逻辑是相同的:同样是执行完成一个宏任务后会立即清空当前队列中产生的所有微任务。
代码语言:javascript复制当然在 NodeJs < 10.0 下的版本,它是会清空一个队列之后才会清空当前队列下的所有 Micro。
setImmediate(() => {
console.log('immediate1 开始')
Promise.resolve().then(() => console.log('immediate' 1, '微任务执行'));
Promise.resolve().then(() => console.log('immediate' 2, '微任务执行'));
console.log('immediate1 结束');
});
setImmediate(() => {
console.log('immediate2 开始');
Promise.resolve().then(() => console.log('immediate' 3, '微任务执行'));
Promise.resolve().then(() => console.log('immediate' 4, '微任务执行'));
console.log('immediate2 结束');
});
/* log
immediate1 开始
immediate1 结束
immediate1 微任务执行
immediate2 微任务执行
immediate2 开始
immediate2 结束
immediate3 微任务执行
immediate4 微任务执行 */
在理解了浏览器中的 EventLoop 后对于 Node 中的 Micro 以及 Macro 执行过程我相信对于大家来说一定是非常简单的。
需要注意的是虽然 NodeJs 官方并不将 proess.nextTick 当作 micro 来对待,但是对于 proess.nextTick 你完全可以将它理解成为 micro 只不过它拥有当前队列中所有其他 micro 的最高优先级。
串联流程
我们稍微来串联一下 NodeJS 中的 EventLoop 事件循环的过程。
Micro
在主调用栈结束后,会优先处理 prcoess.nextTick 以及之前所有产生的微任务:
正如上图所示,我们可以简单的将 Process.nextTick 以及 micro 统一当作 micro 。
timers
之后会正式进入 EventLoop 事件队列,首当其冲的肯定是 timers 定时器 callback 处理阶段:
我们可以看到当进入 timers 阶段时,会检查 timers 中是否存在满足条件的定时器任务。当存在时,会依次取出对应的 timer (定时器产生的回调)推入 stack (JS执行栈)中进行执行。
每当执行完毕同时仍然会进行 Prcess.nextTick -> micro 的步骤从而清空下一个 timer 任务。
poll
此后,在清空队列中所有的 timer 后,Loop 进入 poll 阶段进行轮询,此阶段首先会检查是否存在对应 I/O 的callback 。
如果存在 I/O 相关 callback,那么推入对应 JS 调用栈进行执行,同样每次任务执行完毕会伴随清空随之产生的 Process.nextTick 以及 micro 。
当然,如果次阶段即使产生了 timer 也并不会在本次 Loop 中执行,因为此时 EventLoop 已经到达 poll 阶段了。
它会依次去拿出相关的 I/O 回调推入 stack 中进行清空。
需要额外注意的是在 poll 轮询阶段,会发生以下情况:
- 如果 轮询 队列 不是空的 ,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
- 如果 轮询 队列 是空的 ,还有两件事发生:
- 如果脚本被
setImmediate()
调度,则事件循环将结束 poll(轮询) 阶段,并继续 check(检查) 阶段以执行那些被调度的脚本。 - 如果脚本 未被
setImmediate()
调度,则事件循环将等待回调被添加到队列中,然后立即执行。
- 如果脚本被
注意图中我们是从 timer 阶段之后开始的 Loop 。
其实说到这里,对于文章开头的 EventLoop 的流程图中每一步代表的含义已经进行了相信的解释。我相信这对于大家来说并不是什么难题。
剩下额外的 pending callbacks 以及 idle,prepar 对于我们日常应用特别少,或者说我们根本无法通过 API 来控制这里的执行顺序,所以我们没有深入的去探讨他们。
Node & 浏览器
在分别了解了不同环境下的 EventLoop 执行机制后,我们会发现其实浏览器中和 Node 中的事件循环 EventLoop 本质上执行机制是完全相同的,都是执行完一个宏(macro)任务后清空本次队列中的微(micro)任务。
只不过唯一不同的就是 NodeJs 中针对于 EventLoop 实现一些自定义的额外队列,它是基于Libuv 中自己实现的事件机制。
当然还有必不可少的 process.nextTick ,虽然我们讲过严格意义上它并不属于 micro ,但是我们完全可以将它理解成为拥有最高优先级的 micro 。
结尾
在文章的结尾,感谢每一位可以看到这里的小伙伴。
希望文章中的内容可以帮助到更多的前端同学成长!