2 puntos por GN⁺ 2025-08-23 | 1 comentarios | Compartir por WhatsApp
  • Para crear servidores web de alto rendimiento, antes se usaban diversos modelos basados en eventos como select(), poll(), epoll
  • Pero por las limitaciones de rendimiento de esas llamadas al sistema, apareció io_uring, que introduce un método donde las solicitudes se ponen en una cola para que el kernel las procese de forma asíncrona
  • kTLS hace que el kernel se encargue del cifrado TLS, lo que permite optimizaciones adicionales como el uso de sendfile() y el offloading por hardware
  • La introducción de descriptorless files ofrece un enfoque optimizado para io_uring sin pasar directamente descriptores de archivo
  • A través del proyecto open source tarweb, que combina Rust, io_uring y kTLS, se ofrece HTTPS sin llamadas al sistema adicionales por solicitud, y también se discuten temas de seguridad y gestión de memoria

Evolución de la arquitectura de servidores web de alto rendimiento

  • Desde principios de los años 2000 aumentó la demanda de servidores web de alta capacidad
  • Al principio era común crear un nuevo proceso por cada solicitud, pero por su alto costo surgió la técnica de preforking
  • Después, con la introducción de hilos y la adopción de select(), poll(), la evolución apuntó a reducir el costo de los cambios de contexto
  • Aun así, los enfoques con select() y poll() tienen límites de escalabilidad, porque mientras más conexiones hay, más seguido hay que pasar grandes arreglos al kernel

La llegada de epoll

  • En Linux se introdujo epoll, lo que permitió manejar múltiples conexiones de forma más eficiente que con los métodos anteriores
  • epoll procesa solo los cambios (deltas), reduciendo el consumo innecesario de recursos
  • No elimina por completo todas las llamadas al sistema, pero sí reduce bastante su costo

Resumen de io_uring

  • io_uring agrega las solicitudes a una cola en memoria para que el kernel las procese de forma asíncrona, en lugar de invocar una llamada al sistema por cada petición
  • Por ejemplo, si se pone accept() en la cola, el kernel lo procesa y devuelve el resultado en la cola de completado
  • El servidor web funciona agregando solicitudes a la cola y revisando los resultados en una región de memoria separada
  • Para evitar un bucle ocupado (busy loop), si no hay cambios en la cola, tanto el servidor web como el kernel llaman al sistema solo cuando es necesario, logrando ahorro de energía
  • Con las bibliotecas adecuadas, un servidor activo puede funcionar sin llamadas al sistema adicionales mientras procesa solicitudes

Entornos multi-core y NUMA

  • Considerando los CPU modernos con múltiples núcleos, resulta efectiva una estrategia de un solo hilo por núcleo y de minimizar el uso compartido de estructuras de datos
  • En entornos NUMA, cada hilo se optimiza accediendo solo a la memoria de su nodo local
  • Lograr un balance perfecto en la distribución de solicitudes todavía requiere más investigación

Asignación de memoria

  • Tanto en el kernel como en el servidor web sigue habiendo asignación de memoria, y la asignación en espacio de usuario al final también se conecta con llamadas al sistema
  • Del lado del servidor web, se asignan por adelantado bloques de memoria de tamaño fijo por conexión para prevenir fragmentación y escasez
  • Del lado del kernel también se necesitan búferes de entrada/salida por conexión, y parte de eso puede ajustarse con opciones de socket
  • Si ocurre escasez de memoria, puede derivar en fallas graves

Introducción a kTLS (Kernel TLS)

  • kTLS es una función del kernel de Linux que se encarga de las operaciones de cifrado y descifrado
  • El handshake se maneja en la aplicación, pero después de eso el kernel trata la transmisión de datos como si fuera texto plano
  • Esto permite usar sendfile() y reducir las copias de memoria entre el espacio de usuario y el kernel
  • Si la tarjeta de red lo soporta, también existe la ventaja de hacer offloading por hardware incluso de las operaciones de cifrado

