Fermer

juin 30, 2021

L'état des travailleurs du Web en 2021 —


Le Web est monothread. Cela rend de plus en plus difficile l'écriture d'applications fluides et réactives. Les travailleurs ont une mauvaise réputation, mais peuvent être un outil important et utile dans la ceinture à outils de tout développeur Web pour ce genre de problèmes. Mettons-nous au courant des travailleurs sur le Web !

J'en ai marre de toujours comparer le web à des plateformes dites « natives » comme Android et iOS. Le Web diffuse en continu, ce qui signifie qu'il n'a aucune des ressources disponibles localement lorsque vous ouvrez une application pour la première fois. C'est une telle différence fondamentale que de nombreux choix architecturaux des plates-formes natives ne s'appliquent pas facilement au Web, voire pas du tout. Mais peu importe où vous regardez, le multithreading est utilisé partout . iOS permet aux développeurs de paralléliser facilement le code à l'aide de Grand Central DispatchAndroid le fait via son nouveau planificateur de tâches unifié WorkManager et les moteurs de jeu comme Unity disposent de systèmes de tâches. La raison pour laquelle l'une de ces plates-formes prend non seulement en charge le multithreading, mais le rend aussi simple que possible est toujours la même : assurez-vous que votre application se sente bien .

Dans cet article, je vais décrire mon modèle mental. pourquoi le multithreading est important sur le web, je vais vous donner une introduction aux primitives que nous en tant que développeurs avons à notre disposition, et je parlerai un peu des architectures qui facilitent l'adoption du multithreading, même progressivement.

Le problème des performances imprévisibles

L'objectif est de garder votre application fluide et réactive. Lisse signifie avoir une fréquence d'images stable et suffisamment élevée. Responsive signifie que l'interface utilisateur répond aux interactions de l'utilisateur avec un délai minimal. Ces deux facteurs sont essentiels pour que votre application se sente raffinée et de haute qualité.

