JavaScript · 事件循环 Event Loop

单线程不卡死的真相: 调用栈跑完 → 清空全部微任务 → 取一个宏任务 — 微任务永远插队, 阻塞栈等于阻塞世界

回调注册 落定 入队 下一轮: 先清微任务, 再取一个宏任务进栈执行 调用栈 Call Stack bar() ← 栈顶执行中 foo() main() 栈非空 = 忙: 队列再急也得等 宿主能力 (浏览器 Web APIs / Node libuv) 定时器 setTimeout / setInterval 到期后回调进宏任务队列 DOM 事件 · fetch · 文件/网络 I/O 完成回调进队列, 线程不干等 渲染调度 rAF / requestIdleCallback rAF 在每次绘制前执行, 后台页不触发 微任务队列 Microtasks Promise.then · queueMicrotask · MutationObserver 特权: 本轮 tick 内全部清空, 清空期间新进的也清 宏任务队列 Macrotasks setTimeout · setInterval · I/O 回调 · UI 事件 规矩: 一轮 tick 只取一个, 取完先看微任务 ↓ 一次完整 tick 的时间线 (浏览器视角) ① 同步代码跑完 调用栈清空 ② 清空全部微任务 包括执行中新增的微任务 ③ 渲染 (浏览器) rAF → style → layout → paint ④ 取 1 个宏任务 回到 ① 循环 同步死循环 / 一个 500ms 的长任务 = ②③④ 全部饿死 → 点击无响应、动画掉帧、接口超时, 这就是"卡死"的全部真相 console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4); // 同步先行: 1、4 → 微任务插队: 3 → 宏任务最后: 2 // 输出: 1 4 3 2 (面试高频, 生产排障同款心智模型) Legend 调用栈 (同步执行) 宿主能力 (浏览器/Node 提供) 微任务 (tick 内清空) 宏任务 (每轮一个) 阻塞/危险路径

微任务永远插队

  • • 栈一空, 先清空全部微任务, 再谈宏任务
  • • 清空过程中新产生的微任务也属于本轮
  • • 所以 Promise.then 总是快过 setTimeout(fn, 0)
  • • async/await 只是微任务的语法糖

一轮只取一个宏任务

  • • 取 1 个宏任务 → 执行 → 又先清微任务
  • • 浏览器在两轮之间插入渲染 (rAF/style/paint)
  • • 长宏任务直接推迟渲染, 掉帧的元凶
  • • Node 里 setImmediate 在 check 阶段跑

阻塞栈 = 阻塞世界

  • • 单线程: 一个 500ms 同步计算卡死一切
  • • I/O 不阻塞是因为宿主线程代劳
  • • 真并行只有 Worker 线程一条路
  • • 事件循环延迟是前端/Node 的核心健康指标

💡 一句话理解

JS 是"一个柜台、一个店员"的单线程模型: 调用栈是店员的手, 一次只处理一单; 宿主环境(浏览器/Node)是后厨, 定时器、网络、I/O 这些慢活都外包出去, 干完把单子放进两个队列。店员每处理完手头的单(栈清空), 会先把"加急窗口"微任务队列整队清空, 再从普通队列拿一件新活。所谓异步, 不是同时干, 而是"慢活外包 + 单子排队"的调度艺术。

理解它只需要记住一句: 微任务是插队者, 宏任务是排队者, 调用栈是唯一的车间。所有"顺序之谜"面试题、所有"页面为什么卡"、所有"回调为什么还没执行", 都能从这三者的关系里推出答案。

🧠 必知必会 必考 & 必会

调用栈
函数调用形成栈帧, 后进先出; 栈非空时事件循环完全停摆。所谓"单线程"指的就是这一根栈。
function foo() { bar(); }
function bar() { debugger; } // 关键: DevTools 里看到的栈就是它
foo();  // 栈: main → foo → bar, bar 返回后逐层弹出
微任务 vs 宏任务
两者都是回调队列, 差别在取出的时机: 微任务在每次栈清空后全部清空, 宏任务每轮只取一个。
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)'; // 关键: 本次绘制前生效
});
async/await 的真面目
await 之后的代码 = 包进 then 的微任务。async 函数遇到第一个 await 让出栈, 后续逻辑全部异步。
async function f() {
  console.log('a');          // 同步段, 立即执行
  await null;
  console.log('b');          // 关键: 这里已是微任务
}
f(); console.log('sync'); // → a sync b
Node 六阶段
timers → pending → poll → check(setImmediate) → close 轮转; 微任务在每个阶段之间清空, 所以 Node 里 setImmediate 与 setTimeout(0) 的先后在主模块不确定、在 I/O 回调内确定。
fs.readFile(f, () => {
  setImmediate(() => console.log('immediate'));
  setTimeout(() => console.log('timeout'));
  // 关键: I/O 回调属于 poll 阶段, 下一阶段是 check → immediate 必先
});
process.nextTick
Node 的特权插队队列, 优先于 Promise 微任务, 每阶段间与每宏任务后都会清空; 递归 nextTick 会饿死 I/O。
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 关键: nextTick 是独立且更优先的队列 → nextTick promise
setTimeout(fn, 0) 语义
"最早下下轮执行", 不是"立即"。浏览器有 4ms 嵌套钳制与后台节流; 到期时间一到也只是入队, 何时执行看队列前面还有谁。
let t = Date.now();
setTimeout(() => console.log(Date.now() - t));
for (let i = 0; i < 3e9; i++); // 阻塞 2s
// 关键: 打印 ≈2000+ms, 而不是 0 或 4
事件循环与并行
事件循环是"调度", 不是"并行"。CPU 密集用 Worker 线程真并行, I/O 密集靠宿主线程池+epoll 高并发。
const w = new Worker('worker.js');
w.postMessage(bigArray);      // 关键: 主线程不卡, worker 真开线程
w.onmessage = e => render(e.data);
事件循环延迟监控
健康指标不是 CPU, 是"任务想跑但跑不上"的延迟。Node 用 perf_hooks.monitorEventLoopDelay, 浏览器用长任务 API(PerformanceObserver + longtask)。
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
// 关键: h.percentile(99) / 1e6 毫秒, 持续大于 100ms 说明有长任务
微任务风暴
微任务清空语义是双刃剑: 自我繁殖的微任务(链式 then/递归 queueMicrotask)会无限插队, 宏任务与渲染永远排不上。
function storm() { Promise.resolve().then(storm); }
// 关键: storm() 之后渲染/定时器全部饿死, 页面假死

🏭 生产实战 real world

场景 1 · 10 万行表格渲染卡死 → 任务切片

一次性渲染 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 以内。

场景 2 · 顺序之谜变成排障能力

线上日志时序"不合理": 数据库回调写下的日志, 出现在同函数后面一行日志之前。本质是事件循环时序, 画栈推一遍就能定位:

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。

场景 3 · 微任务风暴: 页面假死 3 秒复盘

一个大对象被 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 的"批处理"是同一原理。

场景 4 · Node 高并发不开线程的真相

单进程 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": 根因是同进程图片缩略图占满线程池, 调大池子 + 拆服务后恢复。

场景 5 · setInterval 重叠: 监控任务越积越多

每 5s 拉一次上游接口, 网络抖动时单次耗时 8s, setInterval 不管死活继续投递, 任务堆积雪崩:

// 错: interval 不管上一次是否完成
setInterval(poll, 5000);
// 对: 递归 setTimeout, 上一次完成才排下一次
async function poll() {
  try { await check(); } finally {
    setTimeout(poll, 5000);   // 关键: 永不重叠, 慢了自动降频
  }
}
poll();

场景 6 · setTimeout 后台节流: 轮询失灵

切到后台 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));

