浅谈 JavaScript Event Loop

3月 01, 2020 ~

前言

JavaScript固有单线程的特性,同一时间只能执行一个任务,并阻塞其他任务的执行。为了利用多核CPU的计算能力,HTML5提出的Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不可以操作DOM,所以并没有改变JS单线程的本质。

既然是单线程,那JS为什么可以执行异步操作,又是如何额外运行任务?本文简单介绍 JS 实现异步的原理—— Event Loop(事件循环)。

1. 追本溯源

业务中经常需要异步或延时处理任务,但异步的任务却并不是等条件满足后在子线程中立即执行,甚至延迟的时间也不一定准确。

1.1 何时执行

这里以setTimeout为例:

/* code-1 */
console.log("start");
setTimeout(()=> console.log('timeout'), 0);
console.log("end");

// 14:00:35.918: start
// 14:00:35.920: end
// 14:00:35.921: timeout
/* code-2 */
console.log("start");
setTimeout(()=> console.log('timeout'), 0);
longTask(2000);  // 为时2s的同步任务
console.log("end");

// 14:01:24.717: start
// 14:01:26.719: end
// 14:01:26.720: timeout

code-1中setTimeout设置了0ms延时,回调函数似乎是立即执行了。然而当code-2主线程中添加了为时2s的同步任务时,回调函数也延迟了2s的时间执行。可以看出,当主线程空闲时,才会执行异步的任务

1.2 排队执行

当添加了多个定时器:

console.log("start");
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(`timeout-${i + 1}`);
  }, 1000);
}
console.log("end");

// 14:02:17.693: start
// 14:02:17.696: end
// 14:02:18.699: timeout-1
// 14:02:18.700: timeout-2
// 14:02:18.700: timeout-3

三个异步任务似乎在主线程空闲后一起执行了。但如果异步任务执行的时长增加:

// ...
    console.log(`timeout-${i + 1}`);
    longTask(2000)
// ...

// 14:02:31.810: start
// 14:02:31.812: end
// 14:02:32.818: timeout-1
// 14:02:34.818: timeout-2
// 14:02:36.825: timeout-3

此时异步的任务执行时长大于setTimeout设置的延时,回调函数已不一定在setTimeout指定的时间执行。可见setTimeout的回调函数之间存在排队执行情况。

1.3 执行顺序

实现异步的任务源很多,例如setTimeout、setInterval、Promise.then等。

setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise then'));
process.nextTick(() => console.log('nextTick'));
// nextTick
// promise then
// timeout

异步任务之间执行的顺序存在一定规则,可见任务不只是简单的排队。

2. 执行栈与任务队列

由于单线程的特性,所有任务需要排队,前一个任务结束,才会执行后一个任务。主线程可以挂起异步的任务,先运行后面的同步任务。异步任务被排入队列,等主线程空闲才一个一个执行。

主线程运行时,会形成一个执行栈(execution context stack),所有的同步任务会被压入栈,异步任务会被放置到任务队列(task queue)。当栈中的同步任务执行完时,主线程会查看任务队列中是否有任务,并将任务压入执行栈执行。

对于定时器之类的任务源,主线程首先要检查一下执行时间,事件只有到了规定的时间,才能返回主线程。

也存在当主线程同步任务耗时超过异步任务定时时间时,异步任务早已到了可以执行的时间,等主线程一空闲就会立即执行:

setTimeout(() => console.log('timeout-1'), 300);
setTimeout(() => console.log('timeout-2'), 200);
longTask(500);
setTimeout(() => console.log('timeout-3'), 100);

// 14:03:08.176: start
// 14:03:08.679: end
// 14:03:08.680: timeout-2
// 14:03:08.680: timeout-1
// 14:03:08.782: timeout-3

3. Event Loop

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

事件循环只有一个,但任务队列可能有多个。事件循环中的异步队列可以分为:macro(宏任务)队列和 micro(微任务)队列。

  • 宏任务主要包括:script(整体代码)、setTimeout、setInterval、setImmediate(NodeJS)、I/O操作、UI rendering(浏览器)等
  • 微任务主要包括:process.nextTick(NodeJS)、Promise.then、Object.observer、MutationObserver(html5)等。

3.1 浏览器的 Event Loop

EventLoop1n0lz3swj

事件循环中,每一次循环称为 tick,每一次 tick ,执行栈选择最先进入队列的宏任务 macroTask(通常是script整体代码),如果有则执行;执行完一个macroTask,检查是否存在微任务 microtask,如果存在则不停的执行,直至清空 microtask 队列。

举个🌰:

console.log("start");

// timeout1
setTimeout(() => {
 console.log('timeout-1');
 new Promise(function (resolve) {
   console.log('timeout-1 promise');
   resolve()
 }).then(() => {
   console.log('timeout-1 then')
 })
}, 2000);

// timout2 loop
for (let i = 1; i <= 3; i++) {
 setTimeout(() => {
   console.log('timout-2 loop: ' + i)
 }, i * 1000);
}

// promise1
new Promise(function (resolve) {
 console.log('promise1');
 resolve()
}).then(() => {
 console.log('then1')
});

// timeout3
setTimeout(() => {
 console.log('timeout-3');
 new Promise(function (resolve) {
   console.log('timeout-3 promise');
   resolve()
 }).then(() => {
   console.log('timeout-3 then')
 })
}, 1000);

// promise2
new Promise(function (resolve) {
 console.log('promise2');
 resolve()
}).then(() => {
 console.log('then2');
});

console.log("end");

分析:

事件循环 当前执行 宏队列 微队列
第一次tick script(整体代码) timeout-3(1s)
timeout2-loop3(3s)
timeout2-loop2(2s)
timeout2-loop1(1s)
timeout1(2s)
promise2.then
promise1.then
第二次tick timeout2-loop1(1s) timeout-3(1s)
timeout2-loop3(3s)
timeout2-loop2(2s)
timeout1(2s)
第三次tick timeout-3(1s) timeout2-loop3(3s)
timeout2-loop2(2s)
timeout1(2s)
timeout-3.promise.then
第四次tick timeout1(2s) timeout2-loop3(3s)
timeout2-loop2(2s)<
timeout-1.promise.then
第五次tick timeout2-loop2(2s) timeout2-loop3(3s)
第四次tick timeout2-loop3(3s)

Promise构造函数第一个参数在new的时候会直接执行;队列先进先出;每一次tick结束,微队列都会被清空。

输出:

promise1
promise2
then1
then2
timout-2 loop: 1
timeout-3
timeout-3 promise
timeout-3 then
timeout-1
timeout-1 promise
timeout-1 then
timout-2 loop: 2
timout-2 loop: 3

注意:浏览器中,宏队列中任务是一个一个执行,微队列的任务是一次清空。

3.2 NodeJS的Event Loop

上文提到的NodeJS中特有的方法,setImmediate,process.nextTick,在队列中有特殊的处理。

  • process.nextTick:可以在当前执行栈的尾部——下一次Event Loop(主线程读取任务队列)之前——触发回调函数,所以它指定的任务总是在当前宏任务执行完后立即执行。
  • setImmediate:可以在当前任务队列的尾部添加事件,它指定的任务总是在下一次时间循环时执行,与setTimeout(fn,0)类似。

需要注意的是,setImmediate与setTimeout(fn,0)的执行顺序并不确定,运行结果可能是 immediate->timeout,也可能是 timeout->immediate。但当我们在外封装一层setImmediate的时候,setImmediate的回调函数总是先执行。

setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

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

这是因为 NodeJS 和浏览器的 Event Loop 机制存在差异。

Node.js采用V8作为JS的解析引擎,而I/O处理方面使用了自己设计的libuv。libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。

libuv库负责Node API的执行,将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。

libuv引擎中的事件循环阶段

node64xr4smye

可分为 6 个阶段:

  • timers 定时器检测阶段:这个阶段执行timer(setTimeout、setInterval)的回调,由 poll 阶段控制
  • I/O callbacks 事件回调阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 闲置阶段:仅node内部使用
  • poll 轮询阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
  • check 阶段:执行 setImmediate() 的回调
  • close callbacks 关闭事件回调阶段:执行 socket 的 close 事件回调
事件循环原理

EventLoop-node7tdhg8le

