01. JavaScript 是单线程的——那异步怎么来的
JavaScript 的单线程意味着同一时间只能做一件事——只有一个调用栈。如果一个请求卡住,后面所有操作都等着。
但浏览器/Node.js 不是单线程的——它们有 Web APIs(浏览器)或 libuv(Node.js)提供的线程池。JS 代码调异步操作时(setTimeout、fetch、文件读写),实际工作交给这些幕后线程去干。
干完之后怎么通知 JS?通过事件循环——幕后线程把回调函数放到一个任务队列里。JS 主线程执行完当前代码后,从任务队列取出回调执行。这就是 JavaScript 异步非阻塞的秘密。
打个比方:你(JS 主线程)是前台收银,顾客下单后你告诉厨房(Web API),然后继续收下一位。菜好了厨房按铃(回调进队列),你手上的顾客处理完了就回头端菜。
javascript
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// 输出:1 4 3 2
// 为什么?因为微任务(Promise)比宏任务(setTimeout)优先级高02. 宏任务与微任务——两个队列
事件循环里任务分两个级别:
宏任务(Macro-task / Task)——setTimeout、setInterval、I/O 回调、UI 渲染(浏览器)、setImmediate(Node.js)。
微任务(Micro-task)——Promise.then/catch/finally、async/await(await 后面的部分)、MutationObserver(浏览器)、queueMicrotask()、process.nextTick(Node.js,比微任务还先执行)。
事件循环的每次迭代(tick):
1. 执行一个宏任务(从宏任务队列取一个)
2. 执行所有微任务(微任务队列清空——包括微任务执行中产生的新微任务,也一并执行完)
3. 如果需要且时间到了,渲染更新(浏览器)
4. 开始下一次迭代
这就是为什么同一个流程里微任务总比下一个宏任务先执行——每次拿一个宏任务后必先把微任务队列清空。
javascript
// 执行顺序测试
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => { console.log('promise 1'); })
.then(() => { console.log('promise 2'); });
console.log('end');
// 输出:
// start
// end
// promise 1
// promise 2
// timeout
//
// 流程:同步代码先执行 → 开始事件循环
// 微任务队列有 promise → 全部清空
// 宏任务队列有 timeout → 执行process.nextTick(Node.js)的优先级比 Promise.then 还高,在微任务队列之前执行。一个 tick 里 nextTick 队列会先清空。
03. async/await 在事件循环里的行为
async/await 本质上是 Promise 的语法糖,但在事件循环里的行为有个细节:
async 函数被调用时同步部分立即执行(new Promise 的 executor 是同步的)。await 后面会被拆成 .then()——await 之前的代码同步执行,await 之后的代码变成微任务。
所以:await promise 后面的代码相当于 promise.then(() => { 后面的代码 }),被放入微任务队列。
多个 async/await 的执行顺序取决于 Promise 何时 resolve。用 await 写的代码看起来像同步,但事件循环里的实际顺序还是异步的。
javascript
async function main() {
console.log('A');
await Promise.resolve();
console.log('B');
}
main();
Promise.resolve().then(() => console.log('C'));
console.log('D');
// 输出:A D B C
// 但注意!B 和 C 实际上都在微任务队列里
// 因为 await 后面的 B 相当于 .then(() => console.log('B'))
// 它是第一个进入微任务队列的,所以 B 先于 C 执行await 后面的代码优先级跟 .then 一样——都是微任务。多个 await 的代码会被排队到微任务队列里依次执行。
04. Node.js 的事件循环与浏览器有什么不同
Node.js 的事件循环基于 libuv,比浏览器的要复杂:
Node.js 的事件循环分 6 个阶段,每个阶段处理特定的回调:
1. timers——setTimeout/setInterval 回调
2. pending callbacks——系统操作的回调(如 TCP 错误)
3. idle/prepare——内部使用
4. poll——获取新的 I/O 事件(大部分回调在这执行)
5. check——setImmediate 回调
6. close callbacks——close 事件的回调(如 socket.on('close'))
每个阶段之间会检查是否有 process.nextTick 和微任务需要执行。
setTimeout(fn, 0) 和 setImmediate(fn) 的区别:在主模块里直接调用时 setTimeout 先执行(取决于当时事件循环在哪个阶段),在 I/O 回调里 setImmediate 先执行。
javascript
// Node.js 中 setTimeout vs setImmediate
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 在主模块里:输出顺序不确定
// 在 I/O 回调里:setImmediate 一定先执行
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出:immediate 先,timeout 后
});Node.js 的 process.nextTick 在同一个 tick 里,在任何微任务之前执行。可以用来做关键操作的优先级保障。
05. 事件循环的实际影响——卡顿与性能
事件循环被阻塞是 Node.js 性能问题的根本原因——一个同步的 CPU 密集操作会卡住事件循环,所有 I/O 都在排队等。
常见阻塞场景:大 JSON 解析、大量同步加密解密、复杂的正则匹配(灾难性回溯)、大循环里的同步操作。
解决方案:
1. 把 CPU 密集任务拆成小块用 setImmediate 或 async/await 让步事件循环
2. Worker Threads 把计算放到独立线程
3. child_process 开子进程跑重任务
4. 用 stream 而不是一次性读取大文件
排查工具:Node.js 内置的 perf_hooks、clinic.js、0x 火焰图、--trace-event-categories。这些工具能看出事件循环哪个阶段吃了多少时间。
javascript
// 阻塞事件循环的代码(坏)
function blockingLoop() {
let sum = 0;
for (let i = 0; i < 10_000_000_000; i++) {
sum += i;
}
return sum;
}
// 让步式——把大任务拆开
async function nonBlockingLoop() {
let sum = 0;
for (let i = 0; i < 1000; i++) {
for (let j = 0; j < 10_000_000; j++) {
sum += j;
}
// 每轮让步事件循环处理其他任务
await new Promise(resolve => setImmediate(resolve));
}
return sum;
}CPU 密集任务不要放在主线程——一次 10 亿的循环能把事件循环卡住几秒。期间服务器对所有请求都不响应。
知识测验
第 1/5 题正确 0
JavaScript 是单线程的,那异步操作怎么来的?