2 points par GN⁺ 2025-08-23 | 1 commentaires | Partager sur WhatsApp
  • Pour créer des serveurs web hautes performances, on utilisait jusqu’ici divers modèles orientés événements comme select(), poll(), epoll
  • Mais face aux limites de performance de ces appels système, io_uring est apparu en introduisant une approche où les requêtes sont placées dans une file que le noyau traite de façon asynchrone
  • kTLS confie au noyau le traitement du chiffrement TLS, ce qui permet des optimisations supplémentaires comme la possibilité d’utiliser sendfile() et l’offloading matériel
  • L’introduction des descriptorless files fournit une méthode optimisée pour io_uring sans transmettre directement de descripteurs de fichiers
  • Le projet open source tarweb, qui combine Rust, io_uring et kTLS, fournit du HTTPS sans appel système supplémentaire par requête, tout en abordant les questions de sûreté et de gestion mémoire

L’évolution de l’architecture des serveurs web hautes performances

  • Depuis le début des années 2000, la demande pour des serveurs web à très forte capacité a augmenté
  • Au départ, il était courant de créer un nouveau processus pour chaque requête, mais le coût élevé de cette approche a conduit à l’apparition de la technique de preforking
  • Ensuite, avec l’introduction des threads et l’adoption de select(), poll(), l’architecture a évolué vers une réduction du coût des changements de contexte
  • Toutefois, avec select() et poll(), plus le nombre de connexions augmente, plus il faut transmettre fréquemment de grands tableaux au noyau, ce qui limite la scalabilité

L’arrivée d’epoll

  • Sous Linux, epoll a été introduit pour permettre une gestion des connexions multiples plus efficace que les approches précédentes
  • epoll ne traite que les changements (deltas), ce qui réduit la consommation inutile de ressources
  • Tous les appels système ne disparaissent pas complètement, mais leur coût diminue fortement

Vue d’ensemble d’io_uring

  • io_uring ajoute les requêtes à une file en mémoire afin que le noyau puisse les traiter de manière asynchrone, au lieu d’appeler un appel système pour chaque requête
  • Par exemple, si accept() est placé dans la file, le noyau le traite puis renvoie le résultat dans la file de complétion
  • Le serveur web fonctionne en ajoutant des requêtes à la file et en consultant les résultats dans une autre zone mémoire
  • Pour éviter une boucle active (busy loop), si rien ne change dans la file, le serveur web et le noyau n’effectuent des appels système que lorsque c’est nécessaire, ce qui permet aussi d’économiser de l’énergie
  • Avec les bibliothèques appropriées, un serveur actif peut fonctionner sans appel système supplémentaire pendant le traitement des requêtes

Environnements multicœurs et NUMA

  • Compte tenu des CPU modernes multicœurs, il est pertinent d’adopter une stratégie d’un seul thread par cœur et de minimiser le partage des structures de données
  • Dans un environnement NUMA, l’optimisation consiste à faire en sorte que chaque thread n’accède qu’à la mémoire de son nœud local
  • Un équilibrage parfaitement uniforme de la répartition des requêtes demande encore des recherches supplémentaires

Allocation mémoire

  • Des allocations mémoire subsistent à la fois dans le noyau et dans le serveur web, et les allocations en espace utilisateur finissent elles aussi par impliquer des appels système
  • Côté serveur web, des blocs mémoire de taille fixe sont préalloués par connexion afin de prévenir la fragmentation et les pénuries
  • Côté noyau, des buffers d’entrée/sortie sont également nécessaires pour chaque connexion, avec certains ajustements possibles via les options de socket
  • En cas de manque de mémoire, cela peut conduire à des pannes graves

Présentation de kTLS (Kernel TLS)

  • kTLS est une fonctionnalité du noyau Linux qui prend en charge les opérations de chiffrement et de déchiffrement
  • Le handshake est traité par l’application, mais ensuite le noyau gère les transferts de données comme s’il s’agissait de texte brut
  • Cela permet d’utiliser sendfile(), réduisant ainsi les copies mémoire entre espace utilisateur et espace noyau
  • Si la carte réseau le prend en charge, il est même possible de déporter les opérations de chiffrement vers le matériel

Descriptorless Files

  • Cette approche vise à réduire la surcharge liée à la transmission directe de descripteurs de fichiers de l’espace utilisateur vers l’espace noyau
  • Avec register_files, on utilise des numéros de fichiers distincts, valables uniquement pour io_uring, et qui n’apparaissent pas dans /proc/pid/fd
  • Les limites ulimit du système continuent toutefois de s’appliquer

Présentation du projet tarweb

  • tarweb est un projet open source de serveur web d’exemple qui met en œuvre l’ensemble des technologies ci-dessus
  • Sa structure consiste à servir le contenu d’un unique fichier tar, en combinant des technologies modernes à hautes performances comme Rust, io_uring et kTLS
  • En usage réel, des problèmes de compatibilité entre io_uring et kTLS (comme l’absence de prise en charge de setsockopt) sont apparus, et certains ont été résolus via des Pull Requests
  • Le projet est encore inachevé, et la bibliothèque rustls de Rust peut effectuer des allocations mémoire pendant le handshake
  • Le point essentiel est qu’un service HTTPS est possible sans appel système supplémentaire par requête

Benchmarks et mesures de performance

  • L’auteur n’a pas encore réalisé de benchmarks suffisants et prévoit des tests de performance après avoir remis le code en ordre

Problèmes de sûreté avec io_uring et Rust

  • Contrairement aux appels système synchrones, avec io_uring, les buffers mémoire ne doivent pas être libérés avant l’événement de complétion
  • Le crate io-uring ne garantit pas la sûreté à la compilation en Rust, et les vérifications à l’exécution sont également insuffisantes
  • En cas de mauvaise utilisation, cela peut provoquer des problèmes graves comparables à ceux du C++, ce qui affaiblit la sûreté intrinsèque de Rust
  • Un crate distinct, safer-ring, exploitant activement le pinning et le borrow checker, est nécessaire
  • Ce sujet fait déjà l’objet de discussions dans la communauté

Références et liens supplémentaires

  • Ce contenu résume un billet discuté sur HackerNews au 2025-08-22

