Mécanique quantique des calculs en JS

Bonjour, je m'appelle Dmitry Karlovsky et moi ... sans emploi. Par conséquent, j'ai beaucoup de temps libre pour jouer de la musique, des sports, de la créativité, des langues, des conférences JS et de l'informatique. Je vais vous parler des dernières recherches dans le domaine du fractionnement semi-automatique de longs calculs en petits quanta de plusieurs millisecondes, qui ont abouti à une bibliothèque miniature $mol_fiber . Mais d'abord, décrivons les problèmes que nous allons résoudre.


Quanta!


Il s'agit d'une version texte de la performance éponyme à HolyJS 2018 Piter . Vous pouvez soit le lire comme un article , soit l' ouvrir dans l'interface de présentation , soit regarder une vidéo .


Problème: faible réactivité


Si nous voulons avoir 60 images stables par seconde, nous n'en avons que 16 avec un peu de millisecondes pour faire tout le travail, y compris ce que le navigateur fait pour afficher les résultats à l'écran.


Mais que se passe-t-il si nous prenons le courant plus longtemps? Ensuite, l'utilisateur observera une interface en retard, inhibant l'animation et similaire de la dégradation UX.


Faible réactivité


Problème: aucune échappatoire


Il arrive que pendant que nous effectuons les calculs, le résultat ne nous intéresse plus. Par exemple, nous avons un défilement virtuel, l'utilisateur le tire activement, mais nous ne pouvons pas le suivre et ne pouvons pas restituer la zone réelle jusqu'à ce que le rendu précédent renvoie le contrôle pour traiter les événements utilisateur.


Ne peut pas être annulé


Idéalement, quelle que soit la durée de notre travail, nous devons continuer à traiter les événements et pouvoir à tout moment annuler le travail que nous avons commencé, mais pas encore terminé.


Je suis rapide et je le sais


Mais que faire si notre travail n'est pas un, mais plusieurs, mais un flux? Imaginez que vous conduisez sur votre lotus jaune fraîchement acheté et conduisez jusqu'au passage à niveau. Lorsqu'il est gratuit, vous pouvez le glisser en une fraction de seconde. Mais ..


Cool car


Problème: pas de simultanéité


Lorsque le passage à niveau est occupé par un train d'un kilomètre, vous devez vous lever et attendre dix minutes jusqu'à ce qu'il passe. Pas pour ça que vous avez acheté une voiture de sport, non?


Attente rapide lente


Et comme ce serait cool si ce train était divisé en 10 trains de 100 mètres chacun et qu'il y aurait plusieurs minutes entre eux pour passer! Vous ne seriez pas si tard alors.


Alors, quelles sont les solutions à ces problèmes dans le monde JS maintenant?


Solution: les travailleurs


La première chose qui me vient à l'esprit: mettons simplement tous les calculs complexes dans un thread séparé? Pour ce faire, nous avons un mécanisme pour les WebWorkers.


Logique des travailleurs


Les événements du flux d'interface utilisateur sont transmis au travailleur. Là, ils sont traités et les instructions sur quoi et comment changer sur la page sont déjà renvoyées. Ainsi, nous enregistrons le flux d'interface utilisateur à partir d'une grande couche de calcul, mais tous les problèmes ne sont pas résolus de cette manière, et en plus de nouveaux sont ajoutés.


Workers: Issues: (De) Sérialisation


La communication entre les flux se produit en envoyant des messages qui sont sérialisés en un flux d'octets, transférés vers un autre flux, et là, ils sont analysés en objets. Tout cela est beaucoup plus lent qu'un appel de méthode direct dans un seul thread.


(Dé) sérialisation


Workers: Issues: Asynchronous only


Les messages sont transmis de manière strictement asynchrone. Et cela signifie que certaines fonctionnalités que je vous demande ne sont pas disponibles. Par exemple, vous ne pouvez pas arrêter l'ascension d'un événement ui à partir d'un travailleur, car au moment où le gestionnaire démarre, l'événement dans le thread d'interface utilisateur termine déjà son cycle de vie.


