Mecánica Cuántica de Cálculos en JS

Hola, mi nombre es Dmitry Karlovsky y yo ... desempleado. Por lo tanto, tengo mucho tiempo libre para tocar música, deportes, creatividad, idiomas, conferencias JS y ciencias de la computación. Te contaré sobre las últimas investigaciones en el campo de la división semiautomática de cálculos largos en pequeños cuantos de varios milisegundos, lo que resultó en una biblioteca en miniatura $mol_fiber . Pero primero, describamos los problemas que resolveremos ...


Quanta!


Esta es una versión de texto del rendimiento homónimo en HolyJS 2018 Piter . Puede leerlo como un artículo , abrirlo en la interfaz de presentación o ver un video .


Problema: baja capacidad de respuesta


Si queremos tener 60 cuadros estables por segundo, entonces solo tenemos 16 con un poco de milisegundos para hacer todo el trabajo, incluido lo que hace el navegador para mostrar los resultados en la pantalla.


Pero, ¿qué pasa si tomamos el flujo por más tiempo? Luego, el usuario observará una interfaz retrasada, inhibiendo la animación y similares de la degradación de UX.


Baja capacidad de respuesta


Problema: sin escape


Sucede que mientras realizamos los cálculos, el resultado ya no nos interesa. Por ejemplo, tenemos un desplazamiento virtual, el usuario lo tira activamente, pero no podemos seguirlo y no podemos representar el área real hasta que la representación anterior devuelva el control para procesar los eventos del usuario.


No se puede deshacer


Idealmente, no importa cuánto tiempo trabajemos, debemos continuar procesando eventos y poder cancelar en cualquier momento el trabajo que hemos comenzado, pero que aún no hemos completado.


Soy rapido y lo se


Pero, ¿qué pasa si nuestro trabajo no es uno, sino varios, sino un flujo? Imagine que conduce en su loto amarillo recién comprado y conduce hasta el cruce del ferrocarril. Cuando es gratis, puedes deslizarlo en una fracción de segundo. Pero ..


Coche genial


Problema: sin concurrencia


Cuando el cruce está ocupado por un tren de un kilómetro, debes pararte y esperar diez minutos hasta que pase. No es por eso que compraste un auto deportivo, ¿verdad?


Rápido espera lento


¡Y qué genial sería si este tren se dividiera en 10 trenes de 100 metros cada uno y hubiera varios minutos entre ellos para pasar! No llegarías tan tarde entonces.


Entonces, ¿cuáles son las soluciones a estos problemas en el mundo JS ahora?


Solución: trabajadores


Lo primero que viene a la mente: ¿vamos a poner todos los cálculos complejos en un hilo separado? Para hacer esto, tenemos un mecanismo para WebWorkers.


Logica de los trabajadores


Los eventos de la secuencia de IU se pasan al trabajador. Allí se procesan y las instrucciones sobre qué y cómo cambiar en la página ya se devuelven. Por lo tanto, guardamos el flujo de la interfaz de usuario de una gran capa de computación, pero no todos los problemas se resuelven de esta manera, y además se agregan otros nuevos.


Trabajadores: Problemas: (De) Serialización


La comunicación entre flujos se produce mediante el envío de mensajes que se serializan en un flujo de bytes, se transfieren a otro flujo y allí se analizan en objetos. Todo esto es mucho más lento que una llamada a método directo dentro de un solo hilo.


(Des) serialización


Trabajadores: Problemas: solo asíncrono


Los mensajes se transmiten estrictamente asincrónicamente. Y esto significa que algunas características que le pido no están disponibles. Por ejemplo, no puede detener el ascenso de un evento ui de un trabajador, ya que para cuando se inicie el controlador, el evento en el hilo de la IU ya completará su ciclo de vida.


Colas de mensajes


Trabajadores: Problemas: API limitadas


Las siguientes API no están disponibles para nosotros en los trabajadores.


  • DOM, CSSOM
  • Lienzo
  • GeoLocation
  • Historia y ubicacion
  • Sincronizar solicitudes http
  • XMLHttpRequest.responseXML
  • Ventana

Trabajadores: Problemas: no se puede cancelar


Y de nuevo, no tenemos forma de detener los cálculos en woker.


Basta!


Sí, podemos detener a todo el trabajador, pero eso detendrá todas las tareas en él.
Sí, puede ejecutar cada tarea en un trabajador independiente, pero requiere muchos recursos.


Solución: Reaccionar fibra


Seguramente muchos escucharon que FaceBook reescribía heroicamente React, dividiendo todos los cálculos en un conjunto de pequeñas funciones lanzadas por un programador especial.


Tricky React Fiber Logic


No entraré en detalles sobre su implementación, ya que este es un gran tema separado. Notaré solo algunas características, por lo que puede no ser adecuado para usted ...


Reaccionar fibra: se requiere reacción


Obviamente, si usa Angular, Vue u otro marco que no sea React, entonces React Fiber es inútil para usted.


¡Reacciona en todas partes!


Reaccionar fibra: solo renderizado


Reaccionar: solo cubre la capa de representación. Todas las demás capas de la aplicación quedan sin cuantificación.


¡No tan rápido!


React Fiber no lo salvará cuando necesite, por ejemplo, filtrar un gran bloque de datos por condiciones difíciles.


Reaccionar fibra: la cuantización está desactivada


A pesar del soporte reclamado para la cuantización, todavía está desactivado de forma predeterminada, ya que rompe la compatibilidad con versiones anteriores.


Trampa de marketing


La cuantización en React sigue siendo algo experimental. Ten cuidado


Reaccionar fibra: la depuración es dolorosa


Cuando activa la cuantización, la pila de llamadas ya no coincide con su código, lo que complica enormemente la depuración. Pero volveremos a este tema.


Todo el dolor de la depuración


Solución: cuantización


Intentemos generalizar el enfoque React Fiber para deshacernos de las desventajas mencionadas. Queremos permanecer en el marco de una secuencia, pero dividimos los cálculos largos en pequeños cuantos, entre los cuales el navegador puede representar los cambios que ya se han realizado en la página, y responderemos a los eventos.


cartas de llamas


Arriba ves un cálculo largo que detuvo al mundo entero en más de 100 ms. Y desde abajo, el mismo cálculo, pero desglosado en segmentos de tiempo de aproximadamente 16 ms, que dieron un promedio de 60 fotogramas por segundo. Como generalmente no sabemos cuánto tiempo llevarán los cálculos, no podemos dividirlo manualmente en trozos de 16 ms por adelantado. por lo tanto, necesitamos algún tipo de mecanismo de tiempo de ejecución que mida el tiempo que lleva completar la tarea y cuándo se excede el cuanto, lo que detiene la ejecución hasta el siguiente cuadro de animación. Pensemos qué mecanismos tenemos implementados para implementar tareas suspendidas aquí.


Concurrencia: fibras - corutinas apiladas


En lenguajes como Go y D existe un modismo como "corutina con una pila", también es una "fibra" o "fibra".


 import { Future } from 'node-fibers' const one = ()=> Future.wait( future => setTimeout( future.return ) ) const two = ()=> one() + 1 const three = ()=> two() + 1 const four = ()=> three() + 1 Future.task( four ).detach() 

En el ejemplo de código, verá one función, que puede pausar la fibra actual, pero en sí misma tiene una interfaz completamente sincrónica. Las two , three y four funciones son funciones síncronas regulares que no saben nada acerca de la fibra. En ellos puedes usar todas las características de javascript en su totalidad. Y finalmente, en la última línea, simplemente ejecutamos las four funciones en una fibra separada.


El uso de fibras es bastante conveniente, pero para admitirlas, necesita soporte en tiempo de ejecución, que la mayoría de los intérpretes de JS no tienen. Sin embargo, para NodeJS hay una extensión nativa node-fibers que agrega este soporte. Desafortunadamente, no hay navegadores disponibles en ningún navegador.


Concurrencia: FSM - corutinas apiladas


En lenguajes como C # y ahora JS hay soporte para "corutinas sin pila" o "funciones asincrónicas". Dichas funciones son una máquina de estado bajo el capó y no saben nada sobre la pila, por lo que deben marcarse con la palabra clave especial "async", y los lugares donde se pueden pausar están "en espera".


 const one = ()=> new Promise( done => setTimeout( done ) ) const two = async ()=> ( await one() ) + 1 const three = async ()=> ( await two() ) + 1 const four = async ()=> ( await three() ) + 1 four() 

Como es posible que tengamos que posponer el cálculo en cualquier momento, resulta que casi todas las funciones de la aplicación tendrán que hacerse asincrónicas. Esto no es solo que la complejidad del código, sino que también afecta en gran medida el rendimiento. Además, muchas API que aceptan devolución de llamada aún no admiten devoluciones de llamada asincrónicas. Un ejemplo sorprendente es el método de reduce de cualquier matriz.


Concurrencia: semi-fibras - reinicios


Intentemos hacer algo similar a la fibra, utilizando solo las funciones que están disponibles para nosotros en cualquier navegador moderno.


 import { $mol_fiber_async , $mol_fiber_start } from 'mol_fiber/web' const one = ()=> $mol_fiber_async( back => setTimeout( back ) ) const two = ()=> one() + 1 const three = ()=> two() + 1 const four = ()=> three() + 1 $mol_fiber_start( four ) 

Como puede ver, las funciones intermedias no saben nada acerca de la interrupción; esto es JS normal. Solo one función conoce la posibilidad de suspensión. Para abortar el cálculo, ella simplemente lanza Promise como una excepción. En la última línea, ejecutamos las four funciones en una pseudo fibra separada, que monitorea las excepciones lanzadas dentro, y si llega Promise , se suscribe a su resolve y luego reinicia la fibra.


Figuras


Para mostrar cómo funcionan las pseudo-fibras, escribiremos un código complicado ...


Cuadro de ejecución típico


Imaginemos que la función de step aquí escribe algo en la consola y hace otro trabajo duro durante 20 ms. Y la función de walk llama al step dos veces, registrando todo el proceso. En el medio, mostrará lo que ahora se muestra en la consola. Y a la derecha está el estado del árbol de pseudofibra.


$ mol_fiber: sin cuantización


Ejecutemos este código y veamos qué sucede.


Ejecución sin cuantización.


Hasta ahora, todo es simple y obvio. El árbol de pseudo-fibra, por supuesto, no está involucrado. Y todo estaría bien, pero este código se ejecuta durante más de 40 ms, lo que no tiene valor.


$ mol_fiber: caché primero


Envuelvamos ambas funciones en un contenedor especial que lo ejecute en una pseudo fibra y veamos qué sucede.


Llenado de cachés


Aquí vale la pena prestar atención al hecho de que para cada lugar de invocación de one función dentro de la fibra de walk , se creó una fibra separada. El resultado de la primera llamada se almacenó en caché, pero en lugar de la segunda, se lanzó Promise , ya que habíamos agotado nuestro tiempo.


$ mol_fiber: segundo caché


Lanzado en el primer fotograma, Promise se resolverá automáticamente en el siguiente, lo que conducirá a un reinicio de la fibra de walk .


Reutilización de caché


Como puede ver, debido al reinicio, de nuevo enviamos "inicio" y "primer hecho" a la consola, pero "primer comienzo" ya se ha ido, ya que está en la fibra con el caché lleno anteriormente, por lo que su controlador es más no llamado Cuando se llena el caché de la fibra de walk , se destruyen todas las fibras incrustadas, ya que la ejecución nunca las alcanzará.


Entonces, ¿por qué first begin imprimir una vez y first done dos? Se trata de idempotencia. console.log : operación no idempotente, cuántas veces la llamas, tantas veces agregará una entrada a la consola. Pero la fibra que se está ejecutando en otra fibra es idempotente, solo ejecuta el identificador en la primera llamada y, en las devoluciones posteriores, inmediatamente el resultado de la memoria caché, sin provocar efectos secundarios adicionales.


$ mol_fiber: idempotencia primero


Vamos a envolver console.log en una fibra, convirtiéndola en idempotente, y veamos cómo se comporta el programa.


llenar cachés idempotentes


Como puede ver, ahora en el árbol de fibra tenemos entradas para cada llamada a la función de log .


$ mol_fiber: segunda idempotencia


En el próximo reinicio de la fibra de walk , las llamadas repetidas a la función de log ya no generan llamadas a la console.log , pero tan pronto como llegamos a la ejecución de las fibras con un caché vacío, las llamadas a console.log reanudan.


Reutilizando cachés idempotentes


Tenga en cuenta que en la consola ahora no mostramos nada superfluo, exactamente lo que se mostraría en código síncrono sin fibra ni cuantificación.


$ mol_fiber: descanso


¿Cómo interrumpe el cálculo? Al comienzo del cuanto, se establece una fecha límite. Y antes de comenzar cada fibra, se verifica si la hemos alcanzado. Y si alcanzas, Promise apresura, lo que se resuelve en el siguiente cuadro y comienza un nuevo cuanto.


 if( Date.now() > $mol_fiber.deadline ) { throw new Promise( $mol_fiber.schedule ) } 

$ mol_fiber: fecha límite


La fecha límite para el cuanto es fácil de establecer. Se agregan 8 milisegundos a la hora actual. ¿Por qué exactamente 8, porque hay hasta 16 para preparar el tiro? El hecho es que no sabemos de antemano cuánto tiempo tendrá que procesar el navegador, por lo que debemos dejar algo de tiempo para que funcione. Pero a veces sucede que el navegador no necesita renderizar nada, y luego con 8 ms quanta podemos insertar otro cuanto en el mismo marco, lo que dará un paquete denso de cuantos con un tiempo de inactividad mínimo del procesador.


 const now = Date.now() const quant = 8 const elapsed = Math.max( 0 , now - $mol_fiber.deadline ) const resistance = Math.min( elapsed , 1000 ) / 10 // 0 .. 100 ms $mol_fiber.deadline = now + quant + resistence 

Pero si solo lanzamos una excepción cada 8 ms, entonces la depuración con la parada de excepción activada se convertirá en una pequeña rama del infierno. Necesitamos algún mecanismo para detectar este modo de depurador. Desafortunadamente, esto solo puede entenderse indirectamente: una persona tarda aproximadamente un segundo en comprender si continúa la ejecución o no. Y esto significa que si el control no volvió al script durante mucho tiempo, entonces el depurador se detuvo o hubo un cálculo pesado. Para sentarnos en ambas sillas, agregamos al cuántico el 10% del tiempo transcurrido, pero no más de 100 ms. Esto no afecta en gran medida a los FPS, pero reduce la frecuencia de detención del depurador en un orden de magnitud debido a la cuantización.


Depurar: probar / atrapar


Ya que estamos hablando de depuración, ¿qué piensas, en qué lugar de este código se detiene el depurador?


 function foo() { throw new Error( 'Something wrong' ) // [1] } try { foo() } catch( error ) { handle( error ) throw error // [2] } 

Como regla general, debe detenerse donde se lanza la excepción por primera vez, pero la realidad es que se detiene solo donde se lanzó la última vez, que generalmente está muy lejos de donde ocurrió. Por lo tanto, para no complicar la depuración, nunca se deben detectar excepciones a través de try-catch. Pero incluso sin un manejo excepcional es imposible.


Depuración: eventos no controlados


Normalmente, un tiempo de ejecución proporciona un evento global que se produce para cada excepción no detectada.


 function foo() { throw new Error( 'Something wrong' ) } window.addEventListener( 'error' , event => handle( event.error ) ) foo() 

Además de la molestia, esta solución tiene un inconveniente tal que todas las excepciones caen aquí y es bastante difícil entender de qué fibra y fibra se produjo el evento.


Depurar: promesa


Las promesas son la mejor manera de manejar las excepciones.


 function foo() { throw new Error( 'Something wrong' ) } new Promise( ()=> { foo() } ).catch( error => handle( error ) ) 

La función pasada a Promise se llama de forma inmediata, sincrónica, pero la excepción no se detecta y detiene de forma segura el depurador en el lugar de su aparición. Un poco más tarde, de forma asíncrona, ya llama al controlador de errores, en el que sabemos exactamente qué fibra dio la falla y qué falla. Este es precisamente el mecanismo utilizado en $ mol_fiber.


Seguimiento de pila: reaccionar fibra


Echemos un vistazo a la traza de pila que obtienes en React Fiber.


Carrera de pila vacía


Como puede ver, tenemos mucha reacción intestinal. De lo útil aquí, solo el punto de ocurrencia de la excepción y los nombres de los componentes son más altos en la jerarquía. No mucho


Seguimiento de pila: $ mol_fiber


En $ mol_fiber, obtenemos un seguimiento de pila mucho más útil: sin agallas, solo puntos específicos en el código de la aplicación a través del cual llegó a una excepción.


Strain de contenido


Esto se logra mediante el uso de la pila nativa, las promesas y la eliminación automática de los intestinos. Si lo desea, puede expandir el error en la consola, como en la captura de pantalla, y ver las tripas, pero no hay nada interesante.


$ mol_fiber: manejar


Entonces, para interrumpir un cuanto, se lanza Promesa.


 limit() { if( Date.now() > $mol_fiber.deadline ) { throw new Promise( $mol_fiber.schedule ) } // ... } 

Pero, como puede suponer, Promise puede ser absolutamente cualquier cosa: para Fiber, en general, no importa qué esperar: el siguiente marco, la finalización de la carga de datos u otra cosa ...


 fail( error : Error ) { if( error instanceof Promise ) { const listener = ()=> self.start() return error.then( listener , listener ) } // ... } 

Fiber simplemente se suscribe para resolver promesas y reinicia. Pero no es necesario lanzar y atrapar promesas manualmente, porque el paquete incluye varios envoltorios útiles.


$ mol_fiber: funciones


Para convertir cualquier función síncrona en una fibra idempotente, simplemente envuélvala en $mol_fiber_func ..


 import { $mol_fiber_func as fiberize } from 'mol_fiber/web' const log = fiberize( console.log ) export const main = fiberize( ()=> { log( getData( 'goo.gl' ).data ) } ) 

Aquí creamos console.log idempotent, y main enseñó a interrumpir mientras esperaba la descarga.


$ mol_fiber: manejo de errores


Pero, ¿cómo responder a las excepciones si no queremos usar try-catch ? Entonces podemos registrar el controlador de errores con $mol_fiber_catch ...


 import { $mol_fiber_func as fiberize , $mol_fiber_catch as onError } from 'mol_fiber' const getConfig = fiberize( ()=> { onError( error => ({ user : 'Anonymous' }) ) return getData( '/config' ).data } ) 

Si devolvemos algo diferente del error, será el resultado de la fibra actual. En este ejemplo, si no es posible descargar la configuración del servidor, la función getConfig devolverá la configuración de forma predeterminada.


$ mol_fiber: métodos


Por supuesto, puede ajustar no solo las funciones, sino también los métodos que usan un decorador.


 import { $mol_fiber_method as action } from 'mol_fiber/web' export class Mover { @action move() { sendData( 'ya.ru' , getData( 'goo.gl' ) ) } } 

Aquí, por ejemplo, subimos datos de Google y los subimos a Yandex.


$ mol_fiber: promesas


Para descargar datos del servidor, basta con tomar, por ejemplo, la función de fetch asíncrona y, con un simple movimiento de la muñeca, la convierte en sincronizada.


 import { $mol_fiber_sync as sync } from 'mol_fiber/web' export const getData = sync( fetch ) 

Esta implementación es buena para todos, pero no admite la cancelación de una solicitud cuando se destruye un árbol de fibras, por lo que debemos utilizar una API más confusa.


$ mol_fiber: cancelar solicitud


 import { $mol_fiber_async as async } from 'mol_fiber/web' function getData( uri : string ) : Response { return async( back => { var controller = new AbortController(); fetch( uri , { signal : controller.signal } ).then( back( res => res ) , back( error => { throw error } ) , ) return ()=> controller.abort() } ) } 

La función que se pasa al reiniciador async se llama solo una vez y el reiniciador se le pasa, en el que debe ajustar las devoluciones de llamada. En consecuencia, en estas devoluciones de llamada, debe devolver el valor o lanzar una excepción. Cualquiera que sea el resultado de la devolución de llamada, también será el resultado de la fibra. Tenga en cuenta que al final devolveremos una función que se llamará en caso de destrucción prematura de la fibra.


$ mol_fiber: cancelar respuesta


Del lado del servidor, también puede ser útil cancelar el cálculo cuando el cliente se ha caído. Implementemos un contenedor sobre midleware que creará una fibra en la que se ejecutará el midleware original. , , , .


 import { $mol_fiber_make as Fiber } from 'mol_fiber' const middle_fiber = middleware => ( req , res ) => { const fiber = Fiber( ()=> middleware( req , res ) ) req.on( 'close' , ()=> fiber.destructor() ) fiber.start() } app.get( '/foo' , middle_fiber( ( req , res ) => { // do something } ) ) 

$mol_fiber: concurrency


, . , 3 : , , - ..


Solicitudes rápidas y lentas


: , . . , , .


$mol_fiber: properties


, ..


Pros:
  • Runtime support isn't required
  • Can be cancelled at any time
  • High FPS
  • Concurrent execution
  • Debug friendly
  • ~ 3KB gzipped


Cons:
  • Instrumentation is required
  • All code should be idempotent
  • Longer total execution

$mol_fiber — , . — , . , , . , , , , . , . .


Links



Call back


Retroalimentación


: , , )


: , .


: . , .


: . , . , .


: , . , )


: , .


: - . , , .


: . , , .


: , . 16ms, ? 16 8 , 8, . , . , «».


: — . Gracias


: . , . !


: , . .


: , , , , , / , .


: , .


: .


: , . mol.


: , , . , , , .


: .


: , . , $mol, , .


: , , . — . .


: - , .


: $mol , . (pdf, ) , .


: , . , .


: , ) .


: . .


: In some places I missed what the reporter was saying. The conversation was about how to use the "Mola" library and "why?". But how it works remains a mystery for me.To smoke an source code is for the overhead.


: , .


: . , . . .


: : . - (, ). , : 16?


Más o menos : no trabajé con fibra. En el informe escuché la teoría del trabajo de la fibra. Pero no entendí absolutamente cómo usar mol_fiber en casa ... Los ejemplos pequeños son geniales, pero ¿cómo se puede aplicar esto en una aplicación grande con 30 fps para acelerar hasta 60 fps? No hubo comprensión. Ahora, si el autor prestara más atención a esto y menos diseño de módulo interno, la calificación sería más alta.

Source: https://habr.com/ru/post/es413791/


All Articles