1 commentaires

 
GN⁺ 2025-08-23
Avis Hacker News
  • Lorsqu’on utilise io_uring pour soumettre des opérations d’écriture, il faut s’assurer que l’emplacement mémoire n’est ni libéré ni écrasé, mais l’API de la crate io-uring ne semble ni aidée par le borrow checker de Rust sur ce point, ni dotée de vérifications à l’exécution
    J’ai lu des articles et des commentaires à ce sujet, et au final cela donne vraiment l’impression qu’il est très difficile de créer une bibliothèque asynchrone Rust sûre autour de io_uring
    Je me souviens aussi qu’Alice de l’équipe tokio a récemment mentionné qu’il n’y avait plus tant d’intérêt que ça à essayer de surmonter ce problème
    Parce qu’en l’état, les performances sont déjà « suffisamment bonnes »
    Référence : https://boats.gitlab.io/blog/post/io-uring/

    • Il y a pas mal de choses frustrantes dans l’async Rust, et c’en est une
      L’async Rust a été conçu à l’époque où epoll était le standard, et IOCP a été largement ignoré
      Si les syscalls synchrones n’ont pas ce problème, c’est parce qu’au moment de l’appel à read, on passe une référence mutable du buffer au noyau, ce qui s’accorde bien avec le modèle natif de ownership/borrow de Rust
      Mais avec les I/O basées sur les complétions, pour que cela colle vraiment au modèle de propriété, il faut garantir que le code utilisateur ne continue pas à s’exécuter tant que l’opération n’est pas terminée, et ce n’est pas faisable avec une structure de polling en machine à états
      Un modèle à base de threads ou de green threads est ici parfaitement adapté
      Rust aurait peut-être été meilleur s’il avait ajouté une « cible dédiée à l’async »
      Les développeurs de Rust ont beaucoup misé sur le modèle asynchrone stackless à base de polling, et on est en train d’en voir l’issue

    • Je pense qu’il existe des modèles de propriété que le borrow checker de Rust ne peut pas bien prendre en charge
      J’appelle ça provisoirement la « propriété patate chaude » : on remet temporairement un buffer, puis on le récupère ensuite
      En Rust, coder ce genre de schéma de manière sûre est très difficile et rend le code désordonné

    • Contrairement à ce qu’a dit Alice de l’équipe tokio, il y a de l’intérêt côté I/O fichier
      Les I/O fichier sont déjà implémentées via spawn_blocking, donc elles rencontrent déjà le même problème de buffer que io_uring, et migrer vers io_uring n’est pas si difficile
      En revanche, l’API existante de tokio::net n’est pas compatible avec une API de buffers fondée sur io_uring, donc on peut faire des vérifications de readiness, mais un support complet est difficile

    • Pour créer une interface io_uring sûre, je pense que la meilleure approche consiste à recevoir des buffers détenus par la ring, puis à les lui rendre au moment de démarrer l’écriture

    • Il n’est pas nécessaire de tout exprimer avec des borrows
      Avec des structures de données comme Slab, on peut rendre ça cancel-safe
      Référence : https://github.com/steelcake/io2

  • J’ai vraiment trouvé cet article passionnant
    J’attends les tests de performance avec impatience, mais j’ai été marqué par le fait que l’auteur veuille d’abord remettre le code au propre avant de publier des benchmarks
    À une époque où seuls les benchmarks semblent compter, voir quelqu’un réfléchir ainsi est rafraîchissant
    Vers 11 ans, quand j’essayais de construire une base de données, je suis tombé sur cgi-bin, et je réalise seulement maintenant que cela lançait un nouveau processus à chaque requête
    sendfile a été un game changer pour les grands forums de jeux qui devaient servir simultanément des téléchargements de démos, et quand on voit des résultats comme les 40 ms gagnées chez Netflix ou les 70 % de temps de chargement en moins sur GTA 5, on sent qu’il y a là une ingénierie encore plus percutante
    Liens associés : Common Gateway Interface, cas Netflix 40 ms, réduction du chargement de GTA Online

    • Pas seulement CGI : les anciennes sessions HTTP de type CERN et Apache fonctionnaient aussi en forquant l’ensemble du serveur
      Avec le temps, cela s’est amélioré, mais la manière dont Apache était configuré a permis à des serveurs légers conçus dès le départ autour d’I/O orientées événements, comme nginx, de devenir extrêmement populaires

    • Je reste sceptique quant à l’efficacité de sendfile
      C’était à la mode à la fin des années 90, mais en pratique je pense que le gain de performance est minime

  • La plupart des orchestrateurs de workloads cloud (CloudRun, GKE, EKS, Docker en local, etc.) désactivent io_uring par défaut
    Tant que ce point ne s’améliore pas, io_uring risque de rester une technologie très de niche pendant un moment

    • Je me demande pourquoi ils désactivent io_uring

    • Dans ce cas, il faut revenir à l’auto-hébergement

  • Lecture vraiment très intéressante
    J’attendrai les benchmarks, donc pas de souci pour prendre son temps, et la priorité donnée par l’auteur au nettoyage du code avant les benchmarks m’a énormément marqué
    Aujourd’hui, beaucoup de projets misent tout sur les scores de benchmarks, donc cette manière de penser est vraiment rafraîchissante et respectable
    Je ne savais pas que ktls et io_uring pouvaient être utilisés de façons aussi variées

  • Voici où en est l’async à l’heure actuelle
    Rust : il faut comprendre Futures, Pin, Waker, runtime async, contraintes Send/Sync, objets de trait async, etc.
    C++20 : coroutines
    Go : goroutines
    Java21+ : threads virtuels

    • Les coroutines C++ utilisent des allocations sur le tas pour éviter le problème que Pin résout
      Cela s’écarte fortement du principe de « zéro overhead » recherché par C++
      Si Rust a mis si longtemps à introduire les traits async et en mettra encore à l’avenir, c’est aussi parce que Rust n’alloue pas les futures sur le tas
      Le compromis performance/portabilité contre complexité peut être valorisé différemment selon les projets

    • Les contraintes liées à Send/Sync gardent aussi leur importance dans d’autres langages, et leur absence permet d’écrire plus facilement du code subtilement incorrect

    • Si on écrit du code Rust « suffisamment correct » et qu’on s’appuie sur des primitives de niveau intermédiaire créées par d’autres, il n’est pas forcément nécessaire de comprendre tous ces concepts

    • Rust impose de comprendre ces concepts, sinon le code ne compile tout simplement pas
      En Go, une goroutine n’est pas de l’async, et sans comprendre les channels on ne comprend pas vraiment les goroutines
      L’implémentation des channels en Go est particulière, au point que le comportement dans les cas limites est difficile à prévoir intuitivement
      En Go, on peut coder sans compréhension profonde, ce qui a ses avantages et ses inconvénients
      Les « threads bon marché » ne sont pas la même chose que l’async
      tarweb (le serveur présenté dans le billet) est une boucle d’événements mono-thread basée sur io_uring, avec l’idée d’avoir un thread par cœur CPU
      Il serait plus juste de parler de « l’état actuel des threads bon marché » que de « l’état actuel de la concurrence massive »
      La plus grande différence entre cheap threads et boucle async, c’est la facilité de raisonnement
      Il y a aussi un inconvénient : même légers, les threads ont besoin d’une taille de pile

  • kTLS constitue clairement un progrès
    Moi aussi, il y a quelques années, j’ai vraiment créé un serveur avec 0 syscall par requête et j’en ai parlé sur mon blog (https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html)
    Mais cela a l’inconvénient de nécessiter en permanence du busy looping
    io_uring a progressé à une vitesse vraiment impressionnante ces dernières années

  • Ce projet est vraiment formidable, et comme j’imaginais quelque chose de similaire depuis longtemps, je suis heureux de voir quelqu’un l’avoir implémenté
    Pour écrire aussi du BPF en Rust, je recommande Aya
    GitHub du projet Aya

  • Je me demande où en est kTLS aujourd’hui
    J’ai demandé il y a peu à un développeur de Cilium : Thomas Graf s’est dit optimiste, mais en pratique le support noyau manque encore dans beaucoup de distributions Linux, donc son activation par défaut semble encore loin

    • C’est dommage, mais je me demande aussi à quel point son activation est compliquée
      Est-ce qu’il faut un noyau personnalisé, ou bien peut-on l’activer directement à l’exécution ?
      FreeBSD l’intègre au noyau et à OpenSSL depuis la version 13, et il est possible de l’activer à l’exécution via sysctl (kern.ipc.tls.enable=1)
      Dans FreeBSD-15, l’activation par défaut est prévue, et chez Netflix kTLS est utilisé depuis presque dix ans pour chiffrer le trafic

    • Globalement, kTLS me semble être une mauvaise idée

  • Je me demande si le modèle un thread par cœur est pertinent dans un système à tranches de temps
    D’après mon expérience, l’« oversubscription » (mettre plus de threads que de cœurs) apporte un vrai gain en temps réel mesuré au mur
    Un thread par cœur me semble surtout adapté quand il n’y a pas d’ordonnancement préemptif
    Bien sûr, dans ce cas on ne parle plus vraiment d’Unix

    • Si l’on cherche une faible latence et un fort débit, isoler les cœurs et épingler les threads peut être efficace
      Cette approche fonctionne bien sous Linux, et elle est souvent utilisée dans les systèmes de trading, même au prix d’une certaine inefficacité
      La plupart des cœurs restent inactifs à tourner à vide sans travail réel, mais pour la latence et le débit c’est optimal

    • Le piège du modèle thread-per-core, c’est de croire qu’on peut n’en prendre que les côtés pratiques
      En réalité, c’est quasiment tout ou rien
      Une implémentation à moitié faite n’est pas efficace du tout
      En revanche, si c’est bien conçu, l’efficacité est élevée dans presque tous les cas
      Les développeurs qui maîtrisent vraiment les subtilités de conception TPC, comme l’équilibrage de charge entre cœurs, sont rares

    • Le modèle thread-per-core n’est efficace que dans les cas « CPU bound »
      Quand, comme dans ce projet de serveur, la majorité du travail est asynchrone et pilotée par événements, le serveur passe théoriquement directement à la requête suivante sans presque attendre d’I/O ou de syscall, et un thread par cœur devient alors exactement la bonne structure
      Mais dans le monde réel, cette situation idéale est rare, donc il faut se méfier de l’idée de se limiter systématiquement à nproc threads

    • Avec io_uring, n’avoir qu’un seul thread utilisateur par cœur n’est peut-être pas un mauvais choix
      Car le noyau fonctionne de toute façon avec un pool de threads

  • J’aimerais aussi voir une approche qui contourne complètement le noyau, dans le style de DPDK