之前在面试中遇到一个关于事件循环和异步同步的问题,当时了解不够深入,现在拾起来学习理解以加深印象。

一,概念

JavaScript引擎:JavaScript引擎是一个负责代码执行,分配内存,垃圾回收,编译代码等的虚拟机(进程)。一个JavaScript引擎会常驻于内存中,等待宿主(浏览器)把JavaScript代码或函数传递给它执行。
宏任务(macrotask):由宿主环境(浏览器)发起的任务,比如setTimeout,setInterval,宏任务包含生成dom对象、解析HTML、执行主线程js代码、更改当前URL还有其他的一些事件如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,macrotask代表一些离散的独立的工作。当执行完一个task后,浏览器可以继续其他的工作如页面重渲染和垃圾回收。
微任务(microtask):由JavaScript引擎自发的任务,他是完成一些更新应用程序状态的较小任务,比如promise,更新dom操作等

二,同步和异步

JavaScript是单线程运行的,同步任务会在主线程顺序执行,但是有时会用到引擎之外的功能,这时候就需要和外部交互形成异步任务。
同步任务是发出一个调用时,在没有得到执行结果之前,该调用就一直不返回。一直等待调值返回。换句话说,就是调用者主动等待调用结果的过程。
异步任务是发出调用后,不等待返回结果,被调用者会通过状态、通知来通知调用者,或通过回调函数处理这个调用。

2.1 process.nextTick和promise.then

process.nextTick() 在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前,而promise.then会被放到MicroTaskQuene中等待执行,所以总是process.nextTick比promise.then先输出。

2.2 setTimeout和setImmediate:
  • setTimeout和setImmediate,在事件循环event loop执行的不同阶段执行,计时器的执行顺序将根据调用它们的上下文而有所不同。如果两者都是从主模块中调用的,则输出顺序将受到进程性能的限制(这可能会受到计算机上运行的其他应用程序的影响,谁先抢到资源谁就先执行)。
    如:

    setTimeout(() => console.log(1));
    setImmediate(() => console.log(2));

    多执行几次的结果:
    image.png

  • 如果在一个I / O周期内执行这两个计时器,则始终首先执行立即回调setImmediate,所以setImmede回调总是先于setTimeout执行。

引用NodeJs文档中的例子:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

输出结果总是如下:
image.png

三,事件循环

引用NodeJs中事件循环的图和解释:
image.png
事件循环的每个阶段都有一个要执行的回调FIFO队列。虽然每个阶段都有其自己的特殊方式,但是通常,当事件循环进入给定阶段时,它将执行该阶段特定的任何操作,然后在该阶段的队列中执行回调,直到队列为空或达到回调最大数量。当队列为空或达到回调限制时,事件循环将移至下一个阶段,依此类推。

由于这些操作中的任何一个都可能调度更多操作,并且在轮询阶段处理的新事件由内核排队,因此可以在处理轮询事件时将轮询事件排队。结果,长时间运行的回调可使轮询阶段运行的时间比计时器的阈值长得多。

**注意:**Windows和Unix / Linux实现之间存在细微差异,但这对于本演示并不重要。最重要的部分在这里。实际上有七个或八个步骤,但是我们关心的那些(Node.js实际使用的那些)是上面的那些。

3.1事件循环的阶段概述
  • timers(计时器):此阶段执行由setTimeout()setInterval()设置的回调。
  • pending callbacks(待处理的回调):执行推迟到下一个循环迭代的I/O回调。
  • 空闲,准备(idle, prepare ):仅在内部使用。
  • 轮询(poll):取出新完成的I/O事件;执行与I/O相关的回调(除了关闭回调close callbacks,计时器timers调度的回调和setImmediate之外,几乎所有这些回调) 适当时,node将在此处阻塞。
  • 检查(check):在这里执行setImmediate()回调。
  • 关闭回调(close callbacks):一些关闭回调,例如socket.on('close', ...)

在事件循环的每次运行之间,Node.js会检查它是否正在等待任何异步I / O或计时器,如果没有,则将其干净地关闭。
引用阮一峰大佬文章中的图更清晰明了:
image.png

3.2.详细介绍各个阶段

  • timers
    计时器可以指定提供的回调的阈值,计时器回调将在经过指定的时间后尽早运行。但是,操作系统调度或其他回调的运行可能会延迟它们。

注意:从技术上讲,[轮询阶段]控制计时器的执行时间。

例如,假设您计划在100毫秒阈值后执行超时,然后脚本开始异步读取耗时95毫秒的文件:

const fs = require('fs');