场景 7 · 批处理 DOM 写入: nextTick 的原理自实现

一次操作触发 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 批量更新调度器的最小内核。

场景 8 · 长任务监控: 上线前抓住卡顿元凶

用 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'] });

场景 9 · Node 优雅退出: 等微任务清完再走

收到 SIGTERM 直接 process.exit, 在途微任务(已落库未提交的收尾)全部蒸发。先停接客, 再等事件循环自然排空:

process.on('SIGTERM', async () => {
  server.close();                          // 1. 停止接新请求
  await drainInflight();                   // 2. 等在途请求(事件循环继续跑)
  await flushLogs();                       // 3. 刷缓冲
  process.exit(0);                         // 关键: 让循环自然排空后再退
});

场景 10 · unhandledRejection: Node 15+ 直接崩进程

老代码里一个没接住的 Promise 拒绝, 升级 Node 后变成进程退出。兜底上报 + 定位源头:

process.on('unhandledRejection', (reason, p) => {
  logger.error('unhandled rejection', { reason, promise: String(p) });
  // 关键: 记录后不要吞掉继续跑, 短期内应修复源头并让它崩
});
// 源头修复: 每个 await 链要么 try/catch 要么 .catch 落到统一错误层

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 以为 setTimeout(fn, 0) 先于 Promise — 0ms 只是"最早下轮入队", 微任务在每轮栈空后插队. 正解: 需要立即异步用 queueMicrotask。
// 错: 以为输出 宏 微
setTimeout(() => console.log('宏'));
Promise.resolve().then(() => console.log('微'));
// 对: 记住顺序 微 宏; 0ms 从不代表"先跑"
坑 2 · 循环里 setTimeout 用 var 捕获 — 打印全是最后一个值, var 没有块作用域. 正解: 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
坑 3 · async 函数当同步用 — 返回值是 Promise, 拿它直接当数据用全是 undefined/thenable. 正解: 调用侧必须 await。
// 错: const user = getUser();      // user 是 Promise, 不是用户
// 对: const user = await getUser(); // → { id: 1, ... }
坑 4 · forEach 里 await — forEach 不认 async, 回调并发执行, "顺序处理"变"同时打爆下游". 正解: for...of。
// 错: ids.forEach(async id => await call(id)); // 全并发
// 对:
for (const id of ids) await call(id);  // → 逐个串行
坑 5 · 微任务风暴饿死渲染 — then 链/queueMicrotask 自我繁殖, 渲染永远插不上队. 正解: 高频通知合并进 rAF 或宏任务。
// 错: function loop() { Promise.resolve().then(loop); }
// 对: function loop() { setTimeout(loop); } // 每轮让渲染插一脚
坑 6 · 长同步计算卡死页面 — 大 JSON.parse / 巨大循环 / 灾难性正则回溯, 一旦进栈全员等待. 正解: 切片、Worker、流式解析。
// 错: const data = JSON.parse(huge);  // 300ms 阻塞
// 对: 交给 worker
worker.postMessage(raw);  worker.onmessage = e => use(e.data);
坑 7 · setInterval 任务重叠 — 单次耗时超过间隔时任务连续执行无间隙. 正解: 递归 setTimeout。
// 错: setInterval(poll, 5000);      // poll 要 8s 就背靠背
// 对: async function poll(){ try{...} finally{ setTimeout(poll, 5000);} }
坑 8 · 后台 tab 定时器被节流 — 切后台心跳/轮询变慢甚至分钟级强休眠. 正解: 心跳放 Worker, 或 visibilitychange 显式上报。
// 错: 主页面 setInterval(beat, 1000) 切后台变 60s
// 对: new Worker('beat.js');  // worker 内不被节流
坑 9 · setTimeout 里 this 丢失 — 回调以普通函数调用, this 变 window/undefined. 正解: 箭头函数或 bind。
// 错: setTimeout(this.refresh, 100);   // this ≠ 实例
// 对: setTimeout(() => this.refresh(), 100);
坑 10 · Node process.exit 跳过收尾 — 直接退出让在途微任务/缓冲日志蒸发. 正解: server.close + 排空后退出。
// 错: process.on('SIGTERM', () => process.exit(0));
// 对: 先停接客, await 排空, 再 exit —— 让事件循环跑完最后一轮
坑 11 · nextTick 递归饿死 I/O — process.nextTick 自我繁殖, 事件循环进不了下一阶段. 正解: 循环逻辑放 setImmediate。
// 错: function spin(){ process.nextTick(spin); }
// 对: function spin(){ setImmediate(spin); } // check 阶段, I/O 能插队
坑 12 · 同步 XHR / alert 阻塞 — 把事件循环整个冻住, 期间所有定时器、渲染、事件全停. 正解: 一律异步请求。
// 错: const r = new XMLHttpRequest(); r.open('GET', u, false);
// 对: const r = await fetch(u);
坑 13 · Promise 构造器里的异步 throw — executor 同步 throw 会 reject, 但 setTimeout 里的 throw 直接砸到全局. 正解: 异步段在回调内 reject。
// 错: new Promise((res, rej) => setTimeout(() => { throw 'x'; }));
// → uncaught, 不会 reject
// 对: new Promise((res, rej) => setTimeout(() => rej('x')));
坑 14 · then 链忘记 return — 链断裂, 下一个 then 拿到 undefined, 错误也断开传递. 正解: 链中必 return。
// 错: p.then(d => { save(d); }).then(() => done());  // done 提前跑
// 对: p.then(d => save(d)).then(() => done()); // 等保存完成
坑 15 · await 放循环里假串行 — 本想并发却逐个等, 尾延迟被拉成总和. 正解: Promise.all + allSettled。
// 错: 10 个请求 × 200ms = 2s
// 对: await Promise.all(urls.map(u => fetch(u))); // → ~200ms
坑 16 · 以为 rAF 回调必触发 — 后台 tab 不渲染就不触发 rAF, 动画/任务切片停在半路. 正解: 后台逻辑用 setTimeout/Worker。
// 错: 切片循环用 rAF 驱动, 用户切走就停
// 对: if (document.hidden) setTimeout(step); else requestAnimationFrame(step);
坑 17 · 嵌套 setTimeout 的 4ms 钳制 — 嵌套第 5 层起最小 4ms, 高频切片吞吐被压. 正解: 控制嵌套深度或用 MessageChannel 宏任务(无钳制)。
const ch = new MessageChannel();
ch.port1.onmessage = step;      // 关键: 宏任务但不被 4ms 钳制
const yield = () => ch.port2.postMessage(null);
坑 18 · unhandledrejection 静默吞掉 — 只 log 不处理不修复, 错误悄无声息丢失. 正解: 上报 + 让源头修复, 不要当全局保险用。
window.onunhandledrejection = e => {
  report(e.reason);  // 关键: 只兜底上报, 不承诺"处理后没事"
};
坑 19 · 事件循环阻塞无监控 — CPU 不高但 P99 爆炸, 没有事件循环延迟指标就无从归因. 正解: Node monitorEventLoopDelay, 浏览器 longtask 观测。
const h = monitorEventLoopDelay(); h.enable();
setInterval(() => gauge.set(h.percentile(99) / 1e6), 5000);
// 关键: 持续大于 100ms = 存在长任务, 去找那个宏任务
坑 20 · 微任务里假设渲染已发生 — 微任务跑在渲染前, 读到的还是旧布局. 正解: 需要最新布局放 rAF, 需要写后读量尺寸自己 getBoundingClientRect 前想清楚时序。
el.style.height = '100px';
Promise.resolve().then(() => {
  // 错: 以为这里能"看到"新渲染 —— 其实还没 paint
});
// 对: 要等渲染完, 用 rAF(可能还要 rAF 套 rAF)