我们常见的JavaScript运行时(runtime)有两个,一个是浏览器环境,一个是Node.js环境
JavaScript 事件循环机制分为浏览器和 Node 事件循环机制,两者的实现技术不一样。
浏览器 Event Loop 是 HTML 中定义的规范,Node Event Loop 是由 libuv 库实现
浏览器的事件循环机制
一、为什么JavaScript是单线程?
背景
JavaScript的单线程,与它的用途有关。
作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。
比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
二、任务队列
背景
为了实现主线程的不阻塞,Event Loop这样的方案应运而生
概念
由于上面的背景,所有任务可以分成两种,
- 同步任务(synchronous)
- 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。同步任务执行时会形成一个任务栈。
- 异步任务(asynchronous)。
- 异步任务指的是,不进入主线程,而由浏览器其他线程执行(比如ajax-->http 异步线程, onclick-->浏览器事件线程),执行完毕后,把回调函数放入"任务队列"(task queue)的任务。(主线程执行栈执行完毕后,会去任务队列查看是否有任务需要处理)
- 异步任务又分为宏任务(macro-task-->Task)和微任务(micro-task-->Job)
- 宏任务: 一个event loop有一个或者多个task队列,Task任务源非常宽泛,比如ajax的onload,click事件,基本上我们经常绑定的各种事件都是Task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeout、setInterval、setImmediate也是宏任务。总结来说宏任务有:++setTimeout++ ++setInterval++ ++setImmediate++ ++I/O++ ++UI rendering++
-
微任务: 微任务队列和宏任务队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个
event loop里只有一个microtask 队列。另外microtask执行时机和Macrotasks也有所差异。总结来说微任务有:++process.nextTick++ ++promises++ ++Object.observe++ ++MutationObserver++ - 执行时机: 在执行栈执行完毕时会立刻先处理所有微任务队列中的事件,清空微任务之后,再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
任务队列:可以理解为一个静态的队列存储结构,非线程,只做存储,里面存的是一堆异步成功后的回调函数,肯定是先成功的异步的回调函数在队列的前面,后成功的在后面。
注意:++是异步成功后,才把其回调函数扔进队列中++,而不是一开始就把所有异步的回调函数扔进队列。比如setTimeout 3秒后执行一个函数,那么这个函数是在3秒后才进队列的。
宏任务与微任务执行机制:
在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。
并且在当前执行栈为空的时候,主线程会查看微任务队列是否有事件存在。
- 如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;
- 如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈,执行完成后再次执行清空微任务队列...如此反复,进入循环
-
如果微任务的回调是自身(递归调用),则会一直执行微任务队列,导致阻塞。
- ++这是因为微任务队列总是在执行后返回到事件循环之前,并继续清空其他微任务++
- 上面的例子: 是每次微任务执行过后又在微任务队列添加微任务,那么事件循环会一直处理微任务,例子3
- 与上面相对,宏任务的回调是自身(递归调用),既不会阻塞也不会堆栈溢出
- 因为宏任务在单个循环周期中一次一个地推入堆栈。主线程执行完毕后,宏任务队列的回调被推入执行栈执行,执行时再次给宏任务队列添加任务,如此反复,所以执行栈最多只有一个任务,所以不会堆栈溢出
例子1:
setTimeout(function () {
console.log(1);
});
new Promise(function(resolve,reject){
console.log(2)
resolve(3)
}).then(function(val){
console.log(val);
})
// 2
// 3
// 1
例子2
setTimeout(function () {
console.log(1);
});
new Promise(function (resolve, reject) {
console.log(2)
resolve(3)
}).then(function (val) {
console.log(val);
})
console.log(4);
// 2
// 4
// 3
// 1
例子3
// 每次调用'foo'都会继续在微任务队列上添加另一个'foo'回调,因此事件循环无法继续处理其他事件(滚动,单击等),直到该队列完全清空为止。因此,它会阻止渲染。
function foo() {
return Promise.resolve().then(foo)
}
例子4
function foo() {
setTimeout(foo, 0);
};
- 先执行script的第一条同步代码,即new Promise中的console.log(2),then后面的不执行, 因为它属于微任务
- 然后执行第二条同步代码console.log(4)
- 执行完script同步代码后,执行异步代码的微任务,console.log(3),没有其他微任务了。
- 执行异步代码的宏任务,定时器,console.log(1)。
三、Event Loop(异步执行的运行机制)
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 异步任务执行有结果后,把相应的回调函数放入"任务队列"之中。
- ++一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列++",看看里面有哪些任务(微任务-->宏任务)。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步,这个过程形成事件循环机制。
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API(可能由其他浏览器其他线程辅助),它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
- heap(堆)是用户主动请求而划分出来的内存区域,比如你new Object(),就是将一个对象存入堆中,可以理解为heap存对象。
- stack(栈)是由于函数运行而临时占用的内存区域,函数都存放在栈里。
例子1:事件循环执行过程:
1 var a = 2;
2 setTimeout(fun A)
3 ajax(fun B)
4 console.log()
5 dom.onclick(func C)
- 主线程在运行这段代码时,碰到2 setTimeout(fun A),把这行代码交给 定时器触发线程 去执行
- 碰到3 ajax(fun B),把这行代码交给 http 异步线程 去执行
- 碰到5 dom.onclick(func C) ,把这行代码交给 浏览器事件线程 去执行
注意:这几个异步代码的回调函数fun A,fun B,fun C,各自的线程都会保存,等待未来加入任务队列,再等待主线程执行
所以这些线程主要干两件事:
- 执行主线程扔过来的异步代码,并执行代码
- 保存着回调函数,异步代码执行成功后,将回调函数推入到任务队列中
问题
所以导致一个现象:
对于setTimeout,setInterval的定时,不一定完全按照设想的时间的,因为主线程里的代码可能复杂到执行很久,所以会发生你定时3秒后执行,实际上是3.5秒后执行(主线程花费了0.5秒)
NodeJs的Event Loop
根据上图,Node.js的运行机制如下:
- V8引擎解析JavaScript脚本。
- 解析后的代码,调用Node API。
- libuv库负责NodeAPI的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎
- V8引擎再将结果返回给用户。
Node中的事件循环是由底层的libuv库负责执行的。libuv为Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力。
libuv产生背景:起初Node只可以在Linux平台上运行,
随着Node的发展,微软注意到了它的存在,并投入了一个团队实现Windows平台的兼容。++兼容Windows和*nix平台主要得益于Node在架构层面的改动,它在操作系统与Node上层模块系统之间构建了一层平台架构,即libuv++。
详解
Node中的事件循环是运行在单线程的环境下(JavaScript在Node环境中的主线程是单线程的,事件循环的线程也是单线程的,这两个不是一个线程)。Node作为一种运行时,它的事件循环是由底层的libuv库实现的
下图展现了事件循环的具体流程:
[图片上传失败...(image-2f059c-1582857637490)]
注意:每个框框里每一步都是事件循环机制的一个阶段。
每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段
官方解释:
- timers:本阶段执行已经安排的 setTimeout() 和 setInterval() 的回调函数。
- 与浏览器事件循环类似,也会出现计时器不准的情况
- I/O callbacks(pending callbacks):执行延迟到下一个循环迭代的 I/O 回调。大多数的回调方法在这个阶段执行,除了timers、close和setImmediate事件的回调函数。
- 此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 *nix 的系统希望等待报告错误。这将被排队以在 挂起的回调 阶段执行。
idle,prepare:仅系统内部使用。
poll(轮询):轮询检索新的 I/O 事件,执行与 I/O 相关的回调(几乎所有情况下,除了timers、close和setImmediate事件的回调函数),事件环可能会在这里阻塞。
- 计算应该阻塞和轮询 I/O 的时间
- 然后,处理 轮询 队列里的事件
- 当事件循环进入 轮询 阶段且 没有计时器时 ,将发生以下两种情况之一:
- 如果 轮询 队列 不是空的 ,事件循环将循环访问其回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬限制。
- 如果 轮询 队列 是空的 ,还有两件事发生:
- 有 setImmediate() 回调事件: 则++事件循环将结束 轮询 阶段++,并进入 check(检查) 阶段以执行这些setImmediate()计划脚本。(先于setTimeout)
- 没有 setImmediate() 回调事件:则事件循环将阻塞住进程,等待回调添加到队列(poll)中,然后立即执行。
- 一旦 轮询 队列为空,事件循环将检查 已到时的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回timers阶段以执行这些计时器的回调。
- check:处理setImmediate()事件的回调。
- setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调++在 轮询 阶段完成++后执行。
- 通常,在执行代码时,事件循环最终会命中轮询阶段,等待传入连接、请求等。但是,如果回调已计划为 setImmediate(),并且轮询阶段变为空闲状态,则它将结束并继续到检查阶段而不是等待轮询事件。
- close callbacks:处理一些准备关闭的回调函数,例如socket.on('close',...)等。
- 如果套接字或处理函数突然关闭(例如 socket.destroy()),则'close' 事件将在这个阶段发出。否则它将通过 process.nextTick() 发出。
个人理解:六个阶段中,三个阶段是高度定制化的
timers --> 定时器setTimeout(), setInterval()
check --> setImmediate()
close callbacks: 准备关闭的回调函数,例如socket.on('close',...)等
poll: 事件循环的主要阶段,不断执行轮询队列的回调函数,队列执行空后,检查check队列是否有回调,有的话进入check --> close callbacks --> timers,没有的话等待在这个阶段,等待新的回调加入
setImmediate() 对比 setTimeout()
setImmediate() 和 setTimeout() 很类似,但调用时机完全不同。
- setImmediate() :在当前 轮询 阶段完成后执行脚本
- setTimeout() :在毫秒的最小阈值经过后运行的脚本
执行计时器的顺序将根据调用它们的上下文而异。
如果二者都从主模块内调用,则计时将受进程性能的约束(这可能会受到计算机上运行的其它应用程序的影响)。
例如,如果运行的是不属于 I/O 周期(即主模块)的以下脚本,则执行两个计时器的顺序是非确定性的,因为它受进程性能的约束:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// 如果性能好,setTimeout(fn, 0)就能够直接执行(先打印timeout)
// 如果不够好,setTimeout(fn, 0)未能执行,就要等到poll阶段结束后 --> 执行完check阶段 --> 再返回到timers阶段执行
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是,如果你把这两个函数放入一个 I/O 循环内调用,setImmediate 总是被优先调用:
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// 先执行fs.readFile --> 进入到I/O callbacks(pending callbacks)阶段
// 进入到poll阶段 --> 执行I/O回调
// poll阶段完毕后 --> 进入check阶段执行setImmediate()
// 队列都执行完毕后 --> 返回到timers阶段,执行setTimeout()
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
setImmediate()比setTimeout()的优点:setImmediate() 在任何计时器(如果在 I/O 周期内)都将始终执行,而不依赖于存在多少个计时器
process.nextTick()
process.nextTick() 即使是异步API的一部分,但是在技术上不是事件循环的一部分
解释
相反,无论事件循环的当前阶段如何,都将在当前操作完成后处理 nextTickQueue。这里的一个操作被++视作为一个从C++ 底层处理开始过渡,并且处理需要执行的 JavaScript 代码++。
回顾上面的流程图,任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前得到解决。这可能会造成一些糟糕的情况, 因为它允许您通过进行递归 process.nextTick() 来“饿死”您的 I/O 调用,阻止事件循环到达 轮询 阶段。
process.nextTick()的好处是:允许在调用回调之前初始化所有变量、函数等,并且在事件循环之前调用可以阻塞事件循环,可以在事件循环之前对用户发出错误警告
官方建议开发人员在所有情况下使用setImmediate而不是process.nextTick(), 因为它更容易被推理(并且它导致代码与更广泛的环境,如浏览器 JS 所兼容。)
为什么要使用 process.nextTick()?(不太理解官方解释)
- 允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前重试请求。
- 有时在调用堆栈已解除但在事件循环继续之前,必须允许回调运行。
官方链接:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/
浏览器与nodeJs事件循环的区别
浏览器维护两个任务队列(微任务/宏任务)、 nodeJs维护六个任务队列(timers/pending callbacks/idle、prepare/poll/check/close callbacks)
浏览器为js引擎提供额外的线程处理异步任务、nodeJs通过底层得libuv库实现事件循环