Arquitectura: Ring Buffers en Memoria Compartida

io_uring usa un par de ring buffers lock-free en memoria compartida entre userspace y el kernel:

  1. Submission Queue (SQ): Vos escribirs requests de I/O aquí. El kernel los lee.
  2. Completion Queue (CQ): El kernel escribe resultados de completion aquí. Vos los leés.

La clave: ambos rings están en regiones memory-mapped accesibles tanto por userspace como por el kernel. Escribir una submission es una escritura en memoria, no una syscall. Leer una completion es una lectura de memoria, no una syscall. Puede batchear docenas de operaciones de I/O, después hacer una sola syscall io_uring_enter() para submittearlas todas, o, con polling kernel-side, saltarte la syscall completamente.

Configurando io_uring en C

#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>

#define QUEUE_DEPTH 64
#define BLOCK_SIZE 4096

struct io_data {
 int fd;
 off_t offset;
 size_t length;
 struct iovec iov;
};

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

 // Inicializar io_uring con queue depth de 64
 ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
 if (ret < 0) {
 fprintf(stderr, "io_uring_queue_init: %s\n", strerror(-ret));
 return 1;
 }

 int fd = open(argv[1], O_RDONLY | O_DIRECT);
 if (fd < 0) {
 perror("open");
 return 1;
 }

 struct stat st;
 fstat(fd, &st);
 off_t file_size = st.st_size;
 off_t offset = 0;

 int pending = 0;
 while (offset < file_size) {
 struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
 if (!sqe) {
 // La SQ está llena, submitteamos lo que tenemos y esperamos
 io_uring_submit(&ring);

 struct io_uring_cqe *cqe;
 io_uring_wait_cqe(&ring, &cqe);
 struct io_data *data = io_uring_cqe_get_data(cqe);

 if (cqe->res > 0) {
 process_block(data->iov.iov_base, cqe->res);
 }

 free(data->iov.iov_base);
 free(data);
 io_uring_cqe_seen(&ring, cqe);
 pending--;

 sqe = io_uring_get_sqe(&ring);
 }

 struct io_data *data = malloc(sizeof(*data));
 posix_memalign(&data->iov.iov_base, BLOCK_SIZE, BLOCK_SIZE);
 data->iov.iov_len = BLOCK_SIZE;
 data->fd = fd;
 data->offset = offset;

 io_uring_prep_readv(sqe, fd, &data->iov, 1, offset);
 io_uring_sqe_set_data(sqe, data);

 offset += BLOCK_SIZE;
 pending++;
 }

 io_uring_submit(&ring);

 while (pending > 0) {
 struct io_uring_cqe *cqe;
 io_uring_wait_cqe(&ring, &cqe);
 struct io_data *data = io_uring_cqe_get_data(cqe);

 if (cqe->res > 0) {
 process_block(data->iov.iov_base, cqe->res);
 }

 free(data->iov.iov_base);
 free(data);
 io_uring_cqe_seen(&ring, cqe);
 pending--;
 }

 io_uring_queue_exit(&ring);
 close(fd);
 return 0;
}

El patrón es el siguiente: se llena la submission queue con tantas operaciones como sea posible, se envían en un batch, y luego se drenan los completions a medida que llegan. El kernel las procesa en paralelo, potencialmente reordenando para scheduling óptimo de I/O.

Buffers Registrados y Archivos Fijos

Para throughput máximo, se desea eliminar todo el overhead por-I/O:

// Pre-registrar buffers, el kernel pinea las páginas, salteando copy_from_user
struct iovec buffers[NUM_BUFFERS];
for (int i = 0; i < NUM_BUFFERS; i++) {
 posix_memalign(&buffers[i].iov_base, 4096, BUFFER_SIZE);
 buffers[i].iov_len = BUFFER_SIZE;
}
io_uring_register_buffers(&ring, buffers, NUM_BUFFERS);

// Pre-registrar file descriptors, el kernel los mantiene abiertos
int fds[NUM_FILES] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, fds, NUM_FILES);

// Ahora usamos los índices registrados en vez de fds/buffers crudos
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, 0, buffers[0].iov_base, BUFFER_SIZE, 0, 0);
sqe->flags |= IOSQE_FIXED_FILE;

Lo que ahorra por operación:

  • Registro de buffers: Elimina copy_from_user(), el kernel ya tiene un mapeo directo a las páginas de tu buffer. En un read de 4KB, ahorra ~200ns.
  • Archivos fijos: Elimina fget()/fput(), el reference counting atómico en el file descriptor. ~100ns.

300ns por operación no suena a mucho hasta que se está haciendo 500K IOPS, y ahí son 150ms de tiempo de CPU por segundo.

Polling Kernel-Side: I/O sin Syscalls

La forma definitiva de io_uring: el kernel pollea la submission queue por vos.

struct io_uring_params params = {
 .flags = IORING_SETUP_SQPOLL,
 .sq_thread_idle = 2000,
};

io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

// Ahora solo escribimos en la SQ, el kernel thread lo levanta
// ¡No hace falta io_uring_submit()!
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);

// Chequear CQ por completions, tampoco syscall
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
 // Procesar completion
 io_uring_cqe_seen(&ring, cqe);
}

Con SQPOLL, todo el path de submission de I/O es libre de syscalls. Escribirs en una región memory-mapped y el kernel thread lo procesa. La aplicación y el kernel se comunican puramente a través de memoria compartida.

Benchmarks: io_uring vs epoll vs sync I/O

Estos benchmarks se corrieron en un c5d.2xlarge (8 vCPU, NVMe local) con kernel 6.6 y fio:

Lecturas Random de 4K (Queue Depth 64)

Engine IOPS Latency (p99) CPU Usage
─────────────────────────────────────────────────────────
sync (1 thread) 85,000 11.7 μs 24%
libaio 310,000 8.2 μs 31%
io_uring 620,000 4.1 μs 28%
io_uring+sqpoll 680,000 3.8 μs 35%*
io_uring+reg_buf 710,000 3.6 μs 26%

io_uring con buffers registrados pega 710K IOPS, 8.3x más que sync I/O. La latencia p99 es 3.2x menor. Y el uso de CPU es menor que libaio porque menos syscalls significan menos overhead de transición al kernel.

io_uring y Node.js: El Estado Actual

Para ser claros: Node.js no usa io_uring en su event loop principal. libuv sigue usando epoll en Linux. Sin embargo, es posible usar io_uring desde Node.js vía addons nativos:

import { IoUring } from 'io-uring';
import { open } from 'fs/promises';

const ring = new IoUring(256);

async function readFileWithUring(path) {
 const fileHandle = await open(path, 'r');
 const fd = fileHandle.fd;
 const buffers = [];

 const stats = await fileHandle.stat();
 let offset = 0;

 while (offset < stats.size) {
 const buf = Buffer.allocUnsafe(4096);
 ring.prep_read(fd, buf, buf.length, offset);
 buffers.push(buf);
 offset += 4096;
 }

 ring.submit();
 const results = await ring.waitCompletions(buffers.length);

 await fileHandle.close();
 return Buffer.concat(buffers.slice(0, results.length));
}

Lo más interesante es lo que pasa adentro de libuv de Node.js. El thread pool que maneja file I/O actualmente usa llamadas bloqueantes read()/write() en worker threads. Si libuv reemplazara eso con submissions de io_uring, el thread pool podría manejar muchas más operaciones de archivo concurrentes sin agregar threads.

Un prototipo demostró throughput de file I/O mejorando 3.2x en una máquina de 32 cores al usar io_uring en vez del thread pool para fs.readFile sobre NVMe:

// Benchmark: leyendo 10,000 archivos de 4KB
// Node.js fs.readFile (libuv thread pool): 42,000 archivos/seg
// io_uring addon nativo: 134,000 archivos/seg
// io_uring con buffers registrados: 158,000 archivos/seg

Operaciones Encadenadas

io_uring soporta linking de operaciones, creando cadenas donde cada operación arranca solo después de que la anterior completa:

// Cadena: abrir archivo -> leer -> cerrar
// Las tres submitteadas de una, ejecutadas en orden

struct io_uring_sqe *sqe;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_openat(sqe, AT_FDCWD, "/data/file.dat", O_RDONLY, 0);
sqe->flags |= IOSQE_IO_LINK;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, size, 0);
sqe->flags |= IOSQE_IO_LINK;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_close(sqe, 0);

io_uring_submit(&ring);

La cadena entera se submittea de un saque, y el kernel la ejecuta sin ninguna intervención de userspace entre pasos.

Consideraciones de Seguridad

io_uring tuvo una historia de seguridad complicada. La superficie de ataque es grande y se encontraron varias vulnerabilidades de escalación de privilegios en 2021-2023. Como resultado:

  • Docker deshabilita io_uring por defecto en su perfil seccomp
  • Google lo deshabilitó en ChromeOS y Android
  • Varias distros Linux agregaron parámetros del kernel para deshabilitarlo

A partir del kernel 6.1+, el modelo de seguridad mejoró significativamente.

Cuándo Realmente Usar Esto

io_uring no es para toda aplicación. El costo de complejidad es real.

Utilizar io_uring cuando:

  • Estás I/O-bound y el overhead de syscalls es medible en tus profiles
  • Necesitás saturar NVMe o dispositivos de red de alta velocidad
  • Estás construyendo storage engines, proxies, o sistemas de base de datos

Quedate con epoll/libuv cuando:

  • Tu bottleneck es lógica de aplicación, no dispatch de I/O
  • Estás corriendo en contenedores donde io_uring puede estar deshabilitado
  • Tu equipo no tiene expertise en systems programming

La realidad es que para la mayoría de las aplicaciones web Node.js, el event loop no es el bottleneck, son las queries a la base de datos, las llamadas a APIs externas, y la serialización JSON. Cambiar de epoll a io_uring no va a ayudar si tu latencia p99 está dominada por una query Postgres de 200ms. Pero para esa clase de aplicaciones donde el overhead de dispatch de I/O importa, servidores de archivos de alto throughput, storage gateways, bases de datos custom, io_uring es transformacional. No es mejora incremental. Es una arquitectura diferente.