Au-delà d'epoll : Concevoir des tunnels inverses à très faible latence avec Linux io_uring

Quick answer
Au-delà d'epoll : Architecturer des tunnels inverses à très faible latence: localhost tunnel answer
A localhost tunnel gives your local app a public HTTPS URL without opening router ports, which is useful for demos, QA, mobile testing, and provider callbacks.
How do I expose localhost without opening ports?
Use a reverse HTTPS tunnel. Your machine connects outbound to the tunnel service, and the public URL forwards requests back to your local app.
When should I use a localhost tunnel?
Use one for webhook testing, OAuth callbacks, client demos, QA previews, mobile device checks, and short-lived development reviews.
Depuis des décennies, la base du réseautage haute performance sur Linux est incontestée. Si vous construisiez un serveur web, un load balancer ou un reverse proxy destiné à gérer le célèbre problème C10K (et plus tard C10M), vous utilisiez epoll. Des géants comme NGINX, HAProxy et Envoy ont été bâtis sur ce modèle d’événements, prouvant sa robustesse à travers le monde. Cependant, alors que nous repoussons les limites du trafic local à haut débit en 2026, les failles de l’architecture epoll deviennent de plus en plus évidentes.
Le problème ne concerne plus la gestion de milliers de connexions ; il s’agit du coût excrémentiel des changements de contexte. Lorsque la vitesse réseau atteint plusieurs centaines de gigabits par seconde et que les budgets de latence se réduisent à quelques microsecondes, l’oscillation continue entre espace utilisateur et espace noyau devient un goulet d’étranglement CPU catastrophique.
Entrez io_uring, la avancée la plus significative dans le domaine de l’I/O Linux de la dernière décennie. En migrant les binaires de tunnels modernes et reverse proxies vers cette API asynchrone avancée — qui exploite des anneaux de mémoire partagée — les ingénieurs éliminent la surcharge syscall sur le chemin critique. Ce changement de paradigme permet de créer des proxies réseau ultra-efficaces capables de gérer des millions de paquets multiplexés avec des pics CPU quasi nuls.
Cet article explore les différences techniques entre le réseautage epoll et io_uring, détaillant le fonctionnement d’un tunnel Linux asynchrone basé sur io_uring, pourquoi le reverse proxy io_uring modifie fondamentalement la conception des architectures d’ingress local à haut débit, et quels sont les compromis en termes de sécurité et d’écosystème en 2026.
L’anatomie du goulet d’étranglement epoll
Pour comprendre pourquoi un reverse proxy basé sur io_uring représente un saut aussi significatif, il faut d’abord analyser pourquoi epoll échoue à atteindre des débits ultra-élevés.
Une brève histoire
epoll a été introduit dans le noyau Linux en version 2.5.44 en octobre 2002 comme une solution scalable pour remplacer les anciens select() et poll(). Contrairement à ses prédécesseurs, dont la complexité était en O(n) à mesure que le nombre de descripteurs surveillés augmentait, epoll fonctionne en O(1) pour les notifications de disponibilité — une amélioration critique pour les serveurs à haute concurrence. Il a été la pierre angulaire de l’ère C10K et alimente aujourd’hui presque tous les serveurs haute performance en production, y compris libuv (Node.js), le poller réseau standard de Go, et les boucles d’événements dans NGINX et HAProxy.
Le paradigme de disponibilité
epoll est un mécanisme de notification de disponibilité. Lorsqu’un reverse proxy gère des milliers de sockets clients, il utilise epoll pour demander au noyau : “Quels descripteurs de fichiers ont des données prêtes à être lues, ou un espace tampon disponible pour écrire ?”
Le flux typique d’un proxy basé sur epoll ressemble à ceci :
- Le proxy appelle
epoll_wait(), bloquant jusqu’à ce qu’un ou plusieurs sockets soient prêts (changement de contexte : utilisateur → noyau → utilisateur). - Le noyau renvoie une liste de descripteurs prêts.
- Pour chaque socket prêt, le proxy émet un
read()ouwrite()système (changement de contexte : utilisateur → noyau → utilisateur, encore). - Si un socket retourne
EAGAIN(indiquant que l’opération bloquerait), le proxy s’arrête et attend le prochain cycle deepoll_wait().
Les coûts cachés du changement de contexte
Bien que epoll_wait scale mieux que ses prédécesseurs, les opérations I/O réelles nécessitent encore des appels système indépendants. Chaque read(), write(), accept(), et close() force un changement de contexte CPU. Lors d’un changement de contexte, le CPU doit sauvegarder l’état des registres en espace utilisateur, vider certaines entrées TLB, sauter en espace noyau, valider les pointeurs, effectuer l’opération, puis revenir. À 10 000 requêtes par seconde, ce coût est négligeable. À 1 000 000 de paquets par seconde, il domine le profil CPU. En environnement à haute concurrence, le proxy peut finir par passer plus de temps à traverser la frontière utilisateur–noyau qu’à exécuter la logique applicative.
De plus, epoll sépare strictement I/O réseau et I/O fichier. Les fichiers réguliers sous Linux sont toujours considérés comme “prêts” par epoll, mais leurs lectures peuvent encore bloquer sur disque. Cela oblige les développeurs de proxy à maintenir des pools de threads séparés pour l’I/O fichier, ce qui introduit contention mutex, surcharge mémoire supplémentaire, et complexité de coordination.
Entrez io_uring : I/O asynchrone redéfini
Développé par Jens Axboe chez Facebook (maintenant Meta), io_uring a été intégré pour la première fois dans le noyau Linux en version 5.1, sortie en mai 2019. Il a été conçu explicitement pour pallier les limitations de l’ancienne interface Linux AIO — qui ne supportait que l’I/O direct, souffrait d’un comportement de blocage non déterministe, et nécessitait au moins deux appels système par opération I/O. L’interface io_uring abandonne complètement le modèle de disponibilité au profit d’un modèle de complétion.
Au lieu de demander au noyau “Ce socket est-il prêt ?”, une application utilisant io_uring dit : “Voici un tampon. Lis les données de ce socket dans ce tampon, et préviens-moi quand tu as terminé.”
Les anneaux de mémoire partagée
La magie de io_uring réside dans ses anneaux de mémoire partagée : lorsque le proxy initialise une instance via io_uring_setup(), il crée deux anneaux circulaires mappés en mémoire partagée accessible à la fois par l’espace utilisateur et le noyau :
- File d’attente de soumission (SQ) : L’application utilisateur écrit ici des SQEs (Submission Queue Entries). Un SQE décrit une opération I/O : un
readv, unwritev, unaccept, un timer, etc. - File d’attente de complétion (CQ) : Le noyau écrit ici des CQEs (Completion Queue Entries). Une fois une opération terminée, le noyau pousse le résultat — octets lus/écrits, ou code d’erreur — dans la CQ.
Puisque ces queues résident en mémoire partagée, l’application peut enchaîner de nombreuses opérations I/O sans faire un seul appel système. Une fois la queue remplie, un seul syscall io_uring_enter() notifie le noyau de commencer le traitement du lot. Le noyau traite les requêtes de façon asynchrone, écrivant directement les résultats dans la CQ pour que l’application les consomme.
En batchant les opérations, io_uring amortit immédiatement le coût des appels système sur plusieurs opérations I/O. Pour un tunnel Linux asynchrone haute performance, cela constitue déjà une amélioration significative. Mais io_uring va bien plus loin.
Un modèle d’I/O unifié
Contrairement à epoll, io_uring offre une interface unifiée pour l’I/O réseau et l’I/O fichier. Un proxy peut soumettre des lectures socket, des écritures socket, des opérations sendfile, et des lectures disque dans le même anneau, recevant leurs complétions dans la même CQ. Cela élimine le besoin de pools de threads séparés pour l’I/O fichier, simplifiant considérablement l’architecture des proxies servant à la fois flux réseau et caches locaux sur fichier.
Architecturer un proxy réseau quasi zéro-syscall
L’objectif pour les architectes de proxies io_uring est la réduction — et sur le chemin critique, l’élimination quasi totale — des appels système. io_uring permet cela via une option appelée IORING_SETUP_SQPOLL.
SQPOLL : contourner io_uring_enter
Lorsqu’une instance io_uring est initialisée avec IORING_SETUP_SQPOLL, le noyau Linux lance un thread dédié à cet anneau. Ce thread scrute en continu la File d’attente de soumission partagée pour de nouvelles entrées, plutôt que d’attendre d’être réveillé par un syscall io_uring_enter().
Voici comment fonctionne le proxy sous SQPOLL :
- L’application proxy écrit de nouvelles opérations réseau (read, write, accept) dans la SQ en mémoire partagée.
- Elle ne fait pas appel à
io_uring_enter(). - Le thread noyau dédié détecte instantanément les SQEs et exécute les opérations réseau.
- Le noyau écrit les résultats dans la CQ.
- L’application lit les résultats dans la CQ en mémoire partagée.
Pendant la transmission continue de données, le processus proxy évite de déclencher un changement de contexte pour chaque opération I/O. L’application reste en espace utilisateur, alimentant les opérations dans la mémoire partagée et lisant les résultats. Le thread noyau gère le reste.
Une nuance importante : si le thread de polling du noyau devient inactif après un timeout configurable (défini via sq_thread_idle en millisecondes), l’application doit le réveiller à nouveau via io_uring_enter(). Un proxy sous charge continue peut maintenir ce thread actif indéfiniment et éviter ce coût.
Exigences de privilèges ont considérablement évolué depuis l’introduction de SQPOLL. Les premiers noyaux nécessitaient CAP_SYS_ADMIN. La version 5.11 a assoupli cela à CAP_SYS_NICE. À partir du noyau 5.13, aucun privilège particulier n’est requis pour SQPOLL sur les noyaux modernes — ce qui en fait une option de déploiement réaliste hors des conteneurs nécessitant des caps élevés.
Tampons fixes et fichiers enregistrés
Pour éliminer tout reste de surcharge, les proxies modernes io_uring utilisent io_uring_register_buffers() et io_uring_register_files(). Dans les proxies epoll traditionnels, chaque read() ou write() nécessite que le noyau traduise les pointeurs mémoire utilisateur et consulte les tables de descripteurs. En enregistrant à l’avance des tampons fixes et des fichiers, le proxy fixe en permanence ces pages mémoire et met en cache les mappages de fichiers dans le noyau. Lorsqu’il soumet un SQE avec un index de tampon enregistré, le noyau évite la surcharge de recherche par opération, permettant des chemins DMA directs entre la NIC et la mémoire utilisateur.
Accept multi-shot
Une fonctionnalité particulièrement puissante pour les reverse proxies est IORING_OP_ACCEPT avec le drapeau IORING_ACCEPT_MULTISHOT, introduit dans Linux 5.19. Un appel accept() traditionnel — même dans io_uring — nécessite une resoumission après chaque nouvelle connexion acceptée. Avec l’accept multi-shot, un seul SQE génère en continu des CQEs pour chaque nouvelle connexion entrante sans besoin de resoumission. Pour un proxy d’ingress à haute concurrence gérant des millions de connexions courtes, cela élimine toute une classe de surcharge de resoumission.
Réseau Zero-Copy : La frontière Linux 6.15
Peut-être la évolution la plus significative récente dans le réseautage io_uring est arrivée avec Linux 6.15 en 2025 : la réception zero-copy native (ZC Rx). Avant cela, io_uring supportait la transmission zero-copy (envoi de données depuis des tampons utilisateur vers la NIC sans copies noyau), mais la réception nécessitait encore une étape de copie noyau vers utilisateur.
La nouvelle fonctionnalité ZC Rx configure une file de réception matérielle pour DMA directement dans la mémoire utilisateur. Le noyau traite les en-têtes de paquets via la pile TCP/IP normale, mais la charge utile ne touche jamais la mémoire noyau. “Lire” depuis un socket devient effectivement un mécanisme de notification : le noyau indique à l’application où dans la mémoire utilisateur les données sont déjà arrivées. Lors d’une démonstration avec cette fonctionnalité, une liaison à 200 Gbit/s a été saturée avec un seul cœur CPU.
Cela propulse les proxies basés sur io_uring à un nouveau niveau : non seulement réduction des appels système, mais une voie vers une réception véritablement zero-copy au niveau matériel, sans complexité de bypass noyau comme avec DPDK.
Le tunnel Linux asynchrone 2026 : Rust et évolutions du runtime
La transition vers io_uring n’est pas simplement un changement d’API noyau ; elle demande une refonte du runtime interne du proxy. epoll repose sur la disponibilité, donc les proxies traditionnels gèrent leurs propres tampons mémoire, en transmettant un pointeur au noyau uniquement lorsque la socket est prête. Avec io_uring, le modèle évolue vers transfert de propriété du tampon (souvent appelé “location de tampon” ou “buffer renting”) : puisque le noyau exécute la lecture ou l’écriture de façon asynchrone, il doit posséder le tampon jusqu’à la fin de l’opération. Si le proxy modifie ou libère le tampon pendant que le noyau écrit encore dedans, cela entraîne une corruption mémoire.
Runtimes en thread par cœur sous Rust
Pour exploiter pleinement io_uring, les développeurs de binaires de tunnels modernes ont majoritairement adopté Rust et des runtimes spécialisés thread-par-coeur. Deux options principales :
- Monoio (ByteDance / CloudWeGo) : un runtime Rust asynchrone basé sur
io_uring/epoll/kqueue avec un modèle thread-par-coeur. Monoio nécessite Linux kernel 5.6+ pourio_uringet implémente la location de tampon nativement. Les benchmarks de ByteDance montrent leur implémentation de passerelle Monoio surpassant NGINX jusqu’à 20% dans des scénarios optimisés, avec des RPC montrant une amélioration de 26% par rapport à des stacks Tokio. - Glommio (initialement Datadog) : un runtime coopératif thread-par-coeur pour Rust basé sur
io_uring, nécessitant kernel 5.8+ et au moins 512 KiB de mémoire verrouillée (RLIMIT_MEMLOCK). Il crée trois instancesio_uringpar thread — un anneau principal, un anneau sensible à la latence, et un anneau de polling — pour un contrôle plus fin de la planification entre latence et débit.
Les deux imposent un modèle sans partage entre CPU :
- Pas de vol de travail : chaque thread CPU possède sa propre instance
io_uringet gère ses propres connexions. - Location de tampon : le proxy “loue” la propriété d’un tampon mémoire au runtime. Lorsqu’une lecture réseau se termine, le runtime retourne la propriété du tampon à la logique applicative.
- Localité du cache : comme les tâches ne migrent jamais entre cœurs, les caches L1 et L2 restent chauds. Pas de contention mutex, pas de surcharge de verrouillage, pas de synchronisation inter-thread.
Des benchmarks indépendants de serveurs HTTP statiques comparant les runtimes Rust io_uring à Tokio standard montrent qu’en un seul thread, Monoio atteint environ 656 000 req/s contre environ 399 000 req/s pour Tokio — un avantage d’environ 64%. À quatre threads, les runtimes io_uring dépassent collectivement 1,1 million req/s, avec tokio-uring et Monoio en tête. À quatre threads, les runtimes basés sur io_uring surpassent le fasthttp de Go d’environ 2,3×.
Dans un scénario de tunnel d’ingress — où un proxy local déchiffre des flux multiplexés entrants (comme HTTP/3 sur QUIC) et les redirige vers des microservices locaux — cette architecture brille. Un seul anneau gère la lecture réseau externe, l’écriture réseau vers le service local, et les événements de timer pour le keepalive, le tout en transactions mémoire partagée.
La réalité de l’écosystème
Il faut être honnête sur la maturité de l’écosystème. Glommio, initialement développé par Glauber Costa chez Datadog, a vu son développement actif diminuer depuis que son auteur a quitté le projet et que l’équipe Datadog a recentré ses efforts. Monoio continue de recevoir des patchs et reste fonctionnel, mais son API pour les nouvelles fonctionnalités io_uring est un peu en retard par rapport à l’évolution rapide du noyau. Apache Iggy, un broker de messages haute performance, a publié début 2026 un retour d’expérience sur la migration vers une architecture io_uring thread-par-coeur, rencontrant des frictions : les runtimes Rust disponibles n’exposent pas encore complètement les primitives io_uring comme le chaining de requêtes, les APIs one-shot, ou les pools de tampons enregistrés, au même niveau que liburing en C.
L’écosystème mûrit, mais les développeurs qui ont besoin des fonctionnalités les plus avancées de io_uring pourraient se retrouver à travailler plus près de l’API C liburing que prévu.
Réseautage epoll vs io_uring : nuances du monde réel
epoll est-il mort ? Absolument pas. Pour la majorité des services web classiques, APIs, et applications à trafic modéré, epoll reste mature, profondément intégré dans les runtimes existants, et parfaitement suffisant. Node.js (libuv) et Go standard utilisent epoll en interne et gèrent des millions de workloads en production chaque jour.
Le cas de io_uring devient plus pertinent à certains seuils opérationnels :
- Débits extrêmement élevés où le coût par syscall devient un facteur CPU mesurable.
- Charges mixtes I/O (réseau + disque) où un modèle asynchrone unifié simplifie l’architecture.
- Sensibilité à la latence en queue où le modèle de threading d’
epollintroduit des jitter de planification à p99/p999. - Chemins de réception zero-copy où le hardware NIC et Linux 6.15+ permettent un DMA direct en mémoire utilisateur.
La documentation de Red Hat est prudente : io_uring a été une victoire pour l’I/O fichier, mais pour le réseau — qui dispose déjà d’APIs non bloquantes — les gains dépendent fortement si la charge est liée aux syscalls. Toujours benchmarker dans des conditions réalistes avant de réécrire toute l’architecture.
Considérations de sécurité : l’épée à double tranchant
Aucun discours sur io_uring en production n’est complet sans aborder sa surface d’attaque. En juin 2023, l’équipe sécurité de Google a rapporté que 60% des exploits soumis à leur bug bounty kernel en 2022 ciblaient io_uring. Google a versé environ 1 million USD en récompenses pour vulnérabilités liées à io_uring. En conséquence, io_uring a été désactivé pour les applications tierces sur Android (avec des politiques SELinux limitant l’accès à certains processus système de confiance), désactivé complètement sur ChromeOS, et restreint sur les serveurs de production Google.
Exemples CVE notables :
- CVE-2021-41073 : gestion mémoire incorrecte permettant une escalade de privilèges locale.
- CVE-2023-2598 : accès hors limites permettant une LPE.
- CVE-2023-21400 : vulnérabilité de double-free dans le noyau 5.10, exploitée avec succès sur Google Pixel 7 dans une preuve de concept.
La surface d’attaque provient de la complexité de io_uring et de sa capacité à contourner la surveillance classique. Les outils EDR et systèmes de détection d’intrusions basés sur syscall qui interceptent read(), write(), sendmsg(), et recvmsg() sont pratiquement aveugles face à un processus opérant uniquement via les anneaux. Les outils classiques comme strace restent silencieux durant le chemin actif des données — car aucun syscall n’est effectué.
Pour les déploiements en production, les mitigations recommandées sont :
- Utiliser des outils de monitoring basés sur eBPF qui instrumentent les tracepoints kernel de
io_uringplutôt que d’intercepter les syscalls. - Restreindre la création d’instances
io_uringvia/proc/sys/kernel/io_uring_disabledetio_uring_groupsur des systèmes multi-tenant. - Limiter aux versions kernel bien patchées ; les versions LTS 5.15 et 6.x ont reçu des backports de sécurité complets.
- Auditer les politiques de sécurité des conteneurs — l’accès à
io_uringdoit être explicitement contrôlé dans les profils seccomp.
Surmonter les défis de io_uring
Malgré sa puissance, architecturer un reverse proxy io_uring comporte des défis techniques :
Dépendance au noyau. io_uring est apparu en 5.1, mais ses fonctionnalités réseau critiques sont arrivées dans des versions ultérieures : accept multi-shot en 5.19, SQPOLL fiable sans privilèges en 5.13, transmission zero-copy en 5.15, réception zero-copy en 6.15. Déployer des tunnels avancés sur des distributions Linux d’entreprise avec noyaux plus anciens (par exemple RHEL 8.x avec noyau 4.18) entraînera un fallback gracieux vers epoll ou une défaillance complète. La version du noyau doit être explicitement ciblée.
Consommation mémoire. Les anneaux et tampons fixés nécessitent de la mémoire verrouillée (RLIMIT_MEMLOCK). Glommio recommande au minimum 512 KiB par thread d’exécuteur. À grande échelle, avec plusieurs anneaux, cela requiert un tuning précis du système pour éviter OOM. Chaque anneau SQPOLL occupe aussi un thread noyau dédié, consommant un cœur CPU.
Ordre et sérialisation. Les CQEs peuvent arriver dans un ordre différent de celui des SQEs soumis (sauf si explicitement liés avec IOSQE_IO_LINK ou IOSQE_IO_HARDLINK). Sur TCP stream, plus d’un envoi ou réception en attente sans ordre explicite peut être risqué, car le noyau peut réordonner leur exécution lors de l’armement du poll. Il faut suivre le contexte de chaque requête via user_data.
Opacité du débogage. Les outils classiques comme strace sont pratiquement aveugles sur un proxy sans syscall en chemin critique. Le débogage nécessite bpftrace ou des programmes eBPF personnalisés pour inspecter directement les tracepoints io_uring. C’est une surcharge opérationnelle non triviale.
Sécurité de l’annulation. Comme le noyau possède les tampons durant les opérations asynchrones, les supprimer ou réutiliser avant la réception du CQE entraîne une corruption mémoire. La gestion de propriété en Rust, avec la location de tampons dans des runtimes comme Monoio et Glommio, y répond — mais uniquement si le code applicatif est structuré correctement.
À suivre en 2026
La trajectoire de io_uring reste ascendante :
- PostgreSQL 18 introduit un backend
io_uringoptionnel pour les données et le WAL, avec des benchmarks précoces montrant un gain de 3× sur des scans à froid, et un gain cumulé de 11–15% avec tampons enregistrés et SQPOLL. - Réception zero-copy au niveau NIC (Linux 6.15) ouvre la voie à des architectures proxy où la charge utile passe directement du NIC à la mémoire applicative, évitant totalement les buffers noyau sur hardware supporté.
- Kernel 7.0 (avril 2026) introduit le mode
IORING_SETUP_NO_SQARRAYavecIORING_SETUP_LINEAR_SEQNO, un mode de file non circulaire conçu pour garder les SQEs en cache L1 pour des soumissions par lots fréquentes et petits.
Conclusion : une évolution mesurée
L’architecture epoll a apporté une valeur énorme pendant plus de deux décennies et n’est pas obsolète pour autant. Mais pour les équipes opérant à la frontière du trafic local à très haut débit — où les arrays NVMe délivrent des millions d’IOPS et les NIC 400 Gbit/s sont une réalité datacenter — le coût par syscall du modèle de disponibilité devient un vrai goulot d’étranglement.
L’API noyau io_uring n’est pas une simple substitution ; c’est une réinvention fondamentale de la communication entre espace utilisateur et espace noyau. Ses anneaux de mémoire partagée, sa sémantique de location de tampon, son mode SQPOLL, et désormais la réception zero-copy matérielle offrent une architecture complète capable d’extraire des performances que les designs basés sur epoll ne peuvent égaler.
Les compromis existent : exigences de version noyau, surface de sécurité complexe et encore en évolution, visibilité limitée avec strace, et runtimes Rust qui n’exposent pas encore toutes les fonctionnalités avancées de io_uring. Aucun de ces points n’est rédhibitoire pour une équipe qui planifie soigneusement, cible des noyaux LTS modernes, utilise l’eBPF pour instrumentation, et teste sous charge réelle avant de s’engager.
Si vous construisez une infrastructure pour un ingress local à très haut débit, du tunneling inversé, ou des API gateways à faible latence, io_uring mérite une évaluation sérieuse. Ce n’est pas le bon choix pour toutes les charges, mais pour celles où il s’intègre, l’écart avec epoll n’est pas marginal — c’est une révolution architecturale.
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.