众所周知,JavaScript 是一门单线程、非阻塞的脚本语言,目的是为了实现与浏览器的交互。

JavaScript 为什么是单线程的?

因为为了处理浏览器与用户之间的交互、网络请求以及DOM操作,这就决定了 JavaScript 必须是单线程的,否则就会有产生复杂的同步问题。假设 JavaScript 有两个线程,在一个线程中用户操作了 一个DOM ,另一个线程中又删除了这个 DOM,那这个时候浏览器要怎么处理,以哪个线程为准呢?

既然是单线程的,那么 JavaScript 的任务就得一个个排队执行,如果遇到到耗时的任务(比如一个网络请求很慢),那后面的任务就执行不了,浏览器就卡着了。这与开头说的 JavaScript 是非阻塞的相矛盾。为了防止主线程的阻塞, JavaScript 就有了 同步 和 异步 的概念。

JavaScript 中的同步和异步

同步: 如果在函数返回结果的时候,调用者能够拿到预期的结果(就是函数计算的结果),那么这个函数就是同步的。同步就会产生阻塞,如下面的代码:

function wait(){
const s = new Date().getSeconds();
while (true) {
if (new Date().getSeconds() - s >= 5) {
break;
}
}
};
wait();
console.log("执行结束!");

上面的代码中,console.log() 需要等 wait 函数执行完毕后才会执行。

异步: 如果在函数返回的时候,调用者还不能购得到预期结果,而是将来通过一定的手段得到(例如回调函数),这就是异步。例如 Ajax 操作。调用异步函数后,调用者不会立即得到结果,当异步函数获得结果时,会通过回调函数返回调用的结果,所以异步函数不会阻塞线程。

浏览器中的 Event Loop

先了解一下数据结构。

堆(heap):基于树抽象数据类型的一种特殊数据结构。

栈(stack): 栈是遵循后进先出 (LIFO) 原则的有序集合。JavaScript 是单线程的也主要体现在这,因为一个线程只有一个调用栈。调用栈也是有深度限制的,当你写了一个无线递归函数时,浏览器会抛出错误警告(Uncaught RangeError: Maximum call stack size exceeded)。

队列(queue): 队列是遵循先进先出 (FIFO) 原则的有序集合。

观察一下浏览器中的Event Loop执行模型:
Loop

如图可知,JavaScript 中分为 堆内存栈内存

JavaScript 中的引用数据类型的大小是不固定的,所以它们就储存在堆内存中。JavaScript 不允许直接访问堆内存中的位置,所以也不能直接去操作它的内存空间,而是操作 对象的引用。引用数据类型( 对象(Object) )的指针储存在栈内存中,该指针指向了堆内存中该实体的起始地址。顺带一提,当形成闭包的时候,实际上作用域链也是保存到了堆中。

JavaScript 中的基本数据类型就储存在栈内存中,它们占用的大小和空间固定,是直接 按值来访问 的。

执行栈

当打开一个网页时,浏览器会将代码传递给引擎去执行,引擎首页会创建一个全局执行环境。全局环境中的代码自上而下有顺序的执行,当遇到一个函数时,JavaScript 会生成一个与这个函数对应的执行环境,又叫做 执行上下文(Context)[1]。这个执行上下文中保存着这个函数的私有作用域、作用域链、参数、此函数作用域中定义的变量和 this 的指向。当这个函数执行完以后,当前执行上下文将从栈中弹出,上下文的控制权限将转移到当前执行栈的下一个执行上下文。当一系列的函数被调用时,这些函数就会按照顺序排列在一个地方,按照类似于栈的控制机制执行,这个地方就是称之为的执行栈。

任务队列

任务队列是一个存放着很多 异步任务 的有序 集合(注意不是队列,因为执行的机制与队列的执行机制是不一样的。在队列中,是队列中的第一个出队。而在任务队列中,是事件循环执行模型抓取任务队列中第一个可以执行的任务)。一个事件循环中存在一个或多个任务队列,每个任务都来自一个特定的任务源。来自特定任务源和指向特定的事件循环的所有任务都必须被添加到同一个任务队列,来自不同任务源的任务可以被添加到不同的任务队列中。

事件循环

为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。每个代理都有一个关联的事件循环,该事件循环对于该代理是唯一的。

