3 points par GN⁺ 2024-07-05 | 1 commentaires | Partager sur WhatsApp
  • La bibliothèque de connexion Rust de Firezone, connlib, gère les connexions réseau et les tunnels WireGuard, et obtient des tests rapides ainsi qu’une grande fiabilité de fonctionnement grâce à une conception sans IO
  • Les protocoles ne manipulent pas directement les sockets : ils sont implémentés comme de pures machines à états, tandis qu’une boucle d’événements appelle des API comme handle_input, poll_transmit, handle_timeout et poll_timeout
  • Déplacer le choix des IO hors de la machine à états réduit la charge de function colouring dans Rust async, et laisse à l’application le choix entre IO bloquantes, IO non bloquantes ou un runtime async particulier
  • En abstraisant les sockets et le temps, on peut vérifier l’écoulement du temps, la perte de paquets et les réponses anormales uniquement avec Instant et Transmit, sans ports réels ni attente
  • En contrepartie, il faut gérer soi-même la boucle d’événements, ce qui peut introduire des bugs subtils ; les workflows séquentiels augmentent le code de machine à états ; et les bibliothèques sans IO restent encore limitées dans l’écosystème Rust

L’architecture sans IO choisie par connlib de Firezone

  • Firezone utilise Rust pour créer un accès distant sécurisé et scalable sur Android, macOS et Linux
  • Au cœur de chaque application se trouve connlib, une bibliothèque qui gère les connexions réseau et les tunnels WireGuard afin de protéger le trafic
  • La stack Rust de Firezone utilise tokio, tungstenite, boringtun, rustls, entre autres, mais sa structure interne diffère du code Rust async habituel
    • Très peu d’appels à tokio::spawn
    • Toutes les communications sont multiplexées sur une seule socket UDP
    • Des API comme handle_timeout, poll_transmit et handle_input reviennent à plusieurs couches
  • Ces caractéristiques signalent une conception sans IO, où la logique de protocole n’effectue pas directement d’IO, mais exprime l’état et les intentions d’entrée-sortie
  • L’écosystème Python dispose d’un site de documentation dédié au sans IO, et en Rust les bibliothèques suivantes utilisent ce motif
    • quinn : implémentation QUIC indépendante
    • quiche : implémentation QUIC de Cloudflare
    • str0m : implémentation WebRTC sans IO

Rust async et le coût du function colouring

  • Les fonctions async de Rust ne peuvent être appelées qu’à l’intérieur d’autres fonctions async, ce qui crée une contrainte de function colouring où toute la chaîne d’appels devient async
  • Cette contrainte impose à la compilation que la possibilité d’interrompre l’exécution puis de la reprendre plus tard fasse partie du contrat d’API de la fonction
  • À cause d’une seule fonction async profondément enfouie dans la stack, d’autres fonctions du chemin d’appel peuvent devoir devenir async pour utiliser .await
  • Les vraies opérations async ont généralement lieu tout en bas de la pile d’appels
    • Écriture dans une socket
    • Lecture d’un fichier
    • Attente de l’écoulement du temps
  • Beaucoup de fonctions async ne font pas directement de travail asynchrone : elles deviennent async parce qu’elles dépendent d’autres fonctions async
  • connlib de Firezone utilise ICE pour la traversée NAT, et STUN pour découvrir des candidates server-reflexive, c’est-à-dire des adresses publiques
  • Un binding STUN est un protocole simple : il envoie un paquet UDP à un serveur et reçoit une réponse UDP contenant l’IP et le port vus par ce serveur
  • Le même exemple STUN peut être écrit presque de la même manière avec l’UdpSocket async de tokio ou avec les IO bloquantes de la bibliothèque standard
  • Pour fournir une fonctionnalité STUN sous forme de bibliothèque, il faut choisir entre une version async et une version bloquante, ou inclure les deux, ce qui crée de la duplication
  • Le code d’exemple se trouve dans firezone/sans-io-blog-example

