Un serveur HTTPS zéro syscall avec io_uring, kTLS et Rust
(blog.habets.se)- 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
ulimitdu 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
rustlsde 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-uringne 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
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 RustMais 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 difficileEn revanche, l’API existante de
tokio::netn’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 difficilePour 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êtesendfilea é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 percutanteLiens 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
sendfileC’é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
ktlsetio_uringpouvaient être utilisés de façons aussi variéesVoici 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
PinrésoutCela 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/Syncgardent aussi leur importance dans d’autres langages, et leur absence permet d’écrire plus facilement du code subtilement incorrectSi 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 CPUIl 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
kTLSconstitue clairement un progrèsMoi 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
kTLSaujourd’huiJ’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
kTLSest utilisé depuis presque dix ans pour chiffrer le traficGlobalement,
kTLSme semble être une mauvaise idéeJe 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 à
nprocthreadsAvec 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
Lien vers l’article : https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf