2 points par GN⁺ 2025-07-14 | 1 commentaires | Partager sur WhatsApp
  • L’introduction de la nouvelle interface d’I/O de Zig permet à l’appelant de choisir et d’injecter lui-même le mode d’implémentation des I/O
  • La nouvelle interface Io, repensée, prend en charge à la fois l’asynchronisme et le parallélisme, avec un fort accent sur la réutilisation du code et l’optimisation
  • Des implémentations de bibliothèque standard variées sont prévues : Blocking I/O, boucle d’événements, pool de threads, green threads, coroutines sans pile, etc.
  • La nouvelle API permet l’annulation des futures et la gestion des ressources, ainsi que le buffering et des comportements d’entrée/sortie plus granulaires
  • Le problème historique du function coloring est résolu, permettant à une même bibliothèque d’être optimisée pour des usages synchrones et asynchrones

Vue d’ensemble

Zig évolue récemment en concevant une nouvelle interface d’I/O asynchrone, avec un accent mis sur la souplesse des opérations d’I/O et la prise en charge du parallélisme. Ce changement sépare l’ancien paradigme async/await afin de permettre aux auteurs de programmes d’adopter une plus grande variété de stratégies d’I/O.

Nouvelle interface d’I/O

Auparavant, les objets liés aux I/O étaient créés et utilisés directement dans le code. Désormais, l’interface Io est injectée par l’appelant.

  • Cette approche, similaire au pattern Allocator, permet au code appelant de choisir et d’injecter l’implémentation concrète des I/O
  • Il devient aussi possible d’appliquer une stratégie d’I/O cohérente au code de packages externes

Principaux changements

  • L’interface Io prend désormais aussi en charge les opérations de concurrence (concurrency)
  • Si le code exprime correctement la concurrence, les implémentations de Io peuvent alors fournir du parallélisme (parallelism)

Exemple de code

  • Deux styles sont comparés : un code sans concurrence (sériel) et un code exprimant la possibilité de parallélisme via io.async et await
    • Code sériel : enregistrement successif dans deux fichiers, sans possibilité d’exploiter le parallélisme
    • Code parallèle : enregistrement dans des fichiers à l’aide de futures, avec un fonctionnement plus efficace dans une boucle d’événements asynchrone

Combinaison de await et try

  • L’usage conjoint de await et try pose un problème : si une erreur survient dans une future, les ressources des autres futures peuvent ne pas être libérées
  • Avec defer et future.cancel, il devient possible d’exprimer clairement l’annulation et le nettoyage appropriés

API Future.cancel

  • Future.cancel() et Future.await() sont idempotentes (les appeler plusieurs fois ne provoque pas d’effet secondaire)
  • Si cancel est appelé sur une future déjà terminée, seules les ressources sont libérées ; si l’opération n’est pas terminée, elle renvoie error.Canceled

Implémentations I/O de la bibliothèque standard

L’interface Io repose sur un polymorphisme d’exécution et peut être implémentée directement ou fournie par un package tiers. La bibliothèque standard de Zig prévoit de proposer plusieurs types d’implémentations I/O.

  • Blocking I/O : utilise simplement les I/O bloquantes classiques de style C, sans surcoût supplémentaire
  • Pool de threads : répartit les Blocking I/O sur un pool de threads système, introduisant une certaine forme de parallélisme. Des optimisations restent nécessaires pour des cas comme les clients réseau
  • Green threads : exploite des appels système asynchrones comme io_uring sous Linux afin de gérer plusieurs threads verts (légers) sur des threads système. Un support de plateforme est nécessaire (Linux x86_64 en priorité)
  • Coroutines sans pile : coroutines basées sur une machine à états ne nécessitant pas de pile explicite. Vise la compatibilité avec certaines plateformes comme WASM. Cela nécessite la réintroduction des properative conventions dans le compilateur Zig

Objectifs de conception

Réutilisation du code

Le principal problème de l’I/O asynchrone est la réutilisation du code : dans d’autres langages, les fonctions bloquantes et asynchrones existent séparément, ce qui fragmente le code. L’approche de Zig permet :

  • à une même bibliothèque de prendre efficacement en charge les modes synchrone et asynchrone
  • à async/await d’éliminer le phénomène de « function coloring », sans dépendre d’un modèle d’exécution unique à l’exécution grâce au système Io

En conclusion, le problème du function coloring est entièrement résolu

Optimisation

  • La nouvelle interface Io est implémentée comme une interface non générique, basée sur des appels virtuels via vtable
  • Les appels virtuels réduisent le gonflement du code, mais ajoutent un léger surcoût à l’exécution. En build optimisé, s’il n’existe qu’une seule implémentation de Io, une de-virtualization (suppression des appels virtuels) est possible
  • Si plusieurs implémentations de Io sont utilisées, les appels virtuels sont conservés afin d’éviter la duplication du code

Stratégie de buffering

  • Auparavant, chaque implémentation (reader/writer) gérait son propre buffering ; désormais, celui-ci est pris en charge au niveau des interfaces Reader et Writer
  • Hormis le flush du buffer, les appels ne passent pas par le chemin des appels virtuels, ce qui facilite l’optimisation

Opérations d’I/O sémantiques

L’interface Writer fournit deux nouvelles primitives pour certaines opérations d’optimisation ciblées.

  • sendFile : inspirée de sendfile de POSIX, permet de transférer des données entre des descripteurs de fichiers directement dans le noyau, en minimisant les copies mémoire
  • drain : prend en charge les écritures vectorisées + le splatting. Permet d’envoyer plusieurs segments de données en lot, avec conversion possible en appel système writev. Le paramètre splat permet de répéter le dernier élément si nécessaire (utile dans les flux comme la compression)

Feuille de route

Une partie de ces changements sera introduite à partir de Zig 0.15.0, mais l’adoption complète devra attendre une prochaine version, car une refonte majeure des bibliothèques est nécessaire. Des modules clés comme SSL/TLS, le serveur HTTP et le client HTTP doivent aussi être redessinés autour du nouveau système Io.

FAQ

Q : Zig est un langage bas niveau ; pourquoi l’async est-il important ?

  • Zig vise la robustesse, l’optimisation et la réutilisation
  • En standardisant les I/O non bloquantes, il devient possible d’ajuster aussi les autres bibliothèques et le code tiers à la stratégie globale d’I/O, tout en améliorant la réutilisabilité

Q : Les auteurs de packages doivent-ils désormais utiliser async partout ?

  • Non. Tout le code n’a pas besoin d’exprimer de la concurrence
  • Un code séquentiel classique fonctionnera lui aussi selon la stratégie d’I/O choisie par l’utilisateur

Q : N’importe quel modèle d’exécution fonctionne-t-il automatiquement dès lors qu’on le branche comme plugin ?

  • Dans la plupart des cas, oui
  • En revanche, en présence d’erreurs de programmation dans le code (par exemple si les conditions nécessaires aux travaux concurrents ne sont pas respectées), le bon fonctionnement n’est pas garanti

Avec des exemples d’exécution, l’article évoque aussi la différence entre asynchronisme et parallélisme, ainsi que la nécessité de concevoir correctement le flux d’exécution.

Conclusion

Avec l’introduction de la nouvelle interface Io, Zig améliore fortement la souplesse dans le choix des stratégies d’entrée/sortie, la réutilisation du code et les possibilités d’optimisation. Les développeurs peuvent ainsi exprimer plus clairement les structures de concurrence et de parallélisme, sans être contraints par une séparation entre fonctions synchrones et asynchrones, tout en s’adaptant efficacement à diverses plateformes et à différents modèles d’exécution.

