Skip to main content

深入理解 Event Loop

一、同步和异步

JavaScript 是单线程的,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。

如果前一个任务耗时很长,后一个任务不得不一直等着。

如果排队是因为计算量大,CPU 忙不过来还能理解,但是很多时候 CPU 是空闲的,但由于 IO(输入输出)设备很慢(例如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。

因此,JavaScript 语言的设计者意识到主线程完全可以不管 IO 设备,直接挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种:

  • 同步任务(synchronous)
  • 异步任务(asynchronous)

1、同步任务

同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。

2、异步任务

异步任务指的是,不进入主线程、而进入任务队列(task queue)的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

异步执行的运行机制:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外还存在一个任务队列(task queue),只要异步任务有了运行结果,就在任务队列之中放置一个事件。
  3. 一旦执行栈中的同步任务执行完,就会读取并执行任务队列的异步任务。
  4. 主线程不断重复上面的第三步。

3、任务队列

任务队列(task queue)是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,任务队列上第一位的事件就自动进入主线程。

二、浏览器的 Event Loop

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

1、宏任务与微任务

浏览器端事件循环的异步队列有两种:

  • 宏任务队列(macro-task)
  • 微任务队列(micro-task)

当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,则读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。

常见的宏任务队列:

  • script 代码块
  • setTimeout
  • setInterval
  • I/O 操作
  • UI 渲染
  • ...

常见的微任务队列:

  • new Promise().then(回调)
  • callback
  • Object.observe
  • process.nextTick(Node 独有)
  • MutationObserver(html5 新特性)
  • ...

2、Event Loop 过程解析

再回过来看上面任务队列的流程图,一个完整的 Event Loop 过程,可以概括为以下阶段:

  • 一开始执行栈为空,微任务队列为空,宏任务队列里有且只有一个 script 脚本(整体代码)。

  • 全局上下文(script 标签)被推入执行栈,同步代码执行。

  • 判断是同步任务还是异步任务,异步任务还可以产生新的宏任务微任务,分别被推入各自的任务队列中。

  • 同步代码执行完,script 脚本会被移出宏任务队列,这个过程本质上是队列的宏任务的执行和出队的过程。

  • 上一步出队的是一个宏任务,这一步处理的是微任务。注意:当宏任务出队时,任务是一个一个执行的;而微任务出队时,任务是一队一队执行的。因此,处理微任务队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。

  • 执行渲染操作,更新界面

  • 检查是否存在 Web worker 任务,如果有,则对其进行处理。

  • 上述过程循环往复,直到两个队列都清空。

总结一下,每一次循环都是一个这样的过程:

3、Event Loop 应用

示例 1

Promise.resolve().then(() => {
console.log('Promise1')
setTimeout(() => {
console.log('setTimeout2')
}, 0)
})
setTimeout(() => {
console.log('setTimeout1')
Promise.resolve().then(() => {
console.log('Promise2')
})
}, 0)

// 依次输出:
// Promise1
// setTimeout1
// Promise2
// setTimeout2

过程分析:

  1. 一开始执行栈的同步任务(这属于宏任务)执行完毕,会去查看是否有微任务队列,然后执行微任务队列中的所有任务输出 Promise1,同时会生成一个宏任务 setTimeout2
  2. 然后去查看宏任务队列,宏任务 setTimeout1setTimeout2 之前,先执行宏任务 setTimeout1,输出 setTimeout1
  3. 在执行宏任务 setTimeout1 时会生成微任务 Promise2,放入微任务队列中,接着先去清空微任务队列中的所有任务,输出 Promise2
  4. 清空完微任务队列中的所有任务后,就又会去宏任务队列取一个,这回执行的是 setTimeout2

示例 2

console.log(1);

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

new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data);
})

setTimeout(() => {
console.log(6);
})

console.log(7);

// 依次输出:
// 1
// 4
// 7
// 5
// 2
// 3
// 6

过程分析:

Step 1

原例子Stack Queue宏任务微任务打印结果
[][]1

Step 2

原例子Stack Queue宏任务微任务打印结果
[callback1][]1

Step 3

原例子Stack Queue宏任务微任务打印结果
[callback1][callback2]1 、4

Step 4

