一、前言:你以为的"异步",可能只是"异象"
很多人觉得异步模型是指 setTimeout、Promise、async/await 的执行顺序问题。但我们要讨论的是:
- 异步是如何调度的?
- 谁在管理这些任务?
- 什么是"事件循环"?它真的存在吗?
- 浏览器或 Node.js 是如何实现这套模型的?
二、宏观认知:JS 的执行环境不是 JS 自己
❗ JS 本身不支持异步!
JS 引擎(如 V8)只有一件事:执行 JS 代码。
一旦你调用异步 API(如 setTimeout),其实是调用了宿主提供的功能,比如:
API | 实际由谁实现 |
---|---|
setTimeout |
浏览器定时器线程 / libuv |
fetch |
浏览器网络线程 |
fs.readFile |
Node.js IO 线程池 |
Promise 本身 |
JS 引擎调度(V8 内部) |
关键点:JavaScript 是语言,异步能力是运行时赋予的。
三、事件循环:不存在的"循环结构"
"事件循环"这个名字很容易让人误解成 while(true) 那种循环,其实它是调度协议,不是 JS 代码结构。
🔄 它是如何调度的?
浏览器(或 Node.js)维护多个任务队列,遵循如下规则:
- 执行一个宏任务(macro task)
- 清空所有微任务(micro tasks)
- 处理渲染或 I/O
- 重复上述流程
四、任务源(Task Source) & 执行优先级
按照 WhatWG HTML 标准,任务来源大致如下:
类型 | 属于哪种任务队列 | 优先级 |
---|---|---|
setTimeout |
宏任务 | 低 |
Promise.then |
微任务 | 高 |
queueMicrotask |
微任务 | 高 |
requestAnimationFrame |
渲染前回调 | 特殊 |
MessageChannel |
宏任务(消息任务) | 中等 |
fetch().then |
微任务 | 高 |
mutationObserver |
微任务 | 高 |
五、微任务队列的本质:V8 实现分析
🔍 microtask checkpoint
在 V8(以及 SpiderMonkey、JavaScriptCore)中,每次执行完一个任务后,都会进入一个叫:
MicrotaskCheckpoint
的阶段
这个阶段里,会:
- 检查是否注册了微任务;
- 依次同步执行这些任务(FIFO);
- 若微任务中又注册新微任务,会继续直到清空为止;
- 若抛出错误,也会进入 host 的错误处理机制。
scss
// V8 调度伪代码
RunOneTask() {
ExecuteMacroTask();
RunMicrotasks(); // microtask checkpoint
MaybeRender();
}
🌊 微任务是"嵌套执行"的
这就是为什么你可以写出"递归注册微任务"的代码:
javascript
Promise.resolve().then(function foo() {
console.log('tick');
Promise.resolve().then(foo);
});
输出是无限刷屏,因为每个微任务执行后注册一个新的微任务。
六、await 背后到底发生了什么?
📖 编译产物 VS 执行策略
javascript
async function run() {
await sleep(1000);
console.log('after');
}
其实会被编译成:
javascript
function run() {
return sleep(1000).then(() => {
console.log('after');
});
}
但 V8 的执行策略是:
- 遇到
await
,保存当前执行上下文(ExecutionContext) - 立即返回控制权
- 将后续代码注册为微任务,等待 Promise 解析完成
- 由微任务调度器恢复执行上下文并接着跑
👉 所以说 await
并不会阻塞线程,它只是"中断后注册续接任务",让出 CPU 控制权。
七、真正的异步线程:Web Worker / libuv threadpool
💡 Web Worker(浏览器)
- 属于浏览器提供的真正多线程
- 没有 DOM 权限(沙箱模型)
- 可以并行执行 heavy task,不阻塞 UI
🧵 Node.js 的 libuv threadpool
- Node 本身是单线程事件循环
- 但 IO 会被调度进 threadpool(最多 4 线程,可调)
- 通过事件机制通知主线程执行回调
这才是现代 JS 环境里"真·并行"的部分,Event Loop 并不意味着整个程序只有一个线程。
八、你从没思考过但必须知道的深度问题
❓ 为什么 setTimeout(fn, 0)
不是立即执行?
→ 它只是"最快加入下一个宏任务队列",而不是立即抢执行栈。
❓ 微任务为什么必须先于下一个宏任务?
→ 因为这样才不会出现状态残留,比如 .then()
中变更了值,必须马上被消费。
❓ requestAnimationFrame 为什么总是最后执行?
→ 它是专为视觉帧刷新设计,每次浏览器准备渲染前才会调用一次。插在事件循环之后,重绘之前。
九、工程落地中的异步细节
⏱ 如何避免 setTimeout 造成节奏不一致?
ini
const frameTime = 1000 / 60;
let last = Date.now();
setTimeout(function loop() {
const now = Date.now();
const delta = now - last;
last = now;
logic(delta);
setTimeout(loop, frameTime - (delta % frameTime));
}, frameTime);
用于实现和 requestAnimationFrame 类似的帧稳定调度
🔚 总结:事件循环不是 JS 的语法,是宿主环境的调度协议
异步模型 ≠ Promise
异步模型 = JS 执行模型 + 调度协议 + 宿主 API + 多线程协作机制
掌握异步模型,不能靠死记执行顺序,而是要:
- 看标准(HTML、ECMA、WHATWG)
- 看实现(V8、libuv、Chromium)
- 看调度机制(任务队列、微任务、渲染帧)