Descriptorless Files

  • Es un enfoque surgido para reducir la sobrecarga que aparece al pasar directamente descriptores de archivo del espacio de usuario al espacio del kernel
  • Con register_files se usan números de archivo enteros separados que solo son válidos dentro de io_uring, y no aparecen en /proc/pid/fd
  • El límite ulimit del sistema sigue aplicando

Introducción al proyecto tarweb

  • tarweb es un proyecto open source de servidor web de ejemplo que aplica todas las tecnologías anteriores
  • Tiene una estructura que sirve el contenido de un único archivo tar, y combina tecnologías modernas de alto rendimiento como Rust, io_uring y kTLS
  • Durante su uso real aparecieron problemas de compatibilidad entre io_uring y kTLS (como la falta de soporte para setsockopt), y algunos se resolvieron mediante Pull Requests
  • El proyecto todavía está incompleto, y la biblioteca rustls de Rust puede realizar asignaciones de memoria durante el proceso de handshake
  • El punto clave es que es posible ofrecer HTTPS sin llamadas al sistema adicionales por cada solicitud

Benchmark y medición de rendimiento

  • El autor todavía no ha realizado suficientes benchmarks y planea hacer pruebas de rendimiento después de ordenar el código

Problemas de seguridad con io_uring y Rust

  • A diferencia de las llamadas al sistema síncronas, en io_uring el búfer de memoria no debe liberarse hasta antes del evento de completado
  • El crate io-uring no garantiza la seguridad en tiempo de compilación de Rust, y también carece de suficientes verificaciones en tiempo de ejecución
  • Si se usa mal, puede llevar a problemas graves, de forma similar a C++, debilitando la seguridad propia de Rust
  • Se necesita un crate separado, safer-ring, que aproveche activamente el pinning y el borrow checker
  • Este problema ya está en discusión dentro de la comunidad

Referencias y enlaces adicionales

  • Este contenido corresponde a una publicación discutida en HackerNews al 2025-08-22

1 comentarios

 
GN⁺ 2025-08-23
Opiniones de Hacker News
  • Al usar io_uring para enviar operaciones de escritura, hay que asegurarse de que la ubicación de memoria no se libere ni se sobrescriba, pero parece que la API del crate io-uring no recibe ayuda del borrow checker de Rust en este punto y tampoco tiene verificaciones en tiempo de ejecución
    He visto artículos y comentarios sobre esta situación, y al final me queda la impresión de que crear una biblioteca asíncrona segura de Rust que encapsule io_uring es realmente difícil
    También recuerdo que Alice, del equipo de tokio, mencionó recientemente que no hay mucho interés en superar este problema
    Porque el rendimiento actual está en un punto de "suficientemente bueno"
    Referencia: https://boats.gitlab.io/blog/post/io-uring/

    • Hay varias cosas de Rust async que me dejan insatisfecho, y esta es una de ellas
      Rust async se diseñó en una época en la que epoll era el estándar y casi no se tomó en cuenta IOCP
      Los syscalls síncronos no tienen este problema porque, al llamar a read, se pasa al kernel una referencia mutable al búfer, lo cual encaja bien con el modelo nativo de ownership/borrow de Rust
      Pero para I/O basada en completion, para que encaje de verdad con el modelo de ownership, hay que garantizar que el código de usuario no siga ejecutándose hasta que termine la operación, y eso no se puede lograr con una estructura de polling de máquina de estados
      Un modelo de threading o una estructura de green threads encaja perfectamente aquí
      Creo que Rust habría estado mejor si hubiera agregado un "objetivo exclusivo para async"
      El equipo de Rust apostó bastante por el modelo asíncrono stackless basado en polling, y estamos viendo cómo termina eso

    • Creo que hay modelos de ownership que el borrow checker de Rust no puede expresar bien
      Temporalmente lo llamo "ownership de papa caliente": entregas un búfer por un rato y luego te lo devuelven
      Escribir este patrón de forma segura en Rust resulta muy difícil y el código se vuelve desordenado

    • A diferencia de lo que dijo Alice del equipo de tokio, sí hay interés en el I/O de archivos
      El I/O de archivos ya está implementado con spawn_blocking, así que ya sufre el mismo problema de búferes que io_uring, y migrarlo a io_uring no es tan complicado
      Pero la API actual de tokio::net no es compatible con una API de búferes basada en io_uring, así que se puede hacer readiness checking, pero es difícil dar soporte completo

    • Para crear una interfaz segura de io_uring, me parece que lo más adecuado es recibir búferes que pertenezcan al ring y devolverlos de nuevo cuando se inicia la escritura

    • No hace falta expresar todo con borrows
      Si usas una estructura de datos como Slab, se puede hacer cancel safe
      Referencia: https://github.com/steelcake/io2

  • Este artículo me pareció realmente muy interesante
    Tengo ganas de ver las pruebas de rendimiento, pero me impresionó que el autor dijera que primero quiere limpiar bien el código antes de hacer benchmarks
    En esta época en la que todo gira alrededor de los benchmarks, se siente refrescante ver a alguien pensando así
    Cuando tenía unos 11 años intenté construir una base de datos y conocí cgi-bin, y recién ahora caigo en cuenta de que ese sistema lanzaba un proceso nuevo por cada solicitud
    sendfile fue un cambio radical cuando los grandes foros de videojuegos tenían que servir descargas de demos en paralelo, y al ver resultados como la reducción de 40 ms en Netflix o el recorte del 70% en tiempos de carga de GTA 5, siento que ahí hay ingeniería aún más impactante escondida
    Enlaces relacionados: Common Gateway Interface, caso de 40 ms de Netflix, reducción de carga en GTA Online

    • No solo CGI; las viejas sesiones HTTP de las líneas de CERN y Apache también funcionaban haciendo fork del servidor completo
      Con el tiempo eso mejoró, pero por la forma en que Apache estaba configurado, servidores ligeros diseñados desde el inicio con I/O basada en eventos, como nginx, terminaron ganando mucha popularidad

    • Soy escéptico respecto a la eficiencia de sendfile
      Se puso de moda a fines de los 90, pero creo que en la práctica la ganancia de rendimiento es mínima

  • La mayoría de los orquestadores de cargas de trabajo en la nube (CloudRun, GKE, EKS, Docker local, etc.) desactivan io_uring por defecto
    Si esto no mejora, parece que io_uring seguirá siendo una tecnología muy limitada por un buen tiempo

    • Me pregunto por qué desactivan io_uring

    • Si la situación es así, habrá que volver al self-hosting

  • Me gustó muchísimo leer esto
    Esperaré los benchmarks, así que puede tomarse su tiempo, y me impresionó muchísimo la mentalidad del autor de priorizar ordenar el código antes que las mediciones
    Hoy en día hay muchos proyectos obsesionados con las cifras de benchmark, así que esta forma de pensar es realmente fresca y admirable
    No sabía que ktls o io_uring pudieran usarse de maneras tan variadas

  • La situación actual del procesamiento asíncrono es más o menos esta
    Rust: hay que entender muchos conceptos como Futures, Pin, Waker, async runtime, límites Send/Sync, async trait objects, etc.
    C++20: coroutines
    Go: goroutines
    Java 21+: hilos virtuales

    • Las coroutines de C++ usan asignación en heap para evitar el problema que resuelve Pin
      Eso se aleja bastante del principio de "costo cero" que busca C++
      Una razón por la que Rust tardó tanto en introducir async traits también es que Rust no asigna sus futures en el heap
      El valor del trade-off entre rendimiento/portabilidad y complejidad puede variar según cada proyecto

    • Las restricciones relacionadas con Send/Sync siguen teniendo sentido también en otros lenguajes, y sin ellas es más fácil escribir código sutilmente incorrecto

    • Si escribes código Rust de nivel "suficientemente bueno" y usas primitivas de nivel medio que ya hizo otra gente, no necesitas conocer necesariamente todos esos conceptos

    • Rust te obliga a que, si no entiendes esos conceptos, simplemente no compile
      En Go, una goroutine no es asincronía, y si no entiendes los canales, no puedes decir que entiendes las goroutines
      La implementación de canales de Go es peculiar, así que el comportamiento en casos límite no siempre se puede predecir de forma intuitiva
      En Go se puede programar sin entender todo a fondo, y eso tiene ventajas y desventajas
      Un "hilo barato" no es lo mismo que asincronía
      tarweb (el servidor del blog) usa una estructura de un solo hilo con event loop basado en io_uring, con la idea de tener un hilo por núcleo de CPU
      Más que "el estado actual de la concurrencia masiva", tal vez sería más correcto decir "el estado actual de los hilos baratos"
      La mayor diferencia entre los cheap threads y un async loop es que razonar sobre ellos es más fácil
      También hay desventajas: cada hilo, aunque sea ligero, necesita tamaño de stack

  • kTLS sí parece un avance claro
    Yo mismo hice hace unos años un servidor con literalmente 0 syscalls por solicitud y escribí una entrada sobre eso (https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html)
    Pero tiene la desventaja de que siempre hay que hacer busy-looping
    io_uring ha evolucionado a una velocidad realmente impresionante en los últimos años

  • Este proyecto está realmente genial, y como llevo mucho tiempo imaginando algo parecido, me alegra que alguien lo haya implementado
    Si quieres escribir también BPF en Rust, recomiendo Aya
    Github del proyecto Aya

  • Tengo curiosidad por el estado actual de kTLS
    Hace poco le pregunté a un desarrollador de Cilium y Thomas Graf dijo que tiene expectativas, pero que en la práctica todavía falta bastante para activarlo por defecto porque muchas distribuciones de Linux no tienen suficiente soporte en el kernel

    • Es una pena, pero también me pregunto qué tan difícil es activarlo
      Si hay que personalizar el kernel, o si se puede habilitar directamente en tiempo de ejecución
      FreeBSD incluye kTLS en el kernel/OpenSSL desde la versión 13, y se puede activar o desactivar en runtime con sysctl (kern.ipc.tls.enable=1)
      En FreeBSD-15 el valor por defecto cambiará a activado, y Netflix lleva casi 10 años usando kTLS para cifrar tráfico

    • En general, kTLS me parece una mala idea

  • Me pregunto si una estructura de un hilo por núcleo tiene sentido en un sistema basado en time slicing
    Por mi experiencia, la "sobresuscripción" (tener más hilos que núcleos) sí da beneficios en tiempo de reloj real
    Me parece que uno por núcleo encaja mejor cuando no hay planificación preventiva
    Claro, entonces ya no estamos hablando de Unix

    • Si quieres baja latencia y alto throughput, aislar núcleos y fijarles hilos puede ser efectivo
      Este enfoque funciona bien en Linux, y en sistemas de trading se usa mucho aunque implique aceptar ineficiencias
      La mayoría de los núcleos se quedan girando en vacío y en realidad no hay trabajo, pero en latencia y throughput es óptimo

    • La trampa del modelo thread-per-core es creer que puedes "tomar solo las partes convenientes"
      En realidad, o te comprometes por completo o no lo usas
      Una implementación a medias no rinde nada bien
      Pero si se diseña correctamente, puede ser muy eficiente en casi cualquier situación
      Hay pocos desarrolladores que realmente conozcan las técnicas de diseño TPC, como el balanceo de carga entre núcleos

    • El enfoque thread-per-core solo es eficiente cuando se está "CPU bound"
      Cuando la mayor parte del trabajo es asíncrono y basado en eventos, como en este proyecto de servidor, el servidor pasa al siguiente request casi sin esperar I/O ni syscalls, así que en teoría un hilo por núcleo es exactamente la estructura correcta
      Pero como en el mundo real casi nunca existe esa situación ideal, hay que recordar que limitarse ciegamente a nproc hilos puede ser peligroso

    • En io_uring, tener solo un hilo de usuario por núcleo tampoco me parece una mala elección
      Porque el kernel funciona con un thread pool

  • También me gustaría ver un enfoque al estilo DPDK, que evita por completo el kernel