2 points par GN⁺ 2023-09-09 | 1 commentaires | Partager sur WhatsApp
  • Le modèle async/await de 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 avec Send, soit être manipulées via des références 'static, et à cause du caractère contagieux de async, ces contraintes se répètent dans tout le code
  • Même si Arc ré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écursion async, 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/await semblable à celui de C# ou Node.js
    • une async fn ne renvoie pas directement une valeur, mais une future ou une promise dont le résultat est récupéré via .await
  • 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::scope qui permettrait de borner la durée de vie d’une future à moins qu’« éternellement »
  • async est contagieux, donc une fonction qui appelle une fonction async doit elle aussi devenir async
    • 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 Arc brouille 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 async ré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 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 async a 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, Pin et 'static en 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

 
GN⁺ 2023-09-09
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/Vulkan
    Si 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, unsafe est 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 concurrence
    J’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. async n’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 sans Rc et Weak, 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 naturel
    Les 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

    • Ces trois dernières années, j’ai développé un simulateur de robot en Rust, et mon expérience est presque la même. En trois ans, j’ai eu environ 5 vrais bugs à l’exécution ; Rust et async ont bien leurs problèmes, mais globalement les avantages l’emportent largement
    • Chercher les interblocages potentiels en suivant l’ordre d’acquisition des verrous me semble être une bonne idée
      Comme lockdep sous 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 faisable
    • Je fais presque la même chose en Java pour un MMO, et le JDK rend ça très facile. Il suffit de créer les modèles depuis le réseau puis de transférer les objets vers le thread UI via une file concurrente ; c’est presque ennuyeusement simple, tout en restant rapide
    • Rust n’est pas exempt de conditions de concurrence : il est exempt de data races
      Des conditions de concurrence peuvent toujours apparaître en dehors de l’accès aux données : https://news.ycombinator.com/item?id=23599598
    • Le problème des priorités peut se résoudre assez facilement
      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 étrange
    Si l’on utilise beaucoup Arc, RwLock et de l’état partagé, ça devient sale ; et la critique selon laquelle, surtout lorsque 'static commence à se répandre partout, il contamine tout comme les fonctions colorées, est juste. Par le passé, j’ai essayé d’ajouter des Arc et de gérer intelligemment les emprunts avec durées de vie, et c’est devenu un bazar
    Mais 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 Arc et de gérer les durées de vie. Si l’on implémente un runtime async, c’est probablement nécessaire, mais je ne vois pas bien pourquoi l’utilisateur moyen d’une bibliothèque devrait se concentrer autant là-dessus

    • La critique me paraît un peu étrange. async ne signifie pas forcément multithreading, et avec de l’async dans 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 fonction
      Je ne comprends pas bien non plus l’inquiétude autour de Send+Sync. D’après mon expérience, la plupart des choses sont facilement Send+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 machine
    • Le paradigme du passage de messages est vraiment excellent, et des langages comme Erlang ont montré qu’il constituait un très bon choix pour les systèmes distribués
      Mais 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 voie Arc et RwLock en Rust
    • Le rêve de Smalltalk et du véritable orienté objet est encore vivant
    • À l’université, un professeur m’a donné ce conseil, et il m’a énormément aidé
      Structurer 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é
    • Comme l’a dit un programmeur avisé : « Ne communiquez pas en partageant la mémoire ; partagez la mémoire en communiquant »
  • async est 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 monde
    Cela 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 async est 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 canaux
    La communauté Rust a utilisé async beaucoup 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, avec tiny_http, rouille ou astra

    • Le cœur du problème est que Rust a mal implémenté les coroutines
      En choisissant des coroutines sans pile, il a introduit async/await et 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ème
      Rust 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 async sans runtime : ils utilisent Tokio, qui fait en pratique presque tout ce que faisait le runtime qu’on voulait éviter
      Résultat, beaucoup d’utilisateurs de Rust async se retrouvent avec le pire des deux mondes. Dans l’embarqué, certains utilisent Rust async avec un runtime très léger, mais ils sont peu nombreux et même eux ne sont pas entièrement convaincus
    • J’ai vu Tokio se retrouver encore une fois tiré comme dépendance dans mon programme. Je ne l’utilise même pas directement : une fonction d’un crate que je n’utilise pas importe reqwest, qui importe h2, qui importe à son tour tokio
    • Je me demande s’il y a encore une raison d’utiliser async quand la plateforme prend en charge les threads virtuels
      En 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
  • async s’est répandu dans tellement de crates que tout le programme finit par devenir async, ou au minimum par dépendre de Tokio pour beaucoup de choses
    Si l’on veut un serveur web, c’est presque « async + tokio ou 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 par async, et des choses comme les closures async donnent l’impression d’ouvrir les portes de l’enfer dans le compilateur
    J’apprécie que Rust lui-même et son compilateur aident à résoudre les problèmes, mais un écosystème proche de « async ou fabrique-le toi-même » n’est pas suffisant

    • De meilleurs primitifs asynchrones dans la bibliothèque standard ou le crate futures auraient beaucoup réduit la douleur
      Il 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-std
  • Je 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 lorsque rustc rend 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 similaire
    Si 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. async est 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 async ne 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 ce qu’est une « bonne conception d’API asynchrone ». Si l’on conçoit un serveur entièrement centré sur l’asynchrone, extensible, maintenable et facile à comprendre, à quoi devrait-il ressembler ?
      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 liburing ou epoll en deux phases, “submit” et “handle”, puis les envoient à d’autres composants. Par exemple, si l’on crée une tcp-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 avec EPOLLOUT/io_uring_prep_writev
      Pour 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} | state3
    • Si ce n’est pas stabilisé, il ne faut pas non plus l’utiliser en production
    • Les commentaires qui partent du principe que l’auteur est débutant en Rust sont amusants. Il se peut au contraire qu’il ait plus d’expérience qu’eux
  • La durée de vie d’un Arc n’est pas inconnue ; elle est déterminée par l’endroit et la manière dont il est détenu
    Le 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

    • Dans ce sens, la durée de vie des objets dans un système à garbage collection a elle aussi une borne inférieure : « tant qu’ils sont référencés »
      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
    • Une bonne partie de la critique donne cette impression. Je pensais que ce serait un article sur la façon dont la transformation async empêche le compilateur d’appliquer à du code non asynchrone certaines optimisations
      Le passage sur le fait de se battre avec Weak semble 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 faibles
      Les 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 Notify et les sémaphores
      Les 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_HORROR
      La 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_blocking
    • Le comptage de références est aussi une forme de garbage collection https://en.wikipedia.org/wiki/Garbage_collection_(computer_s...
      Il est très probable que l’auteur sache ce qu’est Arc et comment il fonctionne ; le point est plutôt qu’en Rust async, on finit par utiliser Arc beaucoup plus souvent que la RAII ordinaire, par rapport à du code synchrone
      Si 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...
    • La durée de vie d’un Arc n’est pas aléatoire, elle est impossible à connaître statiquement
    • En Rust, un Arc peut être déplacé ou emprunté, et peut aussi être utilisé sans toucher au compteur de références
      Dans 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 async est un bazar, et on ne peut pas écrire du code asynchrone comme on écrirait du code synchrone
    Je 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 bonne
    En 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èques

    • Je ne suis pas d’accord avec la fin. async est 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 de Pin par-dessus
      Comme on ne peut pas exécuter de fibres à portée lexicale, on finit par coller des Arc partout ; Pin est difficile à utiliser sans unsafe ; et une toute petite modification d’une fonction asynchrone peut rendre !Send les futures de toute la base de code
    • Les développeurs de bibliothèques sont mieux armés que les utilisateurs pour gérer la complexité. Confier ce travail à des développeurs expérimentés qui construisent l’infrastructure de base va dans le bon sens
    • J’ai vu un exemple de VM wasm pour Rust qui fournit quelque chose ressemblant à de l’ordonnancement M:N transparent, et ce genre d’approche pourrait résoudre la plupart des difficultés liées à async. Il faudra voir comment cela évolue
  • Async 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 longtemps
    Quiconque 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/await par Ron Pressler, qui a implémenté le Project Loom de Java, est également bonne : https://www.youtube.com/watch?v=oNnITaBseYQ

    • Détester JavaScript est amusant, mais il est intéressant de revoir la présentation dans laquelle Ryan Dahl a introduit Node.js pour la première fois : https://www.youtube.com/watch?v=EeYvFl7li9E
      Il é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 auparavant
    • async/await a 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/await ont 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 async formulées ici sont donc valables
  • L’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 performances
    Les 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 vivantes
    Plutôt que de parsemer des Arc partout, j’utilise ce crate unsafe : 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 aboutira
    Un autre gros problème est que async trait exige actuellement des futures boxées, ce qui ajoute un malloc/free à chaque frontière d’appel de fonction ; c’est dans la feuille de route des corrections de cette année
    Le 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 GOTO modernes ; 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

    • La distinction importante est que les futures Tokio elles-mêmes ne sont pas 'static ; seules les futures pouvant être spawn pour exploiter la concurrence du runtime doivent être 'static
      Pour qu’une future soit poll(), elle doit être Pin, et un T: !Unpin qui est Pin doit finir par appeler Drop : https://doc.rust-lang.org/std/pin/#drop-guarantee
      Les futures générées par la fonctionnalité async du compilateur possèdent cette propriété, et on peut aussi ajouter PhantomPinned aux futures écrites à la main. Grâce à cela, après un poll(), on peut considérer les manipulations avec mem::forget comme 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éoccuper
    • Si l’on considère comme une erreur de conception le fait que les fuites mémoire soient sûres, je me demande si vous préféreriez supprimer la mutabilité intérieure, supprimer Rc, ou bien ajouter des frontières de traits unsafe contagieuses