Servidor HTTPS de cero llamadas al sistema con io_uring, kTLS y Rust
(blog.habets.se)- 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_filesse 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
ulimitdel 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
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-uringno recibe ayuda del borrow checker de Rust en este punto y tampoco tiene verificaciones en tiempo de ejecuciónHe 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
epollera el estándar y casi no se tomó en cuenta IOCPLos 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 RustPero 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 complicadoPero la API actual de
tokio::netno 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 completoPara 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 safeReferencia: 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 solicitudsendfilefue 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 escondidaEnlaces 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
sendfileSe 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
ktlso io_uring pudieran usarse de maneras tan variadasLa 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ímitesSend/Sync, async trait objects, etc.C++20:
coroutinesGo:
goroutinesJava 21+: hilos virtuales
Las coroutines de C++ usan asignación en heap para evitar el problema que resuelve
PinEso 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/Syncsiguen teniendo sentido también en otros lenguajes, y sin ellas es más fácil escribir código sutilmente incorrectoSi 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
goroutineno es asincronía, y si no entiendes los canales, no puedes decir que entiendes lasgoroutinesLa 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 CPUMá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
kTLSsí parece un avance claroYo 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
kTLSHace 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
kTLSen el kernel/OpenSSL desde la versión 13, y se puede activar o desactivar en runtime consysctl(kern.ipc.tls.enable=1)En FreeBSD-15 el valor por defecto cambiará a activado, y Netflix lleva casi 10 años usando
kTLSpara cifrar tráficoEn general,
kTLSme parece una mala ideaMe 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
nprochilos puede ser peligrosoEn 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
Enlace al paper: https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf