El futuro de la E/S: io_uring, eBPF, zero-copy y seguridad
En pocos años `io_uring` pasó de un parche de E/S asíncrona para almacenamiento a un motor de syscalls asíncronas de propósito general: los `IORING_OP_*` crecen cada versión, llegan el zero-copy de recepción y los mandos passthrough, y se abre la puerta a eBPF. La misma generalidad que hace volar a PostgreSQL, ScyllaDB y RocksDB lo convirtió en el objetivo de exploits favorito, y por eso el kernel trae hoy un interruptor de apagado.
io_uring nació hacia 2019 para arreglar la vieja E/S asíncrona de almacenamiento. Siete años después es otra cosa: un interfaz de syscalls asíncronas de propósito general. La lista de IORING_OP_* crece en cada versión —lecturas, sockets, madvise, waitid, mandos passthrough— y apunta a un horizonte donde casi cualquier llamada al sistema puede pedirse por el anillo, encadenarse, sondearse y, quizá pronto, gobernarse con eBPF. Pero esa misma generalidad abrió una segunda puerta al kernel, menos vigilada, y por eso la función más importante de io_uring en 2026 podría ser el sysctl que lo apaga.
- Ver por qué cada versión trae más
IORING_OP_*y qué son los mandos passthrough. - Entender el zero-copy de recepción y hacia dónde apunta
io_uringcon eBPF. - Reconocer por qué PostgreSQL, ScyllaDB y RocksDB migran a
io_uring. - Manejar el riesgo: bypass de
seccomp, CVEs y elsysctl kernel.io_uring_disabled.
Cada versión trae más IORING_OP_*
El catálogo de operaciones no para de crecer, y el salto cualitativo son los mandos passthrough: IORING_OP_URING_CMD deja que un driver defina comandos propios que viajan por el anillo. El caso estrella es NVMe: enviar un comando NVMe crudo por /dev/ng0n1 sin pasar por la capa de bloque, para bases de datos que hablan al SSD casi en su idioma.
/* Mando passthrough sobre un socket: consultar bytes pendientes sin recvmsg */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_cmd_sock(sqe, SOCKET_URING_OP_SIOCINQ, fd, 0, 0, NULL, 0);
io_uring_submit(&ring);
/* el resultado llega en cqe->res; NVMe passthrough usa el mismo IORING_OP_URING_CMD */
En NVMe el mando passthrough envía un comando crudo por /dev/ng0n1, esquivando la capa de bloque entera. Requiere un anillo con SQE de 128 bytes (IORING_SETUP_SQE128) para alojar el comando NVMe dentro del propio sqe:
/* NVMe passthrough: comando NVMe crudo por el anillo, sin capa de bloque */
int ng = open("/dev/ng0n1", O_RDWR);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct nvme_uring_cmd *cmd = (struct nvme_uring_cmd *)sqe->cmd;
sqe->opcode = IORING_OP_URING_CMD;
sqe->fd = ng;
sqe->cmd_op = NVME_URING_CMD_IO;
cmd->opcode = nvme_cmd_read; /* el opcode NVMe, no el de io_uring */
cmd->nsid = 1;
cmd->addr = (__u64)(uintptr_t)buf;
cmd->data_len = 4096;
io_uring_submit(&ring);
La lista ya cubre mucho más que leer y escribir: openat, close, statx, fsync, fallocate, fadvise, madvise, renameat, unlinkat, mkdirat, waitid, ftruncate y setxattr, además de las operaciones de red del capítulo anterior. La dirección es clara: io_uring deja de ser una lista cerrada de operaciones de E/S y se vuelve un canal genérico por donde los subsistemas exponen sus verbos de forma asíncrona y por lotes. Cada versión añade opcodes, y con IORING_OP_URING_CMD la puerta queda abierta para que cualquier driver exponga los suyos sin tocar el ABI central del anillo.
Zero-copy de recepción y eBPF
El envío zero-copy (lección 4) tiene ahora su reflejo en recepción. El zcrx enlaza una cola de recepción de la NIC a memoria de usuario preregistrada: los paquetes caen directos en tus páginas, sin la copia del socket al búfer. Como es ABI nueva de kernels 7.x, se registra por la vía cruda.
/* Zero-copy receive (zcrx): la NIC deposita datos en memoria de usuario, sin copia */
struct io_uring_zcrx_ifq_reg reg = {
.if_idx = if_nametoindex("eth0"),
.if_rxq = 0, /* cola RX dedicada al flujo, dirigida por steering de la NIC */
};
syscall(__NR_io_uring_register, ring.ring_fd, IORING_REGISTER_ZCRX_IFQ, ®, 1);
El zcrx cierra el círculo del zero-copy: la memoria de destino se preregistra como un área, la NIC escribe en ella por DMA dirigido a una cola dedicada, y un refill ring devuelve las páginas ya consumidas al pool. Ni una copia entre el kernel y tu proceso en todo el camino de recepción, el eslabón que faltaba frente al send_zc de la lección 4.
El siguiente horizonte es eBPF dentro del anillo: programas verificados que corran entre completados y decidan el eslabón siguiente sin despertar a espacio de usuario —parsear una cabecera y elegir la operación, filtrar, agregar—. Es la culminación de la idea de la lección 3: si el anillo ya ejecuta un grafo de dataflow, dale condicionales seguros y tendrás E/S programable ejecutándose donde están los datos. La integración plena aún madura, pero los mandos passthrough ya son su primera forma, y prototipos como IORING_OP_BPF llevan años explorando ejecutar la lógica de despacho en el propio kernel.
Por qué las bases de datos migran
No es moda: los motores que ya gestionan su propia caché (nivel 24, O_DIRECT) y su propia concurrencia encajan como un guante con un canal de E/S directo, asíncrono y por lotes.
# PostgreSQL 18 (2025): E/S asíncrona real, con io_uring como backend
io_method = io_uring # antes solo había E/S síncrona bloqueante
effective_io_concurrency = 32 # cuántas lecturas anticipadas mantiene en vuelo
PostgreSQL 18
Introdujo E/S asíncrona con io_method = io_uring. La lectura anticipada y el prefetch dejan de bloquear un backend por operación.
ScyllaDB / Seastar
Arquitectura hilo-por-núcleo sobre Seastar, cuyo reactor usa io_uring como backend de disco. Sin bloqueos, sin cambios de contexto.
RocksDB
Usa io_uring para MultiRead y prefetch asíncrono (ROCKSDB_IOURING_PRESENT), solapando E/S de muchos SST en vuelo.
La alternativa histórica, la vieja E/S asíncrona libaio, nunca cumplió: solo era realmente asíncrona con O_DIRECT, bloqueaba en los peores momentos —asignación de metadatos, fallos de página— y arrastraba una API que casi nadie quería tocar. io_uring es lo que libaio prometió y no fue: asíncrono de verdad para cualquier archivo, por lotes, con búferes y descriptores registrados, y sin un hilo bloqueado por cada operación en vuelo.
El patrón común: todos son software que ya sabía que el kernel le estorbaba, que abría con O_DIRECT para esquivar el page cache, y que necesitaba disparar cientos de E/S directas concurrentes sin un hilo por cada una. io_uring es exactamente esa primitiva.
Seguridad: el interruptor de emergencia
La generalidad tiene un reverso oscuro. Como io_uring despacha operaciones desde hilos trabajadores del kernel, un filtro seccomp que bloquee las syscalls openat o connect no atrapa sus equivalentes por el anillo: es una vía de escape de sandbox. Sumado a un historial denso de CVEs en sus primeros años, llevó a Google a deshabilitarlo en ChromeOS, Android y buena parte de su flota, tras constatar que concentraba una fracción enorme de los exploits de kernel encontrados en su programa de recompensas.
# El interruptor del kernel (desde 6.6): kernel.io_uring_disabled
# 0 = habilitado para todos
# 1 = solo procesos con CAP_SYS_ADMIN o en el grupo io_uring_group
# 2 = deshabilitado por completo (varias flotas lo fijan en 2)
sysctl -w kernel.io_uring_disabled=2
Cuando sí lo usas, puedes confinarlo: crear el anillo deshabilitado y declarar qué opcodes se permiten antes de activarlo.
/* Anillo confinado: nace deshabilitado y solo se permiten READ y WRITE */
struct io_uring ring;
struct io_uring_params p = { .flags = IORING_SETUP_R_DISABLED };
io_uring_queue_init_params(256, &ring, &p);
struct io_uring_restriction res[] = {
{ .opcode = IORING_RESTRICTION_SQE_OP, .sqe_op = IORING_OP_READ },
{ .opcode = IORING_RESTRICTION_SQE_OP, .sqe_op = IORING_OP_WRITE },
};
io_uring_register_restrictions(&ring, res, 2);
io_uring_enable_rings(&ring); /* a partir de aquí, solo READ y WRITE en este anillo */
Estas restricciones, fijadas antes de habilitar el anillo, son inmutables: aunque el proceso quede comprometido después, no podrá invocar un opcode fuera de la lista ni registrar recursos prohibidos. Es la defensa en profundidad que convierte a io_uring de superficie de ataque abierta en herramienta acotada, el patrón que debería adoptar cualquier servicio que exponga el anillo a datos no confiables.
Si tu modelo de amenazas se apoya en seccomp para negar open, connect o read, recuerda que un proceso con acceso a io_uring puede pedir esas mismas acciones como opcodes y saltarse el filtro. La defensa correcta es filtrar en seccomp las propias io_uring_setup, io_uring_enter e io_uring_register, o usar kernel.io_uring_disabled, o IORING_SETUP_R_DISABLED con IORING_REGISTER_RESTRICTIONS. Nunca asumas que una política de syscalls cubre lo que entra por el anillo.
/* Cerrar la puerta del anillo en un sandbox: negar las tres syscalls de io_uring */
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(io_uring_setup), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(io_uring_enter), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(io_uring_register), 0);
seccomp_load(ctx); /* sin setup no hay anillo, y con él caen todos sus opcodes */
El sistema clásico y el anillo son dos puertas distintas al mismo kernel, y un filtro de syscalls solo vigila la primera:
flowchart TD App[proceso] -->|syscall openat| SecComp[filtro seccomp] SecComp -->|permitido o bloqueado| K[kernel] App -->|IORING_OP_OPENAT por el anillo| Ring[io_uring] Ring -->|worker del kernel ejecuta la operacion| K Ring -. el filtro de syscalls NO ve esta ruta .- SecComp
El valor kernel.io_uring_disabled=1 es el término medio pragmático: deshabilita io_uring para todos salvo procesos con CAP_SYS_ADMIN o pertenecientes al grupo GID indicado en kernel.io_uring_group. Así una base de datos concreta conserva el anillo mientras el resto del sistema queda a salvo, sin apagarlo del todo con el valor 2. Es la forma de conceder la potencia solo a quien de verdad la necesita.
Cierra el nivel entendiendo qué es de verdad io_uring: es el ABI de syscalls asíncronas que Linux nunca tuvo. Durante cincuenta años la llamada al sistema fue síncrona, de una en una, a través de un trap; el programa pedía algo al kernel y se detenía a esperarlo. io_uring vuelve ese interfaz asíncrono, por lotes, sondeable, encadenable y —en el horizonte— programable con eBPF. No es un read() más rápido: es un contrato distinto con el kernel. Todo lo que estudiaste en el nivel son caras de una sola idea: describe la intención en memoria compartida, deja que el kernel ejecute un grafo de operaciones sin traps ni copias, y quítate de en medio. Los búferes y descriptores registrados sacaron la validación del bucle; SQPOLL e IOPOLL borraron la syscall y la interrupción; los enlaces y el multishot convirtieron el anillo en un programa; el modelo de completado unificó disco y red. Es la misma filosofía que O_DIRECT anticipó en el nivel 24: el kernel como conducto, no como hacedor. Pero aquí está la dialéctica que define su futuro y que debes llevarte grabada: la misma generalidad que hace volar a PostgreSQL y ScyllaDB es la que le da a un atacante una segunda puerta al kernel, menos vigilada que la del trap clásico. El rendimiento máximo y la superficie de ataque máxima no son problemas separados que se puedan optimizar por turnos: son el anverso y el reverso de la misma decisión de diseño —dar a espacio de usuario un canal ancho, directo y programable hacia el corazón del sistema—. Por eso la pieza de ingeniería más madura de todo io_uring quizá no sea ningún opcode nuevo, sino el sysctl que lo apaga. Dominar io_uring no es memorizar su API: es comprender que toda potencia concedida a espacio de usuario es también un riesgo concedido, y que saber cuándo esa moneda vale la pena gastarla es la marca del ingeniero que de verdad entiende el sistema.
- Recorre
include/uapi/linux/io_uring.hde un kernel reciente y cuenta cuántosIORING_OP_*existen; compáralo con los de la versión 5.1 original. - Configura PostgreSQL 18 con
io_method = io_uringy observa coniostatla profundidad de cola frente al modo síncrono. - Escribe un filtro
seccompque bloqueeopenaty demuestra que unio_uringconIORING_OP_OPENATlo esquiva; luego ciérralo filtrandoio_uring_setup. - Crea un anillo con
IORING_SETUP_R_DISABLED, restríngelo aREAD/WRITEconio_uring_register_restrictionsy confirma que otro opcode es rechazado. - Fija
kernel.io_uring_disabled=2y razona en qué clase de despliegue esa decisión es correcta pese a perder el rendimiento.