1 points par GN⁺ 3 시간 전 | 1 commentaires | Partager sur WhatsApp
  • Le reverse proxy TinyGate a amélioré ses performances en passant d’une architecture basée sur des workers à epoll, mais s’est ensuite heurté à ses limites avant d’être réécrit en io_uring
  • epoll est un modèle d’état de préparation qui indique quand une opération I/O est possible, ce qui oblige à appeler séparément read()/write() après epoll_wait
  • io_uring est un modèle d’achèvement centré sur la fin effective des I/O, où l’application et le kernel échangent via un ring buffer partagé avec une file de soumission et une file de complétion
  • io_uring_enter() reste généralement nécessaire, mais permet de soumettre et de récupérer plusieurs opérations en une fois, tandis que IORING_SETUP_SQPOLL réduit les syscalls au prix d’une consommation CPU accrue
  • Sur un serveur Linux moderne utilisant un kernel v5.1+, pour un nouveau projet, io_uring est considéré comme un choix plus adapté qu’epoll

Les limites d’epoll révélées par TinyGate

  • TinyGate était un serveur reverse proxy créé avec des étudiants, et sa première version reposait sur une architecture simple basée sur des workers
  • Cela fonctionnait comme projet pédagogique, mais l’architecture montrait de fortes limites face à des outils comme nginx ou haproxy
  • La deuxième version est passée à une base epoll, avec des performances nettement supérieures à celles de la première
    • Malgré cela, les benchmarks ne permettaient toujours pas de dépasser nginx/haproxy
  • En raison des limites d’epoll, le projet a ensuite migré vers io_uring, ce qui a conduit à une réécriture complète depuis zéro

epoll : notification de disponibilité et répétition des syscalls

  • epoll est une méthode historique de gestion des I/O asynchrones sous Linux, intégrée au kernel Linux en 2002
  • Son principe central est la notification d’état de préparation
    • epoll signale qu’il est possible de lire ou d’écrire
    • La lecture et l’écriture réelles sont ensuite effectuées par l’application via les syscalls read() ou write()
  • Dans le flux habituel, le coût des syscalls se répète à chaque événement
    • epoll_ctl est un syscall ponctuel servant à enregistrer un descripteur de fichier
    • Pour chaque événement I/O réel, il faut epoll_wait puis read()/write()
    • Au final, le traitement des événements accumule continuellement des syscalls supplémentaires
  • Un syscall implique un changement de contexte entre mode utilisateur et mode kernel, et l’overhead augmente avec le nombre de connexions

io_uring : modèle d’achèvement et ring buffer partagé

  • io_uring est apparu en 2019, environ 17 ans après l’intégration d’epoll dans le kernel Linux, et il est pris en charge à partir du kernel v5.1+
  • Contrairement à epoll, il ne se base pas sur le fait qu’une I/O soit possible, mais sur le fait qu’une I/O soit terminée
  • L’application et le kernel utilisent ensemble un ring buffer en mémoire partagée
    • La file de soumission contient les opérations que l’application veut demander au kernel
    • La file de complétion contient les résultats renvoyés par le kernel une fois les opérations terminées
  • Dans la configuration par défaut, il faut appeler io_uring_enter() pour que le kernel examine la file de soumission
    • Un seul appel permet de soumettre plusieurs opérations et de récupérer plusieurs complétions
    • On n’est pas dans un schéma où une paire de syscalls se répète pour chaque opération, comme avec epoll + read()
  • Avec IORING_SETUP_SQPOLL, un thread du kernel scrute la file de soumission
    • En régime normal, cela peut pratiquement éliminer les syscalls
    • Mais ce thread consomme du CPU même lorsque la file est vide
    • Après sq_thread_idle, il peut se mettre en veille, sans pour autant faire disparaître complètement ce coût

