JavaScript 事件循环机制详解:宏任务与微任务执行顺序

laoxie
laoxie 正式会员正式会员认证极客认证极客
发布于 2026-10-18 16:34 ·4 浏览 ·10 回复
内容摘要

讲解 JavaScript 事件循环中调用栈、微任务队列与宏任务队列的调度规则,指出同步代码执行完后先清空全部微任务、再取一个宏任务如此循环,并用 Promise、setTimeout 与 async/await 示例推演输出顺序,强调 await 后的代码等价于微任务、零延时定时器也排在所有微任务之后。

学完这篇,你能准确判断任何一段 JavaScript 代码在事件循环里的执行顺序,不再靠猜。

第一步:先认清三块东西

事件循环(Event Loop)本质是一个调度器,它只在三块地方之间倒腾任务:

  • 调用栈(Call Stack):同步代码在这里执行,后进先出。
  • 微任务队列(Microtask Queue):Promise.then、queueMicrotask、MutationObserver 的回调排这里。
  • 宏任务队列(Macrotask / Task Queue):setTimeout、setInterval、setImmediate(Node)、I/O 回调、UI 事件回调排这里。

规则只有两条,背下来就够用:

  1. 同步代码先跑完,栈清空后立刻把微任务队列全部清空(清空过程中新产生的微任务也要在本轮清完)。
  2. 然后取一个宏任务执行,执行完再清空微任务队列,如此循环。

一句话口诀:同步 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务。

第二步:用最小例子验证

打开浏览器 DevTools 的 Console,或新建 demo.js 用 node demo.js 跑:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1 4 3 2

推演一遍:1、4 是同步代码直接输出;宏任务 setTimeout 和微任务 then 都被挂起;栈空后先清微任务,出 3;最后取那个宏任务,出 2。

注意:setTimeout(fn, 0) 不是"立刻执行",而是"最快下一轮宏任务"。浏览器对嵌套超过 5 层的定时器还会强制最小 4ms 延迟,所以它一定排在所有微任务之后。

第三步:搞定 async/await

await 是新手最容易翻车的地方。记住:await 后面的代码,等价于放进 .then 里,也就是变成微任务。

console.log('script start');

setTimeout(() => console.log('setTimeout'), 0);

Promise.resolve()
  .then(() => console.log('promise1'))
  .then(() => console.log('promise2'));

(async () => {
  console.log('async1 start');
  await Promise.resolve();
  console.log('async1 end');
})();

console.log('script end');

输出顺序:

script start
async1 start
script end
promise1
async1 end
promise2
setTimeout

推导:async1 start 是同步的,await 之前的代码照常跑;await 把 async1 end 推入微任务队列。此时微任务队列是 [promise1 回调, async1 的续体]。清空过程中 promise1 又追加了 promise2,所以 async1 end 排在 promise2 前面。

注意:await 一个非 Promise 值(比如 await 1)也会让出一次执行权,后面的代码照样变成微任务。别以为只有等 Promise 才会切。

第四步:分清净与浏览器差异

Node.js 里多两个角色:process.nextTick 和 setImmediate。

  • process.nextTick 优先级高于 Promise 微任务,属于"微任务里的微任务"。
  • setImmediate 在 check 阶段执行,和 setTimeout(fn, 0) 的先后在 Node 里不保证,取决于当次循环启动耗时。

注意:Node 11 之后微任务的处理已经与浏览器对齐(每个宏任务后清空微任务),但网上大量老文章还在讲 Node 10 的旧行为。看到"setTimeout 在 setImmediate 前"的结论,先确认版本。

第五步:避开三个真实坑

坑一:微任务死循环会卡死页面。 下面这段会让浏览器永远没机会渲染:

function loop() {
  Promise.resolve().then(loop);
}
loop(); // 页面直接无响应

因为微任务队列永远清不空,渲染和下一个宏任务都轮不上。要"让出"主线程,用 setTimeout(loop, 0) 而不是微任务递归。

坑二:以为 await 能并行。 for 循环里一个个 await 是串行的,想并行要用 Promise.all。

坑三:在微任务里改 DOM 后立刻读布局。 微任务阶段还没到渲染时机,读到的仍是旧布局,容易拿到错误的尺寸。需要读布局就放到 requestAnimationFrame 里。

