返回知识中心
指纹

微任务与定时器:代码真正的执行顺序

一次顺序探针:同步代码先跑完,promise 回调按队列顺序排空,嵌套微任务等待,await 以微任务恢复,定时器不会打断阻塞循环。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在声明的 Profile 下重复运行经授权的同源页面 fixture,并保留范围受限的观察:页面以什么顺序执行了它的回调;但它不能改变运行时如何调度自己的队列,不能让定时器抢占同步代码,也不会把回调顺序变成身份信号。本文对一组固定形态运行一次顺序探针,记录同步代码何时结束、promise 回调何时排空、嵌套在微任务里的微任务何时等待前面的队列、await 何时恢复、process.nextTick 相对 promise 落在哪里,以及阻塞循环是否延迟零延迟定时器。

摘要

  • 同步代码总是先跑完:探针在任何 promise 回调之前打印 sync-start,sync-end,因为当前任务会执行到底。
  • promise 回调随后按入队顺序排空,所以 promise-1,microtask-1,promise-2 遵循调度顺序,定时器最后执行。
  • 在微任务里入队的微任务会等待已入队的微任务,所以顺序是 m1,m2,m1-nested,timeout。
  • await null 以微任务恢复,所以 await 之后的代码在零延迟定时器之前执行。
  • 当两者都在定时器回调、setImmediate 回调或 CommonJS 脚本体里入队时,process.nextTick 先于其后入队的 promise;而 promise 回调、queueMicrotask 回调与 ES 模块体则打印 promise,nextTick。
  • 零延迟定时器不能抢占同步块:探针的 50 毫秒循环让定时器在 50 毫秒或更晚触发。
  • 重复同一形态每次给出相同顺序,所以序列可复现,而不是竞态。

目录

一个同步块先执行到底,随后 MICROTASK 队列按回调入队顺序排空,最后 TIMER 回调执行;另一面板显示在微任务里入队的微任务排在已有队列之后,第三面板显示阻塞循环把零延迟定时器推过其延迟

为什么同步代码先于任何回调结束

JavaScript 一次执行一个任务,任务会执行到底,然后任何已入队的回调才开始。探针在同步块开头和结尾各压入一个标签,中间入队两个 promise 回调和一 个定时器,然后读取结果:

node=v26.9.0
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0

两个同步标签先出现,按执行顺序排列,所有回调都在其后。同步块做的任何事都无法让已入队的回调在中间执行,因为运行时不会为了启动已入队任务而中断正在运行的任务。这是下面每个顺序问题的基础:当前任务拥有线程,直到它返回。

这条规则就是为什么长时间同步计算会冻结页面的定时器和 promise 回调。一个耗时 200 毫秒的循环会阻塞每个已入队回调 200 毫秒,而一个期待指示器动画或定时器准时触发的页面在循环返回之前什么也看不到。冻结不是调度 bug;它是按规范运作的完成规则,唯一的修法是把工作拆成更小的任务,让运行时能在其间执行已入队回调。

完成规则还决定一份记录能对回调顺序断言什么。与其他回调在同一任务中执行的回调不是独立观察,因为两者在同一轮里入队并排空。分别给每个回调打时间戳的记录能显示一轮内的顺序,但除非同时记录任务边界,否则说不清回调属于哪一轮,下一节会把这一点讲明白。

promise 回调如何按调度顺序排空

promise 回调和 queueMicrotask 回调共享一个微任务队列,它们按入队顺序排空,而不是按创建顺序。探针入队一个 promise、一个微任务和另一个 promise,再入队一个定时器,然后打印排空顺序:

sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0

两个 promise 和那个微任务严格按入队顺序执行:promise-1,然后 microtask-1,然后 promise-2。定时器最后执行,在整个微任务队列为空之后。一个认为微任务因为看起来更像底层原语就会插到 promise 前面的人错了;队列是先进先出,回调类型不改变它的位置。

这个顺序就是为什么一串 .then 调用在一轮内一个接一个执行,而不是每个占一个定时器。同步 resolve 的 promise 立即入队它的回调,结果上的第二个 .then 再入队一个在第一个之后执行的回调,全部发生在下一个定时器之前。一个等待一串 promise 然后更新屏幕的页面会在一轮里完成整条链,这就是屏幕更新一次出现而不是分阶段出现的原因。

排空顺序也解释了为什么无限制的 promise 回调链可能饿死定时器。一个回调入队另一个 promise 回调,那个又入队另一个,使微任务队列一直非空,运行时在队列清空前不会启动下一个定时器。因此一个由永不停止的 promise 回调构成的循环会阻塞每个定时器和每个输入事件,即使没有任何单个回调长时间阻塞线程。这种饥饿与上面的同步循环是同一冻结,只是通过队列而不是循环到来。这种饥饿正是页面自己的计时测量看到的抖动:performance timing fingerprinting 展示了围绕队列排空读取时钟如何把调度延迟变成一个被记录下来的数字。