function someAsyncOperation(callback) {
  // 假设读取文件需要95ms后才能完成
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// 执行 someAsyncOperation需要95ms才能完成
someAsyncOperation(() => {
  const startCallback = Date.now();

  // 假设执行一些其他的代码需要10ms...
  while (Date.now() - startCallback < 10) {

  }
});

当事件循环进入到轮询阶段,它还是一个空队列(fs.readFile()还没哟执行完),因此需要等待95ms异步读取文件,并需要10毫秒将完成的回调添加到轮询队列中并执行,当回调完成,没有跟多的回调在队列中,因此事件循环将看到已达到最快计时器的阈值,然后返回到计时器阶段以执行计时器的回调。从上面例子可以看出,总共的延迟,也就是从定时器被设置到回调被执行需要105ms。

  • pending callbacks

这个阶段执行的回调是一些系统操作,比如TCP类型错误,如果一个TCP socket 当他尝试连接后接收到了一个ECONNREFUSED,一些* nix系统希望等待报告错误。在 pending callbacks阶段将排队执行。

  • poll

轮询阶段有两个主要的功能:

    1.计算轮询和阻塞I/O的时间
    2.处理轮询队列中的事件

当事件循环进入轮询阶段,此时没有被计划好的定时器,以下两种情况中的一种将会发生:

 1.*如果轮询队列不为空*,则事件循环将遍历其回调队列,使其同步执行,直到队列用尽或达到与系统相关的硬限制为止。 
 2.*如果轮询队列为空,*,则会发生以下两种情况之一:
    如果脚本已通过setimmediate()进行了调度,则事件循环将结束轮询阶段,并继续执行check阶段以执行那些调度的脚本。
    如果setimmediate()尚未安排脚本,则事件循环将等待回调添加到队列中,然后立即执行它们。

一旦轮询队列为空,事件循环将检查达到其时间阈值的计时器。 如果一个或多个计时器准备就绪,则事件循环将返回到timers阶段以执行这些计时器的回调。

  • check

此阶段允许人员在轮询阶段完成后立即执行回调。如果轮询阶段变得空闲,并且脚本已与队列在一起setImmediate(),则事件循环可能会继续进入检查阶段,而不是等待。

setImmediate()实际上是一个特殊的计时器,它在事件循环的单独阶段运行。它使用libuv API,该API计划在轮询阶段完成后执行回调。

通常,在执行代码时,事件循环最终将到达轮询阶段,在该阶段它将等待传入的连接,请求等。但是,如果已安排了回调setImmediate()并且轮询阶段变为空闲,则它将结束并继续检查阶段,而不是等待轮询事件。

  • close callback

如果socket或handle突然关闭(例如socket.destroy()),'close'则将在此阶段发出事件。否则将通过发出process.nextTick()

3.3实例专区
1.先来一个简单的例子分析一下执行过程:

 Promise.resolve()
      .then(() => console.log(1));
    process.nextTick(() => console.log(2));
    (() => console.log(3))();

    setTimeout(function() {
      console.log(4)
      Promise.resolve()
        .then(() => console.log(5))
      process.nextTick(() => console.log(6))
    }, 0);

正确的输出顺序:321465

解析如下:

1.主程序执行Promise.resolve().then(() => console.log(1));时,发现它是微任务,把它先放入microTasks队列。
2.process.nextTick(() => console.log(2));放在nextTick队列中等同步任务执行完后或者说是事件循环进行之前,马上执行。
3. 主程序执行到(() => console.log(3))();发现它是一个立即执行函数(同步任务),*首先输出3*。
4. 接下来setTimout,主程序会将其放入MacroTasks队列中。

JavaScript执行完以上步骤后如下图所示:
image.png

5.nextTick在主程序栈中的任务执行完后立即追加执行,所以接着输出2
6.微任务总是先于宏任务执行,所以先执行Promise.then中的回调,输出1
7.接着执行宏任务内部的回调。
    7.1.顺序执行输出4
    7.2.接着将promise.then放入microTasks等待执行
    7.3.process.nextTick在本次同步任务结束追加执行,输出6
    7.4.最后执行微任务中的promise.then,输出5

2.再来一例,这是阮一峰大佬文章评论区的一个例子,我也是琢磨了好久,现在拿出来分析一下,不对的地方请指出:

setImmediate(function() {//s1
  console.log(1);
  process.nextTick(function() {//p1
    console.log(2);
  });
})
process.nextTick(function() {//p2
  console.log(3);
  setImmediate(function() {//s2
    console.log(4);
  })
})

我们来根据主程序执行的顺序分析,按顺序第一个setImmediate回调命名为s1,第二个命名为s2,第一个setImmediate中的process.nextTick回调命名为p1,第二个process.nextTick命名为p2:

  1.首先s1会被第一轮事件循环放在check队列中待执行
  2.因为主程序当前调用栈为空,所以接着执行p2输出3,此时还有入队的setImmediate,所以在同一轮事件循环的check阶段放入s2
  3.根据队列先进先出的执行顺序,所以先输出s1中的1,再输出s2中的4
  4.只有队列中的回调执行完了也就是清空了这个队列才会进入下一个阶段,此时事件循环check阶段的回调执行完,最后再这个队列结束后追加p1,输出2;

输出结果:image.png

参考文章:

https://nodejs.org/en/docs/gu...
https://juejin.im/post/5a62e1...
https://time.geekbang.org/col...
https://jakearchibald.com/201...
http://www.ruanyifeng.com/blo...

Delia
75 声望3 粉丝

路漫漫其修远兮,吾将上下而求索。