📒【异步】1. 事件循环 & ES6 任务队列

2020-07-27  本文已影响0人  BubbleM

事件循环与任务队列是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)队列

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就引入了这么一个东西!

任务队列

在ES6中,有一个新的概念建立在事件循环队列之上,叫作任务队列。这个概念给大家带来的最大影响可能是Promise的异步特性。
任务队列,是挂在事件循环队列的每个tick之后的一个队列。在事件循环的每个tick中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目(一个任务)。

JavaScript 代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行:

不同源的任务会进入到不同的任务队列.png

宏任务 macro-task(task):

setTimeout 是宿主环境提供的API,其作为一个任务分发器,这个函数会立即执行,而它所要分发但任务,也就是它的第一个参数,才是延迟执行。

setTimeout 函数的返回值是一个整数,返回的是一个 ID号,从1开始。clearTimeout(timeid) 会清除这个定时器,但不会改版id值。

当我们在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,但是该任务就得等到下一轮事件循环执行了。

微任务 micro-task(job):

nextTick 队列会比 Promise 先执行。nextTick中的可执行任务执行完毕之后,才会开始执行Promise队列中的任务。

Node.js VS 浏览器环境中的事件循环机制

  1. 浏览器中有事件循环,Node.js 中也有。事件循环是Node处理非阻塞I/O操作的机制,Node.js 中事件循环的实现是依靠的libuv引擎。由于 Node.js 11 之后,事件循环的一些原理发生了变化,因此 Node11事件循环已与浏览器事件循环机制趋同
  2. chrome浏览器中新标准中的事件循环机制与 Node.js 类似,都有宏任务和微任务之分。但是有些API只有 Node.js 中有,而浏览器中没有,比如 process.nextTicksetImmediate
  3. 浏览器中的微任务是在每个相应的宏任务中执行的。而 Node.js 中的微任务是在不同阶段之间执行的。
  4. 浏览器的 Event-Loop 由各个浏览器自己实现;而 Node 的 Event-Loop 由 libuv 来实现。
  5. 在浏览器中,只有一个微任务队列需要接受处理;在Node中,有两类微任务队列:next-tick队列和其他队列。 其中这个 next-tick 队列,专门用来收敛 process.nextTick 派发的异步任务。在清空队列时,优先清空 next-tick 队列中的任务,随后才会清空其它微任务。
  6. 在浏览器中,我们每次出队并执行一个宏任务;而在 Node 中,我们每次会尝试清空当前阶段对应宏任务队列里的所有任务(除非达到了系统限制);

Node.js 技术架构

Node整体上由这三部分组成:

  1. 应用层:这一层就是大家最熟悉的 Node.js 代码,包括 Node 应用以及一些标准库。
  2. 桥接层:Node 底层是用 C++ 来实现的。桥接层负责封装底层依赖的 C++ 模块的能力,将其简化为 API 向应用层提供服务。
  3. 底层依赖:这里就是最最底层的 C++ 库了,支撑 Node 运行的最基本能力在此汇聚。其中需要特别引起大家注意的就是 V8 和 libuv:

libuv 中 Event-Loop 实现

libuv 主导循环机制共有六个循环阶段:

libuv中Event-Loop.png

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 派发的任务,都被修改为:一旦执行完当前阶段的一个任务,就立刻执行微任务队列。
上述代码输出结果:

测试一下

例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 

例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注册为微任务。

其他

浏览器&Node中的事件循环
JS事件机制可视化
理解Promise之任务队列

上一篇 下一篇

猜你喜欢

热点阅读