- Le modèle
async/awaitde Rust vise la concurrence à grande échelle avec des dizaines de milliers de connexions, mais il entre en tension avec les objectifs de Rust en matière de contrôle bas niveau et de vérification statique des durées de vie, ce qui crée une expérience de développement différente de celle du Rust « normal » - Les threads et les canaux suffisent pour beaucoup de logiciels, mais à l’échelle du C10K, le coût d’une connexion par thread devient trop important, ce qui impose des tâches en espace utilisateur et un ordonnancement par le runtime
- En Rust
async, les données doivent soit être déplacées avecSend, soit être manipulées via des références'static, et à cause du caractère contagieux deasync, ces contraintes se répètent dans tout le code - Même si
Arcrésout des problèmes de compilation, il brouille la durée de vie des objets et des ressources, tandis que d’autres pièges s’ajoutent autour de la récursionasync, de la différence entre future et task, et des appels bloquants qui immobilisent les threads du runtime - En Haskell ou en Go, le « code async » se comporte comme du code ordinaire et le runtime avec le GC masque ces différences, si bien que, pour ce type de programmation, le contrôle explicite de Rust n’apporte pas forcément un avantage net
Pourquoi la concurrence et le parallélisme sont nécessaires
- Un programme rapide doit répondre à deux exigences à la fois
- utiliser plusieurs cœurs CPU pour exploiter pleinement la machine
- continuer à travailler pendant qu’il attend des opérations lentes, comme l’envoi de messages sur Internet ou l’ouverture d’un fichier
- Le parallélisme consiste à exécuter du code en même temps sur plusieurs CPU
- La concurrence est une manière de découper un problème en parties indépendantes
- Les deux ne sont pas identiques, mais si un programme est découpé en composants concurrents, ces composants peuvent être exécutés en parallèle et garder les cœurs occupés
Processus, threads et canaux
- Une façon simple de construire un système concurrent consiste à découper le code en plusieurs processus
- l’ordonnanceur du système d’exploitation exécute des tranches de temps des processus prêts sur les cœurs CPU disponibles
- on retrouve aussi ce modèle quand on relie des commandes shell avec des pipes
- L’approche par processus a un coût important en communication inter-processus
- dans beaucoup d’implémentations, il faut copier les données dans la mémoire de l’OS puis les relire
- la mémoire partagée peut réduire ce coût, mais elle affaiblit l’avantage de l’isolation fournie par l’OS entre les processus
- Les threads évitent cette surcharge en partageant la même mémoire, mais un mauvais usage d’outils de synchronisation comme les mutex, les variables de condition ou les sémaphores peut provoquer des data races et des interblocages
- Le modèle Communicating Sequential Processes de Tony Hoare relie les threads par des files ou des canaux
- les threads ne partagent pas la mémoire, ce qui leur donne une isolation comparable à celle des processus
- les entrées et sorties de chaque thread sont exposées via des canaux, ce qui facilite le raisonnement et le débogage
- les canaux assurent eux-mêmes la synchronisation : si le canal est vide, le récepteur attend ; s’il est plein, l’émetteur attend
- La bibliothèque standard de Rust fournit std::sync::mpsc::sync_channel
- Beaucoup de logiciels se contentent très bien d’une combinaison de threads, de canaux et d’outils comme Rayon pour paralléliser des boucles intensives en CPU
Concurrence en espace utilisateur et Rust async
- Dans les problèmes de type C10K, comme un serveur web avec des dizaines de milliers d’utilisateurs connectés en même temps, le modèle consistant à associer un thread à chaque connexion atteint ses limites
- sous Linux, chaque thread possède un bloc de contrôle de 4 kB, et le changement de thread nécessite un changement de contexte via l’ordonnanceur du système d’exploitation
- Pour la concurrence à grande échelle, certains langages créent et gèrent les tâches en espace utilisateur
- le runtime planifie ces tâches sur un pool de threads de l’OS
- en général, ce pool est configuré avec un thread par cœur CPU afin de maximiser le parallélisme
- cette approche est appelée selon les cas green thread, lightweight thread, lightweight process, fiber ou coroutine
- Rust utilise un modèle
async/awaitsemblable à celui de C# ou Node.js- une
async fnne renvoie pas directement une valeur, mais une future ou une promise dont le résultat est récupéré via.await
- une
- Les futures de Rust sont très petits et rapides grâce à l’ordonnancement coopératif et à leur conception sans pile
- Rust essaie de fournir l’abstraction future tout en promettant au programmeur un contrôle de bas niveau
- il cherche à vérifier statiquement à la compilation la durée de vie de tous les objets et références
- une future découpe le code et les données qu’il référence en milliers de fragments, qui peuvent s’exécuter à tout moment et sur n’importe quel thread selon des conditions connues seulement après le démarrage de l’exécution
- une future qui lit les données d’un client ne devrait s’exécuter que lorsque des données sont disponibles sur ce socket, mais les annotations de durée de vie n’indiquent pas ce moment
- Rust n’intègre pas le runtime des futures au langage et le laisse à des bibliothèques comme Tokio
- l’utilisateur est libre de choisir une alternative adaptée à son environnement
- mais même si l’on imaginait un monde où Tokio serait intégré au langage, les mêmes règles s’appliqueraient, donc ce point reste secondaire ici
La pression exercée par Send, 'static et Arc
- Pour convaincre le compilateur, les données doivent soit être déplacées en étant marquées
Send, soit être transmises via des références à durée de vie'static - Dans du code
async, il est courant que plusieurs tâches partagent un état commun, donc déplacer les données sans les copier n’est souvent pas adapté - Les références sont elles aussi difficiles à utiliser, et il n’existe pas d’équivalent à
thread::scopequi permettrait de borner la durée de vie d’une future à moins qu’« éternellement » asyncest contagieux, donc une fonction qui appelle une fonctionasyncdoit elle aussi devenirasync- ces problèmes de durée de vie et de mobilité doivent donc être résolus en continu, pas seulement dans quelques fonctions
- on peut casser la chaîne en attendant la fin d’une future au runtime avec
block_on, mais cette méthode se compose mal et un runtime imbriqué peut paniquer
- Arc est un outil pour gérer des durées de vie dynamiques sur plusieurs threads ; il permet de satisfaire le borrow checker et de faire compiler le code
- Mais un usage massif de
Arcbrouille la durée de vie des objets et des ressources- on ne sait plus clairement quand des ressources comme la mémoire, les fichiers ou les sockets seront libérées
- on subit des pertes comparables à celles d’un GC sans bénéficier des avantages réels d’un GC, comme un bon débit d’allocation, une faible fragmentation ou l’évitement des fuites cycliques
Pièges supplémentaires du Rust async
- Les coroutines Rust sont sans pile, donc le compilateur transforme chaque coroutine en machine à états qui progresse jusqu’aux points
.await- une fonction
asyncrécursive devient un type défini récursivement - un utilisateur qui veut simplement s’appeler lui-même doit boxer manuellement ou utiliser une crate comme async-recursion
- une fonction
- Une future ne fait rien tant qu’elle n’est pas awaitée
- Une task commence à travailler sur le pool de threads du runtime et renvoie une future qui signale son achèvement
- Rien n’empêche d’appeler du code bloquant à l’intérieur d’une future
- rien n’empêche non plus cet appel de bloquer le thread du runtime sur lequel il s’exécute
- cela entre en conflit avec la raison même d’utiliser
async
Différences avec le Rust ordinaire, Haskell et Go
- Le Rust
asynca une saveur très différente du Rust « normal »- il comporte davantage de pièges
- il est plus difficile à comprendre et à enseigner
- L’utilisateur se retrouve face à deux options
- comprendre en profondeur le fonctionnement réel de l’abstraction et écrire du code complexe
- parsemer le code d’éléments comme
Arc,Pinet'staticen espérant que cela suffise
- Même une équipe de développeurs expérimentés peut vouloir utiliser Rust sur un nouveau projet et se retrouver bloquée par ces détails
- En Haskell ou en Go, le « code async » est du code ordinaire
- ces deux langages masquent derrière un runtime épais la différence entre code bloquant et non bloquant
- les problèmes de durée de vie sont laissés au garbage collection
- Pour ce type de logiciel à très grande concurrence en espace utilisateur, la manière dont le runtime et le GC masquent ces différences peut constituer un avantage pur
- Rust n’est peut-être pas un bon outil pour les logiciels en espace utilisateur à très grande concurrence, et il peut être préférable de l’utiliser sur des projets qui n’ont pas ce type de besoin
1 commentaires
Avis sur Hacker News
Je développe un client de métavers haute performance en Rust, qui compte actuellement environ 40 000 lignes
La vidéo de démo est disponible sur https://video.hardlimit.com/w/tp9mLAQoHaFR32YAVKVDrz
Un vrai métavers doit traiter du contenu créé par les utilisateurs quasiment en temps réel ; il faut donc 2 à 3 fois plus de VRAM que dans des jeux comparables, des centaines de Mbps de bande passante pour charger les assets depuis le serveur, plusieurs CPU, et Vulkan pour paralléliser le rendu et l’upload vers le GPU
Ce n’est pas une concurrence de type « web scale », avec de petits serveurs séparés tournant dans le même espace d’adressage, mais une architecture où travaillent ensemble un thread de rendu haute priorité, un thread de mise à jour des événements réseau, des threads de chargement et décompression d’assets, et plusieurs threads chargés des objets mobiles, des LOD, du nettoyage du cache, etc.
En Rust, j’utilise pas mal de verrous, sans état global en dehors des constantes ; j’utilise des canaux là où c’est approprié, et le principal arbre d’objets est géré en propriété unique, principalement par le thread de mise à jour. Les liens vers les objets graphiques sont gérés avec du comptage de références
Arc, et les meshes et textures sont envoyés au GPU via Rend3/WGPU/VulkanSi je l’avais fait en C++, je serais constamment en train de me battre contre des crashs ; avec Rust, les crashs liés à la mémoire arrivent environ une fois par an, et c’était généralement du code
unsafeécrit par d’autres. Dans mon code,unsafeest interdit ; c’est difficile à compiler, mais une fois que ça compile, ça a tendance à « juste marcher », ce que je trouve bien préférable au débogage de la concurrenceJ’ai aussi des reproches. Rust est solide face aux data races, mais n’empêche pas les interblocages ; il faudrait donc un analyseur statique qui suive l’ordre d’acquisition des verrous le long des chemins d’appel.
asyncn’est pas adapté aux tâches centrées sur le calcul ni aux threads à priorités multiples, mais s’infiltre sans cesse via les dépendances. Les structures courantes avec propriété unique et références inverses sont trop difficiles sansRcetWeak, et le système de traits est aussi complexe, ce qui entraîne du code dupliqué dans les parties de traitement d’assets où l’orienté objet serait naturelLes crates graphiques de base ne sont pas encore mûres. Dire qu’« il y a 5 jeux et 50 moteurs de jeu en Rust » relève d’un problème d’écosystème, pas de langage ; même par rapport à https://gamedev.rs/, le développement sérieux de jeux en Rust semble encore insuffisant. Pour du développement de jeu professionnel avec un calendrier, l’écosystème Rust pour le jeu n’est pas encore prêt ; à mon avis, il lui faudrait encore environ 5 personnes travaillant pendant un an
asyncont bien leurs problèmes, mais globalement les avantages l’emportent largementComme
lockdepsous Linux, on pourrait analyser quels verrous sont acquis alors qu’un autre verrou est déjà détenu, et signaler les combinaisons dangereuses avant même qu’un blocage réel ne se produise. Avec des verrous complexes, il faudrait sans doute des annotations du type « cette classe de verrou est toujours acquise dans l’ordre des adresses », mais cela semble faisableDes conditions de concurrence peuvent toujours apparaître en dehors de l’accès aux données : https://news.ycombinator.com/item?id=23599598
On peut créer plusieurs pools de threads et router les futures vers le bon pool, ou bien écrire sa propre boucle d’événements qui pioche dans plusieurs files d’événements de priorités différentes. La seconde approche, si le temps d’exécution des tâches est borné, permet de faire progresser les tâches de basse priorité même avec un CPU à 100 %, tout en offrant des garanties temps réel souples aux tâches de haute priorité
Sur Rust
async, il y a quelque chose d’un peu étrangeSi l’on utilise beaucoup
Arc,RwLocket de l’état partagé, ça devient sale ; et la critique selon laquelle, surtout lorsque'staticcommence à se répandre partout, il contamine tout comme les fonctions colorées, est juste. Par le passé, j’ai essayé d’ajouter desArcet de gérer intelligemment les emprunts avec durées de vie, et c’est devenu un bazarMais Rust a aussi des canaux. Le code que j’écris actuellement repose surtout sur quelques tâches qui servent des canaux, regardent les messages entrants et, si nécessaire, placent dans les bons canaux les messages à envoyer à d’autres tâches. Il n’y a pas de partage d’objets. Si plusieurs tâches ont besoin d’un gros objet, soit je le place dans une tâche qui renvoie les résultats des requêtes pertinentes sous forme de messages, soit chaque tâche construit sa propre copie dans le flux de messages
Pourtant, il y a énormément d’articles sur la manière d’utiliser
Arcet de gérer les durées de vie. Si l’on implémente un runtimeasync, c’est probablement nécessaire, mais je ne vois pas bien pourquoi l’utilisateur moyen d’une bibliothèque devrait se concentrer autant là-dessusasyncne signifie pas forcément multithreading, et avec de l’asyncdans un même thread, comme il n’y a pas de partage, il n’est pas nécessaire de mettre des mots-clés magiques partout sur ce qui est partagéQuand on passe d’un thread à l’autre, on envoie des signaux via des canaux plutôt que de multiplier l’état partagé. S’il existe un état global vraiment nécessaire, on crée une petite structure qui encapsule un mécanisme d’accès exclusif comme
Arc/RwLock, et du point de vue de l’appelant cela ressemble à un simple appel de fonctionJe ne comprends pas bien non plus l’inquiétude autour de
Send+Sync. D’après mon expérience, la plupart des choses sont facilementSend+Sync, et celles qui ne le sont pas ne devraient pas l’être, ou ne peuvent pas l’être. Il m’arrive aussi de vouloir écrire du code sans penser aux détails, mais quand on a besoin d’une concurrence et d’un parallélisme efficaces, les microsecondes et le débit comptent ; à ce moment-là, il faut écrire correctement du code pour une vraie machineMais ce style de programmation est très différent de JavaScript
async, qui donne l’impression d’ajouter des green threads à du code synchrone. Les gens essaient d’écrire du code dans le style auquel ils sont habitués, et c’est sans doute comme ça qu’ils se retrouvent sur la voieArcetRwLocken RustStructurer le problème comme des données qui circulent entre des tâches, les relier par des files et éviter l’état partagé est une meilleure façon de gérer le multithreading, quel que soit le langage utilisé
asyncest en réalité un Rust bien plus difficile, et les projets qui en ont vraiment besoin représentent sans doute environ 1 % ; c’est dommage qu’il ait été quasiment imposé à tout le mondeCela dit, pour ce 1 %, c’est vraiment excellent. Pour des services dont le cœur consiste à traiter massivement des appels réseau, comme linkerd ou nginx, pour faire tourner dans un jeu un très grand nombre de tâches légères, ou lorsqu’il faut de la concurrence coopérative dans l’embarqué, async Rust devient une arme puissante
La plupart du code système ou applicatif n’a pas besoin d’entrées/sorties asynchrones. Pour une application REST, un pool de threads suffit ; et même quand
asyncest nécessaire, le bon modèle est généralement un modèle hybride qui le limite à une petite partie comme le réseau, le reste étant relié par des threads et des canauxLa communauté Rust a utilisé
asyncbeaucoup trop indistinctement partout, au point que le Rust à E/S bloquantes, dont l’expérience utilisateur est meilleure, est devenu un citoyen de seconde zone dans l’écosystème. Côté frameworks web aussi, il existe plusieurs frameworks asynchrones bien conçus comme Axum ou Warp, alors que côté bloquant les choix sont bien plus limités, avectiny_http,rouilleouastraEn choisissant des coroutines sans pile, il a introduit
async/awaitet le problème des fonctions colorées, ainsi que les frictions évoquées dans l’article. Go, qui utilise des coroutines avec pile, n’a pas ce problèmeRust avait lui aussi envisagé au départ des coroutines avec pile, mais a opté pour un modèle sans pile en considérant qu’il faudrait un runtime de préemption des coroutines et que le coût serait élevé. Pourtant, la plupart des gens n’utilisent pas Rust
asyncsans runtime : ils utilisent Tokio, qui fait en pratique presque tout ce que faisait le runtime qu’on voulait éviterRésultat, beaucoup d’utilisateurs de Rust
asyncse retrouvent avec le pire des deux mondes. Dans l’embarqué, certains utilisent Rustasyncavec un runtime très léger, mais ils sont peu nombreux et même eux ne sont pas entièrement convaincusreqwest, qui importeh2, qui importe à son tourtokioasyncquand la plateforme prend en charge les threads virtuelsEn tant qu’utilisateur de Java, je suis en train d’abandonner tout le paradigme asynchrone pour réécrire mon code avec un modèle bloquant au-dessus de threads virtuels, où le blocage ne pose pas problème
asyncs’est répandu dans tellement de crates que tout le programme finit par devenirasync, ou au minimum par dépendre de Tokio pour beaucoup de chosesSi l’on veut un serveur web, c’est presque «
async + tokioou dégage », et pour les connecteurs SQL, l’ambiance est que si l’on ne veut pas d’asynchrone, il faut les écrire soi-même. Chacun résout à sa façon les problèmes apportés parasync, et des choses comme les closuresasyncdonnent l’impression d’ouvrir les portes de l’enfer dans le compilateurJ’apprécie que Rust lui-même et son compilateur aident à résoudre les problèmes, mais un écosystème proche de «
asyncou fabrique-le toi-même » n’est pas suffisantfuturesauraient beaucoup réduit la douleurIl faudrait par exemple des traits qu’un exécuteur doit implémenter, ou un exécuteur bloquant de base permettant d’exécuter du code asynchrone depuis du code synchrone. Aujourd’hui, rien que créer une bibliothèque compatible avec plusieurs runtimes asynchrones est pénible ; au final, on ne prend souvent en charge que Tokio, ou au mieux on ajoute
async-stdJe ne suis pas spécialiste de Rust
async, mais après avoir écrit ce mois-ci quelques milliers de lignes de Rust synchrone, ce que j’ai ressenti, c’est que lorsquerustcrend une approche difficile, il y a généralement une bonne raison, et qu’il existe une meilleure façon d’obtenir un résultat similaireSi vous êtes en train d’apprendre le langage, je conseillerais d’abord de vous habituer au code synchrone ordinaire, aux boucles, aux conditions et aux règles d’emprunt.
asyncest encore en pleine évolution, non seulement dans son implémentation, mais aussi à un niveau plus philosophique : « qu’est-ce que l’asynchrone et comment doit-il apparaître à l’utilisateur ? »Le compilateur dépend fortement des traits, mais les fonctionnalités permettant aux traits de gérer
asyncne sont pas stabilisées. Il y a par exemple des travaux comme https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-i...Si les fonctionnalités asynchrones des traits ne sont pas stabilisées, attaquer le code asynchrone de Rust parce qu’il n’est pas encore élégant revient finalement à critiquer une première ébauche d’un livre qui sera terminé plus tard
Je me demande aussi comment empêcher l’asynchrone de se propager à toute la base de code
L’idée actuelle serait une architecture où des threads d’E/S divisent les événements système de
liburingouepollen deux phases, “submit” et “handle”, puis les envoient à d’autres composants. Par exemple, si l’on crée unetcp-connection, on peut s’abonner à des événements asynchrones comme “prêt à écrire” ou “prêt à lire”, et l’événement “prêt à écrire” prend les données dans un tampon rempli via un mutex classique, puis les envoie avecEPOLLOUT/io_uring_prep_writevPour la transmission d’événements entre threads, on peut utiliser un tampon circulaire multi-producteur, multi-consommateur suivant le motif LMAX Disruptor. Les threads applicatifs ou les pools de threads ont chacun leur boucle d’événements et traitent ce tampon circulaire
Je travaille aussi sur une syntaxe pour exprimer l’ordre de déclenchement des événements asynchrones ; elle ressemble à un pipeline Bash et s’appelle
statelines:initialstate1 initialstate2 = state1 | {state1a state1b state1c} {state2a state2b state2d} | state3La durée de vie d’un
Arcn’est pas inconnue ; elle est déterminée par l’endroit et la manière dont il est détenuLe décalage dans cet article semble venir du fait que l’auteur essaie de plaquer de force sur Rust un ancien modèle mental, comme celui du garbage collection, plutôt que d’apprendre Rust et de travailler selon les contraintes du langage. C’est un piège courant quand on apprend un nouveau langage, mais Rust y fait trébucher particulièrement souvent
Mais c’est presque l’inverse de l’objectif du borrow checker, qui est de restreindre statiquement la durée de vie des objets à la compilation
En pratique, c’était presque l’inverse. Après environ dix ans de programmation système en C, C++ et Rust, j’utilise maintenant beaucoup Haskell dans mon poste actuel, et cela m’a pas mal ouvert les yeux sur le fait qu’un gros runtime de langage et le garbage collection ne sont pas des monstres dans certains domaines de problèmes
asyncempêche le compilateur d’appliquer à du code non asynchrone certaines optimisationsLe passage sur le fait de se battre avec
Weaksemble indiquer une tentative de construire une structure de propriété complexe, ce qui n’est pas facile dans Rust en général. J’utilise très rarement les pointeurs intelligents faiblesLes channels sont à peine mentionnés, alors que ce sont le principal outil pour faire communiquer différentes parties d’un programme en code asynchrone, ou entre code asynchrone et synchrone. Il existe aussi des abstractions de signalisation comme
Notifyet les sémaphoresLes mutex sont lents et deviennent facilement des goulots d’étranglement, et l’état partagé se complexifie rapidement. C’est connu depuis longtemps. Le problème vient peut-être dès le départ d’une structure du genre
BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORRORLa remarque selon laquelle on ne peut pas empêcher l’appel de code bloquant dans un contexte asynchrone est juste, mais si nécessaire cela se gère assez raisonnablement avec quelque chose comme
tokio::spawn_blockingIl est très probable que l’auteur sache ce qu’est
Arcet comment il fonctionne ; le point est plutôt qu’en Rustasync, on finit par utiliserArcbeaucoup plus souvent que la RAII ordinaire, par rapport à du code synchroneSi 90 % des objets du programme sont comptés par références, il peut être préférable d’utiliser un garbage collector traçant plutôt que de payer le coût de nombreuses petites allocations/libérations sur le tas et d’opérations atomiques. L’exemple du tutoriel Tokio va dans une direction similaire : https://tokio.rs/tokio/tutorial/shared-state
Je me demande si un vrai garbage collector traçant en Rust pourrait rendre significativement plus rapides des applications asynchrones courantes comme les serveurs HTTP : https://manishearth.github.io/blog/2015/09/01/designing-a-gc...
Arcn’est pas aléatoire, elle est impossible à connaître statiquementArcpeut être déplacé ou emprunté, et peut aussi être utilisé sans toucher au compteur de référencesDans beaucoup de cas, il est bien moins coûteux que les objets dans les langages à comptage de références implicite
J’aime Rust, mais
asyncest un bazar, et on ne peut pas écrire du code asynchrone comme on écrirait du code synchroneJe suis de plus en plus convaincu que mélanger les deux est une mauvaise idée, et que l’approche de Go — tout laisser synchrone et ne fournir qu’un unique primitif de channel
async— pourrait être la bonneEn ce moment, je câble de la logique pour appeler des méthodes synchrones depuis une structure qui implémente
Future, et c’est un défi assez intéressant. On peut rendre les abstractions asynchrones à coût nul relativement faciles pour l’utilisateur, mais la douleur retombe sur les développeurs de bibliothèquesasyncest clairement douloureux aussi pour les utilisateurs finaux, et donne l’impression d’utiliser un langage séparé dépourvu de fonctionnalités centrales de Rust comme les durées de vie et les types explicites, avec une bonne couche dePinpar-dessusComme on ne peut pas exécuter de fibres à portée lexicale, on finit par coller des
Arcpartout ;Pinest difficile à utiliser sansunsafe; et une toute petite modification d’une fonction asynchrone peut rendre!Sendles futures de toute la base de codeasync. Il faudra voir comment cela évolueAsync Everything est une mauvaise approche de langage
async/awaitétait une idée horrible destinée à corriger l’absence de vrais threads bloquants dans JavaScript, et on l’ajoute maintenant à tous les langages. Cela va scinder en deux le langage et l’écosystème de bibliothèques, et créer de la souffrance pendant encore longtempsQuiconque a déjà fait du multithreading en dehors de JavaScript sait que les acteurs ou les processus séquentiels communicants sont la meilleure approche du multithreading
La thèse de Joe Armstrong explique aussi que la seule façon de comprendre un programme multithread est d’écrire du code strictement séquentiel pour chaque thread, et de ne pas mélanger au même endroit le code de plusieurs threads. Pour minimiser l’écart conceptuel, une activité concurrente réelle du problème doit correspondre exactement à un processus concurrent du langage de programmation : https://erlang.org/download/armstrong_thesis_2003.pdf
La critique de
async/awaitpar Ron Pressler, qui a implémenté le Project Loom de Java, est également bonne : https://www.youtube.com/watch?v=oNnITaBseYQIl était assez ambivalent sur JavaScript lui-même ; son objectif principal était de trouver une abstraction permettant de gérer une boucle d’événements d’E/S
epoll()sans avoir envie de se crever les yeux. Il avait essayé beaucoup d’autres approches auparavantasync/awaita en réalité commencé non pas avec JavaScript, mais avec C#Anders Hejlsberg, de C#, a aussi créé TypeScript, et des fonctionnalités de TypeScript comme les classes, les fonctions fléchées et
async/awaitont fini par arriver dans ES6+Je pense que c’était une excellente solution pour JS/TS, avec sa boucle d’événements monothread. Mais plus le langage est bas niveau, plus cette abstraction se dégrade ; la plupart des critiques de Rust
asyncformulées ici sont donc valablesL’article explique bien la complexité et les difficultés de Rust
async, mais il est aussi important de rappeler que l’une des philosophies centrales de Rust est la sécurité mémoire sans sacrifier les performancesLes patterns asynchrones de Rust, en particulier la façon dont ils amènent le compilateur à garantir la sûreté des données, illustrent bien cette philosophie. Malgré la complexité, ils ont la valeur d’un modèle de concurrence plus sûr, qui pousse les développeurs à réfléchir en profondeur aux données et au flot d’exécution
Rust n’est peut-être pas la bonne réponse pour toutes les applications utilisateur concurrentes de grande taille, mais dans les systèmes où la robustesse et la sûreté sont prioritaires, le compromis peut être justifié. À mesure que l’écosystème évoluera, il est probable que davantage d’abstractions et de bibliothèques apparaîtront pour réduire ces douleurs
J’écris beaucoup de Rust lock-free basé sur
async. Le principal problème est que les futures Tokio sont'static, et cela vient d’une erreur de conception profondément ancrée dans l’écosystème Rust : la décision selon laquelle les fuites mémoire sont sûresÀ cause de cela, on ne peut pas garantir statiquement qu’une future sera correctement nettoyée. Quand on crée une tâche asynchrone, si quelqu’un oublie la future avec
std::mem::forget, le borrow checker ne peut pas savoir que les références que cette future a transmises transitivement sont toujours vivantesPlutôt que de parsemer des
Arcpartout, j’utilise ce crateunsafe: https://docs.rs/async-scoped/latest/async_scoped/Cela attrape 99 % des bugs que j’aurais créés en C++, donc c’est un compromis raisonnable. Un travail est aussi en cours sur une implémentation sûre de futures non-
'static, et j’espère qu’il aboutiraUn autre gros problème est que
async traitexige actuellement des futures boxées, ce qui ajoute unmalloc/freeà chaque frontière d’appel de fonction ; c’est dans la feuille de route des corrections de cette annéeLe conseil « utilisez simplement des channels » disperse lui aussi le flot de contrôle dans tous les sens dans les grandes bases de code. Les channels me donnent l’impression d’être des
GOTOmodernes ; j’en utilise aussi, mais pas vraiment quand il s’agit simplement de lancer quelques tâches en parallèle puis d’attendre leur terminaison'static; seules les futures pouvant êtrespawnpour exploiter la concurrence du runtime doivent être'staticPour qu’une future soit
poll(), elle doit êtrePin, et unT: !Unpinqui estPindoit finir par appelerDrop: https://doc.rust-lang.org/std/pin/#drop-guaranteeLes futures générées par la fonctionnalité
asyncdu compilateur possèdent cette propriété, et on peut aussi ajouterPhantomPinnedaux futures écrites à la main. Grâce à cela, après unpoll(), on peut considérer les manipulations avecmem::forgetcomme un comportement indéfini, ce qui rend aussi possibles des bibliothèques de futures intrusives et autoréférentielles : https://docs.rs/futures-intrusive/latest/futures_intrusive/Une future peut rester vivante et fuiter à cause d’un
Arc/Rc, mais du point de vue d’un développeur de bibliothèque, ce n’est pas raisonnablement distinguable d’un usage normal, ou ce n’est pas quelque chose dont il faut vraiment se préoccuperRc, ou bien ajouter des frontières de traitsunsafecontagieuses