1 points par GN⁺ 2023-10-08 | 1 commentaires | Partager sur WhatsApp
  • 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 yield doit être sûr entre threads, d’où la contrainte Send
  • 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 + 'static ou Send + Sync + 'static dé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 yield doit ê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
  • 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 Send dans 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

 
GN⁺ 2023-10-08
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 + 'static ne préoccupent pas continueront à l’utiliser

    • Dans ce cas, à moins d’avoir réellement besoin d’une échelle C10M, pourquoi ne pas simplement utiliser un thread par connexion et éviter quelque chose de plus complexe ?
      J’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
    • Je trouve le débat async/await contre coroutines avec pile plus intéressant, mais ce n’est pas le débat de cet article
      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_...
    • Il y a plusieurs sujets dans le monde sur lesquels les gens ne sont pas d’accord
      Cet article traite simplement d’un autre débat, pas de celui que j’aurais aimé le voir traiter
    • On peut utiliser async/await avec des canaux, en se limitant à ne passer comme arguments aux fonctions async que des références immuables ou des types copiables, et en faisant communiquer les types partagés mutables via des canaux
      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 Arc
      Rust donne la puissance nécessaire pour faire les deux
    • D’après ce que je comprends, rien n’empêche d’utiliser des canaux au-dessus du support async existant de Rust
      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

    • La principale raison pour laquelle les frameworks async Rust utilisent le vol de tâches semble être qu’il est facile à activer au niveau du framework et qu’il améliore les performances de nombreuses applications, en particulier celles qui ne sont pas idéalement conçues
      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 curieux de savoir ce que recouvre l’idée de push de données
  • 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 Arc et Mutex
    À 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

    • J’ai écrit ce genre de service, mais je ne l’aurais pas appelé centré sur l’I/O
      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
    • Exact. Tout ce qui veut de la concurrence n’est pas un serveur web
      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
    • tokio prend en charge un exécuteur monothread quand on en a vraiment besoin, et ce n’est pas difficile
      Dans l’API tokio, cela s’appelle LocalSet
      https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
    • J’allais justement écrire à propos de la même citation
      Même si un seul thread sur un seul CPU suffit, on peut quand même vouloir de la concurrence
    • Au lieu de Arc et Mutex, on finira probablement par utiliser Rc et RefCell, et côté code cela me semble presque aussi complexe et verbeux
      Je 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 pire Send + Sync + 'static, tue le plaisir d’utiliser Rust » est beaucoup trop mélodramatique
    Je ne pense pas que supprimer Send + Sync ferait une si grande différence
    Ce qui est le plus pénible, c’est 'static, et ce n’est pas à cause du vol de travail
    Ce 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 bt
    J’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

    • Java reçoit beaucoup de critiques, mais sur ce point, cela fonctionne tout simplement bien depuis environ 25 ans, et c’est incroyablement utile pour résoudre les problèmes
      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/uniq
    • Pour atténuer ce genre de problème, dans mon entreprise nous utilisons une bibliothèque qui journalise périodiquement les tâches en cours d’exécution et le fichier/la ligne où se trouve actuellement chaque tâche
      Il faut ajouter manuellement r.set_location(file!(), line!()); avant chaque point await, 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(), et task.rs contient list_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.

    • Je n’ai pas vraiment vu de problèmes où async affecterait d’autres parties de Rust.
      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é.
    • Je suis d’accord sur l’afflux de développeurs web.
      Ce serait dommage si, en conséquence, l’utilité de Rust pour la programmation système était dégradée.
    • Je pensais au contraire que les applis web étaient idéales pour le thread par cœur.
      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 pense que cela arrive parce que la communauté Rust a assez activement attiré ce type de développeurs afin de gagner du marché et de la visibilité.
      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 Send pour 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 Send uniquement 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 'static et de contraintes Send/Sync parlent largement aux développeurs, et je n’ai pas eu l’impression qu’ils étaient idiots.

    • C’est un renvoi à ce qui a été dit plus haut.
      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 « difficile » est entre guillemets, c’est parce que ce n’est pas nécessairement plus difficile.
      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.
    • Personnellement, il m’arrive aussi clairement d’avoir du mal à comprendre si mon état est Send ou 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 async est 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.

    • Une raison possible est que, si les performances du thread par cœur suffisent, il y a de fortes chances que vous ne vouliez pas utiliser Rust au départ.
      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”.

    • Imaginons qu’on écrive à peu près ce genre de code.
      Il y a un Server, et dans serve, on lit des messages puis on lance chaque handler de message dans une nouvelle tâche avec spawn(async move { ... }).
      Au début, tout fonctionne bien.
      Puis, un jour, on modifie l’implémentation de do_this de sorte que le type de this ne soit plus Send, et spawn(...) produit une erreur de compilation assez hostile indiquant que le type créé par le scope anonyme async move { } n’est pas Send.
      La raison n’est pas forcément évidente, et le message d’erreur n’aide pas beaucoup.
      Si this n’est pas Send, il ne peut pas être conservé à travers le .await de do_that(arg).await.
      Chaque .await est 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.
    • Globalement, le compilateur détecte si quelque chose est Send et Sync.
      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 Send et Sync un 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 Send dans certaines situations, mais pas toutes.
      Et à long terme, cela peut vous revenir en pleine figure sur le plan architectural.
    • Le code ne compile pas si tout ce qui doit l’être n’est pas 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 Arc ou des Mutex.
    • Je pense que l’argument contre le choix du thread-per-core comme valeur par défaut peut être formulé simplement.
      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.