Différences visibles dans les exemples de code

  • Exemple epoll

    • Le descripteur de fichier stdin est enregistré, puis un read() séparé est appelé lorsqu’un événement survient
    • epoll_create1 crée une instance epoll
    • epoll_ctl enregistre STDIN_FILENO
    • epoll_wait bloque jusqu’à ce qu’une lecture soit possible
    • Quand l’événement arrive, les données sont lues via le syscall read()
    • Dans ce flux, chaque événement I/O réel nécessite donc epoll_wait et read
  • Exemple io_uring

    • Il utilise liburing
    • io_uring_queue_init initialise le ring
    • io_uring_get_sqe obtient une entrée de la file de soumission
    • io_uring_prep_read prépare une opération de lecture sur stdin
    • io_uring_submit soumet l’opération et io_uring_wait_cqe attend la complétion
    • L’exemple io_uring ne comporte pas de vérification explicite de l’état de préparation, et n’appelle pas séparément read() au moment de la complétion
    • Par simplification, les deux exemples omettent des traitements d’erreur importants
    • Si stdin ne contient pas de données, ils peuvent se bloquer indéfiniment
    • L’exemple io_uring ne vérifie pas non plus le cas où io_uring_get_sqe() renvoie NULL lorsque la file de soumission est pleine

Conditions supplémentaires pour utiliser io_uring

  • Pour utiliser le zero-copy I/O, il faut préenregistrer les buffers avec io_uring_register_buffers()
    • Cela évite que le kernel remappe la mémoire à chaque opération
    • Pour les transmissions réseau, IORING_OP_SEND_ZC du kernel 6.0+ permet un envoi sans copie du buffer vers le kernel
  • IORING_SETUP_SQPOLL peut réduire les syscalls, mais le prix à payer est la consommation CPU
    • Le thread du kernel continue de faire du polling même lorsque la file est vide
    • Il peut passer en veille après le délai d’inactivité, mais cela ne supprime pas totalement le coût
  • Les erreurs avec io_uring ne reviennent pas directement comme valeur de retour d’un syscall synchrone, mais de façon asynchrone dans le champ res d’une entrée de la file de complétion
    • La gestion des erreurs doit donc se faire via cqe->res

Le choix sur un serveur Linux moderne

  • epoll est une ancienne approche Linux des I/O asynchrones, fondée sur la notification de disponibilité des I/O et sur des appels système séparés
  • io_uring fournit, sur Linux moderne, un modèle basé sur l’achèvement ainsi que la soumission par lots et le traitement groupé des complétions
  • Pour créer un nouveau projet sur un serveur Linux moderne, choisir io_uring dès le départ paraît plus naturel
  • Si l’on peut raisonnablement abandonner la prise en charge des anciens systèmes, il y a peu de raisons de préférer epoll dans un environnement kernel v5.1+