Selon RAILêtre réactif signifie réagir à l'action d'un utilisateur en moins de 100 ms, et être fluide signifie expédier un 60 stable. images par seconde (fps) lorsque quelque chose bouge à l'écran. Par conséquent, en tant que développeurs, nous avons 1000 ms/60 = 16,6 ms pour produire chaque image, ce qui est également appelé « budget d'images ». Je dis « nous »mais c'est vraiment le navigateur qui a 16,6ms pour faire tout le nécessaire pour restituer une trame. Les développeurs américains ne sont directement responsables que d'une partie de la charge de travail que le navigateur doit gérer. Ce travail consiste (mais sans s'y limiter) à :

  • Détecter quel élément l'utilisateur peut ou non avoir tapé ;
  • déclencher les événements correspondants ;
  • exécuter les gestionnaires d'événements JavaScript associés ;
  • calculer les styles ;
  • faire la mise en page ;
  • peindre des calques ;
  • et composer ces calques dans l'image finale que l'utilisateur voit à l'écran ;
  • (et plus …)

Beaucoup de travail. graphique à barres montrant combien de temps les événements, JavaScript, le style, la mise en page, la peinture et la composition prennent chaque image. « />

Le navigateur doit effectuer une variété de travaux pour chaque image qu'il affiche à l'écran. ( Grand aperçu)

Dans le même temps, nous avons un écart de performance qui se creuse. Les téléphones phares de premier plan deviennent plus rapides avec chaque nouvelle génération qui sort. D'autre part, les téléphones bas de gamme deviennent moins chers rendant l'Internet mobile accessible à des données démographiques qui auparavant ne pouvaient peut-être pas se le permettre. En termes de performances, ces téléphones ont plafonné aux performances d'un iPhone 2012. Les applications conçues pour le Web sont censées s'exécuter sur des appareils qui se situent n'importe où dans ce large spectre de performances. Le temps que prend votre morceau de JavaScript pour terminer dépend de la vitesse à laquelle l'appareil sur lequel votre code s'exécute. Non seulement cela, mais la durée des autres tâches du navigateur, telles que la mise en page et la peinture, est également affectée par les caractéristiques de performance de l'appareil. Ce qui prend 0,5 ms sur un iPhone moderne peut prendre 10 ms sur un Nokia 2. Les performances de l'appareil de l'utilisateur sont totalement imprévisibles.

Remarque : RAIL est un cadre directeur depuis 6 ans maintenant. . Il est important de noter que 60fps est vraiment une valeur d'espace réservé pour quel que soit le taux de rafraîchissement natif de l'affichage de l'utilisateur. Par exemple, certains des téléphones pixel les plus récents ont un écran à 90 Hz et l'iPad Pro a un écran à 120 Hz, ce qui réduit le budget d'image à 11,1 ms et 8,3 ms respectivement. Pour compliquer davantage les choses, il n'y a pas de bon moyen de déterminer le taux de rafraîchissement de l'appareil sur lequel votre application s'exécute, à part mesurer le temps qui s'écoule entre les rappels requestAnimationFrame().

JavaScript

JavaScript a été conçu pour s'exécuter en synchronisme avec la boucle de rendu principale du navigateur. Presque toutes les applications Web reposent sur ce modèle. L'inconvénient de cette conception est qu'une petite quantité de code JavaScript lent peut empêcher la boucle de rendu du navigateur de continuer. Ils sont solidaires : si l'un ne termine pas, l'autre ne peut pas continuer. Pour permettre l'intégration de tâches de plus longue durée dans JavaScript, un modèle d'asynchronicité a été établi sur la base de rappels et de promesses ultérieures.

Pour que votre application reste fluidevous devez assurez-vous que votre code JavaScript combiné aux autres tâches que le navigateur doit effectuer (styles, mise en page, peinture,…) ne dépasse pas le budget de trame de l'appareil. Pour que votre application réactive vous devez vous assurer qu'un gestionnaire d'événements donné ne prend pas plus de 100 ms pour qu'il affiche un changement sur l'écran de l'appareil. Il peut être difficile d'y parvenir sur votre propre appareil pendant le développement, mais y parvenir sur tous les appareils sur lesquels votre application pourrait s'exécuter peut sembler impossible.

Le conseil habituel ici est de « fragmenter votre code » ou son phrasé frère « rendez-vous au le navigateur". Le principe sous-jacent est le même : pour donner au navigateur une chance d'envoyer la prochaine image, vous divisez le travail effectué par votre code en morceaux plus petits et redonnez le contrôle au navigateur pour lui permettre de travailler entre ces morceaux.

Il existe plusieurs façons de céder au navigateur, et aucune d'entre elles n'est géniale. Une API de planificateur de tâches récemment proposée vise à exposer directement cette fonctionnalité. Cependant, même si nous avions une API pour le rendement comme wait yieldToBrowser() (ou quelque chose du genre), la technique elle-même est imparfaite : pour travailler en morceaux suffisamment petits pour que votre code génère au moins une fois par image. Dans le même temps, un code qui génère trop souvent peut entraîner la surcharge de la planification des tâches à avoir une influence négative sur les performances globales de votre application. Maintenant, combinez cela avec les performances imprévisibles des appareils, et nous devons arriver à la conclusion qu'il n'y a pas de taille de morceau correcte qui s'adapte à tous les appareils. Ceci est particulièrement problématique lorsque vous essayez de « fragmenter » le travail de l'interface utilisateur, car le fait de céder au navigateur peut rendre des interfaces partiellement complètes qui augmentent le coût total de la mise en page et de la peinture.

Web Workers

Il existe un moyen de ne plus fonctionner. lock-step avec le thread de rendu du navigateur. Nous pouvons déplacer une partie de notre code vers un thread différent. Une fois dans un thread différent, nous pouvons bloquer à notre guise avec du JavaScript de longue durée, sans la complexité et le coût de la fragmentation et du rendement, et le thread de rendu n'en sera même pas conscient. La primitive permettant de le faire sur le Web est appelée travailleur Web. Un travailleur Web peut être construit en transmettant le chemin d'accès à un fichier JavaScript distinct qui sera chargé et exécuté dans ce thread nouvellement créé :

const worker = new Worker("./worker.js");

Before nous allons plus loin, il est important de noter que les Web Workers, les Service Workers et les Worklets sont des choses similaires, mais finalement différentes à des fins différentes :

  • Dans cet article, je parle exclusivement de WebWorkers (souvent simplement « Worker » pour faire court). Un worker est une portée JavaScript isolée s'exécutant dans un thread séparé. Il est généré (et possédé) par une page.
  • Un ServiceWorker est un courte durée de vieune portée JavaScript isolée s'exécutant dans un thread séparé, fonctionnant comme un proxy pour chaque réseau demande provenant de pages de même origine. Tout d'abord, cela vous permet d'implémenter un comportement de mise en cache arbitrairement complexe, mais il a également été étendu pour vous permettre d'exploiter des récupérations en arrière-plan de longue durée, des notifications push et d'autres fonctionnalités qui nécessitent l'exécution de code sans page associée. Cela ressemble beaucoup à un Web Worker, mais avec un objectif spécifique et des contraintes supplémentaires.
  • Un Worklet est une portée JavaScript isolée avec une API très limitée qui peut ou non s'exécuter sur un thread séparé. L'intérêt des worklets est que les navigateurs peuvent déplacer les worklets entre les threads. AudioWorkletCSS Painting API et Animation Worklet sont des exemples de Worklets.
  • Un SharedWorker est un Web Worker spécial, en ce sens plusieurs onglets ou fenêtres de la même origine peuvent référencer le même SharedWorker. L'API est pratiquement impossible à remplir et n'a jamais été implémentée que dans Blink, je n'y ferai donc pas attention dans cet article.

Comme JavaScript a été conçu pour fonctionner en synchronisation avec le navigateur, de nombreux des API exposées à JavaScript ne sont pas thread-safe, car il n'y avait pas de concurrence à gérer. Pour qu'une structure de données soit thread-safe, cela signifie qu'elle peut être consultée et manipulée par plusieurs threads en parallèle sans que son état ne soit corrompu. Ceci est généralement réalisé par des mutex qui verrouillent les autres threads pendant qu'un thread effectue des manipulations. Ne pas avoir à gérer de verrous permet aux navigateurs et aux moteurs JavaScript de faire de nombreuses optimisations pour exécuter votre code plus rapidement. D'un autre côté, cela oblige un travailleur à s'exécuter dans une portée JavaScript complètement isolée, car toute forme de partage de données entraînerait des problèmes en raison du manque de sécurité des threads.

Alors que les travailleurs sont la primitive « thread » du web, ils sont très différents des threads auxquels vous pourriez être habitué de C++, Java & co. La plus grande différence est que l'isolement requis signifie que les travailleurs n'ont accès à aucune variable ou code de la page qui les a créés ou vice versa. Le seul moyen d'échanger des données consiste à transmettre des messages via une API appelée postMessagequi copiera la charge utile du message et déclenchera un événement message à la réception. Cela signifie également que les travailleurs n'ont pas accès au DOM, ce qui rend les mises à jour de l'interface utilisateur à partir d'un travailleur impossibles, du moins sans effort important (comme AMP worker-dom).

 Un tableau tiré de caniuse. com, montrant que chaque navigateur prend en charge Workers.

Les Web Workers sont entièrement pris en charge dans tous les navigateurs depuis IE10. ( Grand aperçu)

La prise en charge des Web Workers est presque universelle, étant donné que même IE10 les prend en charge. Leur utilisation, en revanche, est encore relativement faible, et je pense que cela est dû dans une large mesure à l'ergonomie inhabituelle de Workers.

Modèles de simultanéité de JavaScript

Toute application qui souhaite utiliser Workers doit adapter son architecture aux besoins des travailleurs. JavaScript prend en charge en fait deux modèles de concurrence très différents souvent regroupés sous le terme « Architecture Off-Main-Thread ». Les deux utilisent des Workers, mais de manières très différentes et chacune apportant son propre ensemble de compromis. Toute application donnée se termine généralement quelque part entre ces deux extrêmes.

Modèle de concurrence n°1 : acteurs

Ma préférence personnelle est de penser aux travailleurs comme aux acteurs, tels qu'ils sont décrits dans le Modèle d'acteur. L'incarnation la plus populaire du modèle d'acteur est probablement dans le langage de programmation Erlang. Chaque acteur peut ou non s'exécuter sur un thread séparé et possède entièrement les données sur lesquelles il opère. Aucun autre thread ne peut y accéder, ce qui rend inutiles les mécanismes de synchronisation de rendu comme les mutex. Les acteurs ne peuvent que s'envoyer des messages et réagir aux messages qu'ils reçoivent.

Par exemple, je pense souvent au fil principal comme à l'acteur qui possède le DOM et par conséquent toute l'interface utilisateur. Il est responsable de la mise à jour de l'interface utilisateur et de la capture des événements d'entrée. Un autre facteur pourrait être responsable de l'état de l'application. L'acteur DOM convertit les événements d'entrée de bas niveau en événements sémantiques au niveau de l'application et les envoie à l'acteur d'état. L'acteur d'état modifie l'objet d'état en fonction de l'événement qu'il a reçu, en utilisant potentiellement une machine à états ou même en impliquant d'autres acteurs. Une fois l'objet d'état mis à jour, il envoie une copie de l'objet d'état mis à jour à l'acteur DOM. L'acteur DOM met maintenant à jour le DOM en fonction du nouvel objet d'état. Paul Lewis et moi avons déjà exploré l'architecture d'applications centrée sur les acteurs au Chrome Dev Summit 2018.

Bien sûr, ce modèle ne va pas sans problèmes. Par exemple, chaque message que vous envoyez doit être copié. Le temps que cela prend dépend non seulement de la taille du message, mais également de l'appareil sur lequel l'application est exécutée. D'après mon expérience, postMessage est généralement « assez rapide »mais il existe certains scénarios où ce n'est pas le cas. Un autre problème consiste à trouver l'équilibre entre le transfert de code vers un travailleur pour libérer le thread principal, tout en devant payer le coût des frais généraux de communication et le travailleur étant occupé à exécuter un autre code avant de pouvoir répondre à votre message. Si cela est fait sans précaution, les travailleurs peuvent affecter négativement la réactivité de l'interface utilisateur.

Les messages que vous pouvez envoyer via postMessage sont assez complexes. L'algorithme sous-jacent (appelé « clone structuré ») peut gérer des structures de données circulaires et même Map et Set. Cependant, il ne peut pas gérer les fonctions ou les classes, car le code ne peut pas être partagé entre les portées en JavaScript. De manière quelque peu irritante, essayer de postMessage une fonction générera une erreur, tandis qu'une classe sera simplement convertie silencieusement en un objet JavaScript simple, perdant les méthodes du processus (les détails derrière cela ont du sens mais dépasseraient la portée de cet article). De plus, postMessage est un mécanisme de messagerie fire-and-forget sans compréhension intégrée de la demande et de la réponse. Si vous souhaitez utiliser un mécanisme de demande/réponse (et d'après mon expérience, la plupart des architectures d'applications vous y mènent inévitablement), vous devrez le créer vous-même. C'est pourquoi j'ai écrit Comlinkqui est une bibliothèque qui utilise un protocole RPC sous le capot pour faire sembler que les objets d'un travailleur sont accessibles depuis le thread principal et vice versa. Lorsque vous utilisez Comlink, vous n'avez pas du tout à gérer postMessage. Le seul artefact est qu'en raison de la nature asynchrone de postMessage, les fonctions ne renvoient pas leur résultat, mais une promesse à la place. À mon avis, cela vous donne le meilleur du modèle d'acteur et de la simultanéité de la mémoire partagée.

 Un exemple d'utilisation de Comlink, tiré du README de Comlink.

Comlink enveloppe un travailleur et vous donne accès aux valeurs exposées. ( Grand aperçu)

Comlink n'est pas magique, il doit toujours utiliser postMessage pour le protocole RPC. Si votre application finit par être l'un des cas les plus rares où postMessage est un goulot d'étranglement, il est utile de savoir que ArrayBuffers peut être transféré. Le transfert d'un ArrayBuffer est quasi instantané et implique un transfert de propriété approprié : la portée JavaScript d'envoi perd l'accès aux données dans le processus. J'ai utilisé cette astuce lorsque j'expérimentais l'exécution des simulations physiques d'une application WebVR à partir du thread principal.

Modèle de concurrence n ° 2: Mémoire partagée

Comme je l'ai mentionné ci-dessus, l'approche traditionnelle le threading est basé sur la mémoire partagée. Cette approche n'est pas viable en JavaScript car pratiquement toutes les API ont été construites en supposant qu'il n'y a pas d'accès simultané aux objets. Changer cela maintenant briserait le Web ou entraînerait un coût de performance important en raison de la synchronisation qui est désormais nécessaire. Au lieu de cela, le concept de mémoire partagée a été limité à un type dédié : SharedArrayBuffer (ou SAB en abrégé). Un SAB, comme un ArrayBuffer, est un morceau de mémoire linéaire qui peut être manipulé à l'aide de Typed Arrays ou DataViews. Si un SAB est envoyé via postMessage, l'autre extrémité ne reçoit pas une copie des données mais un handle vers exactement le même morceau de mémoire. Chaque changement effectué par un thread est visible pour tous les autres threads. Pour vous permettre de créer vos propres mutex et autres structures de données concurrentes, Atomics fournit toutes sortes d'utilitaires pour les opérations atomiques ou les mécanismes d'attente thread-safe.

Les inconvénients de cette approche sont multiples. D'abord et avant tout, ce n'est qu'un morceau de mémoire. C'est une primitive de très bas niveau, vous offrant beaucoup de flexibilité et de puissance au prix d'efforts d'ingénierie et de maintenance accrus. Vous n'avez pas non plus de moyen direct de travailler sur vos objets et tableaux JavaScript familiers. C'est juste une suite d'octets. Comme moyen expérimental d'améliorer l'ergonomie ici, j'ai écrit une bibliothèque appelée buffer-backed-object qui synthétise les objets JavaScript qui conservent leurs valeurs dans un tampon sous-jacent. Alternativement, WebAssembly utilise Workers et SharedArrayBuffers pour prendre en charge le modèle de thread de C++ et d'autres langages. Je dirais que WebAssembly offre actuellement la meilleure expérience pour la simultanéité de mémoire partagée, mais vous oblige également à laisser derrière vous une grande partie des avantages (et du confort) de JavaScript et à acheter dans une autre langue et (généralement) des binaires plus gros produits.

Étude de cas : PROXX

En 2019mon équipe et moi avons publié PROXXun clone Web de démineur ciblant spécifiquement les téléphones multifonctions. Les téléphones multifonctions ont une petite résolution, généralement pas d'interface tactile, un processeur sous-alimenté et aucun GPU approprié à proprement parler. Malgré toutes ces limitations, ils sont de plus en plus populaires car ils sont vendus à un prix incroyablement bas et ils incluent un navigateur Web à part entière. Cela ouvre le Web mobile à des données démographiques qui ne pouvaient pas se le permettre auparavant.

PROXX joué par Paul Lewis sur un Nokia 8110.

PROXX fonctionnant sur un Nokia 8110 (« téléphone banane »). ( Grand aperçu)

Pour nous assurer que le jeu était réactif et fluide même sur ces téléphones, nous avons adopté une architecture de type Actor. Le thread principal est responsable du rendu du DOM (via preact et, si disponible, WebGL) et de la capture des événements de l'interface utilisateur. L'ensemble de l'état de l'application et de la logique du jeu s'exécute dans un ouvrier qui détermine si vous venez de marcher sur un trou noir mine et, sinon, quelle partie du plateau de jeu révéler. La logique du jeu envoie même des résultats intermédiaires au thread de l'interface utilisateur pour donner à l'utilisateur une mise à jour visuelle continue.

L'interface utilisateur est continuellement mise à jour même si le travailleur est toujours occupé à déterminer l'état final du champ de jeu.

Autres avantages[19659005] J'ai parlé de l'importance de la fluidité et de la réactivité et de la façon dont les travailleurs vous aident à atteindre ces objectifs plus facilement. Quelque chose qui n'a été exploré qu'en surface, c'est que les Web Workers peuvent également aider votre application à consommer moins de la batterie de votre utilisateur. En utilisant plus de cœurs de processeur en parallèle, le processeur pourrait être en mesure d'utiliser le mode « hautes performances » avec plus de parcimonie, en consommant moins d'énergie dans l'ensemble. David Rousset de Microsoft a effectué une exploration de la consommation électrique des applications Web.

Adopting Web Workers

Si vous avez réussi ici, vous avez, espérons-le, une meilleure compréhension de pourquoi les travailleurs peuvent être utiles. Maintenant, la prochaine question évidente est : Comment ?

Les travailleurs n'ont pas été largement adoptés, il n'y a donc pas non plus beaucoup d'expérience et d'architecture autour des travailleurs. Il peut être difficile de dire à l'avance quelles parties de votre code valent la peine d'être transférées à un travailleur. Plutôt que de défendre une architecture spécifique plutôt qu'une autre, j'ai fait une bonne expérience avec une approche qui permet l'adoption incrémentielle des travailleurs :

La plupart d'entre nous construisent déjà nos applications en utilisant des modules et la primitive de base, car c'est ce que la plupart des bundlers utilisent pour effectuer le regroupement et le fractionnement de code. L'astuce principale est d'être strict sur la séparation de votre code d'interface utilisateur des parties de calcul pur. Cela réduira le nombre de modules qui utilisent l'API à thread principal comme le DOM et, par conséquent, peuvent faire leur travail dans un travailleur. De plus, essayez de vous fier le moins possible à la synchronicité, ce qui vous permet d'adopter facilement des modèles asynchrones tels que les rappels et async/attendre plus tard. Une fois cela en place, vous pouvez déplacer des modules du thread principal vers un travailleur à l'aide de Comlink et de mesure pour voir si cela a un impact positif ou négatif sur vos performances.

Pour adopter des travailleurs dans un existant base de code, les choses peuvent être un peu plus compliquées. Investissez du temps pour analyser de manière critique les parties de votre code qui doivent dépendre du DOM ou d'autres API à threads principaux. Si possible, supprimez ces dépendances par refactorisation et adoptez progressivement le modèle ci-dessus.

Dans les deux cas, l'élément clé est de rendre l'impact de l'architecture hors thread principal mesurable . Ne présumez pas (ou devinez) que quelque chose est plus rapide ou plus lent une fois dans un travailleur. Les navigateurs fonctionnent parfois de manière mystérieuse où quelque chose qui ressemble à une optimisation a l'effet inverse. Il est important d'obtenir des chiffres pour prendre des décisions éclairées !

Web Workers And Bundlers

La plupart des environnements de développement Web modernes utilisent des bundlers pour améliorer considérablement les performances de chargement. Les Bundlers y parviennent en regroupant plusieurs modules JavaScript dans un seul fichier. Avec Workers, cependant, nous avons besoin que le fichier reste séparé comme le constructeur Worker le dicte. Au lieu de lutter contre les bundlers pour faire ce qui est nécessaire, je vois souvent des gens recourir à l'encodage de leur code de travail dans des URL de données ou des URL Blob. Les deux approches posent des problèmes importants : alors que les URL de données ne fonctionnent pas du tout dans Safari, les URL Blob fonctionnent mais n'ont aucun concept d'origine ou de chemin, ce qui signifie que la résolution de chemin et les récupérations ne fonctionneront pas comme prévu. Il s'agit d'un autre obstacle à l'adoption des travailleurs, mais les versions plus récentes des bundlers populaires ont amélioré la gestion des travailleurs :

  • Webpack
    Pour Webpack v4, le plug-in worker-loader a créé Webpack comprend les travailleurs. Étant donné que Webpack v5, Webpack comprend automatiquement le constructeur Worker et peut même partager des modules entre le thread principal et le worker pour éviter le double chargement.
  • Rollup
    Pour Rollup, j'ai écrit rollup-plugin-off- main-threadqui devrait permettre aux travailleurs de travailler immédiatement.
  • Parcel
    Parcel mérite une mention spéciale car les versions v1 et v2 prennent en charge les travailleurs prêts à l'emploi sans configuration supplémentaire.

Avec tous ces bundles, il est courant de développer votre application à l'aide de modules ES. Cependant, cela en soi pose un autre problème.

Web Workers and ES Modules

Tous les navigateurs modernes prennent en charge l'exécution de modules JavaScript via




Source link