HTML标准中的事件循环进程模型,概括如下:

  1. 选择一个包含至少有一个可执行的任务的任务队列作为当前的要执行的任务队列(下面称为 taskQueue),如果没有这样的队列,就跳转至 microtask 的执行步骤

  2. taskQueue 中选择第一个可执行的任务作为 oldestTask,从 taskQueue 中移除 oldestTask

  3. 将事件循环的当前运行中的任务设置为 oldestTask

  4. 执行任务。

  5. 将事件循环的当前运行中的任务设置为 null。

  6. 进入 microtask 执行步骤。

  7. 更新渲染

  8. 循环这整个过程…

进入到 microtask 的执行步骤

  1. 如果执行 microtask 步骤标识为 true,则退出执行。

  2. 设置执行 microtask 步骤标识为 true。

  3. 如果事件循环中的微任务队列不为空:

    3.1 设置事件循环的微任务队列中第一个任务为 oldestMicrotask

    3.2 将事件循环的当前运行中的任务设置为 oldestMicrotask

    3.3 执行 oldestMicrotask

    3.4 将事件循环的当前运行中的任务设置为 null。

  4. 将事件循环的 执行 microtask 步骤表示设置为 false。

下面是根据我理解的事件循环进程模型画出来的示意图。

Process

用文字描述可以解释为:所有的同步任务都会在执行栈立即执行然后出栈,异步任务会进入到异步任务处理模块,然后把异步任务结果放到任务队列中。当执行栈中所有同步任务执行完以后就会去检查任务列队,如果任务队列为空,就会去检查微任务队列,如果有微任务就一次执行完所有的微任务。循环进行 任务入栈执行任务栈为空检查任务队列 的步骤,这个过程不断重复,这就是事件循环。

总结:1.宏任务队列每执行完一个任务就会去检查微任务队列。2.微任务队列中的任务会按照顺序一次执行完成(微任务执行产生的微任务会被加入到队尾,在这个周期被执行),直至微任务队列为空。

宏任务与微任务

宏任务:script(整体代码)、setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)

微任务:Promise.then、 MutaionObserver、process.nextTick (Node.js)

引用文章《微任务、宏任务与 Event-Loop》中的例子来介绍一下宏任务与微任务的关系。

在银行办业务的时候,假设这个银行只有一个窗口,每个客户都会取号排队在这个窗口办理业务。把每一个客户比作 宏任务,那么接待下一位客户的过程就是 宏任务 进入到执行栈的过程。这些排队的客户就形成了一个 任务队列。当客户在窗口办理完业务,业务员会问客户,还有什么可以帮您的吗?这时候如果客户还有其他一些事情要办,那么在还会继续占用着窗口办理业务,其他客户也只能等着,客户的其他事情就像是 微任务。这表明,在当前微任务没有执行完成时,是不会执行下一个宏任务的。根据例子,可以总结出:宏任务与微任务不在同一个队列中,且微任务的执行优先于宏任务

结语

学习那么多理论知识,让我们来检查一下学习成果吧,下面抛出一道烂大街的笔试题。

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');

心里想的答案与实际结果一样吗?不一样也没关系,不要气馁。是不是在 async/await 上遇到了的问题?让我帮帮你吧!

async/await 只是语法糖而已,只要把它转换成 Promise 的形式,你就会一目了然了。例如下面代码:

async function fetch() {
// await 前面的代码
await load();
// await 后面的代码
}

function load() {
// do something...
}

fetch();

await 前面的代码是同步的,调用函数的时候会直接执行;await load() 可以被转换成 Promise.resolve(load());await 后面的代码则会被放到 Promise.then() 中。这样代码就会被转换成下面的形式:

function fetch() {
// await 前面的代码
Promise.resolve(load()).then(() => {
// await 后面的代码
});
}

function load() {
// do something...
}

fetch();

试着转换一下测试题,然后重新运算一下结果,这样是不是就容易多了?

那么恭喜你,你已经掌握了本篇文章涉及的知识点。

说道最后:推荐去看一下 HTML5标准 - Event Loop 原文。还有就是,好记性不如烂笔头,根据自己的理解,试着画一下 Event Loop 执行模型 的流程图来巩固一下知识吧!

参考:
[MDN] 并发模型与事件循环
HTML5标准 - Event Loop
Tasks, microtasks, queues and schedules
Understanding Execution Context and Execution Stack in Javascript
最后一次搞懂 Event Loop
Philip Roberts: Help, I’m stuck in an event-loop.