El curioso caso de las pruebas de rendimiento setTimeout (0)

(Para un efecto completo, lea con voz ronca mientras está rodeado por una nube de humo)

Todo comenzó en un día gris de otoño. El cielo estaba nublado, el viento soplaba y alguien me dijo que eso setTimeout(0)crea, en promedio, un retraso de 4 ms. Afirmaron que ese es el tiempo que lleva sacar la devolución de llamada de la pila, colocarla en la cola de devolución de llamada y volver a la pila. Pensé que sonaba sospechoso (esto es lo que me imaginas en blanco y negro con un cigarro en la boca). Dado que la canalización de renderizado debe ejecutarse cada 16 ms para permitir animaciones fluidas, 4 ms me pareció mucho tiempo. Un largo tiempo.

Algunas pruebas ingenuas en las herramientas de desarrollo lo console.time()confirmaron. El retraso medio en 20 ejecuciones fue de aproximadamente 1,5 ms. Por supuesto, 20 ejecuciones no son suficientes para un tamaño de muestra, pero ahora tenía algo que demostrar. Quería ejecutar pruebas a mayor escala que pudieran darme una respuesta más precisa. Entonces, por supuesto, podría ir y agitar eso en la cara de mi colega para demostrar que estaban equivocados.

¿Por qué más hacemos lo que hacemos?

El método tradicional

De inmediato, me encontré en agua caliente. Para medir cuánto tiempo tardó setTimeout(0)en ejecutarse, necesitaba una función que:

  • tomó una instantánea de la hora actual
  • ejecutado setTimeout
  • luego salió de inmediato para que la pila se limpiara y la devolución de llamada programada pudiera ejecutarse y calcular la diferencia de tiempo
  • y necesitaba que esa función se ejecutara una cantidad suficientemente grande de veces para que los cálculos fueran estadísticamente significativos

Pero la construcción de referencia para esto, el ciclo for, no funcionaría. Debido a que el bucle for no borra la pila hasta que ha ejecutado todos los bucles, la devolución de llamada no se ejecutará inmediatamente. O, para ponerlo en código, obtendríamos esto:

El problema aquí era inherente: si quisiera ejecutar setTimeoutvarias veces automáticamente, tendría que hacerlo desde otro contexto. Pero, siempre que corriera desde otro contexto, siempre habría un retraso adicional desde el momento en que comencé la prueba hasta el momento en que se ejecutó la devolución de llamada.

Por supuesto, podría dejarlo en un barrio bajo como algunos de estos detectives inútiles, escribir una función que haga lo que necesito y luego copiarla y pegarla 10.000 veces. Aprendería lo que quería saber, pero la ejecución estaría lejos de ser elegante. Si fuera a restregar esto en la cara de otra persona, preferiría hacerlo de otra manera.

Entonces vino a mí.

El método revolucionario

Me vendría bien un trabajador web.

Los trabajadores web se ejecutan en un hilo diferente. Entonces, si setTimeoutcoloco la lógica en un trabajador web, podría llamar a eso varias veces.Cada llamada crearía su propio contexto de ejecución, llamaría setTimeouty saldría inmediatamente de la función para que se pudiera ejecutar la devolución de llamada. Tenía muchas ganas de trabajar con los trabajadores web.

Era hora de cambiar a mi Sublime Text de confianza.

Empecé simplemente probando las aguas. Con este código en main.js:

Un poco de plomería aquí para prepararme para la prueba real, pero inicialmente solo quería asegurarme de poder comunicarme correctamente con el trabajador web. Así que esta fue la inicial worker.js:

Y aunque funcionó a las mil maravillas, produjo resultados que debería haber esperado, pero no fue así:

Al estar tan acostumbrado a la sincronicidad en JS, no pude evitar sorprenderme por esto. En el primer momento en que lo vi, mi cerebro registró un error. Pero, dado que cada ciclo configura un nuevo trabajador web y se ejecuta de forma asincrónica, tiene sentido que los números no se impriman en orden.

Puede que me haya sorprendido, pero funcionó como se esperaba. Podría seguir adelante con la prueba.

Lo que quería es que la onmessagefunción del trabajador web se registre t0, llame setTimeouty luego salga inmediatamente para no bloquear la pila. Sin embargo, podría poner funcionalidad adicional dentro de la devolución de llamada, después de haber establecido el valor de t1. Agregué mi postMessageen la devolución de llamada, por lo que no bloquea la pila:

Y aquí está el main.jscódigo:

Esta versión tiene un problema.

Por supuesto, como soy nuevo en los trabajadores web, no lo había considerado al principio. Pero, cuando se seguían imprimiendo varias ejecuciones de la función 0, pensé que algo no estaba bien.

Cuando imprimí las sumas desde adentro onmessage, obtuve mi respuesta. La función principal avanzaba sincrónicamente y no estaba esperando a que regresara el mensaje del trabajador, por lo que calculó el promedio antes de que el trabajador web terminara.

Una solución rápida y sucia es agregar un contador y hacer el cálculo solo cuando el contador haya alcanzado el valor máximo. Así que aquí está el nuevomain.js:

Y aquí están los resultados:

main(10): 0.1

main(100) : 1.41

main(1000) : 13.082

Oh. Mi. Bueno, eso no es genial, ¿verdad? ¿Que está pasando aqui?

Sacrifiqué las pruebas de rendimiento para echar un vistazo al interior. Ahora estoy registrando t0y t1 cuando se crean, solo para ver qué está pasando allí.

Y los resultados:

Resulta que mi expectativa de t1ser calculada inmediatamente después t0también fue errónea. Básicamente, el hecho de que nada sobre los trabajadores web sea sincrónico significa que mis suposiciones más básicas sobre cómo se comporta mi código ya no son ciertas. Es un punto ciego difícil de ver.

No solo eso, sino que incluso los resultados que obtuve main(10)y main(100), que originalmente me hicieron muy feliz y engreído, no eran algo en lo que pudiera confiar.

La asincronicidad de los trabajadores web también los convierte en un proxy poco confiable de cómo se comportan las cosas en nuestra pila regular. Por lo tanto, aunque medir el rendimiento de setTimeoutun trabajador web da algunos resultados interesantes, estos no son resultados que respondan a nuestra pregunta.

El método del libro de texto

Estaba frustrado ... ¿No podría realmente encontrar una solución JS vanilla que fuera elegante y demostrara que mi colega estaba equivocado?

Y luego me di cuenta de que había algo que podía hacer, pero no me gustaría.

Podría llamar de forma setTimeoutrecursiva.

Ahora, cuando llame main, llamaré a testRunnerqué medidas t0y luego programará la devolución de llamada. La devolución de llamada se ejecuta inmediatamente, calcula t1y luego testRunnervuelve a llamar , hasta que se alcanza la cantidad deseada de llamadas.

Los resultados de este código fueron particularmente sorprendentes. Aquí hay algunas impresiones de main(10)y main(1000):

Los resultados son significativamente diferentes cuando se llama a la función 1000 veces en comparación con llamarla 10 veces. He intentado esto repetidamente y obtuve en gran medida los mismos resultados, con main(10)3 a 4 ms y main(1000)5 ms.

Para ser honesto, no estoy seguro de lo que está pasando aquí. Busqué una respuesta, pero no pude encontrar ninguna explicación razonable. Si estás leyendo esto y tienes una idea fundamentada de lo que está pasando, me encantaría saber de ti en los comentarios.

El método probado y verdadero

En algún lugar del fondo de mi mente, siempre supe que llegaría a esto ... Las cosas llamativas son agradables para aquellos que pueden conseguirlas, pero probadas y verdaderas siempre estarán ahí al final. Aunque traté de evitarlo, siempre supe que era una opción. setInterval.

Este código hace el truco con algo de fuerza bruta. setIntervalejecuta la función repetidamente, esperando 50 ms entre cada ejecución, para asegurarse de que la pila esté limpia. Esto no es elegante, pero prueba exactamente lo que necesitaba.

Y los resultados también fueron prometedores. Los tiempos parecen coincidir con mi expectativa original: menos de 1,5 ms.

Finalmente pude poner este caso en la cama. Había tenido algunos altibajos, y mi parte de resultados inesperados, pero al final solo una cosa importaba: ¡había demostrado que otro desarrollador estaba equivocado! Eso fue lo suficientemente bueno para mí.

¿Quieres jugar con este código? compruébalo aquí: //github.com/NettaB/setTimeout-test