DEV Community

Cover image for io_uring: la API asíncrona moderna del kernel Linux
lu1tr0n
lu1tr0n

Posted on • Originally published at elsolitario.org

io_uring: la API asíncrona moderna del kernel Linux

Cuando una aplicación necesita leer un archivo, escribir en una conexión TCP o esperar datos de un disco, el kernel de Linux ofrece tradicionalmente dos caminos: bloquear el proceso hasta que la operación termine, o usar interfaces como epoll y Linux AIO para manejar múltiples operaciones concurrentes. Durante casi tres décadas, esas fueron las opciones dominantes. Pero desde la versión 5.1 del kernel, liberada en mayo de 2019, existe una API que está cambiando radicalmente cómo los programas hacen I/O en Linux: io_uring.

io_uring no es solo otra forma de hacer operaciones asíncronas. Es un rediseño profundo de cómo el espacio de usuario y el kernel comparten trabajo, basado en dos colas circulares en memoria compartida que reducen el costo de las syscalls a casi cero. En esta guía vamos a desarmar el mecanismo desde dentro: qué problema resuelve, cómo está construido, cómo se usa con código real, y por qué motores como ScyllaDB, PostgreSQL, Tokio o Caddy ya migraron sus rutas críticas a esta interfaz.

Qué es io_uring

io_uring es una interfaz del kernel Linux para realizar operaciones de I/O de forma asíncrona, diseñada por Jens Axboe (mantenedor del subsistema de bloque de Linux durante más de dos décadas) e introducida oficialmente en el kernel 5.1. Su nombre es una abreviación de "I/O ring" — anillo de I/O — porque toda su comunicación con el kernel se hace mediante dos colas circulares: la Submission Queue (SQ) y la Completion Queue (CQ).

La idea central de io_uring es minimizar las syscalls. En el modelo tradicional, cada operación de I/O implica una transición del espacio de usuario al espacio del kernel: leer un archivo significa una llamada read(), escribir significa write(), aceptar una conexión significa accept(). Cada una de esas transiciones cuesta cientos de nanosegundos por sí misma, y con mitigaciones como Spectre y Meltdown ese costo escaló hasta varios microsegundos. Cuando una aplicación necesita procesar millones de operaciones por segundo, ese overhead se vuelve dominante.

io_uring resuelve esto con una idea elegante: en lugar de hacer una syscall por operación, el espacio de usuario y el kernel comparten directamente dos buffers en memoria. La aplicación encola operaciones en uno (la SQ) y el kernel deposita los resultados en el otro (la CQ). Las syscalls solo se necesitan ocasionalmente, y en ciertos modos no se necesitan en absoluto.

💭 Clave: el verdadero salto de io_uring no es "hacer I/O asíncrona" — eso ya existía con aio. Es eliminar el costo del cambio de contexto entre usuario y kernel para cada operación.
Submission Queue y Completion Queue compartidas vía mmap entre kernel y proceso.

Cómo funciona internamente io_uring

Para entender io_uring necesitamos repasar tres componentes: las dos colas, los descriptores de operaciones y los modos de operación.

Las dos colas circulares

La Submission Queue (SQ) es donde la aplicación deposita las operaciones que quiere ejecutar. Cada entrada en la SQ se llama Submission Queue Entry (SQE) y describe una operación: qué tipo es (IORING_OP_READ, IORING_OP_WRITE, IORING_OP_ACCEPT, IORING_OP_SENDMSG, etc.), sobre qué descriptor de archivo, en qué buffer, con qué offset y cuántos bytes.

La Completion Queue (CQ) es donde el kernel deposita el resultado de cada operación completada. Cada entrada se llama Completion Queue Entry (CQE) y contiene el resultado (cuántos bytes se leyeron, código de error si falló) más un identificador user_data que la aplicación pasó al encolar el SQE, para correlacionar la respuesta con la operación original.

Ambas colas viven en memoria mapeada con mmap() entre el kernel y el proceso, lo que significa que ambos lados pueden leerlas y escribirlas sin cruzar la barrera del espacio de usuario. La sincronización se hace con barreras de memoria atómicas, no con locks.

Modos de operación

io_uring soporta varios modos según el equilibrio que la aplicación quiera entre throughput, latencia y uso de CPU:

  • Modo interrupt-driven: el modo por defecto. La aplicación llama a io_uring_enter() cuando quiere que el kernel procese las operaciones encoladas. Es el más simple y el más compatible.- Modo IOPOLL: el kernel hace polling activo en lugar de esperar interrupciones. Reduce latencia drásticamente para almacenamiento NVMe, pero quema un núcleo de CPU.- Modo SQPOLL: un kernel thread dedicado vigila la SQ y procesa operaciones automáticamente sin que la aplicación tenga que llamar a io_uring_enter(). Cero syscalls en el camino crítico.