Le cœur du sans IO : séparer la politique de l’implémentation des IO

  • Le cœur du sans IO ressemble au principe d’inversion des dépendances en programmation orientée objet
  • Le code de politique qui décide « quoi faire » ne doit pas dépendre des détails d’implémentation qui exécutent « comment le faire »
  • Si le code qui décide d’envoyer un message réseau dépend directement du code d’envoi sur une socket réelle, le code de plus haut niveau se retrouve lui aussi lié au choix entre async et IO bloquantes
  • Dans l’exemple STUN, le code de politique est identique, mais composé au-dessus de tokio::UdpSocket il devient async, et composé au-dessus de std::net::UdpSocket il devient bloquant
  • Le sans IO ne fait pas appel directement à UdpSocket::send ; il crée à la place une abstraction qui représente l’intention de transmettre
  • Dans l’exemple, Transmit contient les informations suivantes
    • La destination SocketAddr
    • Le payload à transmettre
  • Le code de protocole n’écrit pas directement dans la socket ; il émet un Transmit
  • L’appel réel à UdpSocket::send ou send_to est pris en charge par la boucle d’événements
  • Le code sans IO doit être piloté par la boucle d’événements, de la même manière qu’un Future Rust ne progresse que lorsqu’il est pollé par le runtime

Transformer un binding STUN en machine à états

  • Une requête de binding STUN peut être modélisée comme une machine à états avec les états Sent et Received
  • Dans l’exemple, l’état est représenté par l’enum suivante
    • Sent
    • Received { address: SocketAddr }
  • StunBinding possède l’état courant et une file de Transmit en attente d’envoi
  • Les principales API ont des rôles clairs
    • handle_input : transmet à la machine à états les paquets reçus comme résultat de UdpSocket::recv
    • poll_transmit : permet à la boucle d’événements de récupérer les Transmit que la machine à états veut émettre
    • public_address : consulte l’adresse publique reçue
  • Dans cette structure, la logique de protocole ne modélise que le comportement du programme, sans IO
  • La boucle d’événements envoie sur la socket les paquets à transmettre obtenus via poll_transmit, et s’il n’y en a pas, transmet les données lues depuis la socket à handle_input
  • La boucle d’événements fonctionne sans connaître le détail du fait que STUN est un protocole request-response
  • UDP étant un protocole non fiable, des paquets peuvent être perdus ; STUN requiert donc des timers de retransmission pour atténuer ce problème

Abstraire aussi le temps

  • Dans les protocoles réseau, on a le plus souvent besoin de l’heure courante pour vérifier combien de temps s’est écoulé depuis un instant de référence
    • Vérifier si 5 secondes se sont écoulées après l’envoi d’une requête
    • Vérifier si 30 secondes se sont écoulées depuis le dernier keep-alive
  • Dans ces cas, l’heure réelle de l’horloge murale n’est pas nécessaire ; seule la Duration par rapport à un instant antérieur l’est
  • Instant en Rust n’expose pas l’heure courante, mais permet de mesurer la Duration entre deux Instant
  • Une machine à états sans IO peut disposer de deux API pour les comportements basés sur le temps
    • poll_timeout : renvoie le moment auquel la boucle d’événements doit programmer le prochain timer de réveil
    • handle_timeout : informe la machine à états qu’un timer a expiré
  • L’exemple étend la machine à états pour envoyer une nouvelle binding request 5 secondes après la dernière réponse reçue
  • handle_input reçoit le paquet avec l’Instant courant et le stocke sous la forme State::Received { address, at }
  • La boucle d’événements traite à la fois la réception sur socket et l’expiration des timers, puis reprogramme le timer à partir du résultat de poll_timeout

Composition et flexibilité des API

  • Les principales API de StunBinding, handle_timeout, handle_input, poll_transmit et poll_timeout, ne sont pas propres à STUN
  • La plupart des protocoles réseau peuvent être implémentés sous cette forme, ou une variante, ce qui facilite la composition de machines à états
  • Pour interroger 5 serveurs STUN afin de trouver l’IP publique, on peut créer 5 StunBinding et les appeler dans l’ordre
    • Dans ce cas, le multiplexage des messages STUN doit être correctement implémenté, par exemple avec le TransactionId ou l’adresse du serveur
  • snownet de Firezone combine ICE et WireGuard pour fournir aux applications un tunnel IP fonctionnant dans divers environnements réseau
  • snownet est construit au-dessus de la bibliothèque WebRTC sans IO str0m et de l’implémentation WireGuard presque sans IO boringtun
  • Firezone n’a pas besoin de toute la stack WebRTC, seulement de l’IceAgent qui implémente la RFC 8445
  • Comme str0m est sans IO, il est facile de n’en extraire que l’IceAgent et de le composer avec les machines à états du code existant
  • Une connection de snownet contient un IceAgent et un tunnel WireGuard, et transmet les messages entrants à l’un ou à l’autre

