单线程不卡死的真相: 调用栈跑完 → 清空全部微任务 → 取一个宏任务 — 微任务永远插队, 阻塞栈等于阻塞世界
JS 是"一个柜台、一个店员"的单线程模型: 调用栈是店员的手, 一次只处理一单; 宿主环境(浏览器/Node)是后厨, 定时器、网络、I/O 这些慢活都外包出去, 干完把单子放进两个队列。店员每处理完手头的单(栈清空), 会先把"加急窗口"微任务队列整队清空, 再从普通队列拿一件新活。所谓异步, 不是同时干, 而是"慢活外包 + 单子排队"的调度艺术。
理解它只需要记住一句: 微任务是插队者, 宏任务是排队者, 调用栈是唯一的车间。所有"顺序之谜"面试题、所有"页面为什么卡"、所有"回调为什么还没执行", 都能从这三者的关系里推出答案。
function foo() { bar(); } function bar() { debugger; } // 关键: DevTools 里看到的栈就是它 foo(); // 栈: main → foo → bar, bar 返回后逐层弹出
setTimeout(() => console.log('宏'), 0); Promise.resolve().then(() => console.log('微')); // 关键: 即便延时 0ms, 微任务仍先执行 → 输出 微 宏
Promise.then/catch/finally、queueMicrotask()、MutationObserver; Node 里还有 process.nextTick(更优先)。 queueMicrotask(() => console.log('m1')); Promise.resolve().then(() => console.log('m2')); // 关键: 同一队列按入队序 → m1 m2
setTimeout/setInterval、I/O 回调、UI 事件、setImmediate(Node); 每轮 tick 只消费一个, 浏览器趁机渲染。 setTimeout(t1); setTimeout(t2);
// 关键: t1 与 t2 之间可能隔着一次渲染/一次微任务清空rAF 回调 → style → layout → paint。所以"下一帧"的改动要放进 rAF 而不是微任务。 requestAnimationFrame(() => {
el.style.transform = 'translateX(100px)'; // 关键: 本次绘制前生效
});await 之后的代码 = 包进 then 的微任务。async 函数遇到第一个 await 让出栈, 后续逻辑全部异步。 async function f() { console.log('a'); // 同步段, 立即执行 await null; console.log('b'); // 关键: 这里已是微任务 } f(); console.log('sync'); // → a sync b
setImmediate) → close 轮转; 微任务在每个阶段之间清空, 所以 Node 里 setImmediate 与 setTimeout(0) 的先后在主模块不确定、在 I/O 回调内确定。 fs.readFile(f, () => {
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'));
// 关键: I/O 回调属于 poll 阶段, 下一阶段是 check → immediate 必先
});Promise.resolve().then(() => console.log('promise')); process.nextTick(() => console.log('nextTick')); // 关键: nextTick 是独立且更优先的队列 → nextTick promise
let t = Date.now(); setTimeout(() => console.log(Date.now() - t)); for (let i = 0; i < 3e9; i++); // 阻塞 2s // 关键: 打印 ≈2000+ms, 而不是 0 或 4
Worker 线程真并行, I/O 密集靠宿主线程池+epoll 高并发。 const w = new Worker('worker.js'); w.postMessage(bigArray); // 关键: 主线程不卡, worker 真开线程 w.onmessage = e => render(e.data);
perf_hooks.monitorEventLoopDelay, 浏览器用长任务 API(PerformanceObserver + longtask)。 const { monitorEventLoopDelay } = require('perf_hooks'); const h = monitorEventLoopDelay({ resolution: 20 }); h.enable(); // 关键: h.percentile(99) / 1e6 毫秒, 持续大于 100ms 说明有长任务
function storm() { Promise.resolve().then(storm); } // 关键: storm() 之后渲染/定时器全部饿死, 页面假死
一次性渲染 10 万行, 同步任务占栈 4 秒, 用户点啥都没反应。把工作切成 50ms 的小块, 每块之间让出事件循环:
async function renderRows(rows) { const CHUNK = 500, t0 = performance.now(); for (let i = 0; i < rows.length; i += CHUNK) { renderChunk(rows.slice(i, i + CHUNK)); // 关键: 每 50ms 让出一次, 点击/渲染/网络回调都能插进来 if (performance.now() - t0 > 50) { await new Promise(r => setTimeout(r)); // 宏任务让路, 渲染有机会跑 t0 = performance.now(); } } }
页面从"卡死 4s"变为全程可交互, 输入延迟 P99 从 4000ms 降到 60ms 以内。
线上日志时序"不合理": 数据库回调写下的日志, 出现在同函数后面一行日志之前。本质是事件循环时序, 画栈推一遍就能定位:
async function handler(req) { log.info('A 入口'); const user = await db.query('select ...'); // 让出栈, I/O 外包 log.info('C 拿到结果'); } log.info('B 其他请求'); // 关键: await 让出期间, 其他请求的同步段先进栈 // 日志顺序 A B C 不是 bug: await 之后的代码在微任务里排队
按"同步段 → 微任务 → 宏任务"三段归因, 团队再也不把时序日志当灵异事件提 issue。
一个大对象被 Proxy 拦截, 每个 get 都 queueMicrotask 通知订阅者; 遍历 10 万属性 = 10 万微任务连成串, 渲染被无限插队:
// 错误形态: 通知也走微任务 → 自我繁殖, 渲染饿死 function notify() { queueMicrotask(notify); } // 修复: 通知合并进宏任务帧, 每帧最多一次 let scheduled = false; function notifyBatch() { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; flush(); }); }
假死 3s → 每帧一次批量刷新, 与 Vue nextTick 的"批处理"是同一原理。
单进程 Node 扛 1 万并发连接, 靠的是"epoll 看门 + 回调入队"; 但 fs/dns 走的是 libuv 线程池, 池子默认只有 4 个:
// UV_THREADPOOL_SIZE 上限 1024, 默认 4 $ UV_THREADPOOL_SIZE=16 node server.js // 关键: 网络 I/O 用 epoll(不占池), fs/dns/crypto 占线程池 // 4 个池被 4 个慢查询占满 → 后续文件操作全部排队, 表现为"莫名变慢"
排查过一起"接口偶发慢 2s": 根因是同进程图片缩略图占满线程池, 调大池子 + 拆服务后恢复。
每 5s 拉一次上游接口, 网络抖动时单次耗时 8s, setInterval 不管死活继续投递, 任务堆积雪崩:
// 错: interval 不管上一次是否完成 setInterval(poll, 5000); // 对: 递归 setTimeout, 上一次完成才排下一次 async function poll() { try { await check(); } finally { setTimeout(poll, 5000); // 关键: 永不重叠, 慢了自动降频 } } poll();
切到后台 tab 后心跳从 1s 一次变成 60s 一次, 服务端误判下线。浏览器对后台页定时器节流(最小 1s, 强休眠策略可达分钟级):
// 对时序敏感的心跳改走 Web Worker: worker 内不被节流 const beat = new Worker('heartbeat.js'); beat.onmessage = () => sendBeat(); // worker 里 setInterval(1000) 不被钳制 // 关键: 或服务端放宽超时 + visibilitychange 显式上报状态 document.addEventListener('visibilitychange', () => reportVisibility(document.hidden));
一次操作触发 30 个数据变更、30 次订阅回调, 每个都直接写 DOM 就是 30 次重排。用微任务把同一轮的写合并成一次:
let queued = false; const subs = new Set(); function subscribe(fn) { subs.add(fn); if (!queued) { queued = true; queueMicrotask(() => { // 关键: 同轮多次触发只 flush 一次 queued = false; for (const fn of subs) fn(); }); } }
重排次数 30 → 1, 这就是 Vue/React 批量更新调度器的最小内核。
用 PerformanceObserver 抓 longtask, 上报"哪段代码占栈超过 50ms", 比 P99 掉帧图更有归因价值:
new PerformanceObserver(list => { for (const e of list.getEntries()) { beacon('/perf/longtask', { dur: e.duration, start: e.startTime, attr: e.attribution?.[0]?.name, // 归因到 script/script 外 }); } }).observe({ entryTypes: ['longtask'] });
收到 SIGTERM 直接 process.exit, 在途微任务(已落库未提交的收尾)全部蒸发。先停接客, 再等事件循环自然排空:
process.on('SIGTERM', async () => { server.close(); // 1. 停止接新请求 await drainInflight(); // 2. 等在途请求(事件循环继续跑) await flushLogs(); // 3. 刷缓冲 process.exit(0); // 关键: 让循环自然排空后再退 });
老代码里一个没接住的 Promise 拒绝, 升级 Node 后变成进程退出。兜底上报 + 定位源头:
process.on('unhandledRejection', (reason, p) => { logger.error('unhandled rejection', { reason, promise: String(p) }); // 关键: 记录后不要吞掉继续跑, 短期内应修复源头并让它崩 }); // 源头修复: 每个 await 链要么 try/catch 要么 .catch 落到统一错误层
queueMicrotask。 // 错: 以为输出 宏 微 setTimeout(() => console.log('宏')); Promise.resolve().then(() => console.log('微')); // 对: 记住顺序 微 宏; 0ms 从不代表"先跑"
let 每轮新绑定。 // 错: for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // → 3 3 3 // 对: for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // → 0 1 2
await。 // 错: const user = getUser(); // user 是 Promise, 不是用户 // 对: const user = await getUser(); // → { id: 1, ... }
for...of。 // 错: ids.forEach(async id => await call(id)); // 全并发 // 对: for (const id of ids) await call(id); // → 逐个串行
rAF 或宏任务。 // 错: function loop() { Promise.resolve().then(loop); } // 对: function loop() { setTimeout(loop); } // 每轮让渲染插一脚
// 错: const data = JSON.parse(huge); // 300ms 阻塞 // 对: 交给 worker worker.postMessage(raw); worker.onmessage = e => use(e.data);
setTimeout。 // 错: setInterval(poll, 5000); // poll 要 8s 就背靠背 // 对: async function poll(){ try{...} finally{ setTimeout(poll, 5000);} }
Worker, 或 visibilitychange 显式上报。 // 错: 主页面 setInterval(beat, 1000) 切后台变 60s // 对: new Worker('beat.js'); // worker 内不被节流
bind。 // 错: setTimeout(this.refresh, 100); // this ≠ 实例 // 对: setTimeout(() => this.refresh(), 100);
server.close + 排空后退出。 // 错: process.on('SIGTERM', () => process.exit(0)); // 对: 先停接客, await 排空, 再 exit —— 让事件循环跑完最后一轮
setImmediate。 // 错: function spin(){ process.nextTick(spin); } // 对: function spin(){ setImmediate(spin); } // check 阶段, I/O 能插队
// 错: const r = new XMLHttpRequest(); r.open('GET', u, false); // 对: const r = await fetch(u);
reject。 // 错: new Promise((res, rej) => setTimeout(() => { throw 'x'; })); // → uncaught, 不会 reject // 对: new Promise((res, rej) => setTimeout(() => rej('x')));
return。 // 错: p.then(d => { save(d); }).then(() => done()); // done 提前跑 // 对: p.then(d => save(d)).then(() => done()); // 等保存完成
Promise.all + allSettled。 // 错: 10 个请求 × 200ms = 2s // 对: await Promise.all(urls.map(u => fetch(u))); // → ~200ms
setTimeout/Worker。 // 错: 切片循环用 rAF 驱动, 用户切走就停 // 对: if (document.hidden) setTimeout(step); else requestAnimationFrame(step);
MessageChannel 宏任务(无钳制)。 const ch = new MessageChannel(); ch.port1.onmessage = step; // 关键: 宏任务但不被 4ms 钳制 const yield = () => ch.port2.postMessage(null);
window.onunhandledrejection = e => {
report(e.reason); // 关键: 只兜底上报, 不承诺"处理后没事"
};monitorEventLoopDelay, 浏览器 longtask 观测。 const h = monitorEventLoopDelay(); h.enable(); setInterval(() => gauge.set(h.percentile(99) / 1e6), 5000); // 关键: 持续大于 100ms = 存在长任务, 去找那个宏任务
rAF, 需要写后读量尺寸自己 getBoundingClientRect 前想清楚时序。 el.style.height = '100px'; Promise.resolve().then(() => { // 错: 以为这里能"看到"新渲染 —— 其实还没 paint }); // 对: 要等渲染完, 用 rAF(可能还要 rAF 套 rAF)