Event Loop 机制拆解、调用栈与任务队列状态追踪、async/await 在事件循环中的真实表现
写在前面
先来看一道题,自己先猜一下输出顺序:
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
如果答案是 1 → 2 → 3 → 4,那是错误的,下面可以看看文章内容对你是否有帮助。
如果答案是 1 → 4 → 3 → 2——那说明基础没问题,但是下面的面试题也可以继续看一下答案是否依旧正确。
一、事件循环到底在循环什么?
1.1 一句话说不清,但两句话可以
JavaScript 是单线程的——但「单线程」到底意味着什么?
意味着 JS 引擎在同一时刻只能做一件事。
那么如果用户点了个按钮触发了一个耗时 3 秒的操作,这 3 秒内页面就卡死不动了?
答案是不能。所以浏览器搞了一套机制——主线程忙着呢,我先把耗时的任务丢到一边,等有空了再来处理。
这其实就是事件循环。
1.2 调用栈与任务队列
涉及到两个核心概念:
调用栈(Call Stack):JS 引擎执行代码的地方。函数被调用时压入栈顶,执行完毕后弹出。栈是后进先出(LIFO)。
任务队列(Task Queue):存放异步回调的地方。队列是先进先出(FIFO)。
举个例子:
function foo() {
console.log('foo');
}
function bar() {
foo();
console.log('bar');
}
bar();
// foo
// bar
执行过程:
1. bar() 被调用 → 压入调用栈
调用栈: [bar]
2. bar 内部调用 foo() → 压入调用栈
调用栈: [bar, foo]
3. foo 执行 console.log('foo') → 压入 → 执行 → 弹出
调用栈: [bar, foo]
4. foo 执行完毕 → 弹出
调用栈: [bar]
5. bar 执行 console.log('bar') → 压入 → 执行 → 弹出
调用栈: [bar]
6. bar 执行完毕 → 弹出
调用栈: [] ← 空了
输出顺序:foo → bar
这是同步代码的执行过程,和事件循环还没关系。
1.3 异步代码怎么跑的
看这段:
console.log('start');
setTimeout(() => {
console.log('timeout');
}, 0);
console.log('end');
// start
// end
// timeout
执行过程:
1. console.log('start') 执行 → 输出 "start"
调用栈: []
2. setTimeout(...) 被调用
→ 浏览器 Web API 接管定时器
→ 回调函数被记录,0ms 后放入宏任务队列
调用栈: []
宏任务队列: [() => console.log('timeout')]
3. console.log('end') 执行 → 输出 "end"
调用栈: []
4. 调用栈空了!事件循环开始工作:
→ 检查微任务队列:空
→ 从宏任务队列取一个任务执行
→ console.log('timeout') 执行 → 输出 "timeout"
输出顺序:start → end → timeout
这就是事件循环的基本流程:同步代码先跑完,然后不断检查任务队列,有任务就取出来执行。
二、宏任务与微任务
2.1 为什么要有两种任务队列
如果只有一种队列,那 setTimeout 和 Promise.then 的回调就按先来后到执行。
但 JS 的设计者认为:Promise 的回调更重要,应该优先执行。
所以任务队列被分成了两类:
| 类型 | 包含哪些 | 执行时机 |
|---|---|---|
| 宏任务(MacroTask) | setTimeout、setInterval、I/O、UI 渲染、script 整体代码 | 每轮事件循环取一个执行 |
| 微任务(MicroTask) | Promise.then/catch/finally、MutationObserver、queueMicrotask | 每个宏任务执行完后,清空所有微任务 |
注意几个容易混淆的:
// ❌ Promise 构造函数内的代码是同步的!
new Promise((resolve) => {
console.log('promise 内部是同步的'); // ← 这行同步执行
resolve();
}).then(() => {
console.log('then 是微任务'); // ← 这行异步执行
});
// ✅ async/await 的等价形式
async function test() {
console.log('async 函数体同步部分');
await Promise.resolve();
console.log('await 之后的代码相当于 .then()'); // ← 微任务
}
2.2 事件循环的完整流程
┌─────────────────────────────────┐
│ 执行一个宏任务 │
│ (script 整体代码也算一个) │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ 检查微任务队列,全部执行 │
│ (直到微任务队列为空) │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ 浏览器决定是否渲染 │
│ (通常 16.67ms 一次) │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ 回到顶部,取下一个宏任务 │
└─────────────────────────────────┘
第一步:取出并执行一个宏任务 从宏任务队列(Task Queue)中取出队首的一个任务执行。这里的宏任务包括:整体
<script>代码、setTimeout、setInterval、I/O操作、UI 交互事件(如 click)等。第二步:清空微任务队列(关键防坑点) 宏任务执行完毕后,立即检查微任务队列(Microtask Queue)。必须将当前微任务队列中的所有任务全部执行完毕,直到队列为空,才能进入下一步。微任务包括
Promise.then/catch/finally、MutationObserver、queueMicrotask。
(特别警惕:如果在微任务中不断往队列里添加新的微任务,主线程会一直卡在这里,无法进入渲染,导致页面“假死”。)第三步:判断是否需要更新渲染(浏览器渲染时机) 微任务队列清空后,浏览器会检查当前是否处于渲染时机(即是否有垂直同步信号 VSync 到来)。
(重要纠正:并不是每 16.67ms 强制渲染,而是显示器发出了刷新信号,且主线程刚好处于空闲状态时,才有机会渲染。)第四步(若需要渲染):执行渲染前回调 如果确定本次需要渲染,浏览器会先执行
requestAnimationFrame(rAF)的所有回调函数。这个 API 的回调一定在渲染之前执行,且与显示器刷新频率同步。第五步(若需要渲染):执行渲染流水线 执行
rAF后,浏览器进行样式计算(Recalculate Style)、布局(Layout)、绘制(Paint)、合成(Composite) 等一系列渲染操作,最终将画面呈现到屏幕上。第六步:进入下一轮循环 渲染完成(或不需要渲染)后,本轮事件循环结束,回到第一步,从宏任务队列中取出下一个宏任务继续执行。
简单点说就是 “一宏 → 全微 → 渲染(rAF + 画)”,执行 1 个宏任务,清空所有微任务,到了渲染时机就执行 rAF 并渲染,然后循环往复。
-
关于宏任务优先级:
“宏任务队列其实不止一个,浏览器根据任务源(Task Source)分成了多个队列,比如setTimeout队列和 UI 交互队列。浏览器通常会优先处理用户交互任务(如点击),保证页面高响应性,而不是严格的先进先出。” -
关于 16.67ms 的本质:
“如果某个宏任务执行了 50ms,超过了 16.67ms 的帧预算,那么这一帧的渲染就会被直接跳过(掉帧)。浏览器不会中途打断 JS 去渲染,必须等到当前宏任务和所有微任务都执行完,才会去响应 VSync 信号。”
关键点:微任务队列每次都会被清空,不是只取一个。
这意味着如果一个微任务里又产生了新的微任务,新的微任务也会在当前轮次被执行完。
测试一下:
Promise.resolve().then(() => {
console.log('微任务1');
Promise.resolve().then(() => {
console.log('微任务1产生的微任务');
});
});
Promise.resolve().then(() => {
console.log('微任务2');
});
// 输出:微任务1 → 微任务2 → 微任务1产生的微任务
为什么 微任务1产生的微任务 排在最后?因为在执行 微任务1 时,新产生的微任务被追加到队列尾部。此时队列里已经有 微任务2 了,所以 微任务2 先执行,然后才轮到新产生的那个。
三、5 道面试题拆解
五道面试题是逐步深入的,可以按照顺序看
先尝试自己做一遍,看看是否与答案一致,然后再看拆解过程以及注意点
面试题 1:基础题
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});
console.log('script end');
执行追踪:
步骤1: 执行 console.log('script start')
→ 输出: script start
调用栈: [] 宏任务队列: [] 微任务队列: []
步骤2: 执行 setTimeout(...)
→ Web API 接管,0ms 后回调进入宏任务队列
调用栈: [] 宏任务队列: [setTimeout回调] 微任务队列: []
步骤3: 执行 Promise.resolve().then(...)
→ then 回调进入微任务队列
调用栈: [] 宏任务队列: [setTimeout回调] 微任务队列: [promise1回调]
步骤4: 执行 console.log('script end')
→ 输出: script end
调用栈: [] 宏任务队列: [setTimeout回调] 微任务队列: [promise1回调]
─── script 作为宏任务执行完毕,开始清空微任务 ───
步骤5: 取出 promise1 回调执行
→ 输出: promise1
→ 第一个 .then() 返回新的 Promise,第二个 .then 回调进入微任务队列
调用栈: [] 宏任务队列: [setTimeout回调] 微任务队列: [promise2回调]
步骤6: 取出 promise2 回调执行
→ 输出: promise2
调用栈: [] 宏任务队列: [setTimeout回调] 微任务队列: []
─── 微任务清空,取下一个宏任务 ───
步骤7: 取出 setTimeout 回调执行
→ 输出: setTimeout
答案: script start → script end → promise1 → promise2 → setTimeout
需要注意一个细节:第一个 .then() 执行完后,第二个 .then() 才被放入微任务队列。因为 Promise 链是前一个完成后才把后一个排进去的。
面试题 2:Promise 嵌套
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
return new Promise((resolve) => {
resolve();
});
}).then(() => {
console.log('5');
});
console.log('6');
先说一个最容易踩的坑: new Promise((resolve) => { console.log('3'); resolve(); }) 里面的 console.log('3') 是同步执行的。Promise 构造函数接收的函数是立即执行的。
执行追踪:
步骤1: console.log('1') → 输出: 1
步骤2: setTimeout → 回调进入宏任务队列
步骤3: new Promise 执行
→ 构造函数内 console.log('3') 同步执行 → 输出: 3
→ resolve() 被调用,Promise 状态变为 fulfilled
→ .then() 回调进入微任务队列
步骤4: console.log('6') → 输出: 6
─── 同步代码执行完,清空微任务 ───
步骤5: 执行第一个 .then 回调
→ 输出: 4
→ return new Promise(...) → 返回一个已 resolve 的 Promise
→ ⚠️ 关键:返回的是一个新的 Promise,第二个 .then 要等这个 Promise resolve 后才进入队列
→ 第二个 .then 回调进入微任务队列
步骤6: 执行第二个 .then 回调
→ 输出: 5
─── 微任务清空,取宏任务 ───
步骤7: 执行 setTimeout 回调
→ 输出: 2
答案: 1 → 3 → 6 → 4 → 5 → 2
注意步骤5的那个 return new Promise(...) ——如果你在 .then() 里返回一个 Promise,后续的 .then() 会等这个 Promise resolve 后才执行。这和直接返回一个普通值不一样。
面试题 3:async/await 的陷阱
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise');
resolve();
}).then(() => {
console.log('then');
});
console.log('script end');
这道题比较经典。经常会有人翻车,翻车点在 await async2() 之后的代码到底什么时候执行。
核心知识点: await 之后的代码,等价于放在 .then() 里——是微任务。
也就是说:
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
// ↑ 等价于 ↓
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(() => {
console.log('async1 end');
});
}
注意 async2() 本身是同步调用的——它内部的 console.log('async2') 会立即执行。await 暂停的是 async1 后续的代码,不是 async2 的执行。
执行追踪:
步骤1: console.log('script start') → 输出: script start
步骤2: setTimeout → 回调进入宏任务队列
步骤3: 调用 async1()
→ console.log('async1 start') → 输出: async1 start
→ 调用 async2()
→ console.log('async2') → 输出: async2
→ async2() 返回 Promise(已 resolve)
→ await 暂停 async1,console.log('async1 end') 进入微任务队列
步骤4: new Promise 执行
→ console.log('promise') → 输出: promise
→ resolve() 调用
→ .then() 回调进入微任务队列
步骤5: console.log('script end') → 输出: script end
─── 同步代码执行完,清空微任务 ───
步骤6: 执行 async1 end → 输出: async1 end
步骤7: 执行 .then 回调 → 输出: then
─── 微任务清空,取宏任务 ───
步骤8: 执行 setTimeout → 输出: setTimeout
答案: script start → async1 start → async2 → promise → script end → async1 end → then → setTimeout
这题最容易翻车的地方:很多人以为 async1 end 会在 then 之后执行。
其实不会,因为 await 产生的微任务比 Promise.then 的微任务先入队,所以先执行。
面试题 4:微任务中产生宏任务
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
});
console.log('6');
这题的关键是:微任务执行过程中产生的新宏任务,不会在当前轮次执行。必须等当前所有微任务清空后,才去宏任务队列取。
执行追踪:
步骤1: console.log('1') → 输出: 1
步骤2: setTimeout → 回调A进入宏任务队列
步骤3: Promise.resolve().then → 回调B进入微任务队列
步骤4: console.log('6') → 输出: 6
─── 清空微任务 ───
步骤5: 执行回调B
→ console.log('4') → 输出: 4
→ setTimeout → 回调C进入宏任务队列
宏任务队列: [回调A, 回调C]
─── 微任务清空,取宏任务 ───
步骤6: 执行回调A(先入队的先执行)
→ console.log('2') → 输出: 2
→ Promise.resolve().then → 回调D进入微任务队列
─── 宏任务执行完,清空微任务 ───
步骤7: 执行回调D
→ console.log('3') → 输出: 3
─── 微任务清空,取下一个宏任务 ───
步骤8: 执行回调C
→ console.log('5') → 输出: 5
答案: 1 → 6 → 4 → 2 → 3 → 5
注意 5 排在最后。因为回调C是微任务执行过程中产生的宏任务,它排在回调A后面。而回调A执行时又产生了微任务D,D会在C之前执行(微任务优先于宏任务)。
这就是事件循环的精髓:宏任务 → 清空微任务 → 宏任务 → 清空微任务 → ... 不断循环。
这道题其实有个扩展点:如果回调A执行耗时很长,回调C会先执行吗?
可以回答:
不会。因为 JS 是单线程的,宏任务队列遵循 FIFO(先进先出) 原则。回调A在 setTimeout 调用时(步骤2)就已经入队了,而回调C是在步骤5的微任务执行期间才入队。主线程必须先把队列头部的回调A取出来执行完,才会去取回调C。即使回调A里有 while(1) 死循环,回调C也只能在队列里干等,无法插队。
扩展:下面的代码会输出什么?
setTimeout(() => console.log('A'), 0);
queueMicrotask(() => console.log('B'));
可以将你的答案放到评论区,讨论一下
面试题 5:综合大题
async function async1() {
console.log('A');
await async2();
console.log('B');
}
async function async2() {
console.log('C');
}
console.log('D');
setTimeout(() => {
console.log('E');
Promise.resolve().then(() => {
console.log('F');
});
async1();
}, 0);
async1();
new Promise((resolve) => {
console.log('G');
resolve();
}).then(() => {
console.log('H');
});
console.log('I');
这道题把前面所有知识点都揉在一起了。先自己猜一下,我直接拆解。
执行追踪:
─── 第一轮宏任务:script 整体代码 ───
步骤1: console.log('D') → 输出: D
步骤2: setTimeout → 回调X进入宏任务队列
步骤3: 调用 async1()(外层)
→ console.log('A') → 输出: A
→ 调用 async2() → console.log('C') → 输出: C
→ await 暂停,console.log('B') 进入微任务队列 → 记为微任务B
步骤4: new Promise 执行
→ console.log('G') → 输出: G
→ resolve() 调用
→ .then() 回调进入微任务队列 → 记为微任务H
步骤5: console.log('I') → 输出: I
─── 清空微任务 ───
步骤6: 执行微任务B → console.log('B') → 输出: B
步骤7: 执行微任务H → console.log('H') → 输出: H
─── 微任务清空,取下一个宏任务 ───
─── 第二轮宏任务:回调X ───
步骤8: console.log('E') → 输出: E
步骤9: Promise.resolve().then → 回调F进入微任务队列
步骤10: 调用 async1()(setTimeout 内部)
→ console.log('A') → 输出: A
→ 调用 async2() → console.log('C') → 输出: C
→ await 暂停,console.log('B') 进入微任务队列 → 记为微任务B2
─── 回调X执行完,清空微任务 ───
步骤11: 执行回调F → console.log('F') → 输出: F
步骤12: 执行微任务B2 → console.log('B') → 输出: B
答案: D → A → C → G → I → B → H → E → A → C → F → B
检查一下:D A C G I B H E A C F B,12 个输出,和代码里的 12 个 console.log 一一对应。
这题最容易出错的地方:
- 外层
async1()和setTimeout内部的async1()是两次独立调用,A和C会输出两次 - 微任务B 在微任务H 之前入队,所以先执行
- 回调F 在微任务B2 之前入队(步骤9 比 步骤10 先发生),所以
F在B前面
如果上面的都能正确的做出来,那么可以尝试做一道反直觉的变体代码题, 看看是不是还能一眼看出区别。
// 把 setTimeout 内部的顺序调换一下
setTimeout(() => {
console.log('E');
async1(); // 先调用 async1
Promise.resolve().then(() => {
console.log('F');
});
}, 0);
如果改成这样,输出还会是 **... E → A → C → F → B**吗?为什么?
可以在评论区输出你的答案,一起讨论一下。
四、async/await 在事件循环中的真实位置
这篇是结合了前几篇的文章内容总结出的几道面试题,可以尝试做一下。
那么async/await 和 Promise.then 到底是不是一回事?
在事件循环的层面,是的。await 之后的代码等价于 .then() 回调,都是微任务。
但其实可以看一下上一篇 Generator 文章的内容:
- Generator + 自动执行器 是一种「手动暂停 + 恢复」的模式,暂停时执行权交回主线程
- async/await 借鉴了这个思路,但底层实现不同——它基于 Promise 的微任务机制,不依赖 Generator
简单来说:
Generator 暂停: 函数执行权交出,主线程继续跑别的
async/await 暂停: await 之后的代码被扔进微任务队列,主线程继续跑别的
两者都实现了「暂停」的效果,但机制不一样。Generator 的暂停是语言层面的(函数状态被保存),async/await 的暂停是任务队列层面的(代码被重新调度)。
补充:Generator 本身只提供语法级的中断能力,它不关心异步。只有当开发者手动将 yield 与 Promise 绑定,并用递归 next() 驱动时,它才模拟出异步等待的效果。而 async/await 是原生固化了这种模式,并且由引擎直接调度微任务,省去了手动迭代器的开销。
// 代码演示
// Generator:执行权交还后,外部可以捕获异常或传值回来
function* gen() {
try { yield 1; } catch(e) { console.log(e); }
}
const it = gen();
it.next(); // 交出执行权
it.throw('error'); // 外部强行给内部抛异常,这是语言层面的交互
// async/await:代码一旦被切成微任务,外部无法干预,只能等微任务队列调度它
async function test() {
await 1;
console.log('只能靠事件循环唤醒我');
}
下一篇会持续深度讲解Generator函数
如果觉得有用的话,可以点点关注,感谢~