Comparaison entre epoll et io_uring sous Linux
(sibexi.co)- 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èsepoll_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 queIORING_SETUP_SQPOLLré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()ouwrite()
- Dans le flux habituel, le coût des syscalls se répète à chaque événement
epoll_ctlest un syscall ponctuel servant à enregistrer un descripteur de fichier- Pour chaque événement I/O réel, il faut
epoll_waitpuisread()/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
stdinest enregistré, puis unread()séparé est appelé lorsqu’un événement survient epoll_create1crée une instance epollepoll_ctlenregistreSTDIN_FILENOepoll_waitbloque 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_waitetread
- Le descripteur de fichier
-
Exemple io_uring
- Il utilise
liburing io_uring_queue_initinitialise le ringio_uring_get_sqeobtient une entrée de la file de soumissionio_uring_prep_readprépare une opération de lecture surstdinio_uring_submitsoumet l’opération etio_uring_wait_cqeattend 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
stdinne 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()renvoieNULLlorsque la file de soumission est pleine
- Il utilise
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_ZCdu kernel 6.0+ permet un envoi sans copie du buffer vers le kernel
IORING_SETUP_SQPOLLpeut 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
resd’une entrée de la file de complétion- La gestion des erreurs doit donc se faire via
cqe->res
- La gestion des erreurs doit donc se faire via
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
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 performancesSi 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
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
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 artistiqueJe 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 zonemmapEn réalité, j’aimerais utiliser
sendfileavecio_uring, mais ce n’est pas encore pris en chargeUn 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
splice(2)est implémenté, donc on peut utiliseruringd’une manière proche de sendfile. Ce n’est pas aussi pratique à utiliser quesendfile, mais le fonctionnement devrait être presque identiqueLe 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’utiliseC’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 NAPIMon 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
epollfaite 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éesepolld’Asio pario_uringa 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énementielVers 2050, on aura probablement 20 façons de faire du polling de sockets sur Linux
io_uring. Pour aller plus vite, on a vu apparaître le mode one-shot deio_uring, puis même un mode multi-shotOui,
io_uringest clairement plus rapide queepoll. Dans mon cas,io_uringétait, je crois, environ 20 % plus rapide en requêtes par secondeLe 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_uringC’est pourquoi même des projets d’ingénierie qui visent les meilleures performances possibles, comme Go, n’intègrent pas
io_uringen 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’exploitio_uring, construite avecpollet nonepoll, a parfois été plus rapide queio_uring. En revanche, pour les gros tampons zero-copy,io_uringest imbattableio_uringest utile même pour autre chose que l’E/S asynchrone. Par exemple, on peut implémenter une chaîne d’opérations commemkdirpuis ouverture de ce répertoire comme une seule opération atomiqueEn 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
io_uringpar 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 prouverhttps://access.redhat.com/solutions/4723221
Go devrait aussi réexaminer sa prise en charge. Cela vaudrait le coup d’essayer
io_uringune 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’utiliserio_uring?io_uring— finissent par exiger que l’utilisateur prenne en charge lui-même l’isolation mémoireCela dit, dans le cas de
io_uring, l’anneau est dans le noyau, donc l’utilisateur ne peut pas faire grand-choseJ’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