javascript-Event Loop

JavaScript 事件循环

由于 JavaScript 语言是单线程的,也就是同一时间只能做一件事,前一个任务结束才会执行后一个任务。如果一个任务的时间很长(譬如 IO 设备很慢,需要从网络读取数据等),那么它的下一个任务就不得不等着,这也就造成了严重的 CPU资源的浪费。

任务队列(task queue)

该语言的设计者意识到,此时主线程可以不管 IO 设备,挂起正在等待 IO 设备的任务,继续运行后面的任务。等到 IO 设备返回结果之后再将之前挂起的任务接着执行下去。

在 JavaScript 中,所有的任务可分为两种,同步任务(synchronous)和 异步任务(asynchronous)。所有的 同步任务 都在主线程上执行,形成一个 执行栈(execution context stack)。在主线程之外,还存在一个 任务队列(task queue,是一个先进先出的数据结构),只要异步任务(另外的线程处理的)有了运行结果,就在 任务队列 中放置一个事件。一旦 执行栈 中所有的 同步任务 执行完毕,系统就会读取 任务队列 里头部的事件进入 执行栈 开始执行。执行栈 空了以后,主线程又会去读取 任务队列 是否有任务可以载入 执行栈 执行,如此往复。因此又被称为 事件循环

task queue

如上图所示,任务队列 也是一个事件队列或消息队列。除了 IO 设备的事件以外,还包括用户产生的事件,如鼠标点击、键盘按下、页面滚动以及JS中的定时器等等。只要指定 回调函数,这些事件发生时就会进入 任务队列 等待主线程读取进入 执行栈 执行。
回调函数(callback),就是那些被主进程挂起来的代码,异步任务必须指定回调函数,当事件进入 执行栈 开始执行的时候,主线程执行的就是这个事件对应的回调函数。

事件循环(event loop)

主线程从 任务队列 中读取事件,执行完毕,清空执行栈,继续读取任务队列中的事件。这一中运行机制又被称为 事件循环

event loop

上图中,主线程运行的时候,会产生栈(stack)和堆(heap)。Number、String、Boolean等基本类型主要存放在栈中,且会在程序执行完毕后从栈中弹出。而对象、数组等值则位于堆中,执行栈中存储的变量其实是指向它们的指针,让这些值可以在不同的栈帧中变化,程序执行完毕后指针弹出,而对象失去作用后会被垃圾回收机制回收并释放空间。

在栈中的代码会调用各种外部 API,满足触发条件后,就会在任务队列中添加各种事件。等执行栈空闲,主线程便会读取任务队列头部待执行的事件载入执行栈执行。

总的来说,执行栈中的代码(同步代码),总是在读取任务队列里的事件(异步任务)之前执行。

1
2
3
4
5
6
console.log(1);
setTimeOut(function () { console.log(2) }, 0);
console.log(3);
// 1
// 3
// 2

macrotask与microtask

ES6 中的Promise里又有了一个新的概念 microtask 。进一步,JS 的 任务队列 又分为两种任务类型:macrotaskmicrotask。且 JS 主线程在第一次运行 执行栈 中的代码时也算做一次 macrotask 的执行。

  • macrotask,又称为宏任务(task),每次执行栈执行的代码就是一个宏任务,执行这个 task 的过程中不会执行其它任务。另外浏览器会在一个 task 执行结束后,下一个 task执行之前,对页面进行重新渲染。常见的 task 有:setTimeOutsetIntervalsetImmediate 以及 I/O

  • microtask,又称为微任务(jobs),是在当前 task 执行结束后立即执行的任务,也即是当前 task 之后,下一个 task 之前(浏览器重新渲染之前)。所以 jobs 的响应速度比 task 更快,因为无需等待渲染。另外在某一个 task 执行完后,就会将在它执行期间产生(jobs 执行期间产生的也会被执行)的所有的 jobs。常见的 jobs 有:promiseprocess.nextTick 以及 Object.observe

macrotask and microtask

简单梳理一下就是:

  1. 首先 JS 引擎主线程执行执行栈中的 macrotask(若执行栈为空,则读取任务队列里的 macrotask)。
  2. 执行过程中如果遇到 microtask,就将它添加到 microtaskjobs queue 中。
  3. macrotask 执行完成后,立即依次执行 jobs queue 中的所有 microtask,如果还遇到 microtask,就依旧将它添加到 microtaskjobs queue 中,并一一执行。
  4. 上述过程执行一轮完毕后,开始检查渲染,然后 GUI 线程接管渲染。
  5. 渲染结束后,JS主线程继续接管,读取任务队列,开始下一个 macrotask

还有一点,在 ES6 官方版本中的 Promise 是标准的 microtask 形式,但其 polyfill 中,一般都是通过 setTimeOut 模拟的,所以是 macrotask 的形式。

一个实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
console.log('script start');

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

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

console.log('script end');
// script start
// script end
// promise 1
// promise 2
// setTimeout

简要说明下上面的例子的执行过程:

Cycle1:

  1. JS 主线程开始执行执行栈里面的同步任务(第一个 macrotask 开始)
  2. “script start” 打印
  3. setTimeout 被添加到 macrotask 队列
  4. Promise then 被添加到 microtask 队列
  5. “script end” 打印
  6. 第一个 macrotask 执行完成,执行栈清空, microtask 队列任务 Promise callback 进入执行栈开始执行
  7. “promise 1” 打印,第二个 Promise then被添加到 microtask 队列,执行栈清空
  8. 开始执行第二个 Promise callback,打印 “promise 2”
  9. 此时执行栈清空,microtask 队列也为空
  10. 浏览器在此时按需进行渲染,第一个事件循环结束

Cycle2:

  1. macrotask 队列里的 setTimeOut callback 载入执行栈开始执行
  2. 打印 “setTimeOut”,执行栈清空,macrotask 队列清空
  3. microtask 队列也为空
  4. 浏览器在此时按需进行渲染,第二个事件循环结束

参考

从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
理解 Node.js 事件循环
JavaScript 运行机制详解:再谈Event Loop