4 points par GN⁺ 2024-03-26 | 1 commentaires | Partager sur WhatsApp
  • En Rust, async/await n’est pas un simple substitut aux threads, mais un modèle de programmation qui exprime du code concurrent centré sur les E/S sous forme de machines à états composables
  • Un code qui doit gérer plusieurs connexions à la fois, comme un serveur web, atteint vite les limites d’une exécution purement linéaire, et les threads permettent le traitement concurrent en séparant la gestion des clients avec thread::spawn
  • async/await cède l’exécution aux points await, puis un exécuteur (executor) enchaîne avec d’autres tâches, ce qui permet d’entrelacer un grand nombre de travaux dans un même runtime
  • Une exigence comme un timeout de 3 secondes peut s’ajouter en async en combinant race et Timer, alors qu’avec du code synchrone à base de threads, il faut un wrapper dédié à TcpStream et configurer des timeouts de lecture/écriture, ce qui réduit la généricité
  • Expliquer async uniquement par le surcoût de performance crée des contre-exemples sur les charges CPU bound ; la vraie force de Rust async réside dans sa richesse sémantique et la composabilité de son écosystème

Point de départ du problème de concurrence en Rust

  • Le code Rust classique suit par défaut une structure d’exécution linéaire
    • comme foo(), bar(), baz(), où une tâche ne démarre qu’une fois la précédente terminée
  • Dans les cas où il faut traiter plusieurs choses en même temps, comme sur un serveur web, cette structure linéaire atteint vite ses limites
    • dans une structure qui reçoit les clients avec TcpListener::accept() puis exécute handle_client(), le deuxième client doit attendre pendant le traitement du premier
    • si handle_client() prend quelques millisecondes et qu’il y a 2 clients simultanés, l’attente reste courte
    • mais avec 2 millions de clients simultanés, l’utilisateur en fin de file peut attendre plusieurs minutes

Comment les threads résolvent le problème

  • Les threads du système d’exploitation peuvent sauvegarder en mémoire les valeurs des registres et la pile du programme, exécuter une autre routine, puis reprendre plus tard la routine d’origine
  • Le code d’un serveur web délègue le traitement des clients à un thread séparé sous la forme thread::spawn(move || handle_client(client))
    • le thread principal continue d’appeler accept() pour de nouvelles connexions
    • si le thread qui traite un client se bloque, l’OS peut revenir au thread principal pour accepter la connexion suivante
    • deux clients peuvent ainsi s’exécuter en parallèle avec seulement quelques microsecondes de latence
  • Si un serveur web de production dispose de dizaines de cœurs CPU, l’OS peut non seulement donner l’impression que les threads s’exécutent simultanément, mais aussi en faire tourner réellement plusieurs en même temps

Comment fonctionne async/await

  • Pour la concurrence en espace utilisateur, il existe plusieurs modèles comme la programmation événementielle, les acteurs ou les coroutines, et l’approche choisie par Rust est async/await
  • En simplifiant, le programme est compilé comme un ensemble de machines à états pouvant s’exécuter indépendamment les unes des autres
    • une async fn n’est pas une fonction traditionnelle, mais une fonction qui renvoie une machine à états
    • await incorpore une autre machine à états comme l’une des étapes de la machine à états courante
    • lorsqu’une fonction interne cède l’exécution, par exemple en attendant une nouvelle connexion, la machine à états entière rend le contrôle à l’exécuteur (executor) supérieur
  • Un exécuteur comme smol::Executor lance alors une autre machine à états, créée via spawn, à la place de la machine courante
    • un bloc async move { handle_client(client).await } est une nouvelle machine à états indépendante de main
    • quand main cède l’exécution, l’une des tâches client s’exécute ; quand elle cède à son tour, on passe à la suivante
  • Cette structure permet de gérer simultanément des millions de clients, mais elle ajoute aussi de la complexité avec des notions comme exécuteur, tâche et machine à états

La composabilité révélée par l’exemple du timeout

  • L’une des forces de Rust est la composabilité
    • Iterator permet d’enchaîner plusieurs combinateurs puis de passer le résultat à une autre fonction qui attend à son tour un Iterator
    • avec mpsc::channel(), on peut par exemple filtrer et transformer des valeurs via recv.try_iter().filter(...).map(...) avant de les ajouter à une liste
  • async/await applique cette même composabilité aux fonctions I/O bound
  • Si handle_client() est une fonction asynchrone qui await sur read_to_end, do_something_with_data et write_all, un timeout de 3 secondes peut se construire en combinant deux Future
    • race exécute deux Future en même temps
    • Timer renvoie après une durée donnée
    • il suffit d’envelopper le code de traitement existant dans un bloc async, puis de le faire concourir avec une Future qui renvoie une erreur TimedOut après 3 secondes
  • Cette approche n’est pas liée uniquement à TcpStream
    • le même schéma s’applique à tout type implémentant impl AsyncRead + AsyncWrite
    • on peut donc le remplacer par un flux GZIP au-dessus d’un flux classique, un socket Unix, un fichier, etc.