Les avantages d’écrire soi-même la boucle d’événements

  • Le code sans IO ne fait que représenter l’état du système et ne produit pas d’effets de bord ; la boucle d’événements doit donc interroger l’état, l’exécuter et lui transmettre de nouvelles entrées
  • Cette structure peut ressembler à du boilerplate, mais elle permet d’écrire soi-même la boucle d’événements et donc d’obtenir un contrôle fin
  • L’application peut choisir directement des modes d’exécution comme les suivants
    • Réduire le nombre d’appels système lors de l’envoi de paquets avec sendmmsg
    • Multiplexer plusieurs protocoles sur une seule socket
  • Les auteurs de bibliothèques peuvent se concentrer sur l’implémentation des fonctionnalités de protocole plutôt que sur les débats autour des runtimes async ou la fourniture d’API d’options de socket
  • str0m considère l’énumération des interfaces réseau comme une préoccupation d’IO et la laisse à l’application
  • Il fournit seulement une API pour ajouter une adresse de socket comme candidate ICE à l’état courant
  • Firezone utilise cette structure pour précollecter les candidates TURN avant la création de la connexion, et ainsi optimiser la latence d’établissement de connexion
  • Dans ICE, les deux côtés collectent des candidates, c’est-à-dire des sockets, puis testent la connectivité entre elles

Tests rapides et vérification des cas limites

  • Le code sans IO est par nature dépourvu d’effets de bord, ce qui le rend bien adapté aux tests unitaires
  • Les sockets et le temps étant abstraits, les tests n’ont pas besoin d’ouvrir de vrais ports ni d’attendre réellement
  • Pour tester le comportement au bout de 5 minutes, il suffit de passer un Instant modifié à la fonction et de vérifier le changement d’état
  • Firezone fournit un exemple réel testant si snownet ferme une connection idle après 5 minutes
  • Le transfert de données ne passe pas non plus par de vraies sockets : on récupère le Transmit d’un côté et on le transmet au handle_input de la machine à états de l’autre côté
  • Firezone implémente une machine à états de référence qui décrit comment connlib doit se comporter
  • Cette machine à états de référence sert d’oracle pour les tests
  • Avec le state machine testing de proptest, des milliers de scénarios sont échantillonnés et exécutés de façon déterministe à chaque CI, en comparant la machine à états de référence à l’état réel de connlib
  • Sans IO, il devient aussi facile de tester les défaillances et comportements anormaux suivants
    • Le cas où un paquet est perdu et aucune réponse n’est reçue
    • Le cas où une réponse incorrecte est reçue
    • Le cas où le RTT jusqu’au serveur est très élevé
    • Le cas où aucune interface IPv6 fonctionnelle n’est disponible
    • Le cas où seules des interfaces IPv6 existent
  • Séparer l’implémentation du protocole des effets de bord d’IO réels fait de la détection et du traitement des erreurs une partie du traitement des entrées de la machine à états

Pourquoi Rust et le sans IO vont bien ensemble

  • Rust impose d’indiquer explicitement quel composant ou quelle fonction possède une valeur
  • Lorsqu’on lit depuis une UdpSocket, il faut fournir un &mut [u8] comme espace où recevoir les octets réels
  • Seul le propriétaire d’une valeur peut la rendre mutable ou passer temporairement une référence mutable à une autre fonction
  • Ce modèle explicite de propriété et de mutabilité est à la base de fonctionnalités Rust comme le borrow checker
  • Les API de machine à états d’une conception sans IO sont toutes des fonctions synchrones, qui ne bloquent pas en attendant des IO ou le temps
  • Une machine à états n’étant qu’une structure de données, il est facile d’exprimer les changements d’état avec &mut self et de s’appuyer sur le borrow checker pour assurer la soundness du code
  • À l’inverse, &mut peut sembler plus difficile à manipuler en Rust async
  • Les fonctions async de Rust sont compilées en structures de données qui implémentent Future
  • Pour spawner un Future sur un runtime comme tokio, cette structure de données doit être 'static, et ne peut donc pas contenir de références comme &mut
  • Pour modifier un état externe au Future, on utilise généralement l’un des éléments suivants
    • Un pointeur à comptage de références et un mutex, comme Arc<Mutex<T>>
    • Une approche actor, où plusieurs tasks sont spawnées et reliées par des channels
  • Les deux approches ont un coût à l’exécution
    • Les locks peuvent créer de la contention
    • L’envoi de messages via des channels impose des copies
  • Quand plusieurs tasks s’exécutent dans un runtime dans un ordre non déterministe, cela peut conduire à des race conditions et à des deadlocks
  • Le code de protocole sans IO ne spawn pas de tasks ; les changements d’état n’ont donc besoin que de &mut self
  • Sans tasks ni threads, il n’y a pas besoin de primitives de synchronisation comme Mutex, et en l’absence de channels, les données ont moins besoin d’être copiées
  • Firezone estime qu’après le passage au sans IO, le code est devenu plus facile à comprendre, car il y a moins à suivre : l’autre extrémité des channels, les channels fermés ou quel code verrouille un Mutex

Limites et champ d’application

  • Le sans IO n’est pas une solution universelle
  • Écrire soi-même la boucle d’événements donne un contrôle fort, mais peut au début introduire des bugs subtils difficiles à trouver
  • Par exemple, si la valeur retournée par poll_timeout par une machine à états ne progresse pas vers l’avant, la boucle d’événements peut tomber dans une busy loop
  • Les workflows séquentiels exigent davantage de code
  • Les fonctions async de Rust sont compilées en machines à états où chaque point .await est une transition vers un autre état, ce qui permet aux développeurs d’écrire facilement du code séquentiel avec des IO non bloquantes
  • En sans IO, ces étapes doivent être modélisées manuellement comme une machine à états
  • Un protocole request-response comme StunBinding n’est pas difficile, mais représenter un workflow séquentiel plus grand peut devenir fastidieux
  • La conception sans IO n’est pas encore très répandue dans la communauté Rust
  • La plupart des bibliothèques implémentent des IO bloquantes ou non bloquantes plutôt que du sans IO
  • boringtun appelle Instant::now en interne, ce qui laisse certaines parties non pures ; un ticket lié existe dans cloudflare/boringtun#391

Conclusion

  • Le code sans IO peut sembler déroutant au début, mais une fois qu’on s’y habitue, il s’accorde bien avec les outils de modélisation de machines à états de Rust
  • Sa structure, qui force à traiter les erreurs comme d’autres entrées, convient aussi bien à la façon d’écrire du code réseau
  • Il existe d’autres manières d’écrire du Rust async, et la structured concurrency se situe entre le sans IO et l’approche Rust async évoquée ici
  • Pour en savoir plus sur la structured concurrency, on peut consulter Let futures be futures de withoutboats

1 commentaires

 
GN⁺ 2024-07-05
Commentaires sur Hacker News
  • On présente ça comme une innovation et une avancée, mais en réalité, avant l’arrivée du support du langage pour async/await, c’est ainsi qu’on gérait l’asynchrone en Rust comme en $lang
    Le plus grand gain de productivité dans le développement de firmware embarqué en Rust a été de pouvoir arrêter d’implémenter soi-même une machine à états entre chaque opération d’E/S, et de déplacer les variables locales dans un état personnalisé, pour laisser Rust s’en charger via la syntaxe async/await
    En Rust, async se résout au final en une machine à états automatique qui stocke les valeurs entre les points d’E/S (await)

    • J’essayais d’aborder ce point vers la fin de l’article. Si ce qu’on implémente consiste principalement en des opérations d’E/S séquentielles, ce modèle devient assez pénible
      Mais ce n’est pas toujours le cas. Pour des usages centrés sur les paquets comme QUIC, WebRTC ou IP, les E/S elles-mêmes sont simples. Il suffit d’envoyer et de recevoir des paquets/datagrammes individuels
      Il n’y a pas beaucoup de points .await, donc le compilateur n’a pas grand-chose à générer. En revanche, plusieurs aspects doivent progresser en parallèle, chacun dans son propre future/task, donc la gestion d’état à travers tous ces futures devient facilement du code spaghetti
    • Je ne pense pas que cette explication soit tout à fait exacte. Le point important ici, c’est que la machine à états n’effectue pas directement les E/S. Elle délègue toujours ce travail à la boucle d’événements qui l’héberge, ce qui permet de l’interpréter dans plusieurs contextes
      Il y a moins d’hypothèses sur l’environnement d’exécution, donc c’est plus facile à tester et à composer
      En théorie, async/await pourrait accomplir la même chose en générant des machines à états, mais en pratique c’est assez douloureux et la plupart du code async/await n’est pas pur
      Des langages expérimentaux comme Eff, Koka et Frank offrent un excellent support pour cette manière de programmer. En Haskell aussi, les discussions sur les E/S reposent sur un investissement profond dans des techniques comme les free monads et leurs variantes
      Plus récemment, Unison est un langage intéressant qui explore plusieurs concepts nouveaux tout en plaçant un système d’effets extensible au cœur du langage, ce qui soutient bien ce style de code au niveau du langage
    • Dans les années 1980, on faisait déjà les E/S comme ça en assembleur. Sinon, comment aurait-on écrit une implémentation de YMODEM pilotée par interruptions ?
    • Exactement. La seule chose pénible avec l’async, c’est qu’il faut parsemer du .await partout. J’aimerais bien que ce soit l’inverse : que .await soit le comportement par défaut, et qu’un autre mécanisme syntaxique serve aux cas où ce n’est pas souhaité
    • Ce n’était pas présenté comme révolutionnaire. L’article dit d’ailleurs : « Nous n’avons pas inventé ce pattern ! Il existe même un site web dédié à cela dans l’univers Python. »
      Malgré tout, on voit encore beaucoup trop souvent des bibliothèques de protocoles qui font elles-mêmes les E/S :-(
  • Je tournais ce domaine de problème dans ma tête depuis un moment, et cette approche correspond très bien à la direction que j’envisageais. Cela dit, comme l’indique la note 3 de l’article, il reste encore des points à peaufiner
    J’en suis arrivé à cette réflexion via le débat sur la coloration des fonctions et une découverte fortuite. Je développais une bibliothèque VT100 et les tests unitaires étaient extrêmement difficiles, parce qu’en pratique je faisais quelque chose comme parser::new(stdin()). Lors de la troisième ou quatrième réécriture, j’ai remplacé presque sans y penser le parseur par parser::push(data), et c’est là que j’ai compris que Rust punissait ce que j’en suis venu à appeler l’anti-pattern OOP d’entreprise de « l’obsession de l’encapsulation »
    Maintenant, je vois ce pattern et ses effets néfastes partout, et pas seulement dans les E/S
    Ironiquement, la solution s’apprend déjà avant l’université et au début des études supérieures. L’explication la plus simple d’un ordinateur, c’est une machine qui prend des entrées, traite/transforme des données et produit des sorties. Si cela est lié au débat sur la coloration des fonctions, c’est parce qu’en général seules les entrées et les sorties ont une « couleur » ; la logique centrale, elle, consiste surtout en transformations de données
    C’est peut-être banal à dire, mais vu l’ampleur de la « polémique » autour de la coloration des fonctions, beaucoup de gens ont manifestement été conditionnés à résoudre d’abord les problèmes par encapsulation, au point de passer à côté de cette évidence ou de l’oublier. Les adeptes du fonctionnel doivent probablement bien s’en amuser
    Pour moi, Rust a moins été un parcours d’apprentissage qu’un parcours de désapprentissage puis de réapprentissage. C’est un bon pattern, et je compte l’adopter à l’avenir
    Édit : code lié : https://codeberg.org/jcdickinson/termkit/src/branch/main/src...

    • Pourrais-tu développer la partie sur le passage à parser::push(data) et le fait que Rust punissait un anti-pattern OOP d’entreprise ?
      En tant que grand débutant en Rust, je ne vois pas clairement quel est le problème évident de ce pattern ni comment Rust le « punit »
    • Moi aussi, je suis passé du monde OOP à Rust, et ça fait déjà 6 ans. Pendant les 2 ou 3 premières années, j’ai produit un code horrible
      J’avais des paramètres de type et des traits partout, et j’abusais des struct comme si c’étaient des sortes de classes fournissant des fonctionnalités
      Rust s’en sort souvent mieux quand on évite, si possible, les paramètres de type et les traits définis par soi-même
      L’encapsulation au sens de préserver des invariants, c’est bien. Ça me rappelle cet article « parse, don’t validate » : https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
  • Comment cette conception se compare-t-elle à une approche qui utilise des channels pour envoyer les données vers un handler dédié ? Avec les channels, il y avait plusieurs problèmes
    (1) cela produit souvent un code en toile d’araignée difficile à suivre
    (2) il faut implémenter soi-même des types de messages convertibles en messages pouvant être envoyés sur le réseau
    (3) il faut passer explicitement l’émetteur aux entités concernées ou autorisées
    (4) on peut savoir si l’envoi du message dans le channel a échoué, mais pas si l’envoi réseau de ce message a échoué
    Cela reste tout de même assez pratique. Par exemple, s’il existe un channel ws_handler, il suffit d’y envoyer les données, et si un handler dédié quelque part le permet, il peut aussi envoyer ce message

    • Le point 4 peut être implémenté en passant avec le message un channel pour renvoyer le résultat. On peut même, si on le souhaite, bloquer tout le côté émetteur jusqu’au site d’appel
      le sans-IO peut aussi être utilisé dans les applications, mais il me semble particulièrement utile pour les bibliothèques. Dans une bibliothèque, c’est bien plus intéressant, car cela évite d’imposer au consommateur une manière de faire l’I/O
      En Rust, il existe déjà une fragmentation de l’écosystème entre I/O synchrones et asynchrones, à laquelle s’ajoutent différents runtimes asynchrones, d’où l’importance de ce point
    • Si une conception de type acteur convient au logiciel, les channels fonctionnent bien
      mais, comme dit plus haut, cela entraîne des problèmes. Par exemple, les acteurs/channels peuvent se déconnecter. De plus, pour avoir de la rétropression, il faut impérativement des channels bornés. Et comme cela nécessite des copies, atteindre un débit élevé peut devenir délicat
  • À voir aussi : les monades, en particulier les monades Free(r) et les systèmes d’effets[0]
    L’idée de séparer la logique de l’exécution est un vaste sujet déjà largement traité dans l’écosystème Haskell
    Édition : je n’ai pas mentionné comment étaient encapsulés les appels tokio::select! qui apparaissent quand il faut gérer le temps. Est-ce qu’on transporte un tokio::Runtime pour rendre le code de boucle async sans que le code extérieur ait besoin de l’être ?
    Édition 2 : il est aussi possible que le but n’ait pas été de montrer qu’une bibliothèque encapsulée fait cela, mais plutôt de montrer que l’application externe peut utiliser les bindings dans un contexte async
    Ce qui m’intéressait davantage, c’était de savoir comment, dans le style sans-IO, on peut implémenter une fonction encapsulée qui doit attendre une action ou un timer. Ou bien la réponse attendue est-elle une attente active, ou en pratique une instance de runtime async interne qui remplace l’attente active par quelque chose comme block_in_place
    [0]: https://okmij.org/ftp/Computation/free-monad.html

    • Ici, la « fonction encapsulée » est la struct StunBinding. Elle représente la fonctionnalité de binding STUN. Ce n’est pas une fonction unique qu’on peut simplement appeler, mais quelque chose qui nécessite une boucle d’événements
      L’essentiel est que StunBinding peut vivre dans une bibliothèque, et que l’application peut l’assembler dans sa machine à états du programme. Bien sûr, cela suppose aussi que l’application soit structurée dans le style sans-IO
      La bibliothèque liée snownet fait exactement cela. Le domaine consiste à combiner ICE + WireGuard sans I/O, et c’est utilisé par la bibliothèque connlib, qui compose par-dessus des ACL
      Édition : il n’y a pas d’attente active. À la place, StunBinding expose via poll_timeout une fonction qui indique ce qu’il est en train d’attendre. La manière de le réaliser revient à l’appelant, c’est-à-dire à la boucle d’événements. Quand handle_timeout est appelé avec l’Instant correspondant, l’action appropriée se produit
  • Oh, c’est thomaseizinger !
    J’avais déjà regardé les entrailles de rust-libp2p, donc en lisant l’article ce pattern m’a paru extrêmement familier, et apparemment ce n’était pas un hasard
    Firezone a l’air excellent. Connectez tout !

    • Merci !
      Oui, il y a des similitudes avec rust-libp2p. Mais là-bas, les flux et connexions réels restent encore dans une structure proche de Future, donc c’est plus imbriqué et moins strictement séparé que le sans-IO ici
  • Il y a ce passage disant que les workflows séquentiels demandent plus de code. En Rust, les fonctions async sont compilées en machines à états, et chaque point .await représente une transition vers un autre état. Cela facilite l’écriture, pour les développeurs, de code séquentiel avec de l’I/O non bloquante. Sans async, il faut écrire soi-même une machine à états pour représenter plusieurs étapes
    Est-ce que quelqu’un a essayé de combiner async et sans-IO ? Au moins conceptuellement, on dirait qu’en écrivant une fonction async qui await un helper connaissant le sans-IO, l’ensemble devrait être compilé en machine à états à l’intérieur d’une struct dotée d’une bonne interface sans-IO, et donc pouvoir être appelé facilement même depuis du code non async
    Je ne l’ai jamais fait moi-même, mais les principaux problèmes prévisibles me semblent être l’ergonomie et la gestion de Pin

    • Rust a bien des générateurs/coroutines qui peuvent couvrir en partie le cas d’usage que vous décrivez, mais c’est actuellement une fonctionnalité très instable
      Malheureusement, les coroutines dans leur forme actuelle ont la limitation pénible de n’être exposées qu’au travers du trait std::ops::Coroutine. On ne peut donc pas allouer directement la machine à états interne générée par le compilateur. Et ce, même si la taille de la machine à états est apparemment une constante connue à la compilation
      Pour une coroutine unique qui reste dans une fonction dont la durée de vie est définie, ce n’est pas un problème. Le compilateur peut le déterminer et allouer la machine à états sur la pile
      Mais l’usage le plus utile des coroutines serait sans doute comme élément de file dans un dispositif de boucle d’événements. Cette implémentation est impossible sans boxer les coroutines. Vec> n’est pas une structure de données favorable au cache, et si, avec de l’I/O à concurrence extrêmement élevée, il faut un million d’éléments dans un Vec, cela devient douloureux

    • Si Rust obtient un jour une syntaxe native pour les générateurs, cela pourrait devenir possible. On pourrait alors faire yield transmit pour « écrire » des données tout en restant dans le contexte d’une tâche async. En d’autres termes, chaque socket.write serait remplacé par yield transmit
      Pour lire des données, le générateur serait suspendu (.await) puis reprendrait quand des données entrantes seraient disponibles. Je ne sais pas si une telle syntaxe existe sur nightly, mais cela devrait ressembler à quelque chose comme ceci :

      // Made up gen syntax: gen(yield_type, resume_type)
      gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
      let req = make_stun_request();

yield Transmit {
server,
payload: req
};

let res = .await; // Made up "suspend and resume with argument"-syntax.

let addr = parse_stun_response(res);

addr  
}
  • À un niveau supérieur de la pile, les deux s’accordent plutôt bien. Les E/S non bloquantes, c’est-à-dire async, facilitent l’attente simultanée des E/S socket et du temps. On peut aussi le faire avec des E/S bloquantes en configurant un délai d’expiration de lecture sur le socket, mais utiliser les primitives async est un peu plus simple
    J’ai moi aussi beaucoup réfléchi à la manière de combiner les deux. L’un des problèmes rencontrés est que les fonctions async sont compilées en types opaques. Il devient donc difficile, voire impossible, d’utiliser les fonctionnalités du compilateur qui génèrent une machine à états à partir du code, puisqu’on ne peut plus interagir avec cette machine à états une fois créée. D’une certaine façon, cela casse aussi le borrow checker
    Par exemple, imaginons une tâche async en plusieurs étapes, donc avec plusieurs points await, dont une seule section a besoin d’une référence mutable vers une structure de données partagée. Dès qu’on l’exprime sous forme de fonction async, la référence mutable est capturée dans le type Future généré, et ce type existe pendant toutes les étapes. Par conséquent, Rust n’autorise pas l’exécution simultanée de plusieurs tâches de ce genre
    Le conseil habituel dans ce genre de situation est de « capturer les références mutables aussi brièvement que possible », mais avec async ce n’est pas possible. Découper la fonction async en plusieurs fonctions devient vite pénible, et cela va en partie à l’encontre de l’objectif initial, qui était justement d’exprimer tout cela dans une seule fonction

  • J’avais essayé autrefois d’encoder le protocole HTTP/1.1 comme une machine à états Sans-IO avec des points .await pour les E/S, mais je ne suis pas allé très loin. La différence, c’est que ces E/S ne s’appuyaient pas sur l’enregistrement d’un waker dans un runtime async : elles rendaient le contrôle à l’utilisateur pour qu’il effectue lui-même les E/S. On peut voir cela comme un .await qui se résout vers le « haut » plutôt que vers le « bas »
    Dans le contexte HTTP/1.1, le code async servait en quelque sorte de plan décrivant la manière dont l’utilisateur voulait que l’appel se comporte. À l’époque, je voulais absolument que cela fonctionne en environnement no_std et sans allocateur, et j’ai abandonné faute d’avoir trouvé un moyen d’éviter le dispatch dynamique via Box, qui exige un allocateur

  • https://news.ycombinator.com/item?id=40879547
    Il existe un exemple écrit sous la forme async fn stun. Le code complet du fonctionnement est ici : https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

  • Bien joué ! En exposant l’état, on peut rendre pure n’importe quelle fonction async. L’utilisateur n’a plus qu’à faire avancer la machine à états vers son état suivant
    J’avais essayé autrefois de faire un binding d’OpenSSL pour async Rust, et cette API async suivait elle aussi une conception similaire

    • Après une recherche rapide, je vois qu’OpenSSL a une API d’« async job ». Cela dit, elle semble faire des E/S. Il est même indiqué que la création d’un job est très coûteuse, au point qu’il faut réutiliser les jobs
      Ce que tu trouvais similaire, c’est le fait que la tâche elle-même planifiée comme job est indépendante de la manière dont elle est exécutée ?
      Si on regarde cet exemple [0], cette API async ressemble beaucoup plus aux futures de Rust
      On peut accéder à un « wait context » à l’intérieur du job, le suspendre sous certaines conditions, puis déclencher un réveil pour reprendre l’exécution
      [0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
  • Ce n’est en fait que des E/S asynchrones classiques avec des callbacks au lieu de coroutines

  • En lisant l’article et certains commentaires, j’ai l’impression qu’on a simplement réinventé le style d’architecture hexagonale, ou architecture ports/adapters

  • Je ne vois pas bien ce qu’il faut retenir ici. Tout ce qui est évoqué relève déjà de la programmation réseau de base
    On a l’impression qu’on se focalise sur des questions de plomberie à plus haut niveau et qu’on surinvestit la gestion d’état, mais c’est une question de goût plus qu’un sujet lié au networking
    La chose la plus intéressante que j’aie apprise dans l’article, c’est que Cloudflare exploite un serveur STUN public. Mais même ça n’est pas très utile. La version « bonne » et « utile » du protocole STUN était la première, car elle prenait en charge les change requests, ce qui permettait l’énumération NAT. Dans les versions suivantes de STUN, cette fonctionnalité a été supprimée grâce aux « suggestions utiles » des ingénieurs Cisco ayant contribué à la spécification

    • Dans le contexte de Rust, le grand avantage est selon moi de résoudre le problème de couleur des fonctions. Comme l’explique l’article, cela rend aussi les tests vraiment simples
      Aujourd’hui en Rust, si l’on implémente par exemple une bibliothèque WebRTC fondée sur le runtime async Tokio, son utilisation devient très pénible pour les personnes qui emploient des E/S synchrones, ou un autre runtime (smol, async-std, etc.), ou encore iouring directement
      Avec cette approche, on n’impose pas un choix d’E/S au consommateur, ce qui rend la bibliothèque utile à un public plus large