Un thread par cœur (thread-per-core)
(without.boats)- Le débat sur les runtimes async Rust porte moins sur la question d’un thread OS par cœur que sur le choix entre work-stealing pour équilibrer la charge et une approche share-nothing pour réduire les déplacements de données
- Rust async remplit déjà les conditions essentielles du thread-per-core — concurrence en espace utilisateur et E/S asynchrones — ; la vraie question restante est donc de savoir dans quelle mesure on peut éviter le partage d’état entre les cœurs
- Le work-stealing cherche à réduire le temps d’inactivité des threads afin d’améliorer la latence de queue (tail latency) et l’utilisation CPU, mais en Rust l’état qui traverse un point de
yielddoit être sûr entre threads, d’où la contrainteSend - Le share-nothing peut réduire la latence en gardant les données dans le cache d’un cœur donné, mais la difficulté varie énormément entre un magasin clé-valeur, où le partitionnement d’état est simple, et des cas nécessitant des transactions ou des modifications atomiques
- L’article d’Enberg compare share-nothing et shared-state, mais comme aucun des deux n’utilise le work-stealing, il est difficile de relier directement ses résultats à une critique des exécuteurs Rust fondés sur le work-stealing
Le vrai cœur du débat sur les runtimes async Rust
- Dans la communauté Rust, le débat continue autour du fait que les principaux runtimes async comme tokio utilisent par défaut un exécuteur multithread, avec équilibrage dynamique de nombreuses tâches via le work-stealing
- Certains utilisateurs critiquent ce choix par défaut, estimant que les contraintes
Send + 'staticouSend + Sync + 'staticdégradent l’expérience d’écriture du code - Certains préfèrent aussi les serveurs monothread, mais ici on part du principe qu’un système Rust cherche à utiliser plus d’un cœur CPU
- L’alternative souvent proposée, le « thread-per-core », est réputée à la fois plus rapide et plus simple à implémenter, mais il est difficile d’obtenir simultanément ces deux avantages
La confusion créée par le nom « thread-per-core »
- Les exécuteurs async multithread existants relèvent eux aussi, au sens large, du thread-per-core
- ils créent un thread OS par cœur
- puis planifient sur ces threads un nombre de tâches bien supérieur au nombre de cœurs
- Pekka Enberg résume le thread-per-core comme la combinaison de trois idées
- gérer la concurrence en espace utilisateur plutôt qu’avec des threads noyau coûteux
- utiliser des E/S asynchrones pour éviter que le thread associé à un cœur ne se bloque
- partitionner les données entre les cœurs CPU afin d’éliminer les coûts de synchronisation et les déplacements de données entre caches CPU
- Pour Enberg, les deux premiers points sont importants pour construire des systèmes à haut débit, tandis que le troisième peut n’être nécessaire que sur de très grosses machines multicoeurs
- Si l’on utilise Rust async, les deux premières conditions — concurrence en espace utilisateur et E/S asynchrones — sont déjà remplies
- Le centre du débat n’est donc pas tant le thread-per-core lui-même que le choix d’optimisation entre work-stealing et share-nothing
L’objectif et le coût du work-stealing
- Le work-stealing est une optimisation visant à faire en sorte que tous les threads aient toujours quelque chose à exécuter, afin de réduire la latence de queue
- Dans un système réel, la quantité de travail nécessaire varie selon les tâches
- une requête HTTP peut demander beaucoup plus de travail qu’une autre
- même si la charge initiale est répartie uniformément entre les threads, les différences imprévisibles entre tâches peuvent faire diverger cette répartition avec le temps
- À charge maximale, certains threads peuvent recevoir plus de travail qu’ils ne peuvent en traiter, tandis que d’autres restent inactifs
- tokio, async-std et smol implémentent tous le work-stealing pour réduire la latence de queue et améliorer l’utilisation CPU
- Son coût tient au fait qu’une tâche peut s’arrêter sur un thread puis reprendre sur un autre
- l’état utilisé au-delà d’un point de
yielddoit être sûr entre threads - dans l’API Rust, cela se traduit par l’exigence que le future soit
Send - si l’état du système n’est pas clairement maîtrisé, il devient difficile de décider comment garantir
Send
- l’état utilisé au-delà d’un point de
- Quand l’état migre d’un thread à l’autre, cela entraîne des coûts de synchronisation et des défauts de cache, ce qui entre en conflit avec le principe share-nothing selon lequel chaque CPU ne manipule que son propre état
La logique de performance du share-nothing
- Le share-nothing est une approche qui vise à réduire la latence de queue en gardant les données dans le cache rapide d’un cœur CPU donné, plutôt que dans un cache plus lent partagé par plusieurs cœurs
- L’article d’Enberg, The Impact of Thread-Per-Core Architecture on Application Tail Latency, montre via des benchmarks d’un magasin clé-valeur share-nothing et d’une architecture memcached à état partagé une amélioration de la latence de queue
- Dans la communauté Rust, la façon dont cet article est parfois cité de manière simpliste comme « 71 % de gain de performance » reste superficielle et peu utile
- Le magasin clé-valeur d’Enberg partitionne l’état et les connexions pour obtenir une architecture share-nothing
- il partitionne le keyspace entre plusieurs threads via une fonction de hachage
- il répartit les connexions TCP entrantes entre les threads avec
SO_REUSEPORT - il route les requêtes du thread qui gère la connexion vers le thread responsable du keyspace concerné via un canal de passage de messages
- memcached, lui, partage entre tous les threads la propriété d’un keyspace partitionné, chaque partition étant protégée par un mutex
- Les résultats d’Enberg montrent qu’une architecture fondée sur des canaux peut obtenir une latence de queue plus faible qu’une architecture fondée sur des mutex
- on peut l’expliquer par le fait que les accès répétés à une même partition restent dans le cache d’un même cœur, ce qui réduit les défauts de cache
- Cela dit, il est difficile de considérer qu’une conception évitant les déplacements de données grâce à des fonctionnalités noyau avancées et une architecture soigneusement planifiée soit plus facile à implémenter qu’une simple encapsulation des données derrière des mutex
Les cas où le partitionnement d’état est simple, et ceux où il ne l’est pas
- Un magasin clé-valeur est un bon exemple de système adapté au share-nothing, car l’état applicatif s’y répartit facilement entre plusieurs threads
- Dans des applications plus complexes, s’il faut modifier l’état de plusieurs partitions de manière transactionnelle ou atomique, l’implémentation correcte demande bien davantage de précautions
- Le plaidoyer en faveur du share-nothing rappelle par certains aspects l’emballement passé autour des bases de données à cohérence éventuelle
- les performances peuvent s’améliorer
- mais éviter les bugs dus aux incohérences de données exige alors une conception très rigoureuse
La distance entre l’article d’Enberg et le débat Rust sur le work-stealing
- Ni l’implémentation d’Enberg ni memcached n’utilisent le work-stealing
- Pour cette raison, il est difficile de relier directement les principaux résultats de performance de l’article à l’architecture Rust fondée sur le work-stealing
- Ajouter du work-stealing à l’architecture d’Enberg augmenterait sans doute un peu les déplacements de données, mais pourrait aussi améliorer l’utilisation CPU
- Dans le cas de memcached, il est difficile d’imaginer pourquoi l’ajout de work-stealing n’aiderait pas
- L’implémentation de l’article est conçue pour répartir le travail de manière uniforme à l’avance grâce à un partitionnement équilibré du keyspace et à
SO_REUSEPORT - En conditions réelles, des déséquilibres dynamiques peuvent apparaître
- une hot key peut recevoir davantage de lectures et d’écritures, augmentant la charge du thread qui gère ce keyspace
- certaines connexions peuvent traiter plus de requêtes que d’autres, augmentant la charge du thread qui les gère
- On comprend que le benchmark de l’article ne reproduit pas ces situations de déséquilibre, puisque chaque connexion y exécute une charge constante sur des clés aléatoires
- Même dans un système share-nothing, il est possible de concevoir des mécanismes qui atténuent ces déséquilibres, par exemple en mettant en cache une hot key dans des partitions supplémentaires
- On peut aussi fixer certaines tâches à un cœur précis pour éviter les déplacements d’état, tout en utilisant d’autres formes de work-stealing comme optimisation
Conclusion pratique
- Si l’on conçoit soigneusement un système pour éviter les déplacements de données entre caches CPU, on peut obtenir de meilleures performances qu’en ne le faisant pas
- Mais si le principal grief consiste surtout à devoir ajouter des contraintes
Senddans des generics, il est difficile de dire qu’on est réellement en train de faire ce type d’ingénierie de performance fine - Et si le système doit de toute façon utiliser un état partagé, il est difficile de soutenir qu’en situation de charge le work-stealing n’améliore pas l’utilisation CPU
1 commentaires
Avis sur Hacker News
Personnellement, j’ai l’impression que cet article passe à côté de la vue d’ensemble et s’attarde sur les détails
Le cœur du débat n’est pas l’exécuteur avec vol de tâches et un thread par cœur, mais la question de savoir si async/await est une bonne abstraction pour cela en Rust
Plus j’écris de code async, plus j’ai l’impression que l’abstraction fuit et qu’il est difficile de programmer contre elle
Le modèle de concurrence alternatif que les gens veulent, ce sont des coroutines avec pile au-dessus d’un exécuteur avec vol de tâches, et de la concurrence structurée via des canaux
Tant que quelqu’un n’aura pas implémenté cela et comparé avec async/await et le modèle basé sur les futures, il sera difficile d’avoir une discussion productive ; ceux qui n’aiment pas async l’éviteront, et ceux que
Send + Sync + 'staticne préoccupent pas continueront à l’utiliserJ’ai l’impression que les cas où une telle échelle est nécessaire sont très rares dans la plupart des situations
Ce genre d’articles se contente de dire « les threads du noyau coûtent cher » et passe dessus comme si c’était intrinsèquement vrai, alors qu’en réalité ce n’est pas le cas
Si le travail ne consiste pas seulement à créer en boucle des tâches qui ne font rien, le surcoût des « vrais threads » a de bonnes chances d’être faible, tandis que la simplicité gagnée est énorme
La citation traitée par withoutboats est précisément la partie de l’article lié qui critique le multithreading par défaut et le vol de tâches
[1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
Cet article traite simplement d’un autre débat, pas de celui que j’aurais aimé le voir traiter
On peut aussi créer des serveurs à la Erlang qui possèdent les types mutables et communiquent par canaux, ou bien aller jusqu’au bout avec
ArcRust donne la puissance nécessaire pour faire les deux
Quant aux coroutines avec pile, à ce stade on pourrait tout aussi bien utiliser des threads séparés, donc cela ne me semble pas très utile
Il y a environ 15 ans, le problème initial que le modèle un thread par cœur cherchait à résoudre était d’obtenir scalabilité et efficacité de calcul sur des serveurs multicœurs généralistes
Contrairement à certaines affirmations, le thread par cœur visait explicitement à optimiser les charges de travail centrées sur le CPU ; même s’il a ensuite fallu un traitement I/O plus sophistiqué, il s’est avéré excellent aussi pour les charges de travail orientées I/O à haut débit
En lisant ce genre d’article, j’ai l’impression de voir se répéter rapidement plusieurs erreurs de conception logicielle commises lors de l’introduction des architectures un thread par cœur
Pour être juste, l’informatique théorique autour du thread par cœur vient surtout du HPC et est mal documentée
Cet article se concentre sur un problème délicat des architectures un thread par cœur : l’équilibrage du travail entre cœurs
Le modèle de base comprend quatre variantes : push/pull des données et push/pull de la charge ; le vol de tâches est essentiellement un modèle de pull de charge (load pull)
Cette approche n’a un faible surcoût que lorsqu’on n’en a quasiment pas besoin, c’est-à-dire lorsqu’il existe un équilibre naturel, ce qui est rare dans les vrais problèmes
Dans les charges de travail plus intéressantes, où les déséquilibres dynamiques de charge entre cœurs sont fréquents, le surcoût de coordination fait du vol de tâches un goulot d’étranglement de performance
Cela reste facile à comprendre, donc encore utilisé pour les charges de travail qui s’y prêtent, mais cela se généralise mal
Parmi les charges de travail rares que l’article ne mentionne pas, il existe sans doute des cas où c’est la meilleure solution
Le modèle qui semble gagner le plus de terrain aujourd’hui est le push de données (data push) : moins intuitif, mais nécessitant beaucoup moins de coordination entre threads
Ce modèle ne convient pas non plus à toutes les charges de travail, mais il se généralise bien à la plupart des charges courantes
Les architectures un thread par cœur resteront là. En matière de scalabilité et d’efficacité, on ne peut pas les battre
Le problème est que la plupart des ingénieurs logiciels manquent d’intuition sur ce à quoi ressemble une conception moderne et idiomatique en thread par cœur, et cela est aggravé par le faible nombre d’articles ou de publications traitant le sujet en profondeur
Vu la bio du profil et cette explication, j’imagine que ce ne sont pas les types d’applications que vous traitez principalement
J’aimerais voir des liens vers la littérature pertinente s’il y en a
Je suis d’accord avec la phrase : « Dire que c’est centré sur l’I/O signifie qu’en pratique, écrit en Rust, il n’y a pas assez de travail pour saturer un seul cœur. Dans ce cas, écrivez évidemment un système monothread »
Une bonne partie des applications que j’écris sont des démons qui réagissent à des événements en arrière-plan, et les faire en monothread permet d’éliminer le surcoût de
ArcetMutexÀ ce stade, ce surcoût est surtout une charge syntaxique, mais cela facilite le débogage et la maintenance
Le fait de ne payer que pour ce dont on a besoin est l’un des aspects de Rust que j’apprécie
L’article original auquel celui-ci répond reproche à tokio et à d’autres bibliothèques async de rendre difficile le retour à une architecture monothread simple
C’est un peu exagéré, mais je suis globalement d’accord avec cette critique
Rendre les valeurs par défaut plus complexes au motif que c’est meilleur pour les applications à haut débit me semble contraire aux idéaux de Rust
Ce n’est pas lié au débit : il reste surtout inactif, puis quand du travail arrive, il essaie de le terminer rapidement pour réduire l’utilisation des ressources système
Sauf s’il y a occasionnellement d’énormes pics de travail et que la latence y est alors très importante, utiliser plus d’un thread n’ajoute que de la complexité et du surcoût, sans bénéfice
Dans un système d’exploitation, tous les services système doivent traiter des requêtes IPC simultanément, mais la plupart le font en monothread afin de réduire la consommation CPU totale
Sur un appareil à 4 cœurs, faire des dizaines de services avec un thread par cœur gaspille du CPU et de la RAM
Dans l’API tokio, cela s’appelle
LocalSethttps://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
Même si un seul thread sur un seul CPU suffit, on peut quand même vouloir de la concurrence
ArcetMutex, on finira probablement par utiliserRcetRefCell, et côté code cela me semble presque aussi complexe et verbeuxJe comprends que ce soit moins efficace, mais dans le cas décrit, le coût supplémentaire de quelques opérations atomiques ne serait-il pas de toute façon négligeable ?
À propos de la citation, je suis d’accord pour dire que la formule « le péché originel de la programmation async en Rust est d’avoir fait du multithread la valeur par défaut…
Send + 'static, voire pireSend + Sync + 'static, tue le plaisir d’utiliser Rust » est beaucoup trop mélodramatiqueJe ne pense pas que supprimer
Send + Syncferait une si grande différenceCe qui est le plus pénible, c’est
'static, et ce n’est pas à cause du vol de travailCe que l’on veut, c’est de la concurrence avec portée lexicale
Par exemple quelque chose comme <https://github.com/tokio-rs/tokio/issues/2596>
Une autre chose que je déteste vraiment dans l’async Rust aujourd’hui, c’est le manque d’instrumentation
Au travail, nous avons un problème en production où certaines tâches se bloquent tout simplement, et j’aimerais pouvoir faire l’équivalent de
gdb; thread apply all btJ’espère au moins que <https://github.com/tokio-rs/tokio/issues/5638> sera intégré
Cela existe déjà à titre expérimental, mais d’après mon expérience, cela panique parfois
Aujourd’hui, je vais effectivement écrire une PR pour essayer d’utiliser la version expérimentale lors d’un SIGTERM
Comme on est de toute façon en train de s’arrêter, j’estime que ce n’est pas grave si ça crashe
Aucune de ces plaintes ne serait résolue en supprimant le vol de travail
Et même en continuant la liste, supprimer le vol de travail n’aiderait presque à rien
Pas besoin de débogueur : envoyez SIGQUIT à la JVM, et elle vide sur stderr les traces de pile de tous les threads, puis continue à s’exécuter
Cela inclut aussi les verrous que chaque thread détient ou attend
Cette fonctionnalité me manque chaque fois que j’utilise un autre langage
On peut aussi s’en servir pour du profilage ponctuel en production
Il suffit de prendre plusieurs instantanés, puis de trouver les points chauds avec
grep/sed/sort/uniqIl faut ajouter manuellement
r.set_location(file!(), line!());avant chaque pointawait, mais cela nous a aidés plusieurs fois à expliquer pourquoi le système semblait bloqué[1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t... contient
set_location(), ettask.rscontientlist_tasks()Il n’y a pas de bonne réponse à ce problème : tout dépend du cas d’usage.
Au final, c’est une question de charges de travail centrées sur l’I/O ou sur le CPU, et de l’ampleur des effets négatifs de choses comme l’éviction du cache ou la contention sur les verrous.
Si vous déployez sur un serveur virtuel partagé un serveur HTTP qui communique avec une base de données externe et exécute au milieu une légère logique métier, le vol de travail et la réutilisation des threads semblent intuitivement pertinents.
Bien sûr, il faut toujours benchmarker.
À l’inverse, si vous construisez une base de données ou un système similaire, et que sous charge une forte concurrence et de nombreux changements de contexte provoquent partout de l’éviction de cache et de la contention, vous allez souffrir.
Dans ce cas, le thread par cœur est très pertinent, et le framework async lui-même peut même ne pas avoir de sens.
Il n’existe pas de réponse dogmatique à ce qui est « meilleur ».
Il faut profiler l’application.
Comme je l’ai déjà dit, j’ai l’impression que l’orientation globale de Rust est déformée par l’arrivée massive du développement de services web.
Je ne suis pas encore convaincu que Rust soit le bon langage pour ce type de travail, mais il semble plutôt bien fonctionner pour eux, donc soit.
Cela dit, les discussions publiques autour du langage et les crates mises en avant reflètent aujourd’hui largement ce biais.
C’est aussi le biais d’une grande partie des ingénieurs logiciel de ce forum.
Des applications système comme des moteurs de jeu, des bibliothèques de chiffrement, des noyaux, des outils en ligne de commande ou des compilateurs sont construites avec succès sans toucher à async.
Je maintiens une grosse bibliothèque de chiffrement, et le travail autour d’async ne m’a absolument pas affecté.
Ce serait dommage si, en conséquence, l’utilité de Rust pour la programmation système était dégradée.
L’application elle-même a très peu d’état en dehors des requêtes, et les écouteurs de sockets ainsi que les connexions à la base de données peuvent être répartis par thread.
L’état restant a de fortes chances d’être en grande partie statique entre les requêtes, donc de ne pas entraîner souvent d’invalidation de cache.
Comme il n’y a pas d’état partagé, la gestion de la propriété devrait aussi être plus simple.
Je ne dis pas que c’est bien ou mal, mais Rust doit maintenant vivre avec un flot sans fin de bibliothèques et frameworks web de qualité variable.
Et comme une bonne partie des bibliothèques et crates d’infrastructure centrales ont adopté une approche async d’abord, async restera un sujet de discussion.
On en est au point où il devient difficile, pour un développeur ordinaire, d’écrire du code synchrone banal pour résoudre un problème métier, à moins de déclarer explicitement que le projet n’utilisera pas async comme orientation principale.
Je considère que le fait d’exiger une borne
Sendpour permettre le déplacement de tâches entre les threads de l’exécuteur est un défaut clair du système async de Rust lui-même.Avec le problème fondamental de l’async Drop, cela empêche d’implémenter des API à portée délimitée.
Comme avec les threads, il devrait suffire d’avoir une borne
Senduniquement sur les fonctions comme spawn ou l’envoi de données via des canaux.L’approche sans partage n’est généralement rien de plus qu’un contournement pour masquer ce défaut.
Épingler sélectivement des tâches à un thread/cœur donné a des avantages et peut être réellement utile dans certains cas, mais c’est une discussion plus fine et cela n’a pas grand-chose à voir avec les plaintes des utilisateurs d’async liées à
Send.Bon article, et je recommande de lire au-delà du titre.
La phrase que j’ai préférée était : « si la plus grosse plainte de quelqu’un est d’ajouter une borne
Sendà un générique, j’ai du mal à croire que cette personne fasse de l’ingénierie à ce niveau ».Édition : je suis entièrement d’accord avec le commentaire de « duped ».
Comme je ne connaissais pas le contexte plus large de cette discussion, j’ai peut-être cité cette phrase trop hâtivement.
Le passage disant que « les personnes qui ne voient pas correctement l’état du système peuvent avoir du mal à trouver le meilleur moyen de garantir qu’un future soit
Send» ne sonne-t-il pas un peu arrogant ?Les problèmes de durée de vie
'staticet de contraintesSend/Syncparlent largement aux développeurs, et je n’ai pas eu l’impression qu’ils étaient idiots.Les gens disent que ne pas faire de vol de travail est plus simple et plus rapide.
Mon argument est que c’est l’un ou l’autre.
Pour qu’une approche sans partage soit plus rapide, il faut concevoir le code d’une manière qui n’est pas plus simple que de rendre une architecture à état partagé sûre vis-à-vis des threads.
Le paragraphe suivant contient une phrase parallèle répondant à « lent ».
Je ne pense pas que les personnes qui ont du mal à faire compiler du Rust parallèle et concurrent soient idiotes.
Je n’aime simplement pas qu’on fasse comme si l’API que nous avons créée leur gâchait la vie.
Si c’est quelque chose qu’il faut faire de toute façon, ce n’est pas plus difficile.
Ce n’est pas tant que les gens surréagissent à une tâche triviale, mais plutôt que async force à résoudre plus tôt un problème qu’il faudra résoudre un jour.
C’est assez comparable au vérificateur d’emprunts.
Parfois, il est excessivement restrictif, mais parfois, quand on suppose que tout va bien, il y avait en réalité un cas limite qu’on n’avait pas pris en compte.
Sendou non.C’est pourquoi la phrase citée me parle.
C’est un article qui s’attache aux détails en passant à côté de la vue d’ensemble.
Il n’existe pas de méthode toujours correcte pour obtenir les meilleures performances dans tous les programmes.
On peut débattre autant qu’on veut, mais les avantages et inconvénients du thread par cœur relèvent typiquement du « ça dépend ».
Le problème, c’est qu’à la base, l’utilisation même de
asyncest une optimisation prématurée.99 % des programmes Rust ne sont pas redis ou linkerd.
Ce sont des outils CLI ou des applis web qui seraient suffisamment rapides même écrits en Python ou Ruby.
Je me demande donc pourquoi la communauté a abandonné l’I/O bloquante en Rust, pourquoi tout est devenu async, et pourquoi les développeurs collent
#[tokio::main]partout comme si c’était la valeur par défaut.Il existe des langages qui offrent une meilleure expérience de programmation en échange de moins de vitesse, et Python en est un exemple.
Si vous voulez utiliser Rust, c’est probablement que vous avez besoin de performances supplémentaires ; et puisque vous avez déjà accepté un langage moins confortable, vous pouvez aussi accepter un style moins confortable pour obtenir de meilleures performances.
Je n’ai jamais utilisé Rust, mais je comprends la frustration.
Si, dans une situation où ce n’est même pas nécessaire et où il y a largement de la marge CPU, il faut écrire le code d’une manière particulière pour que l’état puisse être déplacé entre threads, simplement afin de redistribuer une charge qui peut en fait augmenter la latence de bout en bout d’une requête isolée, ça doit être agaçant.
Sur une plateforme où l’état déplaçable est la valeur par défaut et ne casse presque jamais, cette approche pourrait avoir du sens, mais cela ne semble pas être le cas avec Rust.
Ce qui m’intéresse, c’est l’expérience d’utilisation.
Je me demande si c’est plutôt “le code ne compile pas tant qu’on n’ajoute pas l’incantation magique
Send”, ou plutôt “l’état se corrompt pendant le work stealing, et ça échoue de façon intermittente et difficile à déboguer”.Il y a un
Server, et dansserve, on lit des messages puis on lance chaque handler de message dans une nouvelle tâche avecspawn(async move { ... }).Au début, tout fonctionne bien.
Puis, un jour, on modifie l’implémentation de
do_thisde sorte que le type dethisne soit plusSend, etspawn(...)produit une erreur de compilation assez hostile indiquant que le type créé par le scope anonymeasync move { }n’est pasSend.La raison n’est pas forcément évidente, et le message d’erreur n’aide pas beaucoup.
Si
thisn’est pasSend, il ne peut pas être conservé à travers le.awaitdedo_that(arg).await.Chaque
.awaitest en effet un point d’exécution où la future peut céder la main et être planifiée par l’exécuteur sur un autre thread.Si on peut rendre le type
Send, tout va bien.Mais il y a largement des cas où ce n’est pas possible, et il faut alors changer la planification de la future pour quelque chose comme
spawn_local.Pour l’appeler, il peut falloir ajouter pas mal de boilerplate.
C’est ça, le problème de
Send.Il ne s’agit pas simplement d’ajouter une annotation de type : comme il n’est pas toujours évident de savoir si un type implémente
Send, cela s’infiltre subtilement dans le code et peut casser plus tard d’une manière peu claire.SendetSync.Si vous écrivez votre programme de manière thread-safe, il n’y a pas de problème.
Le point central est là.
Les gens se plaignent que l’async de Tokio est difficile parce qu’il exige
SendetSyncun peu partout, mais en réalité, écrire du code concurrent sûr, quel qu’il soit, est difficile en soi.Ce n’est pas intuitif, et le problème est que l’async donne l’impression de le “gérer” automatiquement.
Mais ce n’est pas le cas.
Il faut savoir ce qu’on fait, et le compilateur ne fait que vous aider.
Avec un modèle thread-per-core, on peut masquer le besoin de
Senddans certaines situations, mais pas toutes.Et à long terme, cela peut vous revenir en pleine figure sur le plan architectural.
Send/Sync.Je peux me tromper, mais la méthode paresseuse pour y parvenir consiste généralement à envelopper ce qui peut être partagé dans des
Arcou desMutex.Si c’est centré sur le CPU, le work stealing sera meilleur dans la plupart des cas.
Si c’est centré sur l’I/O, le thread-per-core peut mieux fonctionner, mais de toute façon il y a suffisamment de marge CPU, donc les performances n’ont pas une grande importance.
À mon avis, le work stealing est une meilleure valeur par défaut à intégrer dans l’API du langage.