wandres.dev
IO_URING · rings SQ/CQ

La revolución de io_uring: de readiness a completion

epoll te dice cuándo un descriptor está listo, pero el trabajo de leer o escribir sigue siendo tuyo y cuesta una llamada al sistema por operación. io_uring invierte el modelo: describe la operación, entrégasela al kernel y recoge el resultado cuando esté hecha. El salto de readiness a completion, y por qué epoll y la AIO nativa se quedaron cortos.

⏱ 17 min

Durante todo el nivel 39, epoll fue el rey: le entregabas decenas de miles de descriptores y te avisaba de cuáles estaban listos para no bloquearte jamás. Pero fíjate en lo que epoll nunca hizo por ti: el trabajo. Te decía “el socket 7 ya se puede leer sin bloquear”, y tú, acto seguido, seguías obligado a ejecutar read() con tus propias manos, cruzando otra vez la frontera a modo kernel. epoll optimiza la espera; no toca la operación. io_uring nace de invertir esa premisa hasta el fondo: en lugar de preguntarle al kernel cuándo podrás actuar, le describes la acción completa, se la entregas, y es él quien la ejecuta y te deposita el resultado. Ese paso del modelo de readiness (“avísame cuando pueda”) al de completion (“hazlo y avísame cuando esté hecho”) es el cambio de paradigma de E/S más profundo de Linux en dos décadas.

🎯 Al terminar esta lección sabrás
  • Distinguir el modelo de readiness de epoll del de completion de io_uring.
  • Entender por qué epoll no puede hacer asíncrona la E/S sobre archivos regulares.
  • Reconocer por qué la AIO nativa (io_submit) fracasó como interfaz asíncrona general.
  • Situar io_uring como la primera interfaz que unifica red y almacenamiento asíncronos.

Readiness: el kernel te dice cuándo, tú haces el qué

El contrato de epoll es de disponibilidad. Registras descriptores, te duermes en epoll_wait() y el kernel te despierta con la lista de los que ya no bloquearían. La operación real —el read(), el write()— sigue corriendo por tu cuenta y cuesta su propia llamada al sistema:

int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sock };
epoll_ctl(ep, EPOLL_CTL_ADD, sock, &ev);

struct epoll_event listos[64];
for (;;) {
    int n = epoll_wait(ep, listos, 64, -1);              /* 1 syscall: espera */
    for (int i = 0; i < n; i++) {
        char buf[4096];
        ssize_t r = read(listos[i].data.fd, buf, sizeof buf); /* +1 syscall cada uno */
        procesar(buf, r);
    }
}

Atender n descriptores cuesta 1 + n llamadas al sistema como mínimo: una para enterarte y otra por cada operación que ejecutas. A carga de red altísima —el dominio para el que epoll fue diseñado— esa aritmética se vuelve el cuello de botella: no el hardware, no la latencia de red, sino el peaje de cruzar la frontera usuario/kernel millones de veces por segundo. Y ese peaje, tras las mitigaciones de Meltdown y Spectre, no ha hecho más que encarecerse.

El punto ciego: los archivos regulares

El límite conceptual de epoll aparece cuando dejas la red y tocas el almacenamiento. La pregunta “¿está este descriptor listo para leer sin bloquear?” no tiene sentido para un archivo en disco: un archivo regular está siempre listo. La respuesta a “¿puedo leer ya?” es siempre “sí”… y sin embargo el read() bloqueará mientras el disco sirve el bloque. El kernel es honesto al respecto y ni siquiera te deja intentarlo:

int fd = open("/var/lib/datos.bin", O_RDONLY);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = fd };
/* falla con EPERM: epoll rechaza los archivos regulares,
   porque para el estan siempre listos y read() bloqueara igual en el disco */
if (epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev) < 0)
    perror("epoll_ctl");            /* EPERM */

Aquí está la grieta de todo el modelo de readiness: solo sirve para descriptores que pueden decir “todavía no” —sockets, pipes, terminales—. Para el disco, donde la espera es intrínseca a la operación y no un estado previo a ella, la disponibilidad es un concepto vacío. Linux necesitaba otra cosa para hacer asíncrona la E/S de archivos, y durante quince años esa “otra cosa” fue un chiste amargo.

La promesa rota de la AIO nativa

Linux ya tenía, desde 2003, una interfaz de completion: la AIO nativa, con io_setup(), io_submit() e io_getevents(). Sobre el papel era justo el modelo correcto: describías la operación en un struct iocb, la enviabas y recogías su finalización sin bloquearte.

#include <libaio.h>
io_context_t ctx = 0;
io_setup(128, &ctx);

struct iocb cb, *lista[1] = { &cb };
io_prep_pread(&cb, fd, buf, 4096, 0);   /* describe una lectura */

io_submit(ctx, 1, lista);               /* enviar */
struct io_event ev[1];
io_getevents(ctx, 1, 1, ev, NULL);      /* recoger: ev[0].res = bytes o -errno */

En la práctica fue un fracaso célebre, y su propio mantenedor lo reconoció sin rodeos. Solo era de verdad asíncrona con O_DIRECT: sobre E/S bufferizada, io_submit() se volvía silenciosamente bloqueante, esperando en el page cache mientras fingía ser asíncrona. También podía bloquear al asignar metadatos. La interfaz copiaba el struct iocb al kernel en cada envío, soportaba un puñado escaso de operaciones y jamás sirvió para la red. Era una interfaz de completion que no completaba nada sin condiciones humillantes. El hueco seguía abierto: no existía una E/S asíncrona, general y eficiente para todo tipo de descriptor.

