1 puntos por GN⁺ 3 시간 전 | 1 comentarios | Compartir por WhatsApp
  • 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_wait hay que llamar por separado a read()/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, y IORING_SETUP_SQPOLL reduce 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() o write()
  • En el flujo habitual, el costo de syscall se repite en cada evento
    • epoll_ctl es una syscall única para registrar file descriptors
    • En cada evento real de I/O hacen falta epoll_wait y read()/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_idle puede 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 stdin y, cuando llega un evento, llama aparte a read()
    • Crea una instancia de epoll con epoll_create1
    • Registra STDIN_FILENO con epoll_ctl
    • Se bloquea con epoll_wait hasta 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_wait y read
  • 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 stdin con io_uring_prep_read
    • Envía con io_uring_submit y espera la finalización con io_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() devuelve NULL cuando la cola de envío está llena

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_ZC de kernel 6.0+ ofrece envíos sin copiar el buffer al kernel
  • IORING_SETUP_SQPOLL puede 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 res de la entrada de la cola de finalización
    • El manejo de errores debe hacerse a través de cqe->res

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

 
GN⁺ 3 시간 전
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 rendimiento
    Si 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

    • Las v0 y v1 del repositorio son implementaciones completamente distintas reescritas casi desde cero, y ahora está trabajando en una tercera implementación que probablemente sea la última. Las decisiones de arquitectura también fueron totalmente diferentes
    • Me gustaría ver el benchmark de ese parche
  • 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/

    • El plan era pasar al asignador después de aplicar optimizaciones en otras capas. Ahora estoy estudiando asignadores con estudiantes, y una entrada anterior del blog trataba sobre un asignador personalizado hecho en Zig
  • 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 razonable

  • En 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 con mmap
    En realidad quiero usar sendfile con io_uring, pero todavía no está soportado
    Un 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

    • Como referencia, splice(2) está implementado, así que con uring se puede usar un enfoque parecido a sendfile. No es tan cómodo de usar como sendfile, pero debería funcionar casi igual
  • Si 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á usando
    Es 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 dedicados
    Mi 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

    • Hace poco reemplacé Asio por un loop de eventos epoll hecho 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 empaquetadas
    • En un servidor de base de datos, cambié el backend epoll de Asio por io_uring y 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 eventos
    • Boost es demasiado incómodo. Son enormes bibliotecas dinámicas difíciles de compilar y de usar. Aunque ya usaba CMake, el proceso de instalar Boost y hacer que fuera detectable fue muy fastidioso. Eso sí, fue algo que me pasó en Mac
  • Para 2050, parece que habrá como 20 formas de hacer polling de sockets en Linux

    • Sí, incluso dentro de io_uring. Para ir más rápido apareció el modo one-shot de io_uring, y después incluso surgió el modo multi-shot
  • Sí, io_uring definitivamente es más rápido que epoll. En mi caso, creo que io_uring fue como 20% más rápido en solicitudes por segundo
    El 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_uring
    Por eso, incluso proyectos de ingeniería como Go, que buscan el mayor rendimiento posible, no incorporan io_uring profundamente 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 potenciales

    • La razón principal por la que se desactiva ya quedó resuelta. En la RC más reciente entró soporte para cBPF, así que en lugar de apagarlo por completo, ahora se pueden limitar las operaciones que pueden ejecutarse
    • Depende del caso. A veces mi emulación POSIX de io_uring, hecha con poll y no con epoll, ha sido más rápida que io_uring. Aun así, en buffers grandes de copia cero, io_uring es lo mejor
      io_uring también es útil aunque no se trate de I/O asíncrono. Por ejemplo, se puede implementar una cadena de operaciones como mkdir y luego abrir ese directorio como si fuera una sola operación atómica
      Si 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
    • RHEL 9 y 10 ahora ya soportan io_uring por 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 demostrarlo
      https://access.redhat.com/solutions/4723221
      Go también debería reconsiderar darle soporte. Vale la pena intentarlo
    • En un proyecto como Go, ¿no podría existir la opción de hacer una sola detección de capacidades de io_uring al iniciar el runtime? ¿No sería que los exploits no son solo un problema del programa que decidió usar io_uring, sino del sistema operativo completo?
    • Todo tipo de networking en modo polling —RDMA, DPDK, io_uring— al final tiende a requerir que el usuario se haga responsable del aislamiento de memoria
      Pero en el caso de io_uring, el ring está dentro del kernel, así que el usuario no puede hacer demasiado
      Espero 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