Les contraintes pour implémenter le même timeout en code synchrone avec threads

  • En code bloquant, il est en général difficile d’interrompre un appel système read ou write, et des méthodes comme fermer le descripteur de fichier ne sont pas utilisables en Rust
  • TcpStream fournit set_read_timeout et set_write_timeout
    • on peut définir un timeout distinct pour la lecture et l’écriture
    • mais si un client envoie 1 octet toutes les 2,9 secondes, un timeout simple peut être réinitialisé en permanence
  • Pour s’en protéger, il faut créer un type comme DeadlineStream qui encapsule TcpStream et recalcule avant chaque lecture/écriture le temps restant jusqu’à l’échéance globale pour le réappliquer comme timeout
  • Cette approche peut fonctionner, mais elle a de fortes limites
    • elle reste liée à TcpStream
    • Rust ne propose pas de trait qui abstrait l’usage de set_read_timeout et set_write_timeout
    • l’appliquer à un writer générique demande beaucoup de travail supplémentaire
    • elle ajoute des appels système pour régler les timeouts
    • dans la logique réelle d’un serveur web, elle peut être bien plus pénible à utiliser

Exemples dans l’écosystème Rust async

  • Si l’écosystème HTTP, client compris, a adopté async/await comme mécanisme d’exécution principal, c’est notamment grâce à la composabilité des fonctions
    • on peut insérer les fonctions qui fabriquent des appels HTTP dans de nombreux points d’extension et cas d’usage
  • tower est un exemple représentatif de la composabilité offerte par async/await
    • si un service est implémenté sous forme de fonction async, on peut lui ajouter timeout, limitation de débit, équilibrage de charge, hedging et gestion de la backpressure
    • quel que soit le runtime utilisé ou ce que fait le service en interne, tower permet d’en accroître la robustesse
  • macroquad est un petit moteur de jeu pour Rust, qui exécute son moteur en utilisant async/await dans la fonction principale
    • async/await convient bien pour exprimer les situations où il faut suspendre une fonction linéaire en Rust afin d’attendre une opération
    • cela permet par exemple de poller à la fois une connexion réseau de serveur de jeu et un framework GUI sur le même thread

Les limites d’une explication d’async fondée uniquement sur la performance

  • Le Rust Async Book compare les threads de l’OS et async en expliquant que les threads permettent d’exprimer la concurrence facilement sans changer de modèle de programmation, mais qu’ils sont difficiles à synchroniser, ont un surcoût de performance important, et que même les pools de threads ont du mal à bien prendre en charge des charges massives I/O bound
  • Dans la communauté async, lorsqu’on demande pourquoi utiliser async plutôt que des threads de l’OS, la réponse tend souvent à être : « le surcoût est plus faible, et le reste est pareil »
  • Si les auteurs de serveurs web sont passés à async/await, c’était pour résoudre le C10k problem, mais cela ne signifie pas que tous les utilisateurs doivent choisir async/await pour des raisons de performance
  • Les gains de performance peuvent disparaître selon le contexte
    • sur des charges CPU bound, un workflow à base de threads peut être plus rapide qu’un workflow async équivalent
    • l’avantage de performance ponctuel de Rust async a souvent été suraccentué, tandis que ses avantages sémantiques ont été sous-estimés
  • async/await n’est pas un outil réservé à quelques cas de niche, mais un modèle de programmation puissant pour traiter des schémas difficiles à exprimer de façon concise en Rust synchrone sans recourir à des dizaines de threads et de canaux

Accepter la différence plutôt que vouloir le rendre identique à Rust synchrone

  • La feuille de route des projets Rust indique qu’écrire du async Rust devrait parfois être aussi simple qu’écrire du code synchrone, à part l’usage des mots-clés async et await
  • Mais certains estiment qu’il est fondamentalement difficile de présenter async Rust comme « exactement la même chose que Rust synchrone »
    • même si l’on parvient à rendre les deux modèles similaires à 99 %, l’utilisateur moyen remarquera forcément la différence
  • Plutôt que d’essayer de devenir identique à Rust synchrone, l’écosystème Rust async/await devrait mettre plus clairement en avant ses forces : la composabilité et la puissance d’expression
  • Pour faire de async/await l’option par défaut dès qu’il faut de la concurrence, il faut expliquer ce modèle non pas par des raisons de performance technique, mais par des raisons sémantiques