1 commentaires

 
GN⁺ 2025-07-14
Commentaires Hacker News
  • J’aimerais revenir là-dessus. L’article dit même que Zig a complètement résolu le problème du function coloring, mais je ne suis pas d’accord. Si on reprend les cinq règles du célèbre article « What color is your function? », même si Zig ne distingue pas des couleurs comme async/sync/red/blue, il ne reste au final que deux cas : les fonctions d’IO et les fonctions sans IO. Le problème de la différence dans la manière d’appeler les fonctions selon leur couleur a peut-être été résolu techniquement, mais il faut toujours passer l’IO en argument aux fonctions qui en ont besoin, et ne pas le passer à celles qui n’en ont pas besoin. Au fond, j’ai l’impression que rien ne change. Les fonctions d’IO ne peuvent être appelées que depuis des fonctions d’IO, ce qui ne sort pas non plus du problème de coloring. Bien sûr, on peut aussi passer un nouvel executor, mais je doute que ce soit vraiment ce qu’on veut. On peut faire quelque chose de similaire en Rust. Le fait que les appels de fonctions colorées soient pénibles reste vrai aussi. Le point sur quelques fonctions clés de bibliothèques qui seraient colored ne s’applique ni à Zig ni à Rust. Le cœur du problème du coloring, c’est que les fonctions qui ont besoin d’un contexte — autrement dit un async executor, de l’auth, un allocator, etc. — doivent forcément recevoir ce contexte à l’appel. J’ai du mal à considérer que Zig ait réellement résolu ce point. En revanche, l’abstraction de Zig est extrêmement bien faite, alors que Rust a plus de lacunes là-dessus. Mais le problème du function coloring en lui-même reste présent

    • La différence essentielle avec le function coloring async classique, c’est que le Io de Zig n’est pas simplement une valeur spéciale pour traiter l’asynchrone, mais une valeur nécessaire à tout IO : lire un fichier, dormir, obtenir l’heure, etc. Io n’est pas une propriété de la fonction, c’est une valeur ordinaire qu’on peut placer n’importe où. En pratique, c’est justement ce qui donne l’impression que le problème du coloring est résolu. Dans la plupart des bases de code, l’IO existe déjà quelque part dans le scope, si bien que seules les fonctions de calcul réellement pures n’en ont pas besoin. Si une fonction a soudain besoin d’IO, dans la majorité des cas elle peut simplement le récupérer depuis my_thing.io et l’utiliser immédiatement. On n’a pas la contrainte pénible de Rust où il faut passer un Allocator à toutes les fonctions. Autrement dit, si un chemin de code change et doit faire de l’IO, il n’est pas nécessaire de propager la modification à chaque fonction : on peut l’utiliser directement. En théorie, je suis d’accord pour dire que le function coloring subsiste, mais en pratique presque toutes les fonctions se retrouvent async-colored, donc le problème concret devient quasi nul. D’ailleurs, les développeurs Zig considèrent qu’expliciter le passage d’un Allocator ne crée pas la même gêne que le function coloring. Je pense que Io ne posera pas non plus un gros problème

    • J’ai l’impression qu’un point crucial n’a pas été mentionné. Quand on utilise une bibliothèque Rust, il faut obligatoirement s’aligner sur des contraintes comme async/await, tokio, send+sync, et en pratique une API sync devient inutilisable dans une appli async. À l’inverse, la manière dont Zig transmet l’IO résout ce problème à la racine. Cela évite d’avoir à bricoler des procedural macros ou des multiversions, qui d’ailleurs ne règlent pas si bien que ça le problème des bibliothèques multiversionnées. Il existe différentes discussions sur le mélange async/sync en Rust, et ce lien l’explique aussi : https://nullderef.com/blog/rust-async-sync/. J’espère que Zig saura aussi bien résoudre à l’avenir des aspects comme le cooperative scheduling, l’async haute performance, ou encore l’async thread-per-core

    • Je ne suis pas un spécialiste de la théorie des catégories, mais à force de suivre cette voie de gestion du contexte, on finit par retomber sur la monade IO. Ce contexte peut exister implicitement, mais si on veut vraiment bénéficier de l’aide du compilateur, il faut qu’il apparaisse explicitement dans le système. Et même si les ambitions des langages système finissent souvent enterrées dans des cimetières d’async ou de coroutines, le fait qu’Andrew ait en quelque sorte redécouvert la monade IO et l’ait correctement implémentée est porteur d’espoir. Les fonctions du monde réel ont une couleur. Soit on fixe des règles de passage claires, soit on dérive inévitablement vers des solutions de plus en plus complexes comme co_await en C++ ou tokio. Pour moi, c’est précisément « The Way »

    • Il existe une astuce simple pour rendre toutes les fonctions rouges (ou bleues)

      var io: std.Io = undefined;
      
      pub fn main() !void {
        var impl = ...;
        io = impl.io();
      }
      

      Si on met io dans une variable globale, on n’a plus à se soucier du coloring. C’est une blague, mais le fait qu’il faille effectivement utiliser l’interface Io crée certes un peu de friction, sauf que c’est d’une nature fondamentalement différente de la friction réelle qu’on rencontre avec async/await. À mes yeux, le cœur du problème du function coloring, c’est que la coloration statique imposée par le mot-clé async empêche la réutilisation du code. En Zig, qu’on rende une fonction async ou non, elle reçoit de toute façon l’IO en argument, donc sous cet angle le coloring lui-même perd son sens. Deuxièmement, avec async/await on impose l’usage de coroutines sans pile — autrement dit un basculement de pile piloté par le compilateur — alors que le nouveau système IO de Zig peut, en interne, utiliser de l’async tout en se comportant comme de l’IO bloquant. C’est ça que je considère comme le vrai problème pratique du function coloring

    • Go souffre lui aussi d’un « coloring subtil ». Lorsqu’on utilise des goroutines, il faut toujours passer un argument context pour gérer l’annulation, et beaucoup de fonctions de bibliothèques exigent également un context, ce qui contamine tout le code. Techniquement, on peut ne pas utiliser de context, mais passer context.Background un peu au hasard n’est pas une pratique recommandée

  • Le concept de sans-io a déjà été discuté en Rust et ailleurs ; voir par exemple https://www.firezone.dev/blog/sans-io, https://sans-io.readthedocs.io/, https://news.ycombinator.com/item?id=40872020

    • Si une fonction appelle directement des méthodes d’IO, il devient impossible de séparer l’IO de l’extérieur, donc j’ai du mal à appeler ça du sans-io. Comme expliqué dans les liens, dans un protocole basé sur des flux d’octets, l’implémentation ne doit manipuler que des buffers d’entrée/sortie, et la partie qui reçoit les données du réseau doit impérativement être fournie par l’appelant pour qu’on puisse vraiment parler de sans-io. Pour la sortie aussi, on peut soit écrire uniquement dans des buffers, soit retourner immédiatement un flux d’octets lorsqu’un événement se produit. La manière de le renvoyer dépend de l’implémentation, mais les buffers internes sont utiles quand il faut répondre automatiquement. Le point essentiel, c’est une structure qui ne fait pas directement d’IO
  • Je pense que le problème du function coloring vient de ce que, qu’on le traite sur la pile ou qu’on unwind la pile, l’un des deux finit toujours par rester. Zig affirme résoudre le problème du coloring, mais permet toujours d’utiliser blocking/thread pool/green thread comme mode d’implémentation de l’IO. Or cet IO bloquant n’a jamais été le vrai problème au départ. Tant qu’on évite l’état global par convention, presque tous les langages peuvent déjà faire ça. Les stackless coroutines ne sont pas encore implémentées : on a un peu l’impression d’être dans le « il ne reste plus qu’à dessiner le reste de l’oiseau ». Si on veut de véritables appels de fonctions universels, je pense qu’il y a deux solutions

    • rendre toutes les fonctions async, avec un argument indiquant si elles doivent s’exécuter de façon synchrone ou non (au prix d’une baisse de performance)

    • compiler chaque fonction deux fois, puis choisir la bonne version à l’appel selon le contexte (avec un surcoût en taille de code et des difficultés de gestion des pointeurs de fonction)

      • Je ne fais pas partie de l’équipe centrale, mais j’ai entendu dire qu’après avoir suffisamment expérimenté l’implémentation semiblocking avec de vrais utilisateurs et stabilisé l’API, ils comptent justement appliquer cette solution — insérer de vraies coroutines basées sur des sauts de pile. Aujourd’hui, le compilateur LLVM pour la machine à états des coroutines dépend de libc ou malloc, ce qui pose problème. Comme la nouvelle interface io de Zig prend en charge async/await en espace utilisateur, la migration sera facile quand une vraie solution de frame jumping arrivera, et le débogage sera aussi plus pratique. Et si les coroutines restent compliquées, l’API io pourra tenir avec de petites retouches, donc ils ne veulent pas trop se précipiter vers les stackless coroutines

      • ValueTask<T> de C#/.NET joue un rôle similaire. Si tout se termine de façon synchrone, il n’y a pas de surcoût, et sinon on peut utiliser Task<T> uniquement quand c’est nécessaire. En général, le code se contente d’await, puis au moment de l’exécution le runtime ou le compilateur choisit automatiquement la voie synchrone ou asynchrone

  • J’aime Zig, mais je trouve dommage qu’il se concentre sur les green threads (fibers, stackful coroutines). Rust avait lui aussi un Runtime trait similaire avant la 1.0, qu’il a abandonné pour des raisons de performance. En réalité, les OS, les langages et les bibliothèques ont déjà appris plusieurs fois les limites de cette approche, et il existe aussi de la documentation à ce sujet : https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf. Les fibers ont été mises en avant dans les années 90 comme solution de concurrence scalable, mais aujourd’hui elles ne sont plus recommandées à cause des stackless coroutines, des progrès des OS et du matériel, etc. Si Zig continue dans cette direction, il risque de buter sur les mêmes limites de performance que Go, et aura du mal à devenir un véritable concurrent sur ce terrain. J’espère que std.fs restera disponible pour les cas où les performances comptent

    • L’impression que nous misons « tout » sur les green threads (fibers) est erronée. L’article mentionné par l’OP précise explicitement qu’une implémentation basée sur des stackless coroutines est attendue, et il existe aussi une proposition en ce sens : https://github.com/ziglang/zig/issues/23446. Les performances sont importantes, et si les fibers se révèlent insuffisantes de ce point de vue, elles ne seront pas utilisées de manière générale. Rien dans ce qui est discuté dans cet article n’empêche les stackless coroutines de devenir l’implémentation Io par défaut

    • Je m’interroge sur l’affirmation selon laquelle les green threads auraient de mauvaises performances. Les grandes plateformes de serveurs concurrentes (Go, Erlang, Java) utilisent toutes des green threads ou essaient d’en utiliser. Les green threads peuvent certes être moins adaptés à des langages plus bas niveau à cause de problèmes de compatibilité avec la C FFI, comme en Rust, mais il est difficile d’affirmer que la performance en soi est toujours le problème

    • Comme ce n’est qu’une option parmi d’autres, je ne pense pas qu’on puisse parler de « all-in ». Le choix de l’implémentation se fait dans l’exécutable, pas dans le code des bibliothèques

    • Zig vise un effet similaire au choix qu’a fait Rust lorsqu’il a supprimé les green threads pour les remplacer par un async runtime. L’idée clé, c’est d’officialiser l’intuition « async = IO, IO = async ». Rust propose des async runtimes enfichables comme tokio ; Zig propose des IO runtimes enfichables. La direction, au final, consiste à retirer le runtime du langage, à permettre son branchement depuis l’espace utilisateur, tout en partageant une interface commune pour tout le monde

    • Le document (P1364R0) était controversé, et je pense qu’il défendait une position motivée pour éliminer une approche précise. Pour nourrir la discussion, on peut aussi regarder https://old.reddit.com/r/cpp/comments/1jwlur9/stackful_coroutines_faster_than_stackless/, https://old.reddit.com/r/programming/comments/dgfxde/fibers_arent_useful_for_much_any_more/f3bmpww/ etc.

  • Dans un langage système comme Zig, je trouve un peu étrange d’imposer du polymorphisme runtime jusque dans les opérations d’IO standard les plus courantes. Dans la plupart des cas réels, l’implémentation de l’IO pourrait être déterminée statiquement ; je me demande donc pourquoi on devrait imposer un surcoût runtime

    • Pour l’IO, je pense que le coût du dynamic dispatch sera en pratique presque négligeable. Cela dépend bien sûr de la cible de l’IO, mais dans l’ensemble l’IO est bien plus souvent non limitée par le CPU. C’est justement pour ça qu’on parle d’IO-bound

    • À la question « pourquoi imposer un surcoût runtime à tout le monde ? », j’ai l’impression que dans la plupart des systèmes qui n’utilisent qu’un seul type d’io, le compilateur est censé optimiser jusqu’à supprimer le coût de la double indirection. Et de toute façon, l’IO a presque toujours un autre bottleneck, donc une indirection supplémentaire ne coûte quasiment rien

    • Dans la philosophie de Zig, on accorde davantage d’importance à la taille du binaire. Il y a exactement le même trade-off avec Allocator : par exemple, ArrayListUnmanaged n’est pas générique vis-à-vis de l’allocator, donc chaque allocation passe par du dynamic dispatch. En pratique, le coût d’une allocation de fichier ou d’une écriture dépasse très largement celui d’un appel indirect. Cette obsession pour la taille du binaire est très typique de Zig. À noter que la devirtualization — l’optimisation qui remplace un appel dynamique par un appel statique — relève du mythe

    • Le polymorphisme runtime n’est pas mauvais en soi. Tant qu’on n’est pas dans une tight loop avec l’introduction de branches, ou dans un cas où le compilateur ne peut pas faire d’inlining, ce n’est pas un vrai problème

  • Je n’aime pas énormément voir le nouveau paramètre io exposé un peu partout, mais j’apprécie beaucoup le fait qu’il permette d’utiliser facilement plusieurs implémentations (basées sur des threads, des fibers, etc.) sans imposer une implémentation aux utilisateurs, un peu comme l’interface Allocator. Globalement, c’est une grosse amélioration et, si l’une des implémentations de la stdlib fournit un io synchrone/bloquant sans surcoût supplémentaire, cela respectera parfaitement la philosophie de Zig selon laquelle on ne paie pas pour ce qu’on n’utilise pas

    • Est-ce que « on ne paie pas pour ce qu’on n’utilise pas » est vraiment possible ? À moins d’avoir une équipe minuscule avec une discipline extrême, quelqu’un finira toujours par l’utiliser, et moi je paierai le coût. Et continuer à faire circuler io me paraît plus pénible que de simplement appeler une fonction là où c’est nécessaire
  • En Zig, io.async exprime uniquement l’asynchronisme — le fait que l’ordre des opérations ne soit pas forcément garanti mais que le résultat reste correct — et non la concurrence (concurrency). Le point clé est donc d’avoir séparé la sémantique d’async de celle des appels io. Je trouve cette conception très intelligente

  • J’aime l’idée que cette interface IO permette de créer un VFS (Virtual File System) au niveau du langage

    • En voyant le code d’exemple, je me suis demandé si on ne pourrait pas aussi appliquer une sécurité fondée sur les capabilities du point de vue sécurité. Par exemple, passer à une bibliothèque une instance io qui ne peut lire que dans un répertoire donné. Référence : https://news.ycombinator.com/item?id=44549430
  • Pour apprendre Zig, j’ai essayé d’écrire un petit serveur SSH. Grâce à cette structure IO/boucle d’événements, j’ai trouvé le déroulement du code bien plus facile à comprendre. Merci Andy

    • Je suis curieux de savoir quel aspect du nouveau design t’a permis de mieux comprendre l’event loop/io
  • L’article est vraiment très bien écrit et je l’ai trouvé passionnant. J’attends particulièrement avec intérêt les implications pour WebAssembly. Le fait qu’on puisse utiliser WASI en espace utilisateur et aussi faire du Bring Your Own IO me paraît vraiment fascinant