Comparación entre epoll e io_uring en Linux
(sibexi.co)- El proxy inverso TinyGate mejoró su rendimiento al pasar de una arquitectura basada en workers a epoll, pero después se topó con sus límites y fue reescrito de nuevo con io_uring
- epoll es un modelo de disponibilidad que avisa cuándo el I/O está listo, así que después de
epoll_waithay que llamar por separado aread()/write() - io_uring es un modelo de finalización que opera en función de si el I/O ya terminó, y la aplicación y el kernel intercambian una cola de envío y una cola de finalización mediante un ring buffer compartido
io_uring_enter()sigue siendo necesario por defecto, pero permite enviar y recuperar varias operaciones de una sola vez, yIORING_SETUP_SQPOLLreduce las syscalls a cambio de uso de CPU- Si vas a empezar un proyecto nuevo en un servidor Linux moderno con kernel v5.1+, io_uring se considera una opción más adecuada que epoll
Los límites de epoll que dejó en evidencia TinyGate
- TinyGate era un servidor proxy inverso creado junto con estudiantes, y su primera versión tenía una estructura simple basada en workers
- Como proyecto educativo funcionaba, pero comparado con herramientas como nginx o haproxy tenía grandes limitaciones de arquitectura
- La segunda versión pasó a estar basada en epoll, y mejoró mucho el rendimiento frente a la primera versión
- Aun así, en los benchmarks seguía sin superar a nginx/haproxy
- Después, debido a las limitaciones de epoll, se migró a io_uring y el proyecto tuvo que reescribirse desde cero
epoll: notificación de disponibilidad y syscalls repetidas
- epoll es un mecanismo de gestión de I/O asíncrono usado desde hace mucho tiempo en Linux, e ingresó al kernel de Linux en 2002
- Su idea central es la notificación de disponibilidad del I/O
- epoll avisa que “se puede leer o escribir”
- La lectura y escritura real de los datos la hace después la aplicación mediante las syscalls
read()owrite()
- En el flujo habitual, el costo de syscall se repite en cada evento
epoll_ctles una syscall única para registrar file descriptors- En cada evento real de I/O hacen falta
epoll_waityread()/write() - Como resultado, el procesamiento de eventos sigue acumulando syscalls adicionales
- Las syscalls generan cambios de contexto entre modo usuario y modo kernel, y el overhead crece conforme aumenta el número de conexiones
io_uring: modelo de finalización y ring buffer compartido
- io_uring apareció en 2019, unos 17 años después de que epoll llegara al kernel de Linux, y es compatible con kernel v5.1+
- A diferencia de epoll, no se basa en si el I/O está disponible, sino en si el I/O ya se completó
- La aplicación y el kernel usan juntos un ring buffer en memoria compartida
- En la cola de envío, la aplicación coloca las operaciones que quiere pedirle al kernel
- En la cola de finalización, el kernel publica de vuelta los resultados completados
- En la configuración por defecto, hay que llamar a
io_uring_enter()para que el kernel revise la cola de envío- Una sola llamada puede enviar varias operaciones y recuperar varias finalizaciones
- No es una estructura que repita un par de syscalls por trabajo, como en la combinación de epoll con
read()
- Con
IORING_SETUP_SQPOLL, un hilo del kernel hace polling sobre la cola de envío- En operación normal, las syscalls pueden reducirse casi por completo
- Como el hilo del kernel sigue ejecutándose incluso cuando la cola está vacía, consume CPU
- Después de
sq_thread_idlepuede pasar a sleep, pero eso no elimina el costo
La diferencia vista con ejemplos de código
-
Ejemplo con epoll
- Registra el file descriptor de
stdiny, cuando llega un evento, llama aparte aread() - Crea una instancia de epoll con
epoll_create1 - Registra
STDIN_FILENOconepoll_ctl - Se bloquea con
epoll_waithasta que se pueda leer - Cuando llega el evento, lee los datos con la syscall
read() - En este flujo, cada evento real de I/O necesita
epoll_waityread
- Registra el file descriptor de
-
Ejemplo con io_uring
- Usa
liburing - Inicializa el ring con
io_uring_queue_init - Obtiene una entrada de la cola de envío con
io_uring_get_sqe - Prepara una operación de lectura de
stdinconio_uring_prep_read - Envía con
io_uring_submity espera la finalización conio_uring_wait_cqe - En el ejemplo de io_uring no hay una verificación de disponibilidad separada, ni se llama a
read()aparte al completarse - Para simplificar, en ambos ejemplos faltan manejos importantes de excepciones
- Si no hay datos en
stdin, puede quedarse bloqueado para siempre - El ejemplo de io_uring no comprueba el caso en que
io_uring_get_sqe()devuelveNULLcuando la cola de envío está llena
- Usa
Condiciones adicionales al usar io_uring
- Para usar zero-copy I/O, hay que registrar previamente los buffers con
io_uring_register_buffers()- Eso evita que el kernel tenga que volver a mapear la memoria en cada operación
- En transmisión de red,
IORING_OP_SEND_ZCde kernel 6.0+ ofrece envíos sin copiar el buffer al kernel
IORING_SETUP_SQPOLLpuede reducir syscalls, pero el costo es el uso de CPU- Aunque la cola esté vacía, el hilo del kernel sigue haciendo polling
- Puede pasar a sleep tras el idle timeout, pero eso no hace desaparecer el costo
- Los errores en io_uring no regresan como valor de retorno directo de una syscall síncrona, sino de forma asíncrona en el campo
resde la entrada de la cola de finalización- El manejo de errores debe hacerse a través de
cqe->res
- El manejo de errores debe hacerse a través de
La elección en servidores Linux modernos
- epoll es una forma antigua de I/O asíncrono en Linux, basada en notificar cuándo el I/O está disponible y luego hacer syscalls separadas
- io_uring ofrece en Linux moderno un modelo basado en finalización y procesamiento por lotes tanto para envíos como para finalizaciones
- Si estás creando un proyecto nuevo desde cero en un servidor Linux moderno, lo natural es elegir io_uring
- Si puedes dejar de soportar sistemas antiguos en un plazo razonable, en un entorno con kernel v5.1+ no hay muchas razones para elegir epoll
1 comentarios
Comentarios de Hacker News
Le eché un vistazo rapidísimo al repositorio de GitHub https://github.com/sibexico/TinyGate y parece que todavía no usa afinidad de CPU
Si fijas los hilos y los sockets de escucha a la CPU, y usas
sockopt SO_INCOMING_CPU, se puede exprimir un poco más el rendimientoSi además alineas los sockets salientes por CPU, debería haber una mejora bastante grande, pero hasta donde sé no existe una buena API para eso. Linux tiene APIs de dirección de tráfico/dirección de flujo para NIC compatibles, y si sabes qué hash usa la NIC —probablemente Toeplitz— puedes elegir bien los puertos de origen hacia el backend para que el hash coincida
La meta es hacer que el proxy procese los paquetes sin comunicación entre CPUs
Valdría la pena mirar https://github.com/concurrencykit/ck y https://github.com/microsoft/mimalloc. Deberían encajar bien con un proxy inverso de cero copias y memoria alineada
Si quieres agregar mitigación de DDoS y funciones L4 más avanzadas, también vale la pena revisar https://docs.ebpf.io/ebpf-library/libxdp/libxdp/
Es un artículo realmente bueno
Por este artículo me metí en la madriguera de
uring, el desarrollo del kernel y C. Llevo bastante tiempo desarrollando con Rust y C++, pero hay una simplicidad e incluso una cualidad artística en los programas en C pequeños y de tamaño razonableEn el servidor web basado en
io_uring, todavía no he probado buffers compartidos. Eso es porque, en vez de leer de un archivo y luego escribir, envío directamente desde una región conmmapEn realidad quiero usar
sendfileconio_uring, pero todavía no está soportadoUn artículo con Rust y palabras de moda como kTLS: https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
También salió en HN: https://news.ycombinator.com/item?id=44980865
splice(2)está implementado, así que conuringse puede usar un enfoque parecido a sendfile. No es tan cómodo de usar comosendfile, pero debería funcionar casi igualSi se hiciera con DPDK sería mucho más complejo, pero aparecería la posibilidad de superar ampliamente a nginx en rendimiento
Si además lo haces correr sobre FPGA, se vuelve todavía más complejo
La lección es que, para obtener rendimiento, hace falta una actitud de atravesar las abstracciones como cuchillo caliente en mantequilla, pero eso también hace que todo sea más difícil. El enfoque de sockets y un hilo por conexión era bueno cuando la red era muy lenta en comparación con la CPU, y todavía hoy suele ser el enfoque más simple
Yo también siempre tuve curiosidad por esto, así que hace poco escribí varias implementaciones de un servidor HTTP de archivos para aprender las diferencias clave
https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...
En el contexto de proxies, también habría que mencionar el busy polling de
epoll_wait. Lo revisé recientemente al evaluar opciones de baja latencia, y parecía que incluso sin DPDK/VMA/io_uring era posible acercarse al busy polling en espacio de usuario usando solo sockets simples, y Fastly contribuyó a esto y lo está usandoEs demasiado de bajo nivel como para decir que lo entendí completo; apenas entendí el concepto general, así que dejo los enlaces. Solo funciona por contexto NAPI de
epoll, y no se puede controlar fácilmente el ID de NAPI, pero si usas toda la máquina exclusivamente para el proxy, se podría hacer un truco simple de asignar sockets por ID de NAPI a pollers dedicadosMi caso de uso no era un proxy, sino sondear N sockets en una máquina y luego procesar los datos recibidos. En ese caso no parecía viable, aunque quizá podría funcionar si un solo hilo sondea los contextos NAPI en round-robin. Ojalá algún día se le pueda decir fácilmente al kernel: “confía en mí, al final voy a sondear este socket único, así que no uses nunca la ruta de IRQ”
Discusión previa en HN sobre esta función del kernel: https://news.ycombinator.com/item?id=43749271
Buenas diapositivas de una presentación de un colaborador de Fastly, con diagramas que ayudan a entender el panorama general: https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
Artículos de LWN: https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
Documentación del kernel: https://docs.kernel.org/networking/napi.html#irq-mitigation
Si te gusta C++ y el networking asíncrono, está Boost.Asio
epollhecho por mí y el RPS mejoró alrededor de 16%. Fue un resultado en un servidor SQL de tamaño moderado, así que hay que tener cuidado al usar bibliotecas muy empaquetadasepollde Asio porio_uringy el uso de CPU se disparó. Probablemente depende mucho de cómo se use y de cómo se integre con el código de eventosPara 2050, parece que habrá como 20 formas de hacer polling de sockets en Linux
io_uring. Para ir más rápido apareció el modo one-shot deio_uring, y después incluso surgió el modo multi-shotSí,
io_uringdefinitivamente es más rápido queepoll. En mi caso, creo queio_uringfue como 20% más rápido en solicitudes por segundoEl problema es que hay que habilitarlo explícitamente en el kernel y, por motivos de seguridad, está desactivado en casi todos lados. Parece que hay memoria compartida directa entre el kernel y el espacio de usuario, lo cual da algo de mala espina. Últimamente también ha habido varios exploits dirigidos a
io_uringPor eso, incluso proyectos de ingeniería como Go, que buscan el mayor rendimiento posible, no incorporan
io_uringprofundamente como un valor por defecto razonable. Si quieres asumir el riesgo, puedes usarlo directamente en tu lenguaje favorito. Es más rápido, pero el costo es la posibilidad de exploits potencialesio_uring, hecha conpolly no conepoll, ha sido más rápida queio_uring. Aun así, en buffers grandes de copia cero,io_uringes lo mejorio_uringtambién es útil aunque no se trate de I/O asíncrono. Por ejemplo, se puede implementar una cadena de operaciones comomkdiry luego abrir ese directorio como si fuera una sola operación atómicaSi en networking intentas maximizar los paquetes por segundo, te topas muy rápido con los límites del kernel[1], y al final tienes que aprovechar funciones como GSO/GRO o evitar por completo el stack de red
1: https://github.com/axboe/liburing/discussions/1346
io_uringpor completo de forma predeterminada. Es algo muy reciente, pero con eso ya entran muchos entornos empresariales de Linux. Gemini “dijo” que Ubuntu y SuSE también lo soportan, pero no dio ningún enlace para demostrarlohttps://access.redhat.com/solutions/4723221
Go también debería reconsiderar darle soporte. Vale la pena intentarlo
io_uringal iniciar el runtime? ¿No sería que los exploits no son solo un problema del programa que decidió usario_uring, sino del sistema operativo completo?io_uring— al final tiende a requerir que el usuario se haga responsable del aislamiento de memoriaPero en el caso de
io_uring, el ring está dentro del kernel, así que el usuario no puede hacer demasiadoEspero que mejore gracias a los LLM, pero es un problema difícil de resolver. Incluso para el propio kernel es muy complicado, y muchas personas tampoco entienden bien cómo ajustarlo