Fichiers d'attente de messages


Travailleurs: Problèmes: API limitées


Les API suivantes ne sont pas disponibles pour nous dans les travailleurs.


  • DOM, CSSOM
  • Toile
  • GéoLocalisation
  • Histoire et emplacement
  • Synchroniser les requêtes http
  • XMLHttpRequest.responseXML
  • Fenêtre

Travailleurs: problèmes: impossible d'annuler


Et encore une fois, nous n'avons aucun moyen d'arrêter les calculs dans woker.


Arrête ça!


Oui, nous pouvons arrêter l'ensemble du travailleur, mais cela arrêtera toutes les tâches qu'il contient.
Oui, vous pouvez exécuter chaque tâche dans un travailleur distinct, mais cela consomme beaucoup de ressources.


Solution: réagir la fibre


Beaucoup ont sûrement entendu FaceBook réécrire héroïquement React, brisant tous les calculs qu'il contient en un tas de petites fonctions lancées par un programmateur spécial.


Logique de fibre Tricky React


Je n'entrerai pas dans les détails de sa mise en œuvre, car il s'agit d'un grand sujet distinct. Je ne noterai que quelques fonctionnalités, à cause desquelles cela peut ne pas vous convenir ..


React Fibre: React requis


Évidemment, si vous utilisez Angular, Vue ou un autre framework que React, React Fibre est inutile pour vous.


Réagissez partout!


React Fibre: rendu uniquement


Réagir - ne couvre que la couche de rendu. Toutes les autres couches de l'application restent sans quantification.


Pas si vite!


React Fibre ne vous sauvera pas lorsque vous aurez besoin, par exemple, de filtrer un grand bloc de données dans des conditions difficiles.


React Fibre: la quantification est désactivée


Malgré le support revendiqué pour la quantification, il est toujours désactivé par défaut, car il rompt la compatibilité descendante.


Marketing piège


La quantification dans React est encore une chose expérimentale. Faites attention!


React Fiber: Debug is pain


Lorsque vous activez la quantification, callstack ne correspond plus à votre code, ce qui complique considérablement le débogage. Mais nous reviendrons sur cette question.


Toute la douleur du débogage


Solution: quantification


Essayons de généraliser l'approche React Fibre afin de nous débarrasser des inconvénients mentionnés. Nous voulons rester dans le cadre d'un flux, mais diviser les longs calculs en petits quanta, entre lesquels le navigateur peut restituer les modifications qui ont déjà été apportées à la page, et nous répondrons aux événements.


graphiques de flamme


Ci-dessus, vous voyez un long calcul qui a arrêté le monde entier de plus de 100 ms. Et d'en bas - le même calcul, mais décomposé en tranches de temps d'environ 16 ms, ce qui donne une moyenne de 60 images par seconde. Comme nous ne savons généralement pas combien de temps les calculs prendront, nous ne pouvons pas le diviser manuellement en morceaux de 16 ms à l'avance. par conséquent, nous avons besoin d'une sorte de mécanisme d'exécution qui mesure le temps nécessaire pour terminer la tâche et lorsque la taille du quantum est dépassée, ce qui suspend l'exécution jusqu'à la prochaine image d'animation. Réfléchissons aux mécanismes que nous avons en place pour mettre en œuvre de telles tâches suspendues ici.


Concurrence: fibres - coroutines empilables


Dans des langues comme Go et D, il existe un idiome comme une «coroutine avec une pile», c'est aussi une «fibre» ou «fibre».


 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() 

Dans l'exemple de code, vous voyez one fonction, qui peut mettre en pause la fibre actuelle, mais elle-même a une interface complètement synchrone. Les fonctions two , three et four sont des fonctions synchrones régulières qui ne connaissent rien à la fibre. En eux, vous pouvez utiliser toutes les fonctionnalités de javascript dans leur intégralité. Et enfin, sur la dernière ligne, nous exécutons simplement la fonction four dans une fibre distincte.


