JavaScript之事件循环浅析

JavaScript 是单线程非阻塞语言,这里单线程指的是执行程序的主线程是单线程的,而不是程序只有一个线程。 非阻塞是因为执行主程序虽然是单线程的,但是并不会被阻塞,这里得益于它的异步流程。而异步流程的是通过事件循环(Event Loop

目录
  1. 什么是事件循环
  2. 浏览器的事件循环
  3. Node.js 的事件循环(v10 版本)
  4. 总结

什么是事件循环#

JavaScript 是单线程非阻塞语言,这里单线程指的是执行程序的主线程是单线程的,而不是程序只有一个线程。

非阻塞是因为执行主程序虽然是单线程的,但是并不会被阻塞,这里得益于它的异步流程。而异步流程的是通过事件循环(Event Loop)机制来实现的。浏览器的事件循环和 Node.js 的事件循环实现过程是不一样的。

浏览器的事件循环#

在浏览器种有两种任务,分别为宏任务(marcoTask)和 微任务(microTask)。

宏任务(marcoTask):scriptsetTimeoutsetIntervalsetImmediateI/OUI renderingMessageChannelpostMessage

微任务(microTask):promise.thenObject.observeMutationObserverajax

浏览器事件循环是会先执行一个宏任务,接下来有异步代码的话就先执行微任务。

所以浏览器的一次Event loop顺序是这样的:

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码

代码示例 1:

async function async1() {
  console.log("async1 start"); // 2 第二个同步 执行
  await async2(); // => 3 执行await
  console.log("async1 end"); // 6 异步 第一个微任务 => 进入微任务队列等待 => 执行同步 5 后回来执行
}
async function async2() {
  console.log("async2"); // 3  第三个同步 执行
}
console.log("script start"); // 1 第一个同步 执行
setTimeout(function () {
  console.log("setTimeout"); // 8 异步 第一个宏任务 => 进入宏任务队列等待 => 执行完所有的微任务后执行
}, 0);
async1(); // => 2 执行函数
new Promise(function (resolve) {
  console.log("promise1"); // 4 第四个同步 执行
  resolve();
}).then(function () {
  console.log("promise2"); // 7 异步 第二个微任务 => 进入微任务队列等待 => 执行 第一个微任务后执行
});
console.log("script end"); // 5 第五个同步 执行

代码示例 2:

console.log("start"); // 1 第一个同步 执行
 
// 4 第一个宏任务 => 执行完所有的微任务后执行
setTimeout(() => {
  console.log("timer1"); // 4 宏任务的同步任务
  Promise.resolve().then(function () {
    console.log("promise1"); // 5 宏任务里面的微任务 (执行完所有同步后清空所有的微任务)
  });
}, 0);
 
// 第二个宏任务
setTimeout(() => {
  console.log("timer2"); // 6 宏任务的同步任务
  Promise.resolve().then(function () {
    console.log("promise2"); // 7 宏任务里面的微任务 (执行完所有同步后清空所有的微任务)
  });
}, 0);
 
Promise.resolve().then(function () {
  console.log("promise3"); // 3 第一个微任务
});
 
console.log("end"); // 2 第二个同步 执行

Node.js 的事件循环(v10 版本)#

image.webp

Node.js 的Event Loop并不分宏任务(macroTask)和微任务(microTask),它没有这个概念。

而是分为 6 个阶段,它们会按照顺序反复运行:

  1. timer 阶段。这个阶段执行setTimeoutsetInterval预定的回调。
  2. I/O callbacks 阶段。执行除了close事件的回调、timers(setTimeoutsetInterval等)设定的 回调、setImmediate设定的回调之外的回调。
  3. idle, prepare 阶段。仅 Node.js 内部使用。
  4. poll 阶段。取新的 I/O 事件, 适当的条件下 Node.js 将阻塞在这里。
  5. check 阶段。执行setImmediate设定的回调。
  6. close callback 阶段 。执行close事件的回调。比如socket.on('close', callback)的回调会在这个阶段执行。

重要阶段

  • timer 阶段

timers 阶段会执行setTimeoutsetInterval回调,并且是由 poll 阶段控制的。 同样,在 Node.js 中定时器指定的时间也不是准确时间,只能是尽快执行

  • poll 阶段

poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情

  1. 回到 timer 阶段执行回调

  2. 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制

  • 如果 poll 队列为空时,会有两件事发生

    • 如果有setImmediate回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调

    • 如果没有setImmediate回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

  • check 阶段

    • process.nextTick:这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 micro task 执行。

代码示例 1:

async function async1() {
  console.log("async1 start"); // 2 第二个同步 执行
  await async2();
  console.log("async1 end"); // 7 第二个 promise
}
async function async2() {
  console.log("async2"); // 3 第三个同步 执行
}
console.log("script start"); // 1 第一个同步 执行
setTimeout(function () {
  console.log("setTimeout"); // 8 第一个 setTimeout
}, 0);
async1();
new Promise(function (resolve) {
  console.log("promise1"); // 4 第四个同步 执行
  resolve();
}).then(function () {
  console.log("promise2"); // 6 第一个 promise
});
console.log("script end"); // 5 第五个同步 执行

代码示例 2:

console.log("start"); // 1 第一个同步 执行
 
// 4 第一个 setTimeout
setTimeout(() => {
  console.log("timer1"); // 4 setTimeout 里面的同步任务
  Promise.resolve().then(function () {
    console.log("promise1"); // 6 第二个 promise (在 setTimeout 里面)
  });
}, 0);
 
// 第二个 setTimeout
setTimeout(() => {
  console.log("timer2"); // 5 setTimeout 里面的同步任务
  Promise.resolve().then(function () {
    console.log("promise2"); // 7 第三个 promise (在 setTimeout 里面)
  });
}, 0);
 
Promise.resolve().then(function () {
  console.log("promise3"); // 3 第一个 promise
});
 
console.log("end"); // 2 第二个同步 执行

总结#

主要区别:

在执行 timers 任务setTimeoutsetInterval时,Node.js 会一次性执行完全部这类任务。而浏览器只会先执行一次宏任务(或者所有的同步任务),然后立即去执行全部的微任务,如此循环。

备注:在 Node.js v11 以后的版本中,事件循环机制会被调整,输出的结果会和浏览器的保持一致,以后就不用再纠结和浏览器的差异了。