<!-- Canonical: https://lucioduran.com/es/blog/linux-io-uring-async-io-nodejs-integration
Language: es
Content-SHA256: c936293c978b79c9cde092bdfb1c05cb5fd86aa630c9f1c90fbc0c45a29042a8
-->

Systems Programming

# Linux io\_uring: Arquitectura de I/O Asíncrono e Integración con Node.js

2026-01-117 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration>)

## 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

```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:

```c
// 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.

```c
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:

```javascript
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:

```c
// 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.

## Preguntas frecuentes

### ¿Está io\_uring listo para producción?

Sí, con salvedades. Lo usan en producción Ceph, RocksDB y varios stacks de almacenamiento de cloud providers. La superficie de seguridad fue controversial, se deshabilitó en varios runtimes de contenedores y Android. A partir del kernel 6.1+, el modelo de seguridad mejoró significativamente.

### ¿Node.js usa io\_uring?

No en el event loop principal. libuv todavía usa epoll en Linux. Sin embargo, existen bindings experimentales y el equipo de libuv exploró integración de io\_uring. Para workloads específicos como file I/O, es posible usar io\_uring directamente vía addons nativos.

### ¿io\_uring va a reemplazar a epoll?

Para código nuevo, io\_uring es superior en casi toda dimensión. Pero epoll no se va a ningún lado, es más simple, bien entendido, y suficiente para muchos workloads. io\_uring importa más para servidores I/O-bound de alto throughput donde el overhead de syscalls es medible.

## Tips

1. **Tip:** Utilizar ` IORING_SETUP_SQPOLL ` solo para workloads de alto throughput sostenido. El thread de polling del kernel quema un core de CPU continuamente, para tráfico a ráfagas, el overhead supera al ahorro de syscalls.
2. **Tip:** Registrar los buffers con ` io_uring_register_buffers() ` antes de entrar al hot loop. El kernel fija las páginas y omite el ` copy_from_user ` en cada I/O, solo esto proporciona 15-20% de mejora en throughput en lecturas grandes.
3. **Tip:** Al realizar benchmarks de io\_uring vs epoll, deshabilitar las mitigaciones ` CONFIG_RETPOLINE ` o el delta de overhead de syscalls estará inflado. Los servidores de producción reales tienen mitigaciones habilitadas, por lo que conviene medir con ellas activas para obtener números realistas.
4. **Tip:** Configurar ` IORING_SETUP_COOP_TASKRUN ` en kernel 5.19+ para evitar la interrupción inter-procesador (IPI) en completion. Esto importa enormemente en sistemas NUMA multi-socket.

- io\_uring
- linux
- async-io
- nodejs
- systems-programming
- kernel
- performance
- epoll

[Volver al Blog](<https://lucioduran.com/es/blog>)