L'utilisation de fibres est assez pratique, mais pour les prendre en charge, vous avez besoin d'une prise en charge d'exécution, que la plupart des interprètes JS ne possèdent pas. Cependant, pour NodeJS, il existe une extension native node-fibers qui ajoute cette prise en charge. Malheureusement, aucun navigateur n'est disponible dans aucun navigateur.


Concurrence: FSM - coroutines sans pile


Dans des langages comme C # et maintenant JS, il existe un support pour les «coroutines sans pile» ou les «fonctions asynchrones». De telles fonctions sont une machine d'état sous le capot et ne savent rien de la pile, elles doivent donc être marquées avec le mot-clé spécial "async", et les endroits où elles peuvent être suspendues sont "en attente".


 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() 

Étant donné que nous devrons peut-être reporter le calcul à tout moment, il s'avère que presque toutes les fonctions de l'application devront être rendues asynchrones. Ce n'est pas seulement que la complexité du code, mais affecte également considérablement les performances. En outre, de nombreuses API acceptant les rappels ne prennent toujours pas en charge les rappels asynchrones. Un exemple frappant est la méthode de reduce de tout tableau.


Concurrence: semi-fibres - redémarrage


Essayons de faire quelque chose de similaire à la fibre, en utilisant uniquement les fonctionnalités qui sont disponibles pour nous dans n'importe quel navigateur moderne.


 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 ) 

Comme vous pouvez le voir, les fonctions intermédiaires ne savent rien de l'interruption - c'est JS normal. Seule one fonction connaît la possibilité de suspension. Pour abandonner le calcul, elle lève simplement Promise comme exception. Sur la dernière ligne, nous exécutons la fonction four dans une pseudo-fibre distincte, qui surveille les exceptions levées à l'intérieur, et si Promise arrive, s'abonne à sa resolve , puis redémarre la fibre.


Les chiffres


Pour montrer comment fonctionnent les pseudo-fibres, nous allons écrire un code délicat.


Graphique d'exécution typique


Imaginons que la fonction step ici écrit quelque chose sur la console et fasse un autre travail dur pendant 20 ms. Et la fonction de walk appelle l' step deux fois, enregistrant l'ensemble du processus. Au milieu, il montrera ce qui est maintenant affiché dans la console. Et à droite, l'état de l'arbre pseudofibre.


$ mol_fiber: pas de quantification


Exécutons ce code et voyons ce qui se passe ..


Exécution sans quantification


Jusqu'à présent, tout est simple et évident. L'arbre pseudo-fibre, bien sûr, n'est pas impliqué. Et tout irait bien, mais ce code est exécuté pendant plus de 40 ms, ce qui ne vaut rien.


$ mol_fiber: cache d'abord


Emballons les deux fonctions dans un wrapper spécial qui l'exécute dans une pseudo-fibre et voyons ce qui se passe.


Remplissage des caches


Ici, il convient de prêter attention au fait que pour chaque lieu d'appel de la fonction à l'intérieur de la fibre de walk , une fibre distincte a été créée. Le résultat du premier appel a été mis en cache, mais au lieu du second, Promise été levée, car nous avions épuisé notre tranche de temps.


$ mol_fiber: cache seconde


Lancée dans la première image, Promise sera automatiquement résolue dans la suivante, ce qui entraînera un redémarrage de la fibre de walk .


Ratification du cache


Comme vous pouvez le voir, en raison du redémarrage, nous envoyons à nouveau «start» et «first done» à la console, mais «first begin» est déjà parti, car il est dans la fibre avec le cache rempli plus tôt, c'est pourquoi son gestionnaire est plus pas appelé. Lorsque le cache de la fibre de walk est rempli, toutes les fibres intégrées sont détruites, car l'exécution ne les atteindra jamais.


Alors pourquoi a-t-on first begin imprimer une fois et à en first done deux? Tout est question d'idempotence. console.log - opération non idempotente, combien de fois vous l'appelez, tant de fois cela ajoutera une entrée à la console. Mais la fibre qui s'exécute dans une autre fibre est idempotente, elle n'exécute le handle que lors du premier appel, et lors des retours suivants immédiatement le résultat du cache, sans entraîner d'effets secondaires supplémentaires.


$ mol_fiber: idempotence d'abord


Enveloppons console.log dans une fibre, la rendant ainsi idempotente, et voyons comment le programme se comporte.


remplissage des caches idempotents


Comme vous pouvez le voir, maintenant dans l'arborescence des fibres, nous avons des entrées pour chaque appel à la fonction de log .


$ mol_fiber: idempotence seconde


Au prochain redémarrage de la fibre de walk , les appels répétés à la fonction de log ne conduisent plus à des appels au vrai console.log , mais dès que nous arrivons à l'exécution des fibres avec un cache vide, les appels à console.log repris.


Ratification de caches idempotents


Veuillez noter que dans la console, nous n'affichons plus rien de superflu - exactement ce qui serait affiché en code synchrone sans fibre ni quantification.


$ mol_fiber: pause


Comment le calcul s'interrompt-il? Au début du quantum, un délai est fixé. Et avant de démarrer chaque fibre, il est vérifié si nous l'avons atteinte. Et si vous atteignez, Promise précipite, ce qui est résolu dans le cadre suivant et commence un nouveau quantum.


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

$ mol_fiber: délai


La date limite pour le quantum est facile à fixer. 8 millisecondes sont ajoutées à l'heure actuelle. Pourquoi exactement 8, car il y en a jusqu'à 16 pour préparer le coup? Le fait est que nous ne savons pas à l'avance combien de temps le navigateur devra rendre, nous devons donc laisser un peu de temps pour qu'il fonctionne. Mais il arrive parfois que le navigateur n'ait rien à rendre, puis avec des quanta de 8 ms, nous pouvons insérer un autre quantum dans la même trame, ce qui donnera un emballage dense de quanta avec un temps d'arrêt minimal du processeur.


 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 

Mais si nous lançons une exception toutes les 8 ms, le débogage avec l'arrêt d'exception activé se transformera en une petite branche de l'enfer. Nous avons besoin d'un mécanisme pour détecter ce mode débogueur. Malheureusement, cela ne peut être compris qu'indirectement: une personne prend environ une seconde pour savoir si elle doit continuer son exécution ou non. Et cela signifie que si le contrôle n'est pas revenu au script pendant longtemps, alors soit le débogueur s'est arrêté, soit il y a eu un calcul lourd. Pour nous asseoir sur les deux chaises, nous ajoutons au quantum 10% du temps écoulé, mais pas plus de 100 ms. Cela n'affecte pas beaucoup le FPS, mais il réduit la fréquence d'arrêt du débogueur d'un ordre de grandeur en raison de la quantification.


Déboguer: essayer / attraper


Puisque nous parlons de débogage, que pensez-vous, à quel endroit de ce code le débogueur s'arrête-t-il?


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

En règle générale, il doit s'arrêter là où l'exception est levée pour la première fois, mais la réalité est qu'il ne s'arrête que là où elle a été lancée la dernière fois, ce qui est généralement très loin de l'endroit où elle s'est produite. Par conséquent, afin de ne pas compliquer le débogage, les exceptions ne doivent jamais être interceptées, via try-catch. Mais même sans exception, la manipulation est impossible.


Débogage: événements non gérés


En règle générale, un runtime fournit un événement global qui se produit pour chaque exception non interceptée.


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

En plus d'être encombrante, cette solution présente un inconvénient tel que toutes les exceptions se trouvent ici et il est assez difficile de comprendre à partir de quelle fibre et fibre si l'événement s'est produit.


Débogage: promesse


Les promesses sont le meilleur moyen de gérer les exceptions.


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

La fonction passée à Promise est appelée immédiatement, de manière synchrone, mais l'exception n'est pas interceptée et arrête le débogueur en toute sécurité à l'endroit de son occurrence. Un peu plus tard, de manière asynchrone, il appelle déjà le gestionnaire d'erreurs, dans lequel nous savons exactement quelle fibre a provoqué la panne et quelle panne. C'est précisément le mécanisme utilisé dans $ mol_fiber.


Trace de pile: réagir la fibre


Jetons un coup d'œil à la trace de pile que vous obtenez dans React Fibre.


Stackrace vide


Comme vous pouvez le voir, nous obtenons beaucoup d'intestin React. De l'utile ici, seuls le point d'exception et les noms des composants sont plus élevés dans la hiérarchie. Pas beaucoup.


Trace de pile: $ mol_fiber


Dans $ mol_fiber, nous obtenons une trace de pile beaucoup plus utile: pas de tripes, seulement des points spécifiques dans le code d'application à travers lesquels il est venu à une exception.


Contenu souche


Ceci est réalisé grâce à l'utilisation de la pile native, aux promesses et à l'élimination automatique des intestins. Si vous le souhaitez, vous pouvez étendre l'erreur dans la console, comme dans la capture d'écran, et voir les tripes, mais il n'y a rien d'intéressant.


$ mol_fiber: handle


Donc, pour interrompre un quantum, Promise est lancé.


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

Mais, comme vous pouvez le deviner, la promesse peut être absolument n'importe quoi - pour une fibre, d'une manière générale, peu importe à quoi s'attendre: la trame suivante, l'achèvement du chargement des données ou autre chose ..


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

La fibre s'abonne simplement pour tenir ses promesses et redémarrer. Mais lancer et attraper des promesses manuellement n'est pas nécessaire, car le package comprend plusieurs wrappers utiles.


$ mol_fiber: fonctions


Pour transformer n'importe quelle fonction synchrone en une fibre idempotente, il suffit de l'envelopper dans $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 ) } ) 

Ici, nous avons rendu console.log idempotent, et nous avons appris à interrompre en attendant le téléchargement.


$ mol_fiber: gestion des erreurs


Mais comment répondre aux exceptions si nous ne voulons pas utiliser try-catch ? Ensuite, nous pouvons enregistrer le gestionnaire d'erreurs avec $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 nous renvoyons quelque chose de différent de l'erreur, ce sera le résultat de la fibre actuelle. Dans cet exemple, s'il n'est pas possible de télécharger la configuration depuis le serveur, la fonction getConfig retournera la configuration par défaut.


$ mol_fiber: méthodes


Bien sûr, vous pouvez encapsuler non seulement des fonctions, mais aussi des méthodes à l'aide d'un décorateur.


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

Ici, par exemple, nous avons téléchargé des données de Google et les avons téléchargées sur Yandex.


$ mol_fiber: promesses


Pour télécharger des données depuis le serveur, il suffit de prendre, par exemple, la fonction asynchrone fetch et d'un simple mouvement de poignet la transformer en synchrone.


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

Cette implémentation est bonne pour tout le monde, mais elle ne prend pas en charge l'annulation d'une demande lorsqu'un arbre de fibres est détruit, nous devons donc utiliser une API plus confuse.


$ mol_fiber: annuler la demande


 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 fonction transmise à l'encapsuleur async est appelée une seule fois et l'encapsuleur back est transmis, dans lequel vous devez encapsuler les rappels. Par conséquent, dans ces rappels, vous devez soit renvoyer la valeur, soit lever une exception. Quel que soit le résultat du rappel, il sera également le résultat de la fibre. Veuillez noter qu'au final nous retournons une fonction qui sera appelée en cas de destruction prématurée de la fibre.


$ mol_fiber: annuler la réponse


Côté serveur, il peut également être utile d'annuler le calcul lorsque le client est tombé. midleware un wrapper sur les midleware qui créera une fibre dans laquelle midleware le midleware origine. , , , .


 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 : , , - ..


Demandes rapides et lentes


: , . . , , .


$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 — , . — , . , , . , , , , . , . .


Les liens



Call back


Rétroaction


: , , )


: , .


: . , .


: . , . , .


: , . , )


: , .


: - . , , .


: . , , .


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


: — . Je vous remercie!


: . , . !


: , . .


: , , , , , / , .


: , .


: .


: , . 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?


: . . , mol_fiber … , 30fps 60fps — . — .

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


All Articles