原例子Stack Queue宏任务微任务打印结果
[callback1, callback3][callback2]1 、4

Step 5

原例子Stack Queue宏任务微任务打印结果
[callback1, callback3][callback2]1 、4 、7

到这里,全局 Script 代码执行完毕,进入下一个步骤,从微任务队列中依次取出任务执行,直到微任务队列队列为空。

Step 6

原例子Stack Queue宏任务微任务打印结果
[callback1, callback3][]1 、4 、7 、5

这里微任务队列中只有一个任务,执行完后开始从宏任务队列中取位于队首的任务执行。

Step 7

原例子Stack Queue宏任务微任务打印结果
[callback3][]1 、4 、7 、5 、2

执行 callback1 时又遇到了另一个 Promise,Promise 异步执行完后在微任务中又注册了一个 callback4 回调函数。

Step 8

原例子Stack Queue宏任务微任务打印结果
[callback3][callback4]1 、4 、7 、5 、2

取出一个宏任务执行完毕,然后再去微任务队列中依次取出执行。

Step 9

原例子Stack Queue宏任务微任务打印结果
[callback3][]1 、4 、7 、5 、2 、3

微任务队列全部执行完,再去宏任务队列中取第一个任务执行。

Step 10

原例子Stack Queue宏任务微任务打印结果
[][]1 、4 、7 、5 、2 、3 、6

以上,全部执行完后,Stack Queue、宏任务队列、微任务队列均为空。

打印结果:1 、4 、7 、5 、2 、3 、6

示例 3

async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}

async function async2() {
console.log('async2');
}

console.log('script start');

setTimeout(function () {
console.log('setTimeout');
}, 0);

async1();

new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
});

console.log('script end');

// 依次输出:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

过程分析:

三、Node.js 中的 Event Loop

Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境。

1、Node.js 的架构

Node.js 由 Libuv、Chrome V8、一些核心 API 构成,如图:

  • Node Standard Library:Node.js 标准库,对外提供的 JavaScript 接口,例如模块 http、buffer、fs、stream 等;
  • Node bindings:JavaScript 与 C++ 连接的桥梁,对下层模块进行封装,向上层提供基础的 API 接口;
    • V8:Google 开源的高性能 JavaScript 引擎,使用 C++ 开发,负责把 JavaScript 代码转换成 C++,然后去跑这层 C++ 代码;
    • Libuv:是使用 C 和 C++ 为 Node.js 开发的一个跨平台的支持事件驱动的 I/O 库,同时也是 I/O 操作的核心部分,例如读取文件和 OS 交互,Node 中的 Event Loop 就是由 libuv 来初始化的;

2、Node.js 的运行机制

  • V8 引擎解析 JavaScript 脚本;
  • 解析后的代码,调用 Node API;
  • libuv 库负责 Node API 的执行,它将不同的任务分配给不同的线程,形成一个 Event Loop,以异步的方式将任务的执行结果返回给 V8 引擎;
  • V8 引擎再将结果返回给用户。

3、libuv 中的 Event-Loop

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

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;

r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);

while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
// timers阶段
uv__run_timers(loop);
// I/O callbacks阶段
ran_pending = uv__run_pending(loop);
// idle阶段
uv__run_idle(loop);
// prepare阶段
uv__run_prepare(loop);

timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// poll阶段
uv__io_poll(loop, timeout);
// check阶段
uv__run_check(loop);
// close callbacks阶段
uv__run_closing_handles(loop);

if (mode == UV_RUN_ONCE) {
uv__update_time(loop);
uv__run_timers(loop);
}

r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}

if (loop->stop_flag != 0)
loop->stop_flag = 0;

return r;
}

libuv 引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

从上图可看出 node 中的事件循环的顺序:

外部输入数据 --> 轮询阶段(poll)--> 检查阶段(check)--> 关闭事件回调阶段(close callback)--> 定时器检测阶段(timer)--> I/O 事件回调阶段(pending callbacks)--> 闲置阶段(idle, prepare)--> 轮询阶段...