Completion: describe la acción, recoge el resultado

io_uring, obra de Jens Axboe e incorporado en 2019, cierra ese hueco por fin. Su modelo es de completion puro y sin asteriscos: preparas un descriptor de sumisión que describe qué hacer, lo publicas, y el kernel ejecuta la operación entera —bloqueante o no, en red o en disco, bufferizada o directa— y te deja un descriptor de finalización con el resultado.

/* el mismo trabajo, en completion: describe, envia, recoge */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, 0);  /* vale para archivos, O_DIRECT y sockets por igual */
io_uring_submit(&ring);                     /* una sola syscall envia el lote */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);             /* cqe->res: bytes leidos, o negativo si -errno */

La diferencia con epoll no es de grado sino de naturaleza. En readiness, el kernel te devuelve información (quién está listo) y tú conservas el trabajo; en completion, el kernel te devuelve resultados (qué pasó) porque el trabajo lo hizo él. Y como ya no gira en torno a la disponibilidad sino a la ejecución, el modelo deja de estar atado a los sockets: por primera vez, una misma interfaz hace asíncronos el recv de un socket y el pread de un NVMe con O_DIRECT.

flowchart LR
subgraph Readiness
  R1[epoll_wait espera] --> R2[kernel avisa listo] --> R3[tu ejecutas read] --> R4[procesar]
end
subgraph Completion
  C1[describe la operacion] --> C2[kernel la ejecuta] --> C3[recoges el resultado]
end

Tres generaciones de E/S asíncrona, tres contratos con el kernel, y solo el último cumple sin letra pequeña:

🔔

epoll · readiness

Te avisa de qué descriptor está listo, pero el read/write sigue siendo tuyo y cuesta su syscall. Solo sockets y afines; rechaza los archivos regulares con EPERM.

⚠️

AIO nativa · completion roto

El modelo correcto mal ejecutado: solo asíncrona con O_DIRECT, bloqueante a escondidas sobre E/S bufferizada, sin red y con copia del iocb en cada envío.

🚀

io_uring · completion real

Describe, entrega y recoge cualquier E/S —red, disco, O_DIRECT, bufferizada— por colas compartidas, en lote y sin copias. La primera que unifica todo.

ℹ️
De 2019 a los kernels 7.x

io_uring aterrizó en Linux 5.1 (2019) con un puñado de operaciones. En 2026, sobre kernels de la serie 7.x, ronda el centenar de opcodes —red, archivos, openat, close, statx, splice, sendmsg con copia cero— y es la ruta de E/S de alto rendimiento por defecto en bases de datos, proxies y runtimes asíncronos. Lo que empezó como “AIO que por fin funciona” es hoy la interfaz de E/S más ambiciosa del kernel.

Completion cambia quién trabaja, no solo cuándo avisas

Detente en la diferencia, porque no es una optimización sino un desplazamiento del sujeto de la acción. En el modelo de readiness, el kernel es un sereno que te avisa de que ya puedes pasar: la responsabilidad de cruzar sigue siendo tuya, y cada cruce es una syscall que pagas tú. epoll, por brillante que sea, nunca movió una sola tabla del trabajo real; solo perfeccionó el aviso previo. El modelo de completion reasigna al kernel el trabajo mismo. Tú te conviertes en quien describe intenciones y cosecha resultados, y el kernel en quien ejecuta. De esa reasignación se deducen, con necesidad lógica, todas las victorias de io_uring que verás en el resto del nivel: si el kernel hace el trabajo, puede aceptar muchas descripciones de golpe y ahorrarse las syscalls por operación (nivel 40.4); si el trabajo lo describe una estructura en memoria compartida, no hace falta copiar argumentos ni cruzar la frontera para entregarlos (nivel 40.2); si las operaciones se completan por su cuenta y fuera de orden, cada resultado ha de traer una etiqueta que lo identifique (nivel 40.3). epoll preguntaba cuándo; io_uring responde qué pasó. Y esa preposición distinta reordena toda la E/S de Linux: por fin la red y el almacenamiento, que Unix mantuvo cuarenta años en universos asíncronos separados e incompatibles, caben bajo una única interfaz porque ambos son, vistos desde completion, lo mismo: una acción que se describe, se delega y se recoge.

⚔️ Palpa la frontera entre los dos modelos
  1. Escribe un bucle epoll que atienda dos sockets y cuenta, con strace -c, cuántas llamadas al sistema gasta por cada mensaje procesado.
  2. Intenta añadir un archivo regular a un epoll_fd con epoll_ctl y confirma el EPERM; explica en dos líneas por qué la disponibilidad no significa nada para el disco.
  3. Reproduce con libaio una lectura sobre un archivo abierto sin O_DIRECT y observa, midiendo tiempos, cómo io_submit se comporta de forma bloqueante.
  4. Reescribe esa misma lectura con el esbozo de io_uring de arriba y compara el número de syscalls con strace -c.
  5. Argumenta por qué el modelo de completion, y no el de readiness, es el único capaz de hacer asíncrona la E/S de un archivo en disco.