El modo SQPOLL es especialmente notable. Combinado con el modo IOPOLL, una aplicación puede emitir y recibir operaciones de I/O sin hacer ninguna syscall. Es lo más cerca que ha estado Linux de la performance que se obtiene con frameworks de bypass del kernel como DPDK o SPDK, pero manteniendo la API estándar de archivos y sockets.

El flujo paso a paso

flowchart LR
    A["Aplicación"] -->|"1. Encola SQE"| B["Submission Queue"]
    B -->|"2. io_uring_enter()"| C["Kernel"]
    C -->|"3. Procesa I/O"| D["Completion Queue"]
    D -->|"4. Lee CQE"| A
Enter fullscreen mode Exit fullscreen mode

El ciclo es simple: la aplicación toma un SQE libre, lo rellena con la operación deseada, avanza el puntero de la SQ, opcionalmente llama a io_uring_enter() para notificar al kernel, y más tarde lee los CQEs disponibles en la CQ. Las cuatro fases son independientes — una aplicación puede encolar 1000 SQEs y recolectar 1000 CQEs en lotes, amortizando cualquier costo de transición.

Ejemplo práctico: leer un archivo con liburing

Trabajar directamente con la interfaz cruda de io_uring es complejo: hay que mapear las colas, manejar barreras de memoria y respetar el protocolo binario. Por eso Jens Axboe publica liburing, una librería en C que envuelve todo eso con una API ergonómica. Es la manera estándar de usar io_uring desde C.

Veamos un ejemplo mínimo que lee un archivo de forma asíncrona:

#include - 
#include 
#include 
#include 

int main(int argc, char *argv[]) {
    struct io_uring ring;
    char buffer[4096];
    int fd, ret;

    /* Inicializar el ring con 8 entradas */
    if (io_uring_queue_init(8, &ring, 0) res res));
    } else {
        printf("Leídos %d bytes\n", cqe->res);
        fwrite(buffer, 1, cqe->res, stdout);
    }

    io_uring_cqe_seen(&ring, cqe);
    io_uring_queue_exit(&ring);
    close(fd);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compilando con gcc ejemplo.c -luring -o ejemplo y ejecutando contra cualquier archivo, la diferencia con read() directo no se nota en una sola operación. Pero si en lugar de una sola lectura encolamos 10.000 SQEs antes de llamar a io_uring_submit(), el throughput supera ampliamente al de un loop con read() bloqueante o incluso al de aio_read().
liburing convierte el protocolo binario de io_uring en una API en C ergonómica.

Casos de uso reales en producción

io_uring no es solo una curiosidad teórica del kernel. Desde 2022 se convirtió en una pieza estándar en bases de datos y servidores de alto rendimiento.

PostgreSQL: en la versión 18 (lanzada en 2025) se introdujo soporte experimental de io_uring para la lectura asíncrona de bloques desde disco, reduciendo la latencia de queries que escanean tablas grandes.- ScyllaDB: el motor compatible con Cassandra escrito en C++ usa io_uring desde 2020. Su modelo shard-per-core combinado con io_uring le permite alcanzar más de un millón de operaciones por segundo por nodo.- Tokio (Rust): el runtime asíncrono más popular del ecosistema Rust integra io_uring vía la crate tokio-uring, ofreciendo APIs async/await que internamente usan SQEs y CQEs.- libuv (Node.js): desde 2023 libuv usa io_uring para operaciones de archivo en kernels modernos, lo que mejoró el throughput de filesystem en Node.js sin cambios de API para los desarrolladores.- Caddy y Cloudflare: servidores HTTP de alta concurrencia usan io_uring para multiplexar conexiones TCP con menos overhead que epoll + accept4.

💡 Tip: si usás Tokio en Rust, podés probar tokio-uring sin reescribir tu app: la API es deliberadamente similar a la de Tokio estándar pero los handles internos van por io_uring.

Ventajas y desventajas

Lo que io_uring hace bien

  • Throughput extremo: benchmarks muestran 2x a 3x más operaciones por segundo que epoll en cargas mixtas de red más disco.- Una API uniforme: el mismo mecanismo cubre archivos, sockets, timers, signals, accept, connect, splice, fallocate y más de 60 operaciones distintas.- Linked SQEs: permite encadenar operaciones ("haz read y luego write con el resultado") sin volver al espacio de usuario.- Buffers fijos: registrando buffers de antemano con io_uring_register, el kernel evita pinning repetido de páginas y baja la latencia.- Cancelación nativa: a diferencia de aio, io_uring sí soporta cancelar una operación en vuelo.