=> node 的初始化

  • 初始化 node 环境;输入代码;process.nextTick 回调;执行 microtasks。

=> 进入 Event Loop

  • 进入 timers 阶段
    • 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
    • 执行process.nextTick 任务。执行microtask。退出该阶段。
  • 进入IO callbacks 阶段
    • 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
    • 执行process.nextTick 任务。执行microtask。退出该阶段。
  • 进入 idle,prepare 阶段(其他与编程关系不大)
    • 执行process.nextTick 任务。执行microtask。退出该阶段。
  • 进入 poll 阶段
    • 首先检查是否存在尚未完成的回调,如果存在:
      • 第一种情况:
        • 如果有可用回调(可用回调包含到期的定时器还有一些IO事件等),执行所有可用回调。
        • 执行process.nextTick 任务。执行microtask。退出该阶段。
      • 第二种情况,没有可用回调:
        • 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
    • 如果不存在尚未完成的回调,退出。
  • 进入 check 阶段。
    • 如果有immediate回调,则执行所有immediate回调。
    • 执行process.nextTick 任务。执行microtask。退出该阶段。
  • 进入 closing 阶段。
    • 如果有immediate回调,则执行所有immediate回调。
    • 执行process.nextTick 任务。执行microtask。退出该阶段。
  • 检查是否有活跃的 handles(定时器、IO等事件句柄)。
    • 如果有,继续下一轮循环。
    • 如果没有,结束事件循环,退出程序。
setImmediate(() => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

再看这段代码,由于第一个setImmediate运行时已经在 check 阶段,所以immediate回调先执行。

与浏览器的差异
  • 在事件循环的每一个子阶段退出之前都会检查是否有 process.nextTick 回调、是否有 microtaks并执行
  • timers阶段可执行的setTimeout/setInterval都会依次执行,而浏览器端每次只执行一个宏任务

回到3.1的🌰,在NodeJS下执行的过程:

事件循环 当前执行 宏队列 微队列
第一次tick script(整体代码) timeout-3(1s)
timeout2-loop3(3s)
timeout2-loop2(2s)
timeout2-loop1(1s)
timeout1(2s)
promise2.then
promise1.then
第二次tick timeout2-loop1(1s)
timeout-3(1s)
timeout2-loop3(3s)
timeout2-loop2(2s)
timeout1(2s)
timeout-3.promise.then
第三次tick timeout1(2s)
timeout2-loop2(2s)
timeout2-loop3(3s) timeout-1.promise.then
第四次tick timeout2-loop3(3s)

输出:

14:42:24.520: start
14:42:24.522: promise1
14:42:24.523: promise2
14:42:24.523: end
14:42:24.523: then1
14:42:24.523: then2
14:42:25.528: timout-2 loop: 1
14:42:25.528: timeout-3
14:42:25.528: timeout-3 promise
14:42:25.528: timeout-3 then
14:42:26.524: timeout-1
14:42:26.524: timeout-1 promise
14:42:26.524: timout-2 loop: 2
14:42:26.524: timeout-1 then
14:42:27.522: timout-2 loop: 3

4. 练习

setTimeout(() => {
  console.log(1);
  Promise.resolve().then(() => console.log(2))
}, 0);
Promise.resolve().then(() => {
  console.log(3);
  setTimeout(() => console.log(4), 0)
});

setImmediate(() => {
  console.log(1);
  setTimeout(() => console.log(2), 100);
  setImmediate(() => console.log(3));
  process.nextTick(() => console.log(4))
});
setImmediate(() => {
  console.log(5);
  setTimeout(() => console.log(6), 100);
  setImmediate(() => console.log(7));
  process.nextTick(() => console.log(8))
});

setImmediate(() => {
  console.log(1);
  setTimeout(() => console.log(2), 100);
  setImmediate(() => console.log(3));
  process.nextTick(() => console.log(4))
});
process.nextTick(() => {
  console.log(5);
  setTimeout(() => console.log(6), 100);
  setImmediate(() => console.log(7));
  process.nextTick(() =>console.log(8))
});

答案:

3 1 4 2

1 5 4 8 3 7 2 6

5 8 1 7 4 3 6 2

Livia

人生没有对错,都是选择