前言
宏任务是异步还是同步
笔者最近在复习事件循环这个老生常谈的话题,看到有的文章提到“异步任务分为宏任务和微任务”,即宏任务属于异步任务。这和我理解的不太一样,于是决定重新梳理一遍事件循环。
先说我得出的结论:宏任务跟同步异步无关,可以是同步,也可以是异步,而微任务则全是异步。
下面开始重头讲浏览器的事件循环,希望对各位看官老爷有帮助。
举个栗子🙋♀️🌰
关于事件循环有一个很不错的例子是早餐店。餐馆开门之后,来了一群顾客排队买早餐。甲点了个已经蒸好的馒头,付了钱拿了就走。乙点了碗需要现煮的米粉,于是拿了个号码先去座位上等。后面的客人继续点餐,而在乙的米粉煮好之后,乙便直接去拿走米线。
在这个简单的生活场景中有 2 个重要的点:
- 乙后面的人不需要等待煮米粉。如果丙要的是包子,那么他会比乙先吃到早饭。
- 乙的米粉煮好之后,乙不需要从最后面重新排队,而是直接去拿走米粉。
在浏览器中,也有一套类似的机制来安排各个事件的执行顺序和时机,让“点包子”和“点米粉”能非阻塞式地执行,这套机制就是事件循环。
前置知识
- 堆(Heap):一种数据结构,是利用完全二叉树维护的一组数据。JavaScript 对象被分配在堆中。
- 栈(Stack):只能表尾进行插入或删除操作的线性表,故数据后进先出。JavaScript 有一个调用栈。
- 队列(Queue):只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作的线性表,故数据先进先出。JavaScript 有任务队列和微任务队列。
基本概念
背景:单线程语言之困
JavaScript 本质上是一门单线程语言。对于在它被设计出来的那个年代来说,这样的设计是一个很好的选择。那个时候很少有多核计算机,而且当时预期由 JavaScript 处理的代码量也相对较少。
但是很快计算机就发展成为强大的多核系统,而 JavaScript 也肩负着更多更复杂的任务。再后来,Web API 增加了定时器(setTimeout()
和 setInterval()
)。JavaScript 的运行环境便逐渐发展到包含任务调度、多线程应用开发等强大的特性。事件循环便是 JavaScript 运行时安排和运行代码背后的机制,它相当于是主线程这条繁忙公路的交通指挥员。
事件循环的概念在操作系统中由来已久,并非 JavaScript 首创。除了操作系统,其他语言如 Python 中也存在事件循环。即便是在 JavaScript 中,也存在浏览器和 Node 两种不同的事件循环机制。可见,事件循环是一个概念,不同技术对它的实现细节不尽相同。
实际上,事件循环驱动着浏览器中发生的一切。不过本文重点介绍它如何负责调度和执行在其线程中运行的每一段代码。
调用栈
在 JavaScript 中使用了一个叫调用栈(Call Stack,也叫执行栈)的机制来管理函数的调用顺序。用一个简单示例来理解它:
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
在调用 bar
时,bar
的执行上下文被创建并压入栈中。这个上下文包含了 bar
的变量环境、作用域链和 this
,在一些地方也管它叫帧(Frame),那是更专业的术语。
函数 bar
内部调用了函数 foo
,于是 foo
的上下文也被创建并压入栈中,并且位于 bar
之上(栈的特性)。当 foo
执行完毕、返回时,foo
的上下文就被弹出栈。同理,当 bar
执行完毕时,bar
的上下文也被弹出栈。至此,栈就被清空了。
以上就是一个简单的调用栈从开始到清空的过程。
任务队列
一个 JavaScript 运行时包含了一个用于存储异步任务的任务队列(Task Queue),也称消息队列(Message Queue)。在 JavaScript 开始运行的时候,所有同步代码会按书写顺序在调用栈中依次执行,而异步任务的回调函数则会被放入任务队列,等待执行。
就像开头早餐店的例子中,乙点了米粉之后,乙就去“任务队列”上等着,后面的人可以继续点餐。再用一个简单示例来理解它:
console.log("Start");
setTimeout(() => {
console.log("Timeout callback");
}, 0);
console.log("End");
执行顺序:
- 调用栈:由于
console.log("Start")
是同步任务,因此调用栈立即执行它。 - 异步任务:由于
setTimeout
是异步任务,因此它的回调函数被放入任务队列中,等待执行。即使它设置的延迟是 0 毫秒,也不会立即执行。 - 调用栈:与第 1 步相同,
console.log("End")
立即执行。 - 任务队列:同步任务执行完毕、调用栈已经空了,事件循环就将任务队列中的
setTimeout
回调函数取出、并推入调用栈,即执行console.log("Timeout callback")
。
宏任务和微任务
在上一节中,我们提到了同步任务和异步任务。而在事件循环机制中,JavaScript 提供了另一种任务分类:宏任务和微任务。
宏任务
宏任务指的是计划由标准机制来执行的任何 JavaScript 代码,例如一段同步代码、一个用户事件、一个定时器的回调函数或一次 I/O 操作。在一些地方,“任务”指的就是宏任务。
在下面 3 个时机,宏任务会被添加到任务队列:
- 一段新程序或子程序被直接执行时,例如一个
<script>
元素中运行代码。 - 触发了一个事件,将其回调函数添加到任务队列时。
- 执行到一个由
setTimeout()
或setInterval()
创建的timeout
或interval
,相应的回调函数被添加到任务队列时。
从定义可以看出,宏任务跟同步、异步无关。最开始执行的同步代码就是第一个宏任务。一个 <script>
元素中的代码可以是同步的,而 setTimeout
是异步的,但是它们都是宏任务。
微任务
微任务是在当前宏任务执行完成后,立即执行的任务。微任务的执行是为了确保代码的顺序性和一致性,在进入下一个宏任务之前,先把本轮循环中的所有微任务执行完毕。
在开头的例子中,乙去拿他煮好的米粉就相当于执行一个微任务的回调。微任务的回调可以插队,插在下一个宏任务前面,而不需要重头开始排队。
常见的微任务来源于:
- Promise 的
.then()
和.catch()
回调。 MutationObserver
(DOM 变化观察者)。- Node.js 中的
process.nextTick()
。
设计微任务的目的就是解决异步任务完成后,其回调函数可以插队执行,因此说微任务都是异步任务是没问题的。
关于 Promise 容易混淆:Promise 创建的是异步任务,new Promise(...)
括号内是同步代码,.then()
和 .catch()
回调是微任务。
优先级
微任务的优先级高于宏任务,具体逻辑请看下面介绍。
核心:执行顺序
定义
了解完前面的概念之后,我们终于可以来看事件循环驱动的执行顺序了,这是事件循环的核心。当浏览器拿到一段 JavaScript 代码时,会按以下顺序处理:
- 按书写顺序执行同步代码,包括
await
和new
。碰到宏任务,则放入任务队列,碰到微任务,则放入微任务队列,等待执行。 - 同步代码执行完毕后,执行微任务队列直到清空。这个过程中如果创建了宏任务,则放入任务队列,等待执行;但如果创建了微任务,则会放入微任务队列、在本次迭代中执行。
- (可选)微任务队列清空后,如果页面需要更新,则执行这些必要的渲染和绘制。
- 完成渲染后,本次迭代结束,开始新的迭代:取出任务队列的第一个宏任务放入调用栈执行,逻辑与 1-3 步一致,不断循环直至任务队列清空。
这也是事件循环代码题的解题思路。
例题
下面来看一些例子,解题要点是:
setTimeout
的回调代表了宏任务,new Promise()
是同步任务,Promise 的.then()
代表了微任务。- 执行到
await
时,后面的代码会整体被安排进一个新的微任务,此后的函数体变为异步执行。
在下面的解析中,我们常用“第 n 次迭代”来帮助理解事件循环,这是因为提到“循环”我们容易联想到“迭代”。但在实际开发中,并不会太关心第几次迭代,而是关心事件的执行顺序。
例 1
console.log('Start');
// Timeout 1
setTimeout(() => {
console.log('Timeout 1');
}, 0);
Promise.resolve().then(() => {
// Timeout 2
setTimeout(() => {
console.log('Timeout 2');
}, 0);
console.log('Promise 1');
// Timeout 3
setTimeout(() => {
console.log('Timeout 3');
}, 0);
}).then(() => {
console.log('Promise 2');
});
console.log('End');
第 1 次迭代:
- 同步代码
console.log('Start')
立即执行; - 宏任务
Timeout 1
放入任务队列、等待执行; - 微任务 Promise 的
.then()
放入微任务队列、等待执行; - 同步代码
console.log('End')
立即执行; - 同步代码结束,执行微任务队列,即 Promise 的第 1 个
.then()
:宏任务Timeout 2
放入任务队列,同步代码console.log('Promise 1')
立即执行,宏任务Timeout 3
放入任务队列。 - 执行第 2 个
.then()
的同步代码console.log('Promise 2')
。微任务队列清空,本次迭代结束。
第 2-4 次迭代:依次执行任务队列中的宏任务。
结果为:Start -> End -> Promise 1 -> Promise 2 -> Timeout 1 -> Timeout 2 -> Timeout 3。
例 2
// Promise 1
Promise.resolve().then(() => {
console.log('Promise 1')
// Timeout 2
setTimeout(() => {
console.log('Timeout 2')
}, 0)
})
// Timeout 1
setTimeout(() => {
console.log('Timeout 1')
// Promise 2
Promise.resolve().then(() => {
console.log('Promise 2')
})
}, 0)
第 1 次迭代:
- 没有同步任务,微任务 Promise 1 的
.then()
放入微任务队列等待执行。 - 宏任务 Timeout 1 放入任务队列。
- 执行微任务队列:打印
Promise 1
、把 Timeout 2 放入任务队列。微任务队列清空,本次迭代结束。
第 2 次迭代 Timeout 1:
- 同步任务:打印
Timeout 1
。 - 微任务 Promise 2 的
.then()
放入微任务队列。 - 同步任务结束,执行微任务队列,即打印
Promise 2
。微任务队列清空,本次迭代结束。
第 3 次迭代 Timeout 2:打印 Timeout 2
。
结果为:Promise 1 -> Timeout 1 -> Promise 2 -> Timeout 2。
例 3
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
console.log(3);
new Promise((resolve) => {
console.log(4);
resolve();
console.log(5);
}).then(() => {
console.log(6);
});
console.log(7);
第 1 次迭代:
- 同步代码打印 1;
- 宏任务
setTimeout
放入任务队列; - 同步代码打印 3;
- 同步代码务
new Promise()
打印 4、5; - 微任务 Promise 的
.then()
放入微任务队列; - 同步代码打印 7;
- 同步代码结束,执行微任务队列:打印 6。微任务队列清空,本次迭代结束。
第 2 次迭代:执行宏任务 setTimeout
的回调:打印 2。
结果为:1 -> 3 -> 4 -> 5 -> 7 -> 6 -> 2。
例 4:修改样式
执行以下代码,页面先变红还是先打印 End
?
console.log("Start");
document.body.style.backgroundColor = "red";
console.log("End");
答案:先打印 End
再变红。
这 3 句代码都是同步任务。但是,浏览器会在当前宏任务、微任务队列执行完毕后,再重绘页面,因此是先打印 End 再变红。
一些地方说“修改页面样式是一个宏任务”,这是错误的。可以用下面代码验证:
setTimeout(() => {
console.log("Timeout");
}, 0);
document.body.style.backgroundColor = "red";
const bgColor = getComputedStyle(document.body).backgroundColor;
console.log(bgColor);
输出:rgb(255, 0, 0) -> Timeout。
如果修改样式是宏任务,那么就会被排在 setTimeout
之后,那样打印 bgColor
就不会是红色,而是修改前的颜色。这与事实不符,可见修改页面样式不是宏任务,而是同步代码,只是因为浏览器会在本次迭代的最后来渲染页面,所以修改效果会在所有同步代码结束之后。
例 5:await
async function foo(name) {
console.log(name, "1");
await console.log(name, "2");
console.log(name, "3");
}
foo("甲");
foo("乙");
解析:在函数 foo
中,await
后的内容会被作为微任务放入微任务队列等待执行。函数 foo
等价于:
function foo(name) {
return new Promise((resolve) => {
console.log(name, "1");
resolve(console.log(name, "2"));
}).then(() => {
console.log(name, "3");
});
}
结果:甲 1 -> 甲 2 -> 乙 1 -> 乙 2 -> 甲 3 -> 乙 3。
再看一个例子:
async function foo() {
await console.log(8);
console.log(9);
}
console.log(1);
foo();
setTimeout(() => {
console.log(2);
}, 0);
new Promise((resolve) => {
console.log(4);
resolve();
console.log(5);
}).then(() => {
console.log(6);
});
console.log(7);
第 1 次迭代:
- 同步代码代码打印 1;
- 执行
foo
:await
同步打印 8,await
后的 9 放入微任务队列; setTimeout
回调放入任务队列;- 同步代码
new Promise
打印 4、5,回调.then()
放入微任务队列; - 同步代码代码打印 7;
- 同步代码结束,执行微任务列队:打印 9、6;
第 2 次迭代:setTimeout
回调打印 2。
结果:1 -> 8 -> 4 -> 5 -> 7 -> 9 -> 6 -> 2。
常见面试题
一句话介绍 JavaScript 的事件循环
JavaScript 的事件循环是一种机制,用于处理异步任务,通过不断循环执行任务队列中的事件,确保非阻塞的单线程代码执行顺序。
比较浏览器与 Node 的事件循环
事件循环是计算机的一种运行机制,不同技术在具体实现和调度机制上有所不同。浏览器与 Node 的事件循环差异有:
- 宏任务与微任务的执行顺序
- 浏览器:执行 1 个宏任务 -> 处理所有的微任务 -> 更新渲染 -> 继续下一轮宏任务。
- Node:6 个宏任务队列 + 6 个微任务队列组成一次迭代。
在一个宏任务队列全部执行完毕后,去清空一次微任务队列,然后到下一个等级的宏任务队列,以此往复。六个等级的宏任务全部执行完成,才是一轮循环。
另外 Node 不同版本的事件循环机制也有差别,在讨论时应先指定版本。随着 Node 的更新,其事件循环大体上有与浏览器靠拢的趋势。
执行递归函数时,调用栈是如何运作的
在递归函数的每一次递归调用时,都会生成新的栈帧并压入调用栈。这意味着每一次递归,调用栈都会增加一个新帧。
随着递归结束,栈帧会依次弹出,函数的结果逐步传递回前面的调用栈帧,直到递归完全结束,调用栈恢复到最初状态。
什么是堆栈溢出(Stack Overflow)
当递归函数调用次数过多,超过调用栈的最大容量时,就会发生堆栈溢出(Stack Overflow)。这是因为每次函数调用都会创建一个新的执行上下文,并推入调用栈,而栈的容量是有限的。
这会导致浏览器或运行环境抛出 "Maximum call stack size exceeded"
错误。
为什么 setTimeout(fn(),1000)
中 fn()
不一定是延迟 1 秒执行?
setTimeout
的第 2 个参数指的是回调函数被加入任务队列的延迟时间。如果任务队列中没有其他任务,并且调用栈当前为空,回调函数的延迟执行时间才会是第 2 个参数设定的时间。但是,如果任务队列或调用栈不为空,则需要等待队列前面的任务执行完或调用栈清空,才轮到 setTimeout
的回调函数。
介绍事件循环的分类
浏览器有如下 3 种事件循环:
- Window 事件循环
Window 事件循环驱动所有共享同源的窗口。这里的“窗口”指的是“用于运行网页内容的浏览器级容器”,包括实际的浏览器窗口、标签页或者一个 frame
。不过,同源窗口之间共享事件循环是有条件的,各个浏览器可能并不一样。
- Worker 事件循环
Worker 事件循环驱动 worker 的事件循环,包括所有形式的 worker,如基本的 web worker、shared worker 和 service worker。
- Worklet 事件循环
Worklet 事件循环驱动运行 worklet 的代理。