浅谈 JavaScript Event Loop

2020年3月1日 ~

前言

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

人生没有对错,都是选择

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.