其中,各阶段的作用如下:

  • timers: 定时器检测阶段,执行 timer(setTimeout、setInterval)的回调;
  • I/O callbacks: ,I/O 事件回调阶段,执行一些系统调用错误,例如网络通信的错误回调;
  • idle, prepare: 闲置阶段,仅 node 系统内部使用;
  • poll: 轮询阶段,执行 I/O 回调,同时还会检查定时器是否到期;
  • check: 检查阶段,执行 setImmediate() 的回调;
  • close callbacks: 关闭事件回调阶段,处理关闭的回调,例如 socket.on('close', ...) 就会在这个阶段被触发。

注意:上面六个阶段都不包括 process.nextTick()

下面看 timerspollcheck 这 3 个阶段(日常开发中绝大部分异步任务都在这 3 个阶段处理)

3-1、timers 定时器检测阶段

timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer(setTimeout、setInterval),如果有则把它的回调压入 timer 的任务队列中等待执行。

事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对 timer 的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。举个例子:

setTimeout()setImmediate() 非常相似,区别主要在于调用时机不同。

  • setImmediate 设计在 poll 阶段完成时执行,即 check 阶段;
  • setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行。
setTimeout(function timeout() {
console.log('timeout');
}, 0);

setImmediate(function immediate() {
console.log('immediate');
});
  • 对于以上代码来说,setTimeout 可能执行在前,也可能执行在后。
  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的。进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调。
  • 如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了。

但当二者在异步 I/O callback 内部调用时,总是先执行 setImmediate,再执行 setTimeout

const fs = require('fs')

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

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

3-2、poll 轮询阶段

poll 阶段主要做两件事:

  • 处理 poll 队列的事件;
  • 当有已超时的 timer,执行它的回调函数。

even loop 将同步执行 poll 队列里的回调,直到队列为空或执行的回调达到系统上限,接下来 even loop 会去检查有无预设的 setImmediate(),分两种情况:

  • 若有预设的 setImmediate(), event loop 将结束 poll 阶段进入 check 阶段,并执行 check 阶段的任务队列;
  • 若没有预设的 setImmediate(),event loop 将阻塞在该阶段等待。

注意:没有 setImmediate() 会导致 event loop 阻塞在 poll 阶段,这样之前设置的 timer 就无法执行了。因此,在 poll 阶段 event loop 会有一个检查机制,检查 timer 队列是否为空,如果 timer 队列非空,event loop 就开始下一轮事件循环,即重新进入到 timer 阶段。

3-3、check 检查阶段

setImmediate() 的回调会被加入 check 队列中,从 event loop 的阶段图可以知道,check 阶段的执行顺序在 poll 阶段之后,举个例子:

console.log('start')

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

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

Promise.resolve().then(function () {
console.log('promise3')
})

console.log('end')

// 浏览器输出: Node 输出:
// start start
// end end
// promise3 promise3
// timer1 timer1
// promise1 timer2
// timer2 promise1
// promise2 promise2
  • 一开始执行栈的同步任务(这属于宏任务)执行完毕后(依次打印出 start end,并将 2 个 timer 依次放入 timer 队列),会先去执行微任务(这点跟浏览器端的一样),所以打印出 promise3;
  • 然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise.then 回调放入微任务队列;
  • 同样的步骤执行 timer2,打印 timer2;这点跟浏览器端相差比较大,timers 阶段有几个 setTimeout / setInterval 都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务。

4、宏任务与微任务

Node 端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列。

常见的宏任务队列:

  • setTimeout
  • setInterval
  • setImmediate
  • script(整体代码)
  • I/O 操作等
  • ...

常见的微任务队列:

  • process.nextTick
  • new Promise().the(回调) ...

5、process.nextTick

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

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

process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})

// 依次输出:
// nextTick
// nextTick
// nextTick
// nextTick
// timer1
// promise1

四、两种 Event Loop 的区别

浏览器环境下,微任务的任务队列是在每个宏任务执行完之后执行:

而在 Node.js 中,微任务会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行微任务队列的任务:

举个例子

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

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

// 浏览器输出: Node 输出:
// timer1 timer1
// promise1 timer2
// timer2 promise1
// promise2 promise2

具体处理过程如下:

浏览器和 Node 环境下,微任务任务队列的执行时机不同:

  • 浏览器中,微任务在事件循环的宏任务执行完之后执行;
  • Node.js 中,微任务在事件循环的各个阶段之间执行。