一文带你了解浏览器与Node环境中的Event Loop

我们都知道,Event Loop即事件循环,是指浏览器或Node的一种解决JavaScript单线程运行时不会阻塞的一种机制,即我们经常使用异步的原理。

而浏览器中的事件循环与Node.js事件循环机制各不相同。

1. 浏览器端Event Loop

1.1 我们为什么需要Event Loop

在介绍Event Loop原理前,我们必须得要搞清楚为什么需要Event Loop?

JavaScript是一门单线程语言,当一个函数执行时,它不会被抢占,只有在它运行完毕后才会去运行任何其他的代码,才能去修改这个函数操作的数据。然而当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互,例如点击或滚动;又或者是浏览新闻时图片加载过慢,网页不可能一直卡着直到图片完全显示出来。为了解决这些问题,我们将任务分为了两类:同步任务与异步任务。

在异步任务中,同一时间按照代码顺序等待执行。 通常我们在遇到异步代码时将其挂起并略过,等待同步代码执行完毕后按照特定顺序执行异步代码。接下来我们深入了解一下。

1.2 JavaScript的运行模型

如图所示,当遇到同步代码时会立即执行;而遇到异步代码时将其加入到工作线程中,等异步代码所需时间到达后将其加入到任务队列当中。当执行栈为空时,被处理的消息被移除队列,并作为输入参数来调用与之关联的函数。函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息。

说了这么多,还不如来一个实例更容易理解:

const s = new Date().getSeconds();

setTimeout(function() {
  // 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
  console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500);

while(true) {
  if(new Date().getSeconds() - s >= 2) {
    console.log("looped for 2 seconds");
    break;
  }
}

// 输出结果
// looped for 2 seconds
// Ran after 2 seconds
复制代码

上述的代码执行流程如下:

  • 先将异步代码挂起,放在工作线程中;
  • 然后在运行同步代码while语句,等待间隔2秒;
  • 在500ms时,将异步任务加入任务队列中;到2秒时执行console.log("looped for 2 seconds")
  • 此时执行栈为空,任务队列推送任务setTimeout给执行栈,开始执行。

相信通过上述的代码,大家对Event Loop有了基本的了解。同时我们从代码中也可以发现,函数setTimeout中的时间值参数它代表的是消息被实际加入队列的最小延迟时间,而不是确切的等待时间。 如果任务队列中没有其他消息且栈为空,在该延迟时间过后消息会被立马处理;但如果有其他消息,setTimeout消息必须等待其他消息处理完。

1.3 宏任务与微任务

在ECMA标准升级后,将异步任务分为了微任务和宏任务。

宏任务:是JS中原始的异步任务,包括setTimeoutsetIntervalAJAX等,在代码执行环境中按照同步代码的顺序,逐个进入工作线程挂起,再按照异步任务到达的时间节点,逐个进入异步任务队列,最终按照队列中的顺序进入函数执行栈执行。

微任务:每一个宏任务执行前,程序先检测是否有当次事件循环未执行的微任务,优先清空本次的微任务后,在执行下一个宏任务。每个宏任务内部可注册当次任务的微任务队列,在下一个宏任务执行前运行,微任务也是按照进入队列的顺序执行。 包括PromiseMutationObserve等。

让我们先来看看两者的执行顺序:

执行栈的执行完同步任务后,判断执行栈是否为空。若执行栈为空,则去检查微任务队列是否为空,如果为空则去执行宏任务,否则一次性执行完所有的微任务。

宏任务执行完毕后,检查是否存在微任务队列是否为空。如果为空则继续执行下一个宏任务;否则去执行完所有的微任务, 然后再去执行宏任务, 如此循环。

举个例子:

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

在讲解之前,我们必须得要理解async/await在底层是转换成promisethen的回调函数。当我们使用await,解释器创建一个promise对象,然后把剩下的async函数操作放到then回调函数中。我们需要知道,在同一个上下文当中,总的执行顺序为同步代码 ——> 微任务 ——> 宏任务。

我相信,根据上面的讲述大家应该都能正确理解,这里就不过多讲解了。

2. Node.js环境Event Loop

2.1 执行流程

Node.jsEvent Loop分为六个阶段,它们按照顺序反复执行,在每个阶段后面都会运行微任务队列

  • timers:执行setTimeout()setInterval()中到期的callback。
  • I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
  • idle, prepare:队列的移动,仅内部使用
  • poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  • check:执行setImmediate的callback
  • close callbacks:执行close事件的callback,例如socket.on("close",func)

2.2 setTimeout/setImmediate

在运行过程中,如果timers阶段执行时创建了setImmediate,则会在此轮循环的check阶段中执行;如果timers阶段创建了setTimeout,此时由于timers已取出完毕,则会进入到下一轮循环。check阶段创建timers任务同理。

来个代码演示一下:

const fs = require('fs');
// 此时处在 I/O 周期
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
复制代码

运行结果一定为setImmediate > setTimeout, 因为在I/O阶段读取文件后进行到了poll阶段,然后到check阶段,此时会立刻执行setImmediate,等到进入timers阶段采取执行setTimeout

2.3 Process.nextTick()

process.nextTick()callback添加到next tick队列。

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

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


// 输出结果:4 3 1 2或者4 3 2 1
复制代码

从输出结果可以看到,微任务比宏任务先运行,而在Nodeprocess.nextTickPromise更为优先,所以输出结果4 —> 3;但在Node中没有绝对意义上的0ms,所以setTimeoutsetImmediate顺序不固定。

2.4 浏览器与Node执行顺序比较

setTimeout(()=>{
    console.log('timeout1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timeout2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
复制代码

输出结果如下:

在浏览器中:timeout1 —> promise1 —> timeout2 —> promise2

在Node环境中: timeout1 —> timeout2 —> promise1 —> promise2

下面来解释一下Node环境中的逻辑:

开始时进入timers阶段,执行timeout1的回调,打印出timeout1后将promise。then()放入微任务队列中,相同步骤执行 timeout2。在timers阶段结束后进入下一个阶段前,执行微任务队列中的所有任务,依次打印出promise1promise2.

浏览器的执行顺序就不过多讲述了。

3、总结

总体而言,浏览器端与Node.js的执行顺序大有不同。最大的差异在于浏览器端按照同步代码 —>微任务 —>宏任务的顺序执行;而Node.js环境中按照六个阶段顺序执行,且在每个阶段结束后都会执行微任务队列里的所有任务。

本文就写到这里,如有错误,敬请指正!

猜你喜欢

转载自juejin.im/post/7127582836541882376