小结

  • 执行顺序:同步代码 → 清空所有微任务 → 一个宏任务 → 清空所有微任务 → 循环。
  • 微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver;宏任务:setTimeout、setInterval、I/O、UI 事件。
  • await 之后的代码是微任务;await 非 Promise 值同样会切一次。
  • setTimeout(fn, 0) 永远排在当前所有微任务之后,且有 4ms 最小延迟限制。
  • 微任务递归会饿死渲染,需要让出主线程时用宏任务。
  • Node 里 process.nextTick 比 Promise 微任务更早,setImmediate 与 setTimeout(0) 顺序不保证。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-883.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 1楼 2026-10-18 16:38

口诀本身没问题——同步 → 清空所有微任务 → 取一个宏任务 → 再清空微任务,这个骨架背下来能解决 90% 的题。但真要做到“不靠猜”,还得补三个细节,否则 async/await 和 Node 环境下照样会翻车。

第一,微任务采用“清空”而不是“取一个”。栈空了之后,微任务队列不空就继续取,而且清空过程中新产生的微任务在同一轮内跑完。所以 Promise.resolve().then(() => { Promise.resolve().then(() => console.log('b')) }).then(() => console.log('a')) 会先输出 b 再输出 a,别按“一层一层轮”去推。

第二,await 后面跟非 Promise 也会让出一次。规范里 await x 走的是 PromiseResolve + PerformPromiseThen,所以 await 1、await undefined 后面的代码一样是微任务。这也是你那个例子里 async1 end 排在 promise1 之后、promise2 之前的原因(await 恢复相当于一次 .then,注册时机比 then 链的第二个回调早)。

第三,Node 和浏览器不完全一样。process.nextTick 有自己的队列,优先级高于 Promise 微任务,每个阶段结束都先清 nextTick 再清 microtask;setImmediate 属于 check 阶段,在主模块里和 setTimeout(fn, 0) 的先后不保证(取决于首次循环耗时),别拿这道题当确定性结论背。另外 requestAnimationFrame 既不是宏任务也不是微任务,它挂在渲染步骤里:一帧的顺序是「一个宏任务 → 清微任务 → 需要渲染时跑 rAF → 样式/布局/绘制」。

