Pourquoi choisir async/await plutôt que des threads
(notgull.net)- En Rust,
async/awaitn’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/awaitcède l’exécution aux pointsawait, 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
asyncen combinantraceetTimer, alors qu’avec du code synchrone à base de threads, il faut un wrapper dédié àTcpStreamet configurer des timeouts de lecture/écriture, ce qui réduit la généricité - Expliquer
asyncuniquement par le surcoût de performance crée des contre-exemples sur les charges CPU bound ; la vraie force de Rustasyncré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
- comme
- 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écutehandle_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
- dans une structure qui reçoit les clients avec
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
- le thread principal continue d’appeler
- 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 fnn’est pas une fonction traditionnelle, mais une fonction qui renvoie une machine à états awaitincorpore 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
- une
- Un exécuteur comme
smol::Executorlance alors une autre machine à états, créée viaspawn, à la place de la machine courante- un bloc
async move { handle_client(client).await }est une nouvelle machine à états indépendante demain - quand
maincède l’exécution, l’une des tâches client s’exécute ; quand elle cède à son tour, on passe à la suivante
- un bloc
- 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é
Iteratorpermet d’enchaîner plusieurs combinateurs puis de passer le résultat à une autre fonction qui attend à son tour unIterator- avec
mpsc::channel(), on peut par exemple filtrer et transformer des valeurs viarecv.try_iter().filter(...).map(...)avant de les ajouter à une liste
async/awaitapplique cette même composabilité aux fonctions I/O bound- Si
handle_client()est une fonction asynchrone quiawaitsurread_to_end,do_something_with_dataetwrite_all, un timeout de 3 secondes peut se construire en combinant deuxFuture - 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.
- le même schéma s’applique à tout type implémentant
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
readouwrite, et des méthodes comme fermer le descripteur de fichier ne sont pas utilisables en Rust TcpStreamfournitset_read_timeoutetset_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
DeadlineStreamqui encapsuleTcpStreamet 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_timeoutetset_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
- elle reste liée à
Exemples dans l’écosystème Rust async
- Si l’écosystème HTTP, client compris, a adopté
async/awaitcomme 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
towerest un exemple représentatif de la composabilité offerte parasync/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,
towerpermet d’en accroître la robustesse
- si un service est implémenté sous forme de fonction
macroquadest un petit moteur de jeu pour Rust, qui exécute son moteur en utilisantasync/awaitdans la fonction principaleasync/awaitconvient 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
asyncen 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 utiliserasyncplutô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 choisirasync/awaitpour 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
asynca souvent été suraccentué, tandis que ses avantages sémantiques ont été sous-estimés
- sur des charges CPU bound, un workflow à base de threads peut être plus rapide qu’un workflow
async/awaitn’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 Rustdevrait parfois être aussi simple qu’écrire du code synchrone, à part l’usage des mots-clésasyncetawait - Mais certains estiment qu’il est fondamentalement difficile de présenter
async Rustcomme « 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/awaitdevrait mettre plus clairement en avant ses forces : la composabilité et la puissance d’expression - Pour faire de
async/awaitl’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
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
Mutexstandard et les canauxcrossbeam-channelpeuvent 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 aparking-lot, mais sans la sécurité de poisoning qu’offre le mutex standard en cas de panic d’un threadSi 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
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
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
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-...
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
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
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
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’utilisertokio::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 finiIl 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 difficileIl 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
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
Si un bloc de code dépendait d’un accès exclusif et que l’absence de
awaitle garantissait, ajouter unawaitau milieu peut casser le code. Le threading, au moins, oblige à expliciter dans le code ce qui nécessite un accès exclusifasync 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
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
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/awaitCe 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. Siasync/awaitest si bien, pourquoi ne pas rendre toutes les fonctions implicitement async et utiliser de simples appels de fonction au lieu deawait? On programmerait alors de fait avec des threadsLes 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 garderwaitpour 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 fonctionPour revenir à l’exemple du serveur web, quand on implémente un timeout avec
driver.race(timeout).await, que devient le socket client une fois queracea 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_racesuit le timeout en parallèle du thread à l’aide d’un timer, puis, quand l’échéance est atteinte, appelleclient_thread.interrupt()à la manière de Java.Thread.interrupt()se contente de poser un drapeau si le thread n’est pas bloqué, et lanceInterruptedExceptions’il est bloqué dans un appel d’E/S. Comme il s’agit d’une exception vérifiée, le compilateur force soit à entourerclient.read_to_end(&mut data)d’un try/catch, soit à déclarer l’exception danshandle_client, ce qui évite au programmeur d’oublier de fermer le socket clientrace()sontDrop, etdriverlui-même reste en placeAvec les types tels quels, Rust se plaindra qu’un
Resultn’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 unsleep()pour céder la main comme avec des threads, ce qui rend le tout plus rapide et plus facile à raisonnerLe
Thread.interruptde 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 latenceDans 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/blockpour 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’UIToutes 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
poll()etselect(), mais c’est encore une autre approcheIl 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/AsyncReadont 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 fichiersCe genre de généralisation manque de fondement et semble biaisé par une charge de travail particulière
Comme je ne m’intéresse absolument pas à async et que je veux juste lire des discussions sur Rust, c’est assez pénible
poll(), j’aimerais connaître le cas d’usage. Je n’ai jamais vu cela une seule fois de ma vieL’erreur a été de le pousser aussi sur l’immense majorité des cas d’usage qui n’en ont pas besoin
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 complexesPar exemple, même si on annule (
drop) quelque chose qui utilisespawn_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 annulationCela 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 ? »
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
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
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
dropun 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 commeio-uringavec 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 surpriseshttps://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
mapde la bibliothèque standard avec une closure async, ou les traits standardio::Read/WriteIl 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é
awaitlui-même. Grâce à ces points d’attente explicites, on peut réellement raisonner sur les interactions d’un programme concurrentUne fiber qui fait yield ressemble un peu à un
gotodans 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èvementGreen 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
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
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!
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
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
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