嵌套在微任务里的微任务在哪里等待

从微任务内部入队的微任务不会插到队列前面;它追加到队列末尾,并在已在等待的回调之后执行。探针入队两个微任务,第一个再入队第三个,然后打印顺序:

nested-microtask -> m1,m2,m1-nested,timeout

顺序是 m1,然后 m2,然后 m1-nested,然后定时器。嵌套微任务不会在入队它的 m1 之后立即执行;它要等已在队列中的 m2 执行完。只有那时队列才到达 m1-nested,也只有整个队列为空时定时器才执行。

这个行为就是为什么递归 promise 链在结束前不让出任何东西。一个等待自身延续并入队下一步的回调会不断追加到微任务队列,队列每轮排空一步却永远到达不了定时器。期待定时器在步骤之间穿插的人会吃惊,因为嵌套微任务是追加而不是打断,整条链在一轮内跑完。

嵌套规则也解释了为什么建立在 promise 上的"推迟到下一个 tick"模式不会推迟到当前轮之外。一个入队微任务以"让其他工作运行"的回调只让已入队的微任务先运行;它不让给定时器、输入事件或网络回调,因为那些都要等整个微任务队列清空。推迟到当前轮之外需要定时器或任务队列原语,而不是微任务,而一份只说"推迟到下一个 tick"却不说哪个队列的记录是含糊的。

await 在哪里恢复

await 挂起 async 函数并以微任务恢复它,所以 await 之后的代码在同步块返回之后、任何定时器之前执行。探针等待 null(立即 resolve),并打印顺序:

await-resumes-as-microtask -> async-start,sync,after-await,timeout

async 函数开始并压入 async-start,然后在 await 处挂起。调用之后的同步代码压入 sync。函数随后以微任务恢复并压入 after-await。定时器最后执行。即使 await null 没什么可等,恢复仍然是微任务,仍然在当前同步块结束后执行,这就是 sync 标签出现在 async 函数两半之间的原因。

这个间隙就是为什么看起来顺序执行的 async 函数并不是同步的。调用 async 函数并期待第一行副作用立即可见的人看到了效果,但期待第一个 await 之后的效果在同一语句内可见的人看不到,因为恢复已入队。间隙正好是一个微任务,在单线程阅读中不可见,但在任何给两半都打标签的顺序探针中可见。

恢复顺序也决定两个 async 函数如何交错。两个都等待立即 resolve 值的 async 函数按各自 await 被到达的顺序恢复,因为每次恢复都是入队的微任务。一个启动两个异步加载并期待第一个先完成 await 后工作的页面,只要第一个先到达 await 就是对的,如果是第二个先到就错了。交错是确定的,取决于 await 被到达的顺序,而不是函数被定义的顺序。

nextTick 相对 promise 落在哪里

process.nextTick 是 Node 特有的队列,在多数情况下先于 promise 微任务运行,探针显示它在回调内部取胜:

nextTick-before-promise -> nextTick,promise,timeout
nextTick-beats-later-promise-in-callback -> nextTick,promise

两种情况下 nextTick 回调都在 promise 回调之前、定时器之前执行。这两对都在定时器回调里入队,定时器回调属于宏任务,而 Node 在一个宏任务结束时会把整条 nextTick 队列排空之后才开始排 promise 队列,所以第二例里即使 promise 先入队,nextTick 仍然先跑。

规则比"nextTick 总是赢"更窄。一次对照探针把同样两行放进四种上下文:从 CommonJS 脚本体和从定时器回调里打印出 nextTick,promise,而从 promise 回调和从 queueMicrotask 回调里打印出 promise,nextTick。ES 模块体表现得像微任务那两种,也打印 promise,nextTick。分界跟的是入队时所在的上下文:宏任务会先排空 nextTick 队列再处理 promise 队列,而微任务上下文本身已经在 promise 队列里,所以在那里入队的续体在控制权回到 nextTick 之前就跑了。

这就是本文的探针把每个用例都隔离在定时器回调里的原因,也是它打印的两个顺序都属于宏任务结果的原因。在 CommonJS 顶层入队的探针与它一致;ES 模块顶层或微任务回调会打印相反的顺序。一份只说"nextTick 先运行"、不写上下文的记录,记下的是一个别处无法复现的结果。

为什么定时器从不抢占阻塞循环

零延迟定时器不会打断正在运行的同步块;它只在块返回后触发。探针启动一个定时器,然后运行 50 毫秒循环,并检查定时器何时触发:

timer-not-preemptive -> elapsed_at_least_50=true