1 commentaires

 
GN⁺ 3 시간 전
Commentaires sur Hacker News
  • J’ai jeté un très rapide coup d’œil au dépôt GitHub https://github.com/sibexico/TinyGate et il semble que l’affinité CPU ne soit pas encore utilisée
    En fixant les threads et les sockets d’écoute à un CPU, puis en utilisant sockopt SO_INCOMING_CPU, on peut sans doute gagner un peu plus en performances
    Si on alignait aussi les sockets sortants sur le CPU, l’amélioration pourrait être assez importante, mais à ma connaissance il n’existe pas de bonne API pour cela. Linux dispose d’API de traffic steering / flow steering pour les NIC compatibles, et si l’on connaît le hash utilisé par la NIC — probablement Toeplitz — on peut choisir intelligemment les ports source vers le backend pour faire correspondre le hash
    L’objectif est de faire en sorte que le proxy traite les paquets sans communication inter-CPU

    • Les v0 et v1 du dépôt sont des implémentations complètement différentes, presque entièrement réécrites depuis zéro, et je travaille actuellement sur une troisième implémentation, qui sera probablement la dernière. Les choix d’architecture ont eux aussi complètement changé
    • J’aimerais voir les benchmarks de ce patch
  • Cela vaudrait le coup de regarder https://github.com/concurrencykit/ck et https://github.com/microsoft/mimalloc. Ils conviendraient bien à un reverse proxy zero-copy et aligné en mémoire
    Si vous voulez ajouter de la protection DDoS et des fonctionnalités L4 plus avancées, https://docs.ebpf.io/ebpf-library/libxdp/libxdp/ mérite aussi le détour

    • Le plan était d’appliquer des optimisations à d’autres couches avant de passer à l’allocateur. En ce moment, j’étudie les allocateurs avec des étudiants, et l’un des précédents billets du blog portait sur un allocateur personnalisé écrit en Zig
  • C’est un vraiment très bon article
    À cause de lui, je suis tombé dans le terrier du lapin de uring, du développement kernel et du C. Cela fait déjà un bon moment que je développe en Rust et en C++, mais il y a dans les petits programmes C de taille raisonnable une simplicité, voire une forme d’élégance artistique

  • Je n’ai pas encore testé les buffers partagés sur un serveur web basé sur io_uring. C’est parce qu’au lieu de lire depuis un fichier puis d’écrire, on envoie directement depuis une zone mmap
    En réalité, j’aimerais utiliser sendfile avec io_uring, mais ce n’est pas encore pris en charge
    Un article avec Rust et kTLS comme mots-clés à la mode : https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
    Il a aussi été posté sur HN : https://news.ycombinator.com/item?id=44980865

    • Pour information, splice(2) est implémenté, donc on peut utiliser uring d’une manière proche de sendfile. Ce n’est pas aussi pratique à utiliser que sendfile, mais le fonctionnement devrait être presque identique
  • Le faire avec DPDK rendrait les choses bien plus complexes, mais cela offrirait une chance de surclasser nginx en performances
    Le faire tourner sur FPGA rendrait les choses encore plus complexes
    La leçon, c’est que pour obtenir de la performance, il faut parfois traverser les abstractions comme un couteau chaud dans du beurre, mais qu’en contrepartie tout devient plus difficile. Les sockets et le modèle un thread par connexion étaient une bonne approche à l’époque où le réseau était très lent comparé au CPU, et cela reste encore aujourd’hui souvent l’approche la plus simple

  • Je me suis toujours posé la question moi aussi, alors j’ai récemment écrit plusieurs implémentations d’un serveur de fichiers HTTP pour bien assimiler les différences essentielles
    https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...

  • Dans le contexte des proxys, il faudrait aussi mentionner le busy polling de epoll_wait. J’ai regardé cela récemment en étudiant des options de faible latence, et il semblait possible d’obtenir quelque chose de proche du busy polling en espace utilisateur avec de simples sockets, sans DPDK/VMA/io_uring, et Fastly y a contribué et l’utilise
    C’est trop bas niveau pour que je prétende en comprendre l’ensemble ; je n’en ai saisi que le concept, donc je laisse les liens. Cela ne fonctionne que par contexte NAPI epoll, et il n’est pas facile de contrôler l’ID NAPI, mais si l’on consacre toute la machine au proxy, un petit contournement simple consiste à attribuer les sockets à des pollers dédiés par ID NAPI
    Mon cas d’usage n’était pas un proxy, mais le polling de N sockets sur une machine puis le traitement des données reçues. Dans ce cas, cela ne semblait pas vraiment faisable, même si cela pourrait peut-être l’être en faisant du polling round-robin des contextes NAPI dans un seul thread. J’aimerais qu’un jour il soit facile d’indiquer au kernel : « fais-moi confiance, je finirai bien par poller ce socket unique, donc n’emprunte jamais le chemin IRQ »
    Ancienne discussion HN sur cette fonctionnalité du kernel : https://news.ycombinator.com/item?id=43749271
    Bon support de présentation d’un contributeur de Fastly, avec des schémas qui aident à comprendre la vue d’ensemble : https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
    Article LWN : https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
    Documentation du kernel : https://docs.kernel.org/networking/napi.html#irq-mitigation

  • Si vous aimez le C++ et le réseau asynchrone, il y a Boost.Asio

    • Récemment, j’ai remplacé Asio par une boucle d’événements epoll faite maison, et le RPS s’est amélioré d’environ 16 %. C’était le résultat sur un serveur SQL de taille modérée, donc il faut se méfier quand on utilise des bibliothèques bien empaquetées
    • Sur un serveur de base de données, remplacer le backend epoll d’Asio par io_uring a fait grimper fortement l’utilisation CPU. Cela dépend probablement beaucoup de la façon dont on l’utilise et de la manière dont on l’intègre au code événementiel
    • Boost est vraiment trop pénible. Ce sont d’énormes bibliothèques dynamiques difficiles à compiler et à utiliser. Même en utilisant déjà CMake, le processus pour installer Boost et le rendre détectable a été extrêmement agaçant. Cela dit, c’était sur Mac
  • Vers 2050, on aura probablement 20 façons de faire du polling de sockets sur Linux

    • Oui, et c’est aussi le cas à l’intérieur de io_uring. Pour aller plus vite, on a vu apparaître le mode one-shot de io_uring, puis même un mode multi-shot
  • Oui, io_uring est clairement plus rapide que epoll. Dans mon cas, io_uring était, je crois, environ 20 % plus rapide en requêtes par seconde
    Le problème, c’est qu’il faut l’activer explicitement dans le noyau, et qu’il est désactivé presque partout pour des raisons de sécurité. Il semble y avoir un partage mémoire direct entre le noyau et l’espace utilisateur, ce qui est assez inquiétant. Il y a aussi eu récemment plusieurs exploits visant io_uring
    C’est pourquoi même des projets d’ingénierie qui visent les meilleures performances possibles, comme Go, n’intègrent pas io_uring en profondeur comme valeur par défaut raisonnable. Si vous êtes prêt à accepter le risque, vous pouvez l’utiliser directement dans le langage de votre choix. C’est plus rapide, mais le prix à payer est un risque potentiel d’exploit

    • La raison principale de cette désactivation est désormais résolue. La dernière RC intègre la prise en charge de cBPF, ce qui permet de limiter les opérations exécutables au lieu de tout désactiver
    • Ça dépend des cas. Mon émulation POSIX de io_uring, construite avec poll et non epoll, a parfois été plus rapide que io_uring. En revanche, pour les gros tampons zero-copy, io_uring est imbattable
      io_uring est utile même pour autre chose que l’E/S asynchrone. Par exemple, on peut implémenter une chaîne d’opérations comme mkdir puis ouverture de ce répertoire comme une seule opération atomique
      En réseau, si l’on cherche à maximiser le nombre de paquets par seconde, on atteint très vite les limites du noyau[1], et il faut alors exploiter des fonctionnalités comme GSO/GRO ou contourner complètement la pile réseau
      1 : https://github.com/axboe/liburing/discussions/1346
    • RHEL 9 et 10 prennent désormais entièrement en charge io_uring par défaut. C’est très récent, mais cela couvre ainsi de nombreuses installations Linux en entreprise. Gemini a aussi « dit » qu’Ubuntu et SuSE le prennent en charge, mais sans fournir de lien pour le prouver
      https://access.redhat.com/solutions/4723221
      Go devrait aussi réexaminer sa prise en charge. Cela vaudrait le coup d’essayer
    • Pour un projet comme Go, il n’y aurait pas l’option de faire une détection des capacités de io_uring une seule fois au démarrage du runtime ? Un exploit n’est-il pas un problème de l’OS entier, et pas seulement du programme qui a choisi d’utiliser io_uring ?
    • Tous les types de réseau en mode polling — RDMA, DPDK, io_uring — finissent par exiger que l’utilisateur prenne en charge lui-même l’isolation mémoire
      Cela dit, dans le cas de io_uring, l’anneau est dans le noyau, donc l’utilisateur ne peut pas faire grand-chose
      J’espère que cela s’améliorera à l’avenir grâce aux LLM, mais c’est un problème difficile à résoudre. C’est aussi très difficile à traiter côté noyau, et beaucoup de gens ne comprennent pas vraiment comment l’optimiser