事件循环与任务队列是JS中比较重要的两个概念。这两个概念在ES5和ES6两个标准中有不同的实现。尤其在ES6标准中,清楚的区分宏观任务队列和微观任务队列才能解释Promise一些看似奇怪的表现。
JS引擎本身所做的只不过是在需要的时候,在给定的任意时刻执行程序中的单个代码块。JS引擎并不是独立运行的,它运行在宿主环境:Web浏览器、Node.js、机器人等各种设备中。所有这些环境都有一个共同点(thread,线程),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用JavaScript引擎,这种机制被称为事件循环。
所以说,JavaScript引擎本身并没有时间的概念,只是一个按需执行JavaScript任意代码片段的环境。“事件”(JavaScript代码执行)调度总是由包含它的环境进行。
直到ES6,JavaScript才真正内建有直接的异步概念,即ES6从本质上改变了在哪里管理事件循环,ES6精确指定了事件循环的工作细节,这意味着在技术上将其纳入JavaScript引擎的势力范围,而不是只由宿主环境来管理。
ES6中Promise的引入,这项技术要求对事件循环队列的调度运行能够直接进行精细控制。
事件循环
一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO、定时器会向事件队列中加入事件。
var eventLoop = []; //用做队列的数组,先进先出
var event;
while(true){ //永远执行
if(eventLoop.length > 0){ //一次tick
event = eventLoop.shift(); //拿到队列中的下一个事件
try {
event();
} catch (err) {
reportError(err);
}
}
}
在浏览器的事件循环中,首先大家要认清楚 3 个角色:函数调用栈、宏任务(macro-task)队列 和 微任务(micro-task)队列。
- 函数调用栈:当引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入调用栈。后面每遇到一个函数调用,就会往栈中压入一个新的函数上下文。JS引擎会执行栈顶的函数,执行完毕后,弹出对应的上下文。
- 任务队列:对于一些异步的任务,不需要立刻被执行,属于待执行的任务,会按照一定规则排队,等待被推入调用栈的时刻到来。这个队列就是任务队列。
Q:为什么需要 Event Loop?
javascript的一个特点就是单线程,但是很多时候我们仍然需要在不同的时间去执行不同的任务,例如给元素添加点击事件,设置一个定时器,或者发起Ajax请求。因此需要一个异步机制来达到这样的目的,事件循环机制也因此而来。
var a = 1;
var b = 2;
function foo(){
a++;
b = b * a;
a = b + 3;
}
function bar(){
b--;
a = 8 + b;
b = a * 2;
}
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);
由于JavaScript的单线程特性,foo()以及bar()中的代码具有原子性。也就是说,一旦foo()开始运行,它的所有代码都会在bar()中的任意代码运行之间完成,或者相反。这称为完整运行特性。
由于foo()不会被bar()中断,bar()也不会被foo()中断,所以这个程序只有两个可能的输出,取决于这两个函数哪个先运行。如果存在多线程,且foo()和bar()中的语句可以交替运行的话(并行线程,共享内存),可能输出的情况会增加不少。
同一段代码有两个可能输出意味着存在不确定性!但是,这种不确定性是在函数(事件)顺序级别上,而不是多线程情况下的语句顺序级别。因此,这一确定性要高于多线程情况。
在JavaScript的特性中,这种函数顺序的不确定性就是通常所说的竞态条件,foo()和bar()相互竞争,看谁先运行。具体来说,因为无法可靠预测a和b的最终结果,所以才是竞态条件。
🤔:如果JavaScript中某个函数由于某种原因不具有完整运行特性,那么可能的结果就会多得多,对吧?实际上,ES6就引入了这么一个东西!
- 异步:是关于现在和将来的时间间隙;(事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改)
JavaScript程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。 - 并行:是关于能够同时发生的事情;(并行计算的最常见工具就是进程和线程)并行线程
- 并发:两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运行是否并行执行(在独立的处理器或处理器核心上同时运行)。并发可以看作是“进程”级(或任务级)的并行。
并发指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时在运行(尽管在任意时刻只处理一个事件)。
单线程事件循环是并发的一种形式。
任务队列
在ES6中,有一个新的概念建立在事件循环队列之上,叫作任务队列。这个概念给大家带来的最大影响可能是Promise的异步特性。
任务队列,是挂在事件循环队列的每个tick之后的一个队列。在事件循环的每个tick中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目(一个任务)。
- 事件循环队列:玩过了一个游戏之后,需要重新到队尾排队才能再玩一次;
- 任务队列:玩过了游戏之后,插队接着继续玩;
JavaScript 代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行:
- 一个线程中,事件循环是唯一的,但任务队列可以拥有多个;
- 任务队列又分为 macro-task(宏任务)与 micro-task(微任务),在最新标准中,它们被分别称为 task 与 jobs ;
- 来自不同源的任务会进入到不同的任务队列,其中 setTimeout 和 setInterval 是同源的,常见的任务队列如下:
- 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去;
- 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成;
宏任务 macro-task(task):
- script(整体代码)
- setTimeout
- setInterval
- setImmediate(仅存在于Node.js环境中)
- I/O操作
- request / AnimationFrame
- UI render
setTimeout 是宿主环境提供的API,其作为一个任务分发器,这个函数会立即执行,而它所要分发但任务,也就是它的第一个参数,才是延迟执行。
setTimeout 函数的返回值是一个整数,返回的是一个 ID号,从1开始。clearTimeout(timeid) 会清除这个定时器,但不会改版id值。
当我们在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,但是该任务就得等到下一轮事件循环执行了。
微任务 micro-task(job):
- process.nextTick(仅存在于Node11之后 是微任务的一种)
- MutationObserver(html5新特性)
- Object.observe(已废弃)
- Promise
- Async/Await(实际上就是promise)
nextTick 队列会比 Promise 先执行。nextTick中的可执行任务执行完毕之后,才会开始执行Promise队列中的任务。
Node.js VS 浏览器环境中的事件循环机制
- 浏览器中有事件循环,Node.js 中也有。事件循环是Node处理非阻塞I/O操作的机制,Node.js 中事件循环的实现是依靠的libuv引擎。由于 Node.js 11 之后,事件循环的一些原理发生了变化,因此 Node11事件循环已与浏览器事件循环机制趋同。
- chrome浏览器中新标准中的事件循环机制与 Node.js 类似,都有宏任务和微任务之分。但是有些API只有 Node.js 中有,而浏览器中没有,比如
process.nextTick
及setImmediate
。 - 浏览器中的微任务是在每个相应的宏任务中执行的。而 Node.js 中的微任务是在不同阶段之间执行的。
- 浏览器的 Event-Loop 由各个浏览器自己实现;而 Node 的 Event-Loop 由 libuv 来实现。
- 在浏览器中,只有一个微任务队列需要接受处理;在Node中,有两类微任务队列:next-tick队列和其他队列。 其中这个 next-tick 队列,专门用来收敛 process.nextTick 派发的异步任务。在清空队列时,优先清空 next-tick 队列中的任务,随后才会清空其它微任务。
- 在浏览器中,我们每次出队并执行一个宏任务;而在 Node 中,我们每次会尝试清空当前阶段对应宏任务队列里的所有任务(除非达到了系统限制);
Node.js 技术架构
Node整体上由这三部分组成:
- 应用层:这一层就是大家最熟悉的 Node.js 代码,包括 Node 应用以及一些标准库。
- 桥接层:Node 底层是用 C++ 来实现的。桥接层负责封装底层依赖的 C++ 模块的能力,将其简化为 API 向应用层提供服务。
- 底层依赖:这里就是最最底层的 C++ 库了,支撑 Node 运行的最基本能力在此汇聚。其中需要特别引起大家注意的就是 V8 和 libuv:
- V8 是 JS 的运行引擎,它负责把 JavaScript 代码转换成 C++,然后去跑这层 C++ 代码。
- libuv:它对跨平台的异步I/O能力进行封装,同时也是我们本节的主角:Node 中的事件循环就是由 libuv 来初始化的。
浏览器的 Event-Loop 由各个浏览器自己实现;而 Node 的 Event-Loop 由 libuv 来实现。
libuv 中 Event-Loop 实现
- timers阶段:执行 setTimeout 和 setInterval 中定义的回调;
- pending callbacks:直译过来是“被挂起的回调”,如果网络I/O或者文件I/O的过程中出现了错误,就会在这个阶段处理错误的回调(比较少见,可以略过);
- idle, prepare:仅系统内部使用。这个阶段我们开发者不需要操心。(可以略过);
- poll (轮询阶段):重点阶段,这个阶段会执行I/O回调,同时还会检查定时器是否到期;在 poll 阶段处理的回调中,如果既派发了 setImmediate、又派发了 setTimeout,那么这个顺序是板上钉钉的——一定是先执行 setImmediate,再执行 setTimeout。
- check(检查阶段):处理 setImmediate 中定义的回调;
- close callbacks:处理一些“关闭”的回调,比如
socket.on('close', ...)
就会在这个阶段被触发。
Node11前后的变化
setTimeout(() => {
console.log('timeout1');
}, 0);
setTimeout(() => {
console.log('timeout2');
Promise.resolve().then(function() {
console.log('promise1');
});
}, 0);
setTimeout(() => {
console.log('timeout3')
}, 0)
Node11开始,timers 阶段的setTimeout、setInterval等函数派发的任务、包括 setImmediate 派发的任务,都被修改为:一旦执行完当前阶段的一个任务,就立刻执行微任务队列。
上述代码输出结果:
- Node v9.3.0:
timeout1 timeout2 timeout3 promise1
在 timers 阶段,依次执行了所有的 setTimeout 回调、清空了队列。 - Node v12.4.1:
timeout1 timeout2 promise1 timeout3
- 浏览器:
timeout1 timeout2 promise1 timeout3
测试一下
例1:
console.log('script start');
async function async1(){
await async2();
console.log('async1 end');
}
async function async2(){
console.log('async2 end');
}
async1();
setTimeout(function(){
console.log('setTimeout')
}, 0);
new Promise(resolve => {
console.log('Promise');
resolve();
}).then(function(){
console.log('promise1');
}).then(function(){
console.log('promise2');
})
console.log('script end');
// script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout
- 首先,事件循环从宏任务队列开始,此时宏任务队列中只有一个script(整体代码)任务。所以执行代码,输出
script start
; - 执行 async1(),会调用 async2(),输出
async2 end
。此时会保留 async1 函数的上下文,将await后面的代码注册为一个微任务,然后跳出 async1 函数; - 遇到 setTimeout,产生一个宏任务;
- 遇到Promise实例,Promise构造函数中的第一个参数,是在new的时候执行,因此不会进入任何其他的队列,而是直接在当前任务直接执行了,输出
Promise
,后续的 .then 产生第二个微任务,会被分发到 micro-task 的Promise队列中; - 继续执行代码,输出
script end
; - 代码逻辑执行完成(当前宏任务执行完毕),开始执行当前宏任务产生的微任务队列,输出
async1 end
,继续执行下一个微任务输出promise1
,该微任务遇到 then,产生一个新的微任务; - 执行产生的微任务,输出
promise2
,当前微任务队列执行完毕; - 执行下一个宏任务,输出
setTimeout
;
例2:
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
return Promise.resolve().then(()=>{
console.log('async2 end1')
})
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// script start => async2 end => Promise => script end => async2 end1 => promise1 => promise2 => async1 end => setTimeout
此时执行完awit并不先把await后面的代码注册到微任务队列中去,而是执行完await之后,直接跳出async1函数,执行其他代码。然后遇到promise的时候,把promise.then注册为微任务。