- 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_timeoutetpoll_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
InstantetTransmit, 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_transmitethandle_inputreviennent à plusieurs couches
- Très peu d’appels à
- 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
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
connlibde 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’
UdpSocketasync detokioou 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::UdpSocketil devient async, et composé au-dessus destd::net::UdpSocketil 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,
Transmitcontient les informations suivantes- La destination
SocketAddr - Le
payloadà transmettre
- La destination
- Le code de protocole n’écrit pas directement dans la socket ; il émet un
Transmit - L’appel réel à
UdpSocket::sendousend_toest 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
FutureRust 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
SentetReceived - Dans l’exemple, l’état est représenté par l’enum suivante
SentReceived { address: SocketAddr }
StunBindingpossède l’état courant et une file deTransmiten attente d’envoi- Les principales API ont des rôles clairs
handle_input: transmet à la machine à états les paquets reçus comme résultat deUdpSocket::recvpoll_transmit: permet à la boucle d’événements de récupérer lesTransmitque la machine à états veut émettrepublic_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
Durationpar rapport à un instant antérieur l’est Instanten Rust n’expose pas l’heure courante, mais permet de mesurer laDurationentre deuxInstant- 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éveilhandle_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_inputreçoit le paquet avec l’Instantcourant et le stocke sous la formeState::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_transmitetpoll_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
StunBindinget les appeler dans l’ordre- Dans ce cas, le multiplexage des messages STUN doit être correctement implémenté, par exemple avec le
TransactionIdou l’adresse du serveur
- Dans ce cas, le multiplexage des messages STUN doit être correctement implémenté, par exemple avec le
snownetde Firezone combine ICE et WireGuard pour fournir aux applications un tunnel IP fonctionnant dans divers environnements réseausnownetest construit au-dessus de la bibliothèque WebRTC sans IOstr0met de l’implémentation WireGuard presque sans IOboringtun- Firezone n’a pas besoin de toute la stack WebRTC, seulement de l’
IceAgentqui implémente la RFC 8445 - Comme
str0mest sans IO, il est facile de n’en extraire que l’IceAgentet de le composer avec les machines à états du code existant - Une connection de
snownetcontient unIceAgentet 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
- Réduire le nombre d’appels système lors de l’envoi de paquets avec
- 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
str0mconsidè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
Instantmodifié à la fonction et de vérifier le changement d’état - Firezone fournit un exemple réel testant si
snownetferme 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
Transmitd’un côté et on le transmet auhandle_inputde la machine à états de l’autre côté - Firezone implémente une machine à états de référence qui décrit comment
connlibdoit 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 deconnlib - 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 selfet de s’appuyer sur le borrow checker pour assurer la soundness du code - À l’inverse,
&mutpeut 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
Futuresur un runtime commetokio, 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
- Un pointeur à comptage de références et un mutex, comme
- 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_timeoutpar 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
.awaitest 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
StunBindingn’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
boringtunappelleInstant::nowen 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
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
$langLe 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,
asyncse résout au final en une machine à états automatique qui stocke les valeurs entre les points d’E/S (await)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 spaghettiIl 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
.awaitpartout. J’aimerais bien que ce soit l’inverse : que.awaitsoit le comportement par défaut, et qu’un autre mécanisme syntaxique serve aux cas où ce n’est pas souhaité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 parparser::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...
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 »
J’avais des paramètres de type et des traits partout, et j’abusais des
structcomme si c’étaient des sortes de classes fournissant des fonctionnalitésRust 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 messagele 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
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 untokio::Runtimepour 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
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énementsL’essentiel est que
StunBindingpeut 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-IOLa bibliothèque liée
snownetfait exactement cela. Le domaine consiste à combiner ICE + WireGuard sans I/O, et c’est utilisé par la bibliothèqueconnlib, qui compose par-dessus des ACLÉdition : il n’y a pas d’attente active. À la place,
StunBindingexpose viapoll_timeoutune 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. Quandhandle_timeoutest appelé avec l’Instantcorrespondant, l’action appropriée se produitOh, 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 !
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 iciIl 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
.awaitrepré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 étapesEst-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
PinRust 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 compilationPour 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 unVec, cela devient douloureuxSi Rust obtient un jour une syntaxe native pour les générateurs, cela pourrait devenir possible. On pourrait alors faire
yield transmitpour « écrire » des données tout en restant dans le contexte d’une tâche async. En d’autres termes, chaquesocket.writeserait remplacé paryield transmitPour 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
gensyntax: gen(yield_type, resume_type)gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
let req = make_stun_request();
yield Transmit {
server,
payload: req
};
À 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 simpleJ’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 fonctionasync, la référence mutable est capturée dans le typeFuturegé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 genreLe 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
.awaitpour 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.awaitqui 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_stdet sans allocateur, et j’ai abandonné faute d’avoir trouvé un moyen d’éviter le dispatch dynamique viaBox, qui exige un allocateurhttps://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
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écificationAujourd’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