定时器在 50 毫秒或更晚触发,而不是在零。运行时无法抢占正在运行的任务,所以定时器等待循环结束,然后在下一轮执行。延迟不是定时器的 bug;它是第一节的完成规则应用于更长的块,也是让长计算冻结页面的同一规则。

同一规则解释了为什么用忙循环构建的"sleep"比定时器更糟。一个等待 200 毫秒的忙循环阻塞每个已入队回调 200 毫秒,而一个 200 毫秒后触发的定时器让每个已入队回调在期间运行。把定时器换成循环来"简化"的代码把调度变成冻结,而一份显示定时器迟到的记录证明的是循环运行过,不是定时器坏了。

阻塞用例也设定了一个测量的期望。在已知块之后测量定时器延迟的探针可以用延迟作为块运行过的证据,因为定时器不可能提前触发。不控制块就测量延迟的探针分不清迟到的定时器和慢机器,所以记录必须把块及其时长与观察到的延迟一起写出,否则数字毫无意义。回调最终得到的顺序往往是排序留下的顺序:数组排序与比较器 展示了为什么默认排序按字符串排、为什么比较器可能溢出,所以处理已排序批次的回调继承的是那个顺序,而不是它被写就时的顺序。

确定性检查把上面的一切闭环:

repeat-deterministic -> spt|spt|spt unique=1

同一形态跑三次给出相同顺序,s,然后 p,然后 t,只有一个唯一结果。顺序不是偶然看起来稳定的竞态;它是队列的规范行为,重复运行确认了这一点。一个在重复之间发现不同顺序的探针会是在测量别的东西,比如一个延迟短于队列排空的定时器,记录就必须解释变化,而不是称其为不确定性。

记录一次顺序读数

一份顺序读数支撑一个很窄的断言:这个运行时、这个形态、这些回调按这个顺序入队,产生了这个序列。六个字段让它可复核,且没有一个假定队列类型或轮次边界:

  1. 运行时及其事件循环,因为浏览器没有 process.nextTick,而是在当前任务末尾调度 promise 微任务。
  2. 每个回调入队的确切队列,promise、queueMicrotask、nextTick 或定时器,因为队列决定顺序而回调类型不决定。
  3. 回调入队的顺序,因为微任务队列是先进先出,没有入队顺序的记录无法复现排空。
  4. 轮次边界,形态是在宏任务(如定时器或 setImmediate 回调)、CommonJS 脚本体,还是在微任务(如 promise 或 queueMicrotask 回调)里运行,因为同样两行在前两种里打印 nextTick,promise,在微任务里打印 promise,nextTick。
  5. 同步块时长,因为阻塞循环延迟每个定时器,没有它的记录无法解释迟到的定时器。
  6. 同一形态是否重复,因为单次运行分不清规范顺序与看起来稳定的竞态。

这样的读数不能显示的是页面顺序如何与它向服务器报告的东西交互。页面可以以确定顺序运行回调,却仍然发送一个时间取决于网络的请求,而看到请求到达的服务器无法从中推断页面的微任务顺序。关于回调通常处理的字节,TextDecoder 与 TextEncoder 展示了为什么同样字节按标签解码不同;关于回调经常在其上运行的模式,RegExp lastIndex 与 flags 展示了为什么重复使用的 global 模式在调用之间翻转答案。

两个习惯让读数随时间保持诚实。第一,把每个顺序用例隔离在它自己的任务里,因为共享作用域的探针可能留下已入队的工作并改变下一个用例的顺序。第二,把队列类型和轮次边界与序列一起记录,因为同样两行可能因运行位置不同而产生两种顺序。promise 队列也不等于组合器,promise 组合器说明调用方究竟拿到哪几个已结算 promise 的结果。

公开来源

结论

把队列当作顺序契约的一部分:同步代码先结束,promise 与 queueMicrotask 回调先进先出排空,在微任务里入队的微任务追加到末尾,await 以微任务恢复,所以这些都在零延迟定时器之前运行。process.nextTick 在定时器回调、setImmediate 回调以及 CommonJS 脚本体内胜过其后入队的 promise,而 promise 回调、queueMicrotask 回调与 ES 模块体打印相反顺序,所以把每个用例隔离在自己的任务里,并在记录中写明上下文。定时器从不抢占阻塞循环,这就是 50 毫秒循环让零延迟定时器迟到触发的原因。记录队列类型、入队顺序、轮次边界和块时长,并重复一次形态,这样一份存储的序列可复现,而不是看起来稳定的竞态。

延伸阅读

关于回调通常处理的字节,阅读 TextDecoder 与 TextEncoder;关于回调经常在其上运行的模式,阅读 RegExp lastIndex 与 flags。

#微任务#事件循环#setTimeout#Promise#nextTick

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。