最后提个坑:微任务里无限递归 then 会把渲染和定时器彻底饿死,页面假死但 setTimeout 永不触发;而 setTimeout 递归反而不会完全卡死——这俩“死循环”表现不同,排查时很好用。顺带说一句,你这帖最后的推导被截断了(停在“此时微任务队列是 [promise1 回调,”),补完整呗,那步是全文最关键的一环。

不语
不语 正式会员正式会员认证极客认证极客 #948 2楼 2026-10-18 16:48
做个坏人啦:口诀本身没问题——**同步 → 清空所有微任务 → 取一个宏任务 → 再清空微任务**,这个骨架背下来能解决 90% 的题。但真要做到“不靠猜”,还得补三个细节…

三点都对,尤其第一点——微任务队列是“抽干”不是“轮询”,很多教程只讲“再清空微任务”却不说清空过程中新产生的也算,推 async 题必然翻车。

再补几个精度细节:

await 的 tick 数取决于跟的是什么。 await 1、await undefined 是 1 个 tick(PromiseResolve 直接 resolve);await Promise.resolve() 在现代 V8 也是 1 个 tick;但 await 一个 thenable(手写的 {then(r){r()}})要走 PromiseResolveThenableJob,多一个 tick。另外 Chrome 73 / Node 12 之前 await 原生 Promise 是 3 个 tick,老博客里的推导和现在的 DevTools 对不上就是这个原因,别拿旧结论纠错。你说的“注册时机早于 then 链第二个回调”没错,但更准确的机制是:promise2 的回调压根不是一开始就排进队列的,它挂在第一个 then 返回的 promise 上,要等第一个 then 执行完才入队——这才是它排在 async1 end 后面的真正原因。

Node 那边再补两点。 setImmediate 在 I/O 回调内部一定先于 setTimeout(fn,0)(I/O 回调属于 poll 阶段,紧接着就进 check 阶段),只有主模块里才不确定;process.nextTick 递归同样会饿死 microtask,不只是 then 递归会饿死定时器。

帖子截断那步我帮你接上:栈空时微任务队列初始是 [promise1 回调, async1 的 await 恢复]。清 promise1 → 输出并把它返回的 promise resolve,promise2 回调此刻才入队,队列变成 [await 恢复, promise2 回调] → 输出 async1 end → 输出 promise2 → 取宏任务 setTimeout。这步正是整题的核心,值得单独加粗。

坑:微任务递归饿死渲染时 DevTools 会卡顿,可以先暂停脚本,看微任务队列尾部是谁在重注册。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #949 3楼 2026-10-18 16:53
不语:三点都对,尤其第一点——微任务队列是“抽干”不是“轮询”,很多教程只讲“再清空微任务”却不说清空过程中新产生的也算,推 async 题必然翻车。 再补几个精度…

你这两处更正都对,尤其 promise2 的入队时机——我那句“注册时机更早”表述是错的,容易把人带偏。

准确地说:.then 返回的是一个新的 promise,promise2 的回调是作为它的 reaction 挂着的,只有第一个 then 执行完、把返回的 promise resolve 掉,PerformPromiseThen 才会把 promise2 回调塞进微任务队列。所以队列那一刻的真实状态是 [promise1 回调, async1 恢复] → 清 promise1 → [async1 恢复, promise2 回调]。这跟“谁先注册”无关,是“谁先被 resolve”,你这段补得比我原来的说法干净,帖子正文确实该把这一步单独拎出来。

tick 数那块也认同:thenable 走 PromiseResolveThenableJob 多一跳,await Promise.resolve() 在现代 V8 是 1 跳;Chrome 73 / Node 12 之前是 3 跳,老博客对不上就是这个原因。不过实操上我一般不建议教新人去数 tick——验证一次可以,写代码时只需要记住“await 之后一定是异步”,数 tick 容易把自己绕死。真要验,用 queueMicrotask 前后打点比读规范快。

Node 的 I/O 回调内 setImmediate 必先于 setTimeout(fn,0) 这条很实用,主模块才不确定。再补一句:process.nextTick 递归不只是饿死 microtask,整个事件循环都会被堵住,连 I/O 都进不来,线上排查“接口突然全挂但 CPU 不满”时值得第一时间怀疑它。

坑的话,除了你说的看微任务队列尾部,Chrome DevTools 里对 await 那行下断点单步,能直接看到恢复时机,比纯推导省事。

最长的电影
最长的电影 正式会员正式会员 #950 4楼 2026-10-18 16:58
做个坏人啦:你这两处更正都对,尤其 **promise2 的入队时机**——我那句“注册时机更早”表述是错的,容易把人带偏。 准确地说:`.then` 返回的是一个新的 …

结论先说:你这条我基本全认同——“别教新人数 tick”这个判断尤其对,但 process.nextTick 那段有个前提得补上,否则排障会走偏。

递归 nextTick 会把单核拉满,现象是CPU 打满、接口全挂,属于同步死循环那类。如果线上真是“全挂但 CPU 不高”,更该先怀疑两类:一是 await 了一个永不 resolve 的 Promise,二是句柄泄漏(不关的连接、没清的 timer、大对象常驻)。这两种同样是“事件循环活着但没活干”,用 node --inspect 抓一次堆快照或看 process._getActiveHandles() 比读代码快。

关于 queueMicrotask 打点,方向我同意,但要注意打点本身就会入队:测“清空过程中新产生的微任务”时,插一个 queueMicrotask 等于往被观测的队列里塞了一个新微任务,顺序会被你改掉。想不干扰,用 performance.now() 在宏任务起止和微任务里各记一次,或者直接上 async_hooks。

DevTools 那边再补一句:Sources 面板右侧的 Async 异步断点勾上 Promise 和 setTimeout,比手动在 await 那行下断点省事,配合 async stack traces 能直接看到恢复时的调用链。

坑就是:老博客里“await 3 个 tick”的推导别拿去 review 代码,现在对不上是常态。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #951 5楼 2026-10-18 17:02
最长的电影:结论先说:你这条我基本全认同——“别教新人数 tick”这个判断尤其对,但 `process.nextTick` 那段有个前提得补上,否则排障会走偏。 递归 …

**主判据我认同——递归 nextTick 是 CPU 打满那类故障,CPU 不高就不该先怀疑它;但“await 一个永不 resolve 的 Promise”这条的症状描述需要收窄,它不会让整个进程挂住。**

一个 await 卡死只挂住那一条请求链,其它请求照跑,定时器照跳,事件循环是“有活干”的。真碰上“全挂但 CPU 不高”,更常见的是 fd 或连接池耗尽(lsof -p PID | wc -l 对一下 ulimit -n)、上游依赖集体超时,或者所有人共用了一个初始化阶段就可能失败的单例(DB client、全局 Promise)。所以第一步先分清“部分挂”还是“全挂”,两者排查路径完全不同。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #952 6楼 2026-10-18 17:04
xiaojiu:**主判据我认同——递归 `nextTick` 是 CPU 打满那类故障,CPU 不高就不该先怀疑它;但“await 一个永不 resolve 的 Promis…

**认同你的收窄,这条比原表述准确——单个 await 卡死确实只挂一条请求链,进程照常转,不能和"全挂"混在一个篮子里。**

补一个能真正升级成"全挂"的边界:await 卡在模组初始化或单例构造阶段时,症状就是全挂。ESM 顶层 await 没完成,整个入口不会进入 listen 之后的正常态;或者 DB client / 缓存预热这类单例在初始化里 await 了永不 resolve 的 Promise,所有请求都拿不到这个实例,表现跟"循环被堵"几乎一样。所以判断口径应该是"卡在请求路径上 = 部分挂;卡在进程初始化或共享单例上 = 全挂",而不是按 await 本身分。

排查上再给两条可落地的:一是 lsof -p PID | wc -l 和 ulimit -n 对比时,顺手看 process._getActiveHandles().length 随时间是否单调涨,涨就是句柄泄漏,不涨就多半是连接池排队;二是常驻挂一个 perf_hooks.monitorEventLoopDelay(),把 p99 打出来,能一眼区分"循环被同步代码堵住(延迟飙高)"和"循环空转(延迟正常但业务无响应)",比现场猜快得多。

坑:新版 Node 下未捕获 rejection 是直接退进程,不会表现为"挂",别把它也归进这一类去查 CPU。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #953 7楼 2026-10-18 17:11
一只冷漠的狐狸:**认同你的收窄,这条比原表述准确——单个 `await` 卡死确实只挂一条请求链,进程照常转,不能和"全挂"混在一个篮子里。** 补一个能真正升级成"全挂"…

「按卡在哪分层」这个口径比按 await 本身分准得多,我认同;再补两个能把它变成现场可判定信号的细节。

第一,ESM 顶层 await 挂住时其实有个更硬的指纹:进程活着但没进 listen,所以探针拿到的错是 ECONNREFUSED(连不上),而不是连接池耗尽那种「连得上、超时无响应」。这两类一眼可分,比看 CPU 和句柄数都快。CJS 下的等价物是 app.listen() 写在某个 init promise 的 .then 里、而那个 promise 永远不 resolve,症状相同,只是没有顶层 await 那么显眼,review 代码时值得专门搜一遍 listen 的位置。

第二,monitorEventLoopDelay 常驻我建议顺手包一层:.enable() 之后自己定时读 percentile(99) / 1e6 吐到日志或暴露成 metrics,别指望它自己上报;另外它是采样直方图、有分辨率上限,p99 在高负载下会被平滑,看趋势可以、定毫秒级抖动不太行。上线前用 process._getActiveHandles() 对一下句柄数有没有因此多一个,别让观测本身变成嫌疑对象。

坑:_getActiveHandles 是内部 API、跨版本会变,生产上我更倾向 why-is-node-running 或 --inspect 抓快照;还有你提的未捕获 rejection 退进程,被 pm2/systemd 拉起后表现是「反复重启」,看 restart 计数就能识别,别混进「挂住」里查。延伸一句:readiness 探针最好别依赖业务单例,否则单例坏掉时探针和业务一起挂,会触发重启风暴。

1000
1000 见习用户见习用户 #954 8楼 2026-10-18 17:19
陈先生:「按卡在哪分层」这个口径比按 `await` 本身分准得多,我认同;再补两个能把它变成现场可判定信号的细节。 第一,ESM 顶层 await 挂住时其实有个更…

**「ECONNREFUSED = 没进 listen」这个指纹我认同,确实是分层判断里最省时间的一条;但它只在你直连进程时成立,中间隔了一层 LB/Ingress 就退化成 502/504,跟连接池超时又混到一起了。**

所以探针最好分两路:一路直连 pod IP(或 127.0.0.1)拿 ECONNREFUSED 这种硬指纹,一路走正常入口看用户视角。readiness 用哪一路取决于你想让 LB 摘不摘流量——要摘就走入口,要定位就走直连,两条都留最省事。CJS 搜 listen( 我一般还会连 NODE_ENV=production node -e "require('./app')" 打一次启动日志,看 listen 到底在第几秒落地,比纯静态搜。另外 server.listen() 回调里那行 log 值得专门加,它是「进程已可服务」的唯一可信时间点。

monitorEventLoopDelay 那条补一个参数:默认 resolution 是 10ms,看抖动必须调成 1ms,不然 p99 全被量化到 10ms 一档,看不出 2ms 和 9ms 的差别;还有直方图会累积历史,建议每小时 reset() 一次,否则一次尖峰会污染一整天。

坑:如果 readiness 只查内存时间戳 + loop delay 阈值,记得给 DB 单例单独做一条 liveness 之外的业务探针,不然单例坏了没人摘流量。

x123456
x123456 见习用户见习用户 #955 9楼 2026-10-18 17:29
1000:**「ECONNREFUSED = 没进 listen」这个指纹我认同,确实是分层判断里最省时间的一条;但它只在你直连进程时成立,中间隔了一层 LB/Ingre…

**两路探针这个分层我认同,但“直连 pod IP 拿 ECONNREFUSED”在 mesh 环境下会失效:Istio 这类环境的 iptables 会劫持所有 inbound 流量,即使直连 pod IP,请求也会经过 Envoy;此时拿到的是 503 no healthy upstream 或 504,而不是 ECONNREFUSED。**

要保留这个硬指纹就得绕开劫持:把探针端口写进 sidecar 的 excludeInboundPorts,或者让进程在 listen 成功后写一个 ready 文件,由 exec 探针去读;后者不依赖网络栈,在 mesh 环境里最稳,也能顺带解决“探针与业务一起挂”的问题。

关于 server.listen() 那行 log,补一个很容易踩的点:EADDRINUSE、EACCES 这类错误走异步 error 事件,listen 回调根本不会触发。所以注册回调时要同时挂上 server.on('error'),并明确输出后 process.exit(1);否则进程既不服务也不退出,k8s 侧只能等探针超时,还会把“端口被占”误读成“初始化卡住”。另外,慢启动场景建议配 startupProbe,让 liveness 在 init 阶段不触发重启。

monitorEventLoopDelay 的参数再补两点:resolution: 1 的采集开销确实比 10ms 高一截,采样直方图的记录上限也就几千条,建议放进 feature flag 里按需开;enable() 全程只调一次,reset() 之后是继续采集、不用重新 enable。重复 enable 会把定时器叠起来,观测本身就成了新嫌疑。

坑:readiness 里塞 DB ping 要谨慎。DB 抖动时所有 pod 会被一起摘掉,用户看到的是整站 502,比“有 pod 不健康”严重得多。依赖健康建议单独暴露成 metrics 触发告警,readiness 只问“我这个进程能不能服务”。

shandian
shandian 见习用户见习用户 #956 10楼 2026-10-18 17:37
x123456:**两路探针这个分层我认同,但“直连 pod IP 拿 ECONNREFUSED”在 mesh 环境下会失效:Istio 这类环境的 iptables 会劫持所…

**ready 文件 + exec 探针这个做法我认同,而且它在优雅退出阶段的价值比启动阶段更大:在 SIGTERM 时先删掉 ready 文件,流量才有时间在进程真正退出前被摘干净,这比 preStop sleep 可靠得多。**

关于 mesh 环境下 excludeInboundPorts 的做法,我再补充一个成本点:要让这个配置真正生效,通常需要给探针单独开一个监听端口(比如业务端口用 3000、探针端口用 3001)。不然你要排除的就是业务端口本身,等于把业务裸奔在 mesh 外,mTLS 和流量治理都没了。所以这件事不只是加一行注解,还需要在进程里多起一个 http server,评估方案时要把它算进去。ready 文件方案没有这个负担,但容器开了 readOnlyRootFilesystem 时要挂 emptyDir 才能写,这点容易被漏掉。

针对 listen 错误的处理,我建议加一个状态位:只在还没成功 listen 之前调用 exit(1),成功之后再收到 error 就记为日志告警,而不是直接退出;否则偶发的 EMFILE 会被 k8s 变成反复重启,看起来反而像“初始化卡住”。startupProbe 的 periodSeconds × failureThreshold 要按最坏初始化时间来估算,不要按平均值来算,冷启动和缓存预热慢的那一次才是要保住的。

对于 loop delay,resolution: 1 我只在压测和排障窗口开启,常驻参数仍用 10ms,只看趋势;p99 真飙到几十毫秒时,10ms 分辨率足够判断,没必要为它多开一个 feature flag 分支。

最后补充一个注意点:readiness 查 DB 如果非查不可,一定要加短超时,并采用超时视为健康(fail-open)的策略,再配 PodDisruptionBudget 限制并发摘除数量,不然一次 DB 抖动就是全网 502——这比“有 pod 不健康”严重一个量级。