1 commentaires

 
GN⁺ 2024-03-26
Avis sur Hacker News
  • async/await mono-thread est un modèle simple et bien connu, et c’est ainsi que fonctionne JavaScript
    Les threads permettent de mobiliser plusieurs CPU pour résoudre un problème, et Rust aide à gérer les verrous. On peut aussi avoir des threads avec des priorités différentes, ce qui peut être nécessaire pour des tâches compute-bound
    En revanche, async/await multi-thread devient vite compliqué. Dès qu’une portion compute-bound sérieuse entre en jeu, elle finit en pratique par bloquer le thread partagé avec les autres tâches, ce qui fait facilement s’effondrer le modèle
    Le multi-thread compute-bound en Rust ne fonctionne pas aussi bien qu’on pourrait l’espérer. Dans l’allocator mémoire du dépôt, plusieurs threads peuvent marteler le même verrou et provoquer un effondrement par contention de futex ; en particulier, pendant l’agrandissement d’un buffer, une recopie peut se produire alors que tout l’allocator est verrouillé, ce qui devient très coûteux. L’allocator de bibliothèque dans les .DLL d’émulation des bibliothèques Microsoft de Wine est vulnérable à ce problème, au point que tout le temps CPU partait dans le spinlock et que les performances chutaient d’un facteur à deux chiffres, alors que l’implémentation Microsoft n’avait pas ce problème
    De plus, le Mutex standard et les canaux crossbeam-channel peuvent souffrir de famine due à un mutex non équitable. Si plusieurs threads verrouillent une ressource, travaillent, la libèrent, puis recommencent, un thread peut continuer à gagner et les autres se faire évincer. Si un mutex équitable est nécessaire, il y a parking-lot, mais sans la sécurité de poisoning qu’offre le mutex standard en cas de panic d’un thread
    Si l’on n’est pas dans de l’I/O-bound, cela devient bien plus complexe
    https://users.rust-lang.org/t/mutex-starvation/89080

    • Oui. J’ai surtout traité des calculs I/O-bound, mais même là, des problèmes de contention apparaissent
      Si le débit I/O est de toute façon la limite, on peut se demander à quoi sert un million de coroutines. Si elles épuisent immédiatement un pool de connexions DB de taille 10, les coroutines ne vous sauveront pas ; elles rendent surtout le débogage, les contournements et le raisonnement plus difficiles
    • Cela donne l’impression qu’il faudra peut-être finalement repenser le problème au niveau matériel
      Les problèmes CPU-bound semblent ramener à des interruptions/reprises systématiques, et si l’on pouvait faire une commutation de contexte équitable et efficace, basée sur une file, pour n threads d’exécution en cours — par exemple un CPU avec n contextes actifs — alors je me demande si le problème ne deviendrait pas essentiellement un problème d’allocation de ressources
    • Je ne sais pas pourquoi on redécouvre sans cesse les difficultés du multitâche coopératif
      Même Go, que je considère comme un langage conçu de manière responsable, est parti au début sur du coopératif avant de devoir finalement passer au préemptif. Cela ne veut pas dire que le multitâche coopératif ne sert à rien, mais il devrait porter une étiquette d’avertissement, et mieux encore, certains types de code devraient peut-être être empêchés statiquement de s’exécuter
      En lien, je joins « What color is your function »
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • Je me retrouve sans cesse à répéter qu’il s’agit d’un détail d’implémentation
      Les exécuteurs async/await multi-thread peuvent traiter correctement la famine, et l’implémentation .NET supporte même du très mauvais code qui mélange appels bloquants et asynchrone
      https://news.ycombinator.com/item?id=39530435
      https://news.ycombinator.com/item?id=39786142
      https://news.ycombinator.com/item?id=39721626
    • Les fonctions colorées créées par async/await ajoutent aussi un coût au développement et à la maintenance logicielle
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
      Si le logiciel n’a pas besoin d’une très forte scalabilité, les compromis d’async peuvent ne pas en valoir la peine
  • Le cœur du débat entre async/await et les threads n’est pas de savoir lequel est le plus complexe, mais le fait de scinder l’écosystème en deux, avec un camp relégué au rang de citoyen de seconde zone, ce qui crée des frictions si on fait le mauvais choix pour un projet
    On peut les mélanger, mais quand il le faut, c’est bricolé et inefficace. À l’heure actuelle, l’écosystème Rust a de fait décidé que dès qu’il y a de l’I/O, tout est pratiquement lié à l’écosystème async/await, et comme presque tout ce qu’on veut faire en Rust implique de l’I/O, sauf rares exceptions, il faut en général ignorer les bibliothèques non asynchrones, que le reste de l’application veuille ou non de l’async
    Si Rust avait adopté une abstraction plus composable que async/await, et que cette composabilité n’avait pas exigé de transformer le reste en async/await, la plupart des plaintes auraient sans doute disparu

    • D’accord avec ce diagnostic. J’étais arrivé à la même conclusion dans mon article sur l’async en Rust[0]
      Le pire, c’est que l’écosystème ne se contente pas d’être coupé en deux : même dans le code async, on se retrouve en général fortement lié à un exécuteur, le plus souvent Tokio. En étendant le problème de la couleur des fonctions, au lieu d’avoir bleu (sans I/O), vert (I/O bloquantes), rouge (I/O async), on a en pratique bleu, vert, rouge (Tokio), violet (async-std), orange (smol)
      À mon avis, la meilleure solution à ce problème est le pattern sans-I/O. Si on isole tout le code bleu et qu’on utilise l’inversion de contrôle pour l’I/O et le temps, on peut faire en sorte que la logique cœur du protocole ignore l’I/O, et qu’elle soit facile à envelopper avec plusieurs formes d’I/O
      0: https://hugotunius.se/2024/03/08/on-async-rust.html
    • Il ne faut ignorer les bibliothèques non async que lorsqu’il existe deux bibliothèques au choix et que tout le reste est identique. C’est rare
      Utiliser du code bloquant dans une application async n’est pas aussi fluide qu’on pourrait l’espérer, mais ce n’est pas non plus difficile. Au lieu de foo(), il suffit d’utiliser tokio::spawn_blocking(foo).await, ce qui exécute le nouveau code sur un thread séparé et renvoie une future qui se termine quand ce thread a fini
    • En C#, c’est globalement similaire
      Il existe bien des options non asynchrones pour l’I/O, mais si on choisit l’option async, on est en pratique forcé de rendre toute la chaîne async jusqu’à Main(). Il existe aussi des moyens d’appeler en sécurité une méthode async depuis une méthode synchrone, mais cela rend le débogage extrêmement difficile
  • Il manque beaucoup de choses dans l’article
    async/await s’exécute dans un contexte de thread unique, donc il n’y a pas besoin de verrous ni de synchronisation, mais si on exécute async/await sur plusieurs threads pour exploiter les cœurs CPU, il faut à nouveau des verrous et de la synchronisation. Cette complexité peut être cachée dans du code externe. Par exemple, au lieu de synchroniser l’accès à une seule connexion DB, il est plus simple d’ouvrir une connexion DB par tâche async, mais cela peut affecter les performances avec SQLite ou PostgreSQL
    La propagation des erreurs n’est pas claire avec async/await. C’est particulièrement vrai quand on essaie de regrouper des tâches async, et Happy Eyeballs en est un exemple typique
    Si on parle d’I/O réseau, il faut aussi traiter la rétropression. L’implémentation de async/await dans CPython est tristement connue pour son manque de rétropression réseau, avec les problèmes que cela entraîne

    • async/await a plusieurs problèmes, mais c’est de loin mon plus gros reproche
      Le “Design Patterns” du Gang of Four était en grande partie un livre de recettes pour contourner les défauts du C++, et pourtant des gens ont appliqué ces patterns à des langages qui n’avaient pas ces défauts
      Rust n’est pas JavaScript et sait bien exécuter plusieurs threads. Il n’était donc pas obligatoire d’utiliser async/await, et on aurait pu essayer d’autres solutions, plus dignes d’un langage système
      Mais pour faire avaler Rust aux programmeurs JavaScript, il fallait async/await. without.boats a écrit qu’ils ont « poussé async/await avec le zèle sincère d’une hypothèse selon laquelle la survie de Rust dépendait de cette fonctionnalité »
      https://without.boats/blog/why-async-rust/
      La question de savoir si async/await convenait techniquement bien à Rust importait peu ; on a l’impression que Rust devait avoir async/await parce que les programmeurs JavaScript y étaient habitués
    • async/await est aussi, comme les threads, un mécanisme de concurrence, et dès qu’on accède à de la mémoire partagée, il faut toujours des verrous. Je ne sais pas d’où vient l’idée qu’il n’y en aurait pas besoin
    • async peut être plus effrayant du point de vue des verrous
      Si un bloc de code dépendait d’un accès exclusif et que l’absence de await le garantissait, ajouter un await au milieu peut casser le code. Le threading, au moins, oblige à expliciter dans le code ce qui nécessite un accès exclusif
      async signifie aussi qu’on gère soi-même l’ordonnancement de ses threads. C’est acceptable avec beaucoup d’I/O et peu de code CPU-bound, mais dès qu’il y a du code CPU-bound, même occasionnellement, on finit par jouer au scheduler
    • Quand j’ai rejoint une équipe qui utilisait Node.js, j’ai été confronté au problème de la rétropression
      Plusieurs services tombaient tout simplement en ABEND, et venant de Java, cette absence m’a surpris. Il a même été difficile d’expliquer à l’équipe comment corriger le problème
      À cause de la propagation des erreurs, je n’utiliserais pas async/await si j’avais le choix. En solo, peut-être, mais s’il faut travailler avec d’autres, utiliser des bibliothèques et aligner tout le monde sur la même compréhension, alors certainement pas
      Je n’ai pas encore vraiment utilisé la concurrence structurée au niveau du langage, mais j’ai beaucoup d’espoir dans le Project Loom de Java. À première vue, il semble rendre ce débat caduc
    • En Rust, async/await ne s’exécute pas uniquement dans un contexte de thread unique
  • Il y a des problèmes dans l’article
    Il n’y a qu’un seul exemple, celui d’un serveur web, et la solution côté threads est mal traitée. En plus, la question semble partir du principe que les gens veulent des threads de l’OS plutôt que async/await
    Ce que veulent les programmeurs, ce sont des threads au sens conceptuel et sémantique. Pouvoir écrire une logique séquentielle sans annotations bizarres comme async. Si async/await est si bien, pourquoi ne pas rendre toutes les fonctions implicitement async et utiliser de simples appels de fonction au lieu de await ? On programmerait alors de fait avec des threads
    Les threads de l’OS coûtent cher à cause de leurs piles allouées statiquement, et ce que nous voulons, ce sont des threads peu coûteux capables d’être exécutés par millions sur un seul CPU. Mais sans le vocabulaire lourd de async/await. On peut garder wait pour l’attente bloquante au sens classique — attendre un événement ou la fin d’un autre thread — mais on n’en veut pas pour les appels de fonction
    Pour revenir à l’exemple du serveur web, quand on implémente un timeout avec driver.race(timeout).await, que devient le socket client une fois que race a signalé une erreur de timeout ? Est-ce qu’il ne fuit pas en restant ouvert et connecté ?
    La version à base de threads du timeout peut elle aussi être rendue presque identique à async/await, avec quelque chose comme threaded_race(client_thread, timeout).wait. threaded_race suit le timeout en parallèle du thread à l’aide d’un timer, puis, quand l’échéance est atteinte, appelle client_thread.interrupt() à la manière de Java. Thread.interrupt() se contente de poser un drapeau si le thread n’est pas bloqué, et lance InterruptedException s’il est bloqué dans un appel d’E/S. Comme il s’agit d’une exception vérifiée, le compilateur force soit à entourer client.read_to_end(&mut data) d’un try/catch, soit à déclarer l’exception dans handle_client, ce qui évite au programmeur d’oublier de fermer le socket client

    • Les valeurs internes de race() sont Drop, et driver lui-même reste en place
      Avec les types tels quels, Rust se plaindra qu’un Result n’est pas traité, et si un nouveau socket a été créé localement dans la future, il sera nettoyé
      L’un des points forts des futures en Rust, c’est qu’on peut définir tout leur comportement environnant. Contrairement au modèle où toutes les fonctions sont bloquantes, Rust permet d’indiquer à quel moment l’exécution doit être repoussée à la tâche suivante de la file, et de disposer d’un état stocké explicitement (la structure Future) afin de poller la tâche arbitrairement vite. Il n’y a donc pas besoin de faire un sleep() pour céder la main comme avec des threads, ce qui rend le tout plus rapide et plus facile à raisonner
      Le Thread.interrupt de Java revient au fond à quelque chose de proche d’une boucle de sleep, et cela peut suffire pour la plupart des applications. Mais Rust est un langage système : dans l’embarqué, on ne peut pas se permettre cette approche, et elle n’est pas non plus souhaitable pour un noyau ou des applications à faible latence
    • Certains programmeurs veulent une logique séquentielle, mais beaucoup veulent exactement l’inverse
      Dans la plupart des cas, ils se soucient peu de savoir s’il s’agit d’appels système bloquants ou non bloquants au niveau de l’OS, mais ils veulent comprendre le flux de contrôle du programme qu’ils lisent, savoir où il attend et comment il peut s’exécuter en parallèle
      J’aimerais plutôt qu’il existe une paire de mots-clés blocking/block pour le travail avec des fonctions bloquantes. Les appels bloquants peuvent facilement ralentir l’ensemble de façon insidieuse, et j’ai vu bien trop d’apps péniblement lentes parce qu’un appel système bloquant s’était retrouvé dans le thread d’UI
    • L’idée de rendre toutes les fonctions implicitement async et de les utiliser comme de simples appels de fonction a déjà été tentée plusieurs fois au fil des décennies. Il suffit de chercher « RPC »
      Toutes les tentatives visant à unifier synchrone et asynchrone ont échoué. Il existe de grandes différences sémantiques entre du code exécuté dans un thread, entre plusieurs threads, ou même entre plusieurs machines. Si l’on essaie d’abstraire cela, le résultat finit toujours par être insuffisant ; autant l’apprendre correctement dès le départ
    • Il me semble que withoutboats a dit dans un article que la vraie réponse, c’était la compatibilité avec C
    • On peut aussi écrire du code avec poll() et select(), mais c’est encore une autre approche
  • Il est intéressant de voir une sorte de campagne presque marketing pour sauver la face de async/await
    D’après mon expérience, ce n’était pas seulement une erreur technique : cela a aussi eu un coût important pour la communauté. Au lieu de se concentrer sur des fonctionnalités de langage réellement utiles, les efforts de Rust se sont détournés à cause de cette confusion
    Cela dit, j’ai toujours de grandes attentes pour le langage, et je pense qu’il reste le meilleur de ceux dont nous disposons aujourd’hui. Je m’inquiète simplement de voir ce conflit se prolonger indéfiniment
    PS : les exemples AsyncWrite/AsyncRead ont l’air convaincants, mais en réalité, si l’on se limite à *nix, on peut faire la même chose avec des threads et des descripteurs de fichiers

    • J’ai déjà utilisé async en firmware, et ça m’a sauvé la vie
      Ce genre de généralisation manque de fondement et semble biaisé par une charge de travail particulière
    • Je ne connais pas assez bien Rust pour savoir si c’est juste, mais empiriquement, on dirait que 9 discussions Rust sur 10 que je vois aujourd’hui sur HN/reddit tournent autour d’async
      Comme je ne m’intéresse absolument pas à async et que je veux juste lire des discussions sur Rust, c’est assez pénible
    • Si quelqu’un pense que les threads sont plus rapides que poll(), j’aimerais connaître le cas d’usage. Je n’ai jamais vu cela une seule fois de ma vie
    • Ce n’est pas une erreur technique : c’est une excellente solution quand on a besoin de code async ultra-faible latence
      L’erreur a été de le pousser aussi sur l’immense majorité des cas d’usage qui n’en ont pas besoin
    • Je me demande s’il existe des éléments concrets pour étayer l’idée que le travail sur async aurait privé le langage d’autres fonctionnalités utiles
      Beaucoup de grands projets Rust dépendent d’async non seulement pour les gains de performance par rapport aux alternatives fondées sur les threads, mais aussi pour des raisons d’architecture. Sur les principales charges de travail liées aux E/S, ces avantages sont faciles à constater. Le fait que des personnes intelligentes, qui résolvent des problèmes réels, aient largement adopté async dans les principaux crates me semble être un signal fort qu’async est bel et bien une fonctionnalité de langage utile
      Le conflit se joue surtout sur Hacker News et reddit, sous la forme de gens qui n’ont pas besoin d’async mais se mettent en colère parce que des crates d’E/S qu’ils utilisent le veulent désormais. Je comprends que la situation ne soit pas amusante, et il est vrai qu’async a de vrais problèmes, encore en cours de résolution. Ce n’est pas parfait. Mais j’ai l’impression que la fracture autour d’async visible sur les forums n’est ni aussi large ni aussi dramatique dans les projets réels.
  • L’élément majeur qui manque, c’est l’annulation
    un future est très facile à annuler. À l’inverse, annuler un thread est aussi sale qu’un jeu de tape-taupe, et l’arrêt forcé d’un thread n’est pas fiable à cause du risque de laisser des verrous acquis
    Dans le modèle async de Rust, on peut attacher un délai d’expiration externe à n’importe quel future. Pas besoin que chaque fonction I/O terminale prenne en charge une option de timeout, ni de propager ce timeout à toute la pile d’appels
    En combinant la gestion de l’état en cours avec les gardes Drop, qui sont une bonne pratique en Rust, on peut annuler facilement et de façon fiable même des tâches volumineuses et complexes

    • Ce n’est pas que tous les futures sont faciles à annuler, c’est qu’il est facile de faire comme si on les avait annulés
      Par exemple, même si on annule (drop) quelque chose qui utilise spawn_blocking, cela continue à s’exécuter en arrière-plan et l’utilisateur peut ne pas s’en rendre compte. Les opérations de système de fichiers async implémentées avec un pool de threads continuent elles aussi à s’exécuter après annulation
      Cela peut mener à des bugs difficiles à comprendre du genre : « pourquoi le service échoue-t-il en disant que le fichier est en cours d’utilisation alors que je suis sûr que plus rien n’écrit dans ce fichier ? »
    • Si on a pu implémenter des futures async, on aurait aussi pu implémenter des threads annulables à la place
      Le problème est assez isomorphe. Les appels système sont difficiles, mais qu’on les fasse dans un thread ou dans un future async, si on effectue les mêmes appels système on rencontre exactement les mêmes problèmes d’annulation
    • Je ne vois pas pourquoi l’annulation des threads est si difficile
      Il suffit d’avoir un état partagé, comme un drapeau accessible à tous les threads, puis de faire vérifier ce drapeau par la boucle de travail. S’il est à false, on retourne et il ne reste plus qu’à faire un join du thread
    • D’après mon expérience, l’annulation ne valait pas vraiment la peine de s’en inquiéter autant
      Si une tâche n’est plus utile, il suffit que cette information finisse par être visible pour la fonction appelée pour cette tâche. Je ne le vérifie pas spécialement, sauf juste avant de lancer quelque chose de très coûteux, comme le démarrage d’un RPC
  • La meilleure question est sans doute : « pourquoi async/await plutôt que les fibers ? »
    Je sais que Rust avait des green threads avant la 1.0 et les a supprimés délibérément, mais il existe plusieurs approches pour implémenter une concurrence basée sur les fibers, notamment des méthodes qui n’imposent pas d’intégrer un runtime lourd au langage
    Si j’ai bien compris l’article, il semble surtout louer le fait qu’on puisse drop un future à n’importe quel moment. Avec des threads, on ne peut pas faire quelque chose de similaire pour des raisons évidentes, et même si c’était techniquement possible, ce serait extrêmement peu sûr. Mais cette capacité a un coût énorme. On ne peut pas combiner des exécuteurs à complétion comme io-uring avec des tableaux alloués sur la pile, ni exécuter des sous-tâches sur d’autres threads de l’exécuteur ; et en pratique, en écrivant du Rust synchrone, cela crée aussi des pièges subtils et des problèmes de fiabilité qui peuvent être de très mauvaises surprises
    https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
    L’annulation des tâches devrait fondamentalement être une annulation coopérative, et l’annulation non coopérative, même si elle paraît pratique en surface, ressemble à une mauvaise fonctionnalité qui cache de profonds problèmes
    Je trouve aussi étrange qu’on vante la composabilité de async/await. Dans le Rust actuel, sans proper effect system, c’est très loin d’être vraiment composable à cause de son caractère contagieux. Il suffit par exemple d’essayer d’utiliser la méthode map de la bibliothèque standard avec une closure async, ou les traits standard io::Read/Write

    • Introduire des fibers dans Rust comme abstraction de concurrence coopérative en espace utilisateur forcerait plusieurs choix de conception
      Il faudrait choisir entre des piles de type spaghetti stack, une bibliothèque de memory mapping au niveau du processus, ou une limitation à des piles de taille fixe
      Les trois approches posent des problèmes lorsqu’il faut interagir avec du code écrit dans des langages ayant un ABI différent. Par exemple, si du code C est appelé depuis une fiber et que ce code C essaie ensuite de reprendre une autre fiber, cela peut devenir assez complexe
      L’un des avantages de async/await, c’est le mot-clé await lui-même. Grâce à ces points d’attente explicites, on peut réellement raisonner sur les interactions d’un programme concurrent
      Une fiber qui fait yield ressemble un peu à un goto dans le monde de la concurrence. Quand on appelle une méthode, on ne sait pas si le traitement va s’arrêter comme effet secondaire, ni si l’état du monde aura changé lorsqu’il reprendra. Comme il faut coder de manière défensive dès qu’on touche au monde extérieur, les fibers conviennent mieux à des tâches isolées qui s’exécutent puis communiquent leur achèvement
      Green threads, fibers et coroutines partagent ici le même problème. La concurrence coopérative en espace utilisateur ne résout pas vraiment les parties difficiles de la concurrence ; elle se contente plutôt de déplacer les papiers sur le bureau. Rust async/await est plus explicite, donc il ne masque pas les effets de bord que d’autres mécanismes cachent
    • Je ne vois pas comment les fibers résoudraient le problème de l’annulation. N’est-ce pas pratiquement équivalent ?
      J’ai l’impression qu’un code basé sur des fibers est difficile à suivre parce qu’il faut garder en tête le thread en cours d’exécution. Au moins pour moi, il est bien plus facile de suivre une valeur qui va finir par être produite
    • Les fibers avec pile ne sont pas très bonnes pour le code bas niveau
      Voir la revue de Gor Nishanov pour le comité C++ http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136..., également liée ici : https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10.... Le résumé est clair lui aussi : DO NOT USE FIBERS!
    • Comme on parle de Ruby, la situation threads/GVL est différente de Rust, mais c’est peut-être de ce genre de chose qu’il s’agit
      https://m.youtube.com/watch?v=qKQcUDEo-ZI
      Je pense que cela montre assez bien en quoi async/await est contagieux et maladroit, et pourquoi les fibers constituent un paradigme bien meilleur, du moins dans l’implémentation Ruby
    • Dans les problèmes mathématiques, l’annulation coopérative peut être franchement pénible
      Un algorithme d’optimisation peut appeler un problème de recherche de racine, qui peut lui-même appeler un intégrateur d’ODE, et chaque niveau peut tourner très longtemps. Il faut propager des jetons d’annulation partout, mais les frameworks de calcul numérique ne le prennent généralement pas en charge
      On peut, et on devrait, imposer une limite d’itérations à tous les algorithmes, mais avec des algorithmes imbriqués cela garantit surtout que tout s’arrêtera avant la fin de l’année, pas forcément dans les 5 secondes
      Dans ce genre de problème, je peux garantir que ce que je fais consiste en une grande quantité de calculs mathématiques, des allocations et les page faults qui vont avec, aucune E/S, et seulement l’écriture de chaînes de log dans un objet Queue de la bibliothèque standard qui sera traité par le thread principal, lequel ne sera pas annulé. Toute autre fonctionnalité nécessaire peut aussi être renvoyée au thread principal via cette Queue
      J’ai le sentiment qu’au XXIe siècle, on devrait pouvoir résoudre ce problème sans être obligé de faire passer de force des jetons d’annulation partout, ni d’écrire défensivement contre du code qui tourne longtemps sans jamais vérifier le jeton
  • Encore une discussion async/await où les gens ne comprennent pas async/await, n’arrivent pas à imaginer pourquoi on peut avoir besoin d’un mécanisme de concurrence sur un seul thread, et partent du principe que personne n’en a besoin
    La programmation d’interface utilisateur, la communication avec le GPU et la communication entre runtimes en sont de bons exemples, et il y en a sûrement d’autres
    Les threads, qu’ils soient green ou non, ne conviennent pas à ces cas, alors que async/await, si

    • On peut très bien utiliser des threads dans une GUI, et j’ai déjà écrit plusieurs applications GUI qui les utilisaient de manière assez efficace
    • Il est certainement important de pouvoir gérer explicitement plusieurs tâches sur un seul thread
      Si l’on peut obtenir le même binaire tout en implémentant une autre fonctionnalité du langage moins pénible pour les utilisateurs, cela mérite aussi d’être discuté
  • L’un des principaux avantages de l’async/await de Rust est qu’il peut fonctionner même en l’absence de threads ou de mémoire dynamique
    C’est suffisamment puissant pour écrire du code concis sur un microcontrôleur, par exemple pour attendre qu’une interruption lise des données I2C arrivant dans un certain buffer. C’est une abstraction de plus haut niveau qui permet d’utiliser la concurrence sans trop exposer l’interaction avec le runtime sous-jacent
    Tous les logiciels majeurs sur lesquels j’ai travaillé ont implémenté quelque chose de ce genre sous une forme ou une autre. Même dans du code sans concept moderne de coroutines C++, j’ai utilisé Apple Grand Central Dispatch, Intel Threading Building Blocks, etc. Sinon, la logique métier se retrouve soit bloquée de façon très inefficace sur les E/S, soit avec un nombre énorme de threads qui transforme le développement et le débogage en enfer, soit recouverte de détails d’implémentation du runtime sous-jacent, soit avec un mélange des trois
    Si on n’utilise pas les abstractions déjà disponibles dans le langage lui-même ou dans les bibliothèques, on finit par les fabriquer soi-même, ce qui est difficile et a de fortes chances d’être globalement inférieur à ce qui est largement utilisé. Je l’ai déjà fait moi-même pour C++ par le passé : https://github.com/goto-opensource/asyncly

  • L’auteur semble confondre deux choses
    L’une est celle des threads en espace utilisateur / green threads, l’autre celle de la concurrence structurée
    Le premier point est bien un avantage de l’async/await, mais pas un avantage qui lui est propre. Il existe des exemples, comme Go ou Java Loom, où c’est possible sans le problème de la coloration des fonctions
    Le second peut être implémenté aussi bien avec des threads OS qu’avec des green threads. Il suffit de regarder la JEP sur la Structured Concurrency de Java
    https://openjdk.org/jeps/462