Microtareas y temporizadores: el orden real de tu código
Sonda de orden: el código síncrono termina primero, las promesas drenan en orden de cola, las microtareas anidadas esperan, await reanuda como microtarea y el timer no interrumpe.
BotBrowser Team
Quieres la documentación estructurada de Huella digital?
Este artículo forma parte de la biblioteca editorial. Para pasos de configuración, material de referencia y actualizaciones continuas, entra en la sección de docs.
BotBrowser soporta repetir un fixture autorizado del mismo origen bajo un perfil declarado y conservar observaciones acotadas sobre el orden en que una página ejecutó sus callbacks, pero no puede cambiar cómo un entorno planifica sus propias colas, no puede hacer que un temporizador interrumpa código síncrono y no convierte un orden de callbacks en una señal de identidad. Esta revisión ejecuta una sonda de orden contra un conjunto fijo de formas y registra cuándo termina el código síncrono, cuándo drenan los callbacks de promesa, cuándo una microtarea encolada dentro de una microtarea espera a la cola que tiene delante, cuándo reanuda un await, dónde cae process.nextTick respecto a una promesa, y si un bucle bloqueante retrasa un temporizador de retardo cero.
Resumen
- El código síncrono siempre termina primero: la sonda imprime
sync-start,sync-endantes de cualquier callback de promesa, porque la tarea actual se ejecuta hasta el final. - Los callbacks de promesa drenan después en el orden en que se encolaron, así que
promise-1,microtask-1,promise-2siguen el orden de planificación y el temporizador va al final. - Una microtarea encolada dentro de una microtarea espera a las microtareas ya encoladas, así que el orden es
m1,m2,m1-nested,timeout. await nullreanuda como microtarea, así que el código tras elawaitcorre antes que un temporizador de retardo cero.process.nextTickgana a una promesa encolada después cuando ambas están en un callback de temporizador o desetImmediate, o en el cuerpo de un script CommonJS; un callback de promesa o dequeueMicrotasky el cuerpo de un módulo ES imprimenpromise,nextTick.- Un temporizador de retardo cero no puede interrumpir un bloque síncrono: el bucle de fifty milliseconds de la sonda hace que el temporizador dispare a los fifty milliseconds o después.
- Repetir la misma forma da el mismo orden siempre, así que la secuencia es reproducible y no una carrera.
Contenido
- Por qué el código síncrono termina antes que cualquier callback
- Cómo drenan los callbacks de promesa en orden de planificación
- Dónde espera una microtarea encolada dentro de una microtarea
- Dónde reanuda await
- Dónde cae nextTick respecto a una promesa
- Por qué un temporizador nunca interrumpe un bucle bloqueante
- Registra una lectura de orden
Por qué el código síncrono termina antes que cualquier callback
JavaScript ejecuta una tarea a la vez, y una tarea se ejecuta hasta el final antes de que empiece cualquier callback encolado. La sonda empuja una etiqueta al principio y al final del bloque síncrono, encola dos callbacks de promesa y un temporizador entre medias, y lee el resultado:
node=v26.9.0
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0
Las dos etiquetas síncronas van primero, en el orden en que corrieron, y todo callback va después. Nada de lo que hace el bloque síncrono puede dejar que un callback encolado corra en medio, porque el entorno no interrumpe una tarea en ejecución para empezar una encolada. Esa es la base de toda pregunta de orden posterior: la tarea actual es dueña del hilo hasta que retorna.
Esa regla es por lo que un cálculo síncrono largo congela los temporizadores y los callbacks de promesa de una página. Un bucle que tarda doscientos milisegundos bloquea cada callback encolado durante esos doscientos milisegundos, y una página que espera que un indicador se anime o que un temporizador dispare a tiempo no ve nada hasta que el bucle retorna. El congelamiento no es un fallo de planificación; es la regla de finalización funcionando como se especifica, y la única solución es partir el trabajo en tareas más pequeñas para que el entorno corra los callbacks encolados entre ellas.
La regla de finalización también decide qué puede afirmar un registro sobre un orden de callbacks. Un callback que corrió en la misma tarea que otro callback no es una observación separada de la primera, porque ambos se encolaron y drenaron en un turno. Un registro que marca la hora de cada callback por separado puede mostrar el orden dentro de un turno, pero no puede mostrar a qué turno perteneció un callback salvo que registre también el límite de tarea, que la sección siguiente hace explícito.
Cómo drenan los callbacks de promesa en orden de planificación
Los callbacks de promesa y los de queueMicrotask comparten una cola de microtareas, y drenan en el orden en que se encolaron, no en el orden en que se crearon. La sonda encola una promesa, una microtarea y otra promesa, luego un temporizador, e imprime el orden de drenaje:
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0
Las dos promesas y la microtarea corren exactamente en el orden en que se encolaron: promise-1, luego microtask-1, luego promise-2. El temporizador corre al final, después de que toda la cola de microtareas esté vacía. Quien espera que la microtarea se adelante a las promesas porque parece una primitiva de más bajo nivel se equivoca; la cola es primero en entrar, primero en salir, y el tipo de callback no cambia su posición.
Ese orden es por lo que una cadena de llamadas .then corre una tras otra en un solo turno en vez de una por temporizador. Una promesa que se resuelve de forma síncrona encola su callback de inmediato, y un segundo .then sobre el resultado encola otro callback que corre después del primero, todo antes del temporizador siguiente. Una página que espera una cadena de promesas y luego actualiza la pantalla hace toda la cadena en un turno, que es por lo que la actualización de pantalla aparece de golpe en vez de por etapas.
El orden de drenaje también es por lo que una cadena ilimitada de callbacks de promesa puede dejar sin turno a los temporizadores. Un callback que encola otro callback de promesa, que encola otro, mantiene la cola de microtareas no vacía, y el entorno no empieza el temporizador siguiente hasta que la cola esté vacía. Un bucle construido con callbacks de promesa que nunca para bloquea por eso todo temporizador y todo evento de entrada, aunque ningún callback bloquee el hilo mucho tiempo. La inanición es el mismo congelamiento que el bucle síncrono de arriba, llegando por la cola en vez de por un bucle. Esa inanición es lo que una medición de tiempo de la propia página ve como jitter: performance timing fingerprinting muestra cómo una lectura de reloj alrededor de un drenaje de cola puede convertir un retraso de planificación en un número que un colector registra.
Dónde espera una microtarea encolada dentro de una microtarea
Una microtarea encolada desde dentro de una microtarea no se adelanta a la cola; se añade al final de la cola y corre después de los callbacks que ya esperan. La sonda encola dos microtareas donde la primera encola una tercera, e imprime el orden:
nested-microtask -> m1,m2,m1-nested,timeout
El orden es m1, luego m2, luego m1-nested, luego el temporizador. La microtarea anidada no corre justo después de m1 que la encoló; espera a que m2, que ya estaba encolada, haya corrido. Solo entonces la cola llega a m1-nested, y solo cuando toda la cola está vacía corre el temporizador.
Ese comportamiento es la razón por la que una cadena de promesas recursiva no cede a nada hasta que termina. Un callback que espera su propia continuación y encola el paso siguiente sigue añadiendo a la cola de microtareas, y la cola drena un paso por pasada sin llegar nunca al temporizador. Quien espera que el temporizador se intercale entre pasos se sorprende, porque la microtarea anidada se añade en vez de interrumpir, y toda la cadena corre en un turno.
La regla anidada también es por lo que un patrón de "aplazar al siguiente tick" construido sobre promesas no aplaza más allá del turno actual. Un callback que encola una microtarea para "dejar correr otro trabajo" solo deja correr primero a las microtareas ya encoladas; no cede a un temporizador, a un evento de entrada ni a un callback de red, porque todos esos esperan a que toda la cola de microtareas se vacíe. Aplazar más allá del turno actual necesita un temporizador o una primitiva de cola de tareas, no una microtarea, y un registro que dice "aplazado al siguiente tick" sin nombrar qué cola es ambiguo.
Dónde reanuda await
Un await suspende la función asíncrona y la reanuda como microtarea, así que el código tras el await corre después de que el bloque síncrono retorna y antes de cualquier temporizador. La sonda espera null, que se resuelve de inmediato, e imprime el orden:
await-resumes-as-microtask -> async-start,sync,after-await,timeout
La función asíncrona empieza y empuja async-start, luego se suspende en el await. El código síncrono tras la llamada empuja sync. La función reanuda después como microtarea y empuja after-await. El temporizador corre al final. Aunque await null no tiene nada que esperar, la reanudación sigue siendo una microtarea y sigue corriendo después de que termine el bloque síncrono actual, que es por lo que la etiqueta sync aparece entre las dos mitades de la función asíncrona.
Ese hueco es por lo que una función asíncrona que parece secuencial no es síncrona. Quien llama a una función asíncrona y espera que el efecto secundario de su primera línea sea visible de inmediato ve el efecto, pero quien espera que el efecto tras el primer await sea visible en la misma sentencia no lo ve, porque la reanudación está encolada. El hueco es exactamente una microtarea, invisible en una lectura de un solo hilo pero visible en cualquier sonda de orden que etiquete ambas mitades.
El orden de reanudación también decide cómo se intercalan dos funciones asíncronas. Dos funciones asíncronas que ambas esperan un valor resuelto de inmediato reanudan en el orden en que se alcanzaron sus await, porque cada reanudación es una microtarea encolada. Una página que lanza dos cargas asíncronas y espera que la primera termine su trabajo tras el await primero acierta mientras la primera alcanzó su await primero, y falla si lo hizo la segunda. El intercalado es determinista y depende del orden en que se alcanzaron los await, no del orden en que se definieron las funciones.
Dónde cae nextTick respecto a una promesa
process.nextTick es una cola específica de Node que corre antes que las microtareas de promesa en la mayoría de los casos, y la sonda lo muestra ganando dentro de un callback:
nextTick-before-promise -> nextTick,promise,timeout
nextTick-beats-later-promise-in-callback -> nextTick,promise
En ambos casos el callback de nextTick corre antes que el callback de promesa y antes que el temporizador. Los dos pares se encolaron dentro de un callback de temporizador, que es una macrotarea, y al terminar una macrotarea Node vacía toda la cola de nextTick antes de empezar la cola de promesas, así que nextTick gana también en el segundo caso aunque la promesa se haya encolado primero.
La regla es más estrecha que "nextTick siempre gana". Una sonda de seguimiento encoló el mismo par en cuatro contextos e imprimió nextTick,promise desde el cuerpo de un script CommonJS y desde un callback de temporizador, y luego promise,nextTick desde dentro de un callback de promesa y desde dentro de un callback de queueMicrotask. El cuerpo de un módulo ES se comporta como los casos de microtarea y también imprime promise,nextTick. La división sigue el contexto desde el que se encolan las líneas: una macrotarea vacía su cola de nextTick antes de la cola de promesas, mientras que un contexto de microtarea ya está dentro de la cola de promesas, así que una continuación encolada ahí corre antes de que el control vuelva a nextTick.
Por eso la sonda de este artículo aísla cada caso en un callback de temporizador, y por eso los dos órdenes que imprime son resultados de macrotarea. Una sonda que encola el par en el nivel superior de CommonJS coincide con ella; el nivel superior de un módulo ES o un callback de microtarea imprime lo contrario. Un registro que dice "nextTick corre primero" sin nombrar el contexto ha registrado un resultado que no se reproducirá en ningún otro sitio.
Por qué un temporizador nunca interrumpe un bucle bloqueante
Un temporizador de retardo cero no interrumpe un bloque síncrono en ejecución; dispara solo después de que el bloque retorna. La sonda arranca un temporizador y luego corre un bucle de fifty milliseconds, y comprueba cuándo disparó el temporizador:
timer-not-preemptive -> elapsed_at_least_50=true
El temporizador disparó a los fifty milliseconds o después, no a cero. El entorno no puede interrumpir la tarea en ejecución, así que el temporizador espera a que el bucle termine y luego corre en el turno siguiente. El retraso no es un fallo del temporizador; es la regla de finalización de la primera sección aplicada a un bloque más largo, y la misma regla que hace que un cálculo largo congele una página.
La misma regla es por lo que un "sleep" construido con un bucle ocupado es peor que un temporizador. Un bucle ocupado que espera doscientos milisegundos bloquea cada callback encolado durante doscientos milisegundos, mientras un temporizador que dispara tras doscientos milisegundos deja correr cada callback encolado entre medias. Un código que reemplaza un temporizador por un bucle para "simplificar" convierte una planificación en un congelamiento, y un registro que muestra un temporizador disparando tarde es la evidencia de que el bucle corrió, no de que el temporizador estaba roto.
El caso bloqueante también fija la expectativa para una medición. Una sonda que mide la latencia de un temporizador tras un bloque conocido puede usar la latencia como evidencia de que el bloque corrió, porque el temporizador no puede disparar antes. Una sonda que mide latencia sin controlar el bloque no puede distinguir un temporizador tarde de una máquina lenta, así que el registro tiene que nombrar el bloque y su duración junto a la latencia observada, o el número no significa nada. El orden que un callback finalmente recibe suele ser el orden que dejó un sort: Orden de array y comparadores muestra por qué el orden por defecto ordena por cadena y por qué un comparador puede desbordar, así que un callback que procesa un lote ordenado hereda ese orden en vez del que se escribió para él.
La comprobación de determinismo cierra el círculo sobre todo lo anterior:
repeat-deterministic -> spt|spt|spt unique=1
Tres ejecuciones de la misma forma dan el mismo orden, s, luego p, luego t, con un único resultado. El orden no es una carrera que parece estable una vez; es el comportamiento especificado de las colas, y una ejecución repetida lo confirma. Una sonda que encontrara órdenes distintos entre repeticiones estaría midiendo otra cosa, como un temporizador cuyo retardo era más corto que el drenaje de la cola, y el registro tendría que explicar la variación en vez de llamarla no determinismo.
Registra una lectura de orden
Una lectura de orden sostiene una afirmación estrecha: este entorno, esta forma, con estos callbacks encolados en este orden, produjeron esta secuencia. Seis campos la mantienen verificable, y ninguno asume un tipo de cola ni un límite de turno:
- El entorno y su bucle de eventos, porque un navegador no tiene
process.nextTicky ordena las microtareas de promesa al final de la tarea actual. - La cola exacta en que se puso cada callback, promesa,
queueMicrotask,nextTicko temporizador, porque la cola decide el orden y el tipo de callback no. - El orden en que se encolaron los callbacks, porque la cola de microtareas es primero en entrar, primero en salir y un registro sin el orden de encolado no puede reproducir el drenaje.
- El límite de turno, si la forma corrió en una macrotarea como un callback de temporizador o de
setImmediate, en el cuerpo de un script CommonJS, o en una microtarea como un callback de promesa o dequeueMicrotask, porque las mismas dos líneas imprimennextTick,promiseen los dos primeros ypromise,nextTicken los de microtarea. - La duración del bloque síncrono, porque un bucle bloqueante retrasa cada temporizador y un registro sin ella no puede explicar un temporizador tarde.
- Si la misma forma se repitió, porque una sola ejecución no puede distinguir un orden especificado de una carrera que parece estable.
Lo que tal lectura no puede mostrar es nada sobre cómo interactúa el orden de una página con lo que informa a un servidor. Una página puede correr sus callbacks en un orden determinista y aun así enviar una petición cuyo tiempo depende de la red, y un servidor que ve llegar una petición no puede inferir de ella el orden de microtareas de la página. Para los bytes que suele procesar un callback, TextDecoder y TextEncoder muestra por qué los mismos bytes decodifican distinto según la etiqueta, y para el patrón sobre el que un callback suele correr, RegExp lastIndex y flags muestra por qué un patrón global reutilizado cambia su respuesta entre llamadas.
Dos hábitos mantienen la lectura honesta con el tiempo. Primero, aísla cada caso de orden en su propia tarea, porque una sonda de ámbito compartido puede dejar trabajo encolado y cambiar el orden del caso siguiente. Segundo, registra el tipo de cola y el límite de turno junto a la secuencia, porque las mismas dos líneas pueden producir dos órdenes distintos según dónde corrieron. Una cola de promesas tampoco es un combinador, y los combinadores de promesas muestran cuál de varias promesas ya asentadas recibe quien llama.
Fuentes públicas
- MDN: uso de microtareas con queueMicrotask()
- MDN: queueMicrotask()
- MDN: await
- MDN: setTimeout()
- Node.js: el bucle de eventos, temporizadores y process.nextTick()
- BotBrowser: referencia de consistencia de automatización
Conclusión
Trata la cola como parte del contrato de orden: el código síncrono termina primero, los callbacks de promesa y queueMicrotask drenan primero en entrar, primero en salir, una microtarea encolada dentro de una microtarea se añade al final, y await reanuda como microtarea, así que todo eso corre antes de un temporizador de retardo cero. process.nextTick gana a una promesa encolada después dentro de un callback de temporizador o de setImmediate y dentro del cuerpo de un script CommonJS, mientras que un callback de promesa o de queueMicrotask y el cuerpo de un módulo ES imprimen lo contrario, así que aísla cada caso en su propia tarea y nombra el contexto en el registro. Un temporizador nunca interrumpe un bucle bloqueante, que es por lo que un bucle de fifty milliseconds hace que un temporizador de retardo cero dispare tarde. Registra el tipo de cola, el orden de encolado, el límite de turno y la duración del bloque, y repite la forma una vez, para que una secuencia guardada sea reproducible en vez de una carrera que parece estable.
Lectura siguiente
Para los bytes que suele manejar un callback, lee TextDecoder y TextEncoder; para el patrón sobre el que un callback suele correr, lee RegExp lastIndex y flags.
Este artículo forma parte de la guía: ¿Qué es la huella digital del navegador?
Artículos Relacionados
Combinadores Promise: qué falla y qué nunca se asienta
Huella digitalperformance.now: resolución del reloj y fingerprinting
Huella digitalOrden de claves: enteros ordenan, cadenas se quedan
PlataformaPrioridades de scheduler.postTask para tareas web
Huella digitalUnicode: cuatro identidades de texto que lee una página
Huella digitalOrden de array y comparadores: una función fácil de errar
Lleva BotBrowser de la investigación a producción
Usa estas guías para entender el modelo y después avanzar hacia validación multiplataforma, contextos aislados y despliegue de navegador preparado para escalar.