前言
你是否也曾有过这样的困惑:明明写的是 "顺序执行" 的代码,结果却先拿到了后面请求的结果;明明只是多开了一个浏览器标签,内存占用却直线飙升;明明 setTimeout 设置了 100ms 延迟,实际执行却慢了半拍?
那些被我们挂在嘴边的 "进程""线程",并非只是教科书上的抽象名词;V8 引擎的 "单线程" 背后,藏着不为人知的协作逻辑;而 event-loop 的循环往复,悄悄决定了每一行异步代码的执行时机。我们每天和这些机制打交道,却常常在 "好像懂了" 和 "又被绕晕" 之间反复横跳 ------ 异步的本质不是 "等待",而是 "调度",单线程的表象下,藏着多线程的默契配合。
本文将拨开这些概念的迷雾,从底层逻辑出发,拆解从进程线程到 async/await 的完整链路,带你看清那些 "似懂非懂" 的 JavaScript 运行真相。
进程、线程
-
进程:cpu运行指令加载和保存上下文所需的时间
-
线程:cpu 执行指令需要的时间
-
比如:浏览器多开一个 tab 页,就是增加一个进程
-
渲染线程
-
js 引擎线程
-
HTTP 请求线程 等等
- 因为 js 可以修改DOM,所以js 引擎线程和 渲染线程是互斥的
-
V8
-
v8 在执行js 的过程中默认只开一个线程
-
只开一个线程就会带来:异步
-
单线程处理代码的过程:遇到同步任务就会立即执行,遇到异步任务就会存放到任务队列中,等待js 引擎线程空闲时,再执行任务队列中的异步任务
event-loop
在一开始的时候只分同步代码和异步代码,但是到后面发现有些异步代码需要加急处理,于是在异步代码加入任务队列后的基础上有分为微任务和宏任务。简单来说就是微任务是有特权的,可以插在宏任务前头。
-
微任务:promise.then(), process.nextTick(), MutationObserver
-
宏任务:script, setTimeout(), setInterval(), ajax, I/O, UI-rendering
执行顺序
- 先执行同步代码 (这属于宏任务),这个过程如果遇到异步任务,就存入对应的队列
- 同步执行完之后,执行微任务队列中的代码
- 微任务全部结束后,有需要的话就渲染页面
- 执行宏任务 (下一次循环的开始)
async await
- 函数前面加一个 async 等同于函数内部返回了一个 promise实例对象
- await 必须跟 async 配合使用,并且 await 后面如果不接一个 promise 对象的话,await 便无法约束它
- await fn() 把 fn() 当成同步看待, 是因为 await 会把它后续的代码挤到微任务队列中去
js
console.log('script start');
async function async1() {
await async2()
console.log('async1 end');
}
async function async2() {
console.log('async2 end');
}
async1()
setTimeout(() => {
console.log('setTimeout');
}, 0)
new Promise((resolve, reject) => {
console.log('promise');
resolve()
})
.then(() => {
console.log('then1');
})
.then(() => {
console.log('then2');
});
console.log('script end');
如果能将上面这段弯弯绕绕的代码吃透,那基本就跨过了 event-loop 的第一道门槛。来逐行推演一遍。
代码执行推演
第一步,主线程从上往下执行同步代码:
console.log('script start')→ 立即输出 script startasync1()被调用,进入 async1 函数体await async2()→ 先执行 async2,输出 async2 end ;await 会把 async1 函数 await 之后的代码(即console.log('async1 end'))挂起,塞入微任务队列- async1 函数返回一个 Promise(因为 async 修饰),但此时没人 await async1() 的结果,继续往下走
setTimeout→ 宏任务,塞入宏任务队列new Promise(...)→ 构造函数同步执行,输出 promise ;resolve()调用后,第一个.then被塞入微任务队列console.log('script end')→ 立即输出 script end
此时同步代码跑完了,主线程开始清空微任务队列:
- 第一个微任务:
console.log('async1 end')→ 输出 async1 end - 第二个微任务:
.then(() => console.log('then1'))→ 输出 then1 ;then1 执行完后,第二个.then被塞入微任务队列 - 第三个微任务:
console.log('then2')→ 输出 then2
微任务清空后,执行宏任务:
setTimeout回调 → 输出 setTimeout
最终输出顺序:
arduino
script start
async2 end
promise
script end
async1 end
then1
then2
setTimeout
有人可能会问:为什么 async1 end 在 then1 前面?因为 await async2() 挂起 async1 后续代码的时机,早于 new Promise 的 .then 被塞入微任务队列的时机。微任务队列是先进先出的,所以 async1 end 先执行。
不过这里有个细节在不同浏览器和不同 V8 版本下结果可能不同。V8 73 之前(Chrome 73 以下),async1 end 的输出位置会有差异,因为早期 V8 对 await 的实现是用了两个微任务,后来优化成了一个。这也是为什么有的面试题答案在不同资料里对不上的原因。
await 的底层转换
async/await 说到底是 Promise 的语法糖,但 V8 引擎在编译阶段会对它做一次转换。大致规则如下:
js
async function async1() {
await async2()
console.log('async1 end')
}
会被转换成类似这样的形式:
js
function async1() {
return async2().then(() => {
console.log('async1 end')
})
}
也就是说,await xxx 本质上等价于把 await 后面的代码包进一个 .then 回调里。这就是为什么 await 后面的代码不会立即执行,而是被推到微任务队列。
但 V8 实际的实现比这复杂一点:它会用 PromiseResolveThenableJob 来处理 await 的操作数,确保即使 await 后面跟的不是 Promise(比如 await 42),也会被包成一个 resolved Promise。这意味着 await 后面的代码至少要经过一次微任务轮转才会执行,不会"跳队"。
async 函数的返回值
js
async function foo() {
return 1
}
// 等价于
function foo() {
return Promise.resolve(1)
}
async 修饰符保证函数一定返回 Promise。如果函数体里 return 了一个 Promise,那就直接返回它;如果 return 了一个普通值,就包成 Promise.resolve(value);如果没 return,返回 Promise.resolve(undefined)。
这个设计的好处是调用方永远可以 .then() 链式调用,不用担心"有时候是 Promise,有时候不是"的问题。
浏览器和 Node.js 的 event-loop 差异
很多人以为 event-loop 只有一套规则,实际上浏览器和 Node.js 的实现是有区别的。
浏览器的 event-loop
浏览器的规范来自 HTML Living Standard,一个完整的循环:
- 执行一个宏任务(从宏任务队列取一个)
- 执行所有微任务(清空微任务队列)
- 判断是否需要渲染(requestAnimationFrame、UI rendering)
- 如果需要渲染,执行 rAF 回调
- 渲染页面
- 回到第 1 步
关键点:每一轮循环只执行一个宏任务,然后清空所有微任务。不会出现宏任务还没执行完就去执行下一个宏任务的情况。
Node.js 的 event-loop
Node.js 用的是 libuv,循环分为 6 个阶段(phase):
- timers → 执行 setTimeout / setInterval 到期的回调
- pending callbacks → 执行系统级回调(如 TCP 错误回调)
- idle, prepare → 内部使用
- poll → 获取新的 I/O 事件,执行 I/O 回调
- check → 执行 setImmediate 回调
- close callbacks → 执行 close 事件回调(如 socket.on('close'))
每个阶段切换之间会清空微任务队列(process.nextTick 和 Promise.then)。注意 process.nextTick 的优先级高于普通微任务,它会在每个阶段结束后、微任务之前执行。
一个经典差异案例
js
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
在浏览器里没有 setImmediate,不用考虑。在 Node.js 里,这两行代码的输出顺序不确定 ------ 取决于进入 timers 阶段时 1ms 的定时器是否已经到期。
但如果把它们放在 I/O 回调里:
js
const fs = require('fs')
fs.readFile('test.txt', () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
这种情况下 immediate 一定先于 timeout 输出。因为 I/O 回调在 poll 阶段执行完后,下一个阶段就是 check(setImmediate),再下一轮循环才会到 timers。
这个知识点看起来像面试八股,但它背后体现的是 libuv 阶段切换的设计逻辑:阶段之间是有固定顺序的,不是随机调度。
实际开发中的 event-loop 陷阱
陷阱一:阻塞主线程
js
console.log('start')
setTimeout(() => console.log('timeout'), 0)
// 模拟耗时同步操作
const start = Date.now()
while (Date.now() - start < 2000) {}
console.log('end')
输出:start → (等 2 秒)→ end → timeout
setTimeout 的回调再怎么延迟设为 0,也得等主线程把同步代码跑完才会执行。这就是为什么前端性能优化一直强调"不要在主线程做耗时操作"------ 一旦主线程被阻塞,UI 渲染、用户交互、定时器全部会卡住。
如果真要做耗时计算,用 Web Worker 开一个独立线程,或者用 requestIdleCallback 把任务拆成小块在空闲时间执行。
陷阱二:微任务递归导致页面卡死
js
function recursive() {
Promise.resolve().then(recursive)
}
recursive()
这段代码不会栈溢出,因为每次都是通过微任务调度的,调用栈不会累积。但它会无限递归地塞微任务,导致宏任务永远得不到执行机会------页面完全冻结,setTimeout、UI 渲染、用户点击全部失效。
同理:
js
function loop() {
console.log('loop')
queueMicrotask(loop)
}
loop()
queueMicrotask 是标准 API,直接把回调塞入微任务队列。效果一样------页面卡死。
陷阱三:Promise 链和 async/await 混用的时序问题
js
async function foo() {
console.log('foo start')
await bar()
console.log('foo end')
}
function bar() {
return new Promise(resolve => {
console.log('bar inside promise')
resolve()
})
}
foo()
console.log('main end')
执行顺序:foo start → bar inside promise → main end → foo end
为什么 main end 在 foo end 前面?因为 await bar() 把 foo end 推到了微任务队列,主线程的同步代码 console.log('main end') 先执行。
这种时序问题在面试里经常被问到,实际开发中如果不注意,可能会导致依赖执行顺序的逻辑出 bug。比如你以为 await 之后的代码会在某个同步操作之前执行,结果恰恰相反。
从宏观看微观:一张图理清全链路
把前面所有概念串起来,JavaScript 异步执行的完整链路是:
arduino
代码执行
│
├─ 同步代码 → 主线程直接执行
│
└─ 异步代码 → 交给 Web API / Node API 处理
│
├─ 计时器(setTimeout)→ 计时到期后,回调进入宏任务队列
├─ I/O 操作(fetch, fs)→ 操作完成后,回调进入宏任务队列
├─ Promise.then / await 后续 → 进入微任务队列
└─ process.nextTick(Node.js)→ 进入 nextTick 队列(优先级最高)
│
▼
event-loop 循环调度
├─ 浏览器:宏任务 → 清空微任务 → 渲染 → 下一轮
└─ Node.js:阶段切换 → 清空 nextTick → 清空微任务 → 下一阶段
理解了这张图,再去看任何异步代码,都能画出它的执行时序。不是背答案,而是从原理推导。
总结
回头看开头那些困惑:
- "顺序执行的代码,结果却先拿到后面的请求" → 异步请求不阻塞主线程,回调在任务队列里等机会插队
- "多开一个标签页,内存飙升" → 每个标签页是一个独立进程,有自己的 V8 实例和内存堆
- "setTimeout 100ms 实际更慢" → setTimeout 的延迟只是"最早可执行时间",如果主线程在忙,回调得排队等
从进程线程到 event-loop 到 async/await,整条链路的核心思想其实就一个:JavaScript 是单线程的,但它背后的运行环境不是。浏览器和 Node.js 提供了多线程的 Web API / libuv 来处理 I/O,通过 event-loop 把异步结果回调到主线程执行。async/await 是这套机制的语法糖,让异步代码写起来像同步,但底层还是 Promise + 微任务调度。
把这些底层逻辑想透了,写代码时自然就知道什么时候该用 Promise、什么时候该用 async/await、什么时候要注意微任务插队的问题。不是为了面试背八股,是为了写出真正靠谱的异步代码。