Lo que conviene tener en cuenta

  • Curva de aprendizaje: el modelo mental de SQEs, CQEs y barreras es distinto al de los syscalls clásicos. liburing ayuda mucho, pero hay que entender el protocolo.- Superficie de ataque: entre 2022 y 2024 se reportaron varias vulnerabilidades de uso después de liberación en io_uring. Google y Docker llegaron a deshabilitarlo por defecto en algunos entornos por precaución, lo que muestra que un kernel con muchísima funcionalidad expuesta requiere auditoría continua.- Requiere kernel reciente: aunque está en mainline desde 5.1, varias features clave (registered buffers, multishot accept, zero-copy send) llegaron en 5.10, 5.19 y 6.0. Para producción se recomienda 6.x.- Debugging más difícil: strace ve poco — no hay una syscall por operación que mostrar. Hay que aprender a usar bpftrace o el tracing del propio io_uring.

⚠️ Ojo: si tu workload no satura I/O o no tiene miles de operaciones concurrentes, io_uring no te dará ventaja perceptible. Es una herramienta para sistemas que ya pelean con el límite del kernel, no una optimización para todos.

Cuándo elegir io_uring frente a epoll o threads

La pregunta correcta no es "¿io_uring es mejor que epoll?". Es "¿cuál es mi cuello de botella?".

Si tu aplicación maneja unas decenas de conexiones y la lógica de negocio domina, un pool de threads bloqueantes funciona perfecto. Si manejás miles de conexiones de red pero las operaciones de archivo son raras, epoll sigue siendo simple y adecuado. Si tu aplicación combina I/O de red y disco a alto volumen, o necesitás latencias predecibles bajo carga, io_uring es donde la diferencia se nota.

El caso ideal de io_uring es un servicio que:

  • Tiene miles o millones de operaciones de I/O concurrentes.- Mezcla diferentes tipos de I/O (sockets + archivos + timers).- Está limitado por el costo de las syscalls, no por la lógica de negocio.- Corre en un kernel 5.10+ con la posibilidad de actualizar a 6.x.

El futuro: zero-copy, ublk y eBPF integrados

io_uring no para de crecer. Las versiones recientes del kernel agregaron envío y recepción zero-copy (IORING_OP_SEND_ZC), permitiendo enviar grandes payloads sin copiar bytes entre buffers de usuario y kernel. La operación ublk permite implementar drivers de bloque enteros en espacio de usuario usando io_uring como transporte. Y la integración con eBPF permite que programas BPF inspeccionen y filtren operaciones io_uring antes de que el kernel las procese.

El roadmap apunta a que en el mediano plazo io_uring no sea solo una API de I/O, sino el bus de comunicación universal entre el espacio de usuario y el kernel para casi cualquier subsistema. Es un cambio de arquitectura del mismo tamaño que la introducción de epoll en 2002.

📖 Resumen en Telegram: Ver resumen

Preguntas frecuentes

¿io_uring reemplaza a epoll?

No completamente, pero sí lo cubre. Cualquier patrón que se pueda hacer con epoll se puede hacer con io_uring, normalmente con menos overhead. epoll sigue siendo válido para aplicaciones simples donde la complejidad extra de io_uring no se justifica. Para nuevos servicios de alto rendimiento, io_uring es la dirección recomendada.

¿Funciona en macOS o Windows?

No. io_uring es exclusivo de Linux. macOS tiene kqueue (parecido a epoll) y Windows tiene IOCP (Completion Ports), que son el equivalente conceptual más cercano al modelo de colas de io_uring pero con APIs distintas.

¿Es seguro habilitar io_uring en producción?

Sí, con un kernel actualizado. Las vulnerabilidades reportadas entre 2022 y 2024 ya fueron corregidas. Lo que sí conviene es deshabilitarlo en contenedores no confiables — se puede restringir vía seccomp filtrando la syscall io_uring_setup, o globalmente vía sysctl kernel.io_uring_disabled=2.

¿Qué versión mínima del kernel necesito?

El mínimo absoluto es 5.1, pero para producción se recomienda 5.10 o superior porque trae features esenciales como buffers registrados y mejoras de rendimiento. Para zero-copy y multishot accept, hace falta 5.19+. La distribución más conservadora con buen soporte es Debian 12 (kernel 6.1), Ubuntu 22.04 (5.15) o RHEL 9.

¿Puedo usar io_uring desde Python o Go?

Sí. En Python existe la librería python-liburing y desde Python 3.12 hay propuestas para integración nativa en asyncio. En Go, el runtime no usa io_uring por defecto (sigue con netpoller basado en epoll) pero existen librerías como iouring-go y gain que lo exponen para casos donde la performance lo justifica.

¿Cuánta diferencia de rendimiento puedo esperar?

Depende del workload. En servidores HTTP simples, las mejoras frente a epoll suelen estar entre 10% y 30%. En cargas mixtas de disco y red, o con muchísimas operaciones cortas, las mejoras llegan a 2x o 3x. En benchmarks de almacenamiento NVMe puro con IOPOLL, se han reportado mejoras de 5x frente a Linux AIO.

Referencias

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Top comments (0)