2 points par GN⁺ 2025-10-28 | 1 commentaires | Partager sur WhatsApp
  • L’auteur, qui apprenait le langage Zig tout en menant un projet de réécriture de l’index AcoustID, a tenté une nouvelle approche après s’être heurté aux limites de la programmation réseau
  • Pour reproduire dans Zig les modèles d’E/S asynchrones et de concurrence qu’il utilisait auparavant en C++ et en Go, il a décidé de développer sa propre bibliothèque
  • Le résultat est Zio, une bibliothèque qui implémente dans Zig un modèle de concurrence de style Go, permettant d’écrire du code asynchrone sans callbacks, avec une apparence synchrone
  • Zio prend en charge les E/S réseau et fichier asynchrones, les canaux, les primitives de synchronisation, la surveillance des signaux et plus encore, avec des performances supérieures en mode mono-thread à Go ou à Tokio de Rust
  • Ce projet montre la possibilité de combiner les performances de niveau système de Zig avec un modèle de concurrence moderne, et est considéré comme un tournant important pour l’expansion de l’écosystème Zig

Le langage Zig et la motivation initiale

  • L’auteur suivait depuis longtemps Zig, à l’origine conçu comme un langage bas niveau pour les logiciels audio, sans toutefois ressentir de besoin concret de l’utiliser
    • Son intérêt s’est renforcé après avoir vu Andrew Kelley, créateur de Zig, réimplémenter son algorithme Chromaprint en Zig
  • Il a profité du projet de réécriture de l’index inverse d’AcoustID pour apprendre Zig, et a finalement obtenu une implémentation plus rapide et plus scalable que la version C++
  • Mais lors de l’ajout de l’interface serveur, il s’est heurté à un manque de support pour le réseau asynchrone

Approches précédentes et leurs limites

  • Dans la version C++ précédente, les E/S asynchrones étaient gérées via le framework Qt ; l’approche était basée sur des callbacks, mais restait utilisable grâce à la richesse du support
  • Dans un prototype ultérieur, il avait exploité la simplicité du réseau et de la concurrence en Go, mais Zig ne proposait pas un niveau d’abstraction comparable
  • Pour implémenter en Zig un serveur TCP et une couche cluster, il aurait fallu créer un grand nombre de threads, ce qui s’est révélé inefficace
    • Pour résoudre cela, il a écrit lui-même un client Zig pour le système de messagerie NATS (nats.zig) et a ainsi exploré en profondeur les capacités réseau de Zig

L’arrivée de la bibliothèque Zio

  • Fort de cette expérience, il a publié Zio : une bibliothèque d’E/S asynchrones et de concurrence pour Zig
  • Zio vise à écrire du code asynchrone sans callbacks ; en interne, les E/S restent asynchrones, mais la structure apparaît comme synchrone de l’extérieur
  • Il s’agit d’une implémentation limitée d’un modèle de concurrence de style Go adaptée à Zig
    • Les tâches de Zio prennent la forme de coroutines stackful avec une pile de taille fixe
    • Lors d’un appel à stream.read(), l’opération d’E/S est exécutée en arrière-plan, puis la tâche reprend une fois terminée pour renvoyer le résultat
  • Cette approche simplifie à la fois la gestion de l’état et la lisibilité du code

Ensemble de fonctionnalités et structure du runtime

  • Zio prend en charge les E/S réseau et fichier entièrement asynchrones, les primitives de synchronisation (mutex, variables de condition, etc.), les canaux de style Go, la surveillance des signaux OS, etc.
  • Les tâches peuvent s’exécuter en mode mono-thread ou multi-thread
    • En mode multi-thread, les tâches peuvent se déplacer entre les threads, ce qui réduit la latence et améliore l’équilibrage de charge
  • Zio implémente les interfaces standard Reader/Writer, assurant la compatibilité avec des bibliothèques externes

Performances et comparaison

  • L’auteur n’a pas encore publié de benchmark officiel, mais indique avoir constaté des performances supérieures en mode mono-thread à Go et à Tokio de Rust
  • Le coût du changement de contexte est aussi faible qu’un appel de fonction, offrant des transitions pratiquement gratuites
  • Le mode multi-thread n’est pas encore aussi robuste que Go/Tokio, mais affiche des performances comparables ou légèrement supérieures
    • L’ajout futur d’une fonctionnalité de fairness pourrait entraîner une légère baisse des performances

Exemples de code et usages

  • La documentation inclut un exemple de serveur HTTP basé sur Zio
    • zio.net.Stream est utilisé pour accepter les connexions, chaque connexion étant traitée dans une tâche distincte
    • zio.Runtime gère l’exécution des tâches et l’ordonnancement des E/S
  • Cette structure permet d’écrire des E/S asynchrones comme du code synchrone, avec un contrôle de flux clair et une gestion explicite de la libération des ressources

Perspectives et portée

  • Grâce à Zio, l’auteur estime que Zig peut aller au-delà d’un simple langage pour du code système haute performance et évoluer vers un langage complet pour le développement d’applications réseau
  • La prochaine étape consiste à réécrire le client NATS sur la base de Zio et à développer des bibliothèques client/serveur HTTP basées sur Zio
  • Ce projet est vu comme une tentative majeure d’étendre l’infrastructure réseau et de concurrence de l’écosystème Zig, en construisant un modèle de runtime moderne comparable à ceux de Go ou Rust

1 commentaires

 
GN⁺ 2025-10-28
Avis Hacker News
  • On dit que le changement de contexte est presque gratuit au niveau d’un appel de fonction, mais en pratique il existe des coûts subtils, comme la perturbation du prédicteur de branchement (branch predictor)
    On ne sait pas clairement si la conception async de Zig utilise des paires call/return matérielles, ou si elle est traduite sur la base de sauts indirects
    Pour faire un benchmark rigoureux, il faudrait comparer le temps d’exécution total d’un programme avec des bascules continues entre deux tâches et celui d’un programme entièrement synchrone. C’est assez délicat
    • Avec des coroutines sans pile, si l’on alterne en permanence entre deux tâches tout en bas de la pile d’appels, et que le code de bascule de pile est inliné, on peut éviter en grande partie la pénalité de désaccord call/ret
      Si l’on peut contrôler le compilateur, il est aussi possible de remplacer les call/ret du code I/O par des sauts explicites
      À long terme, on aimerait que les CPU introduisent un méta-prédicteur (meta-predictor) pour mieux prédire les coroutines avec pile
    • Dans Zig, l’async a actuellement disparu au niveau du langage, et l’OP a donc implémenté lui-même le changement de tâche en espace utilisateur
    • En faisant un simple test de ping-pong entre coroutines, on a déjà obtenu des chiffres difficiles à croire par rapport à d’autres solutions
    • Un nouvel async doit bientôt être ajouté à Zig, donc certains préfèrent attendre avant de creuser sérieusement
      Article lié : Zig new async I/O
  • Les coroutines stackful ont du sens quand on a suffisamment de RAM
    J’utilise Zig en environnement embarqué (ARM Cortex-M4, 256KB RAM), et je m’en sers pour garantir la sécurité mémoire dans l’interopérabilité avec le C
    Je préfère l’async coloré à la Rust. L’aspect magique qui donne l’impression de code synchrone est agréable, mais dans une grosse base de code, le problème est qu’il devient difficile de distinguer quelles fonctions sont bloquantes
    • En réalité, tout code synchrone est une illusion créée par le logiciel
      Le CPU ne se bloque pas réellement sur les I/O, et les threads OS eux-mêmes sont des coroutines stackful implémentées par l’OS
      Le langage peut simplement implémenter cette illusion de manière plus efficace, mais le fond reste le même
    • Le nouveau Zig IO devrait adopter une structure colorée (colored) de façon plus élégante que Rust
      La couleur dépendra du fait qu’une fonction effectue ou non des I/O, et l’appel indiquera explicitement s’il est async
      Zig vise aussi à calculer la taille de pile nécessaire à l’appel d’une fonction, ce qui devrait réduire le problème de gaspillage de RAM des coroutines stackful
    • C’est précisément pour cela que Zig cherche à exprimer les I/O explicitement : permettre de suivre quelles fonctions sont bloquantes
  • Certains estiment qu’il est encore trop tôt pour adopter Zig. Le modèle d’I/O est en train de beaucoup changer, et cela donne l’impression qu’il faudra encore quelques années
    • J’ai moi aussi quitté Zig en 2020 pour une raison similaire.
      Mais le projet reste très actif, et j’apprécie qu’il privilégie une bonne conception plutôt qu’une sortie rapide
      Pour l’instant j’utilise Go ou C en attendant la 1.0
    • Quelques années passent vite. Zig est déjà un langage tout à fait utilisable. Ceux qui veulent l’utiliser l’utilisent, les autres non
    • En réalité, le moment est mauvais. Un gros changement I/O est prévu pour la 0.16, et même l’auteur n’utilise pas encore les fonctionnalités les plus récentes
      Moi aussi, j’attendrai la 0.16 pour les travaux centrés sur les I/O
    • Mais pour les travaux liés aux I/O, si l’on utilise l’interface buffered reader/writer de Zig 0.15, il n’y aura pas de grands changements
    • Au contraire, je pense que c’est faux. Le langage Zig lui-même n’est pas en train de changer radicalement ; il est en train d’ajouter une nouvelle API std.Io puissante
      Le code existant continue de fonctionner, et la nouvelle API est plus ergonomique et performante
      J’ai moi-même migré un projet existant vers la nouvelle API Reader/Writer, et le code est devenu bien plus propre
  • La raison pour laquelle l’async basé sur les callbacks est devenu la norme reste toujours mystérieuse
    Une approche comme libtask semble bien plus propre
    Rust a aussi adopté un async basé sur les callbacks, et je ne comprends pas bien pourquoi
    Référence : libtask
    • Les coroutines sans pile peuvent être implémentées dans le langage lui-même, avec l’avantage d’une interaction prévisible avec les fonctionnalités existantes
      Mais manipuler directement la pile peut entrer en conflit avec la gestion des exceptions, le GC, le débogueur, etc.
      Il est aussi difficile de fusionner ce genre de changement au niveau LLVM, donc du point de vue des concepteurs de langage, il y a beaucoup de contraintes réalistes
    • Les recherches menées par Microsoft pour le standard C++ ont conclu que les coroutines stackless avaient un surcoût mémoire bien plus faible et offraient plus de liberté dans la conception de l’exécuteur
    • L’inconvénient des approches à la zio ou libtask, c’est qu’il faut estimer soi-même la taille de pile
      Trop petite, elle déborde ; trop grande, elle gaspille de la mémoire
      La taille de pile nécessaire varie aussi selon les plateformes, ce qui pose un problème de portabilité
      Si l’issue Zig #157 est résolue, cette approche pourrait devenir meilleure
    • Dans le cas de libtask, la taille de pile des threads est floue et bien plus grande qu’un état async classique
    • L’async de Rust n’est pas basé sur les callbacks mais sur le polling
      Autrement dit, il existe trois façons d’implémenter l’async
      1. Basé sur les callbacks (Node.js, Swift)
      2. Basé sur des piles complètes (Go, libtask)
      3. Basé sur le polling (Rust)
        Rust est transformé en machine à états statique que le runtime interroge par polling
        Le modèle stackful gaspille beaucoup de mémoire et complique la gestion de la taille de pile
        Pour éviter cela, Rust a adopté une structure stackless, et Zig devrait permettre de choisir entre les deux approches
        Référence : code coroutine de zio
  • Une lecture TCP peut rester bloquée pendant un mois ; on se demande donc à quoi ressemblera l’interface de timeout I/O
    • Sur un socket TCP, on peut définir des timeouts de lecture/écriture avec setsockopt
      Zig fournit une couche d’API POSIX
      Référence : documentation setsockopt
    • Actuellement, std.Io.Reader de Zig n’a pas conscience des timeouts
      Une structure fonctionnant comme asyncio.timeout de Python est à l’étude
      Exemple de code :
      var timeout: zio.Timeout = .init;
      defer timeout.cancel(rt);
      timeout.set(rt, 10);
      const n = try reader.interface.readVec(&data);
      
    • La plupart des frameworks async négligent les timeouts et l’annulation
      En réalité, c’est la partie la plus difficile
  • Scala dispose déjà d’une bibliothèque de concurrence appelée ZIO
    Référence : zio.dev
  • J’ai récemment été impressionné par Tokio de Rust, et si Zig pouvait aussi implémenter une concurrence de style Go sans GC, j’aimerais vraiment l’essayer
    • Go peut utiliser des astuces comme des piles extensibles à l’infini grâce au GC
      Mais Zig m’a impressionné par sa capacité à exprimer proprement des API de haut niveau tout en restant un langage bas niveau
  • J’ai découvert Zig pour la première fois via le site de Bun. Le projet évolue vraiment très vite ces temps-ci
  • Dans une ancienne version C++, j’avais implémenté les I/O asynchrones avec Qt, mais cette fois je suis passé à Go
    Zig comme Go ont désormais de nouveaux bindings Qt
    • Go : miqt
    • Zig : libqt6zig
      Je voudrais des bindings pour Rust. cxx-qt est le seul projet encore maintenu, mais je ne veux utiliser ni QML ni CMake. Je veux utiliser Qt uniquement avec Rust + Cargo
  • Scala a déjà un framework connu appelé ZIO, ce qui montre bien à quel point il est difficile de trouver un nom