wandres.dev
IO_URING AVANZADO · buffers registrados, liburing

Sondeo sin syscalls: SQPOLL e IOPOLL

Aun con búferes registrados, `io_uring_enter` sigue siendo una llamada al sistema por lote. `IORING_SETUP_SQPOLL` pone a un hilo del kernel a vaciar tu cola de envío sin que hagas ni una syscall, e `IORING_SETUP_IOPOLL` sondea el completado del NVMe sin interrupciones. El sondeo, la idea más vieja, vuelve arriba del todo.

⏱ 17 min

Registrar búferes y descriptores (lección 1) sacó del camino caliente el fget y el get_user_pages. Pero queda un peaje: io_uring_submit sigue llamando a io_uring_enter para avisar al kernel de que hay trabajo, y el completado sigue llegando por una interrupción del dispositivo. A la velocidad de un NVMe de 2026 —millones de operaciones por segundo por núcleo— tanto la syscall como la interrupción cuestan más que la propia E/S. Dos modos de sondeo los eliminan: uno borra la syscall de envío, el otro borra la interrupción de completado.

🎯 Al terminar esta lección sabrás
  • Configurar IORING_SETUP_SQPOLL y entender el hilo iou-sqp del kernel.
  • Saber cuándo hay que despertar a ese hilo con IORING_SQ_NEED_WAKEUP.
  • Activar IORING_SETUP_IOPOLL para sondear el completado de un NVMe con O_DIRECT.
  • Razonar el intercambio: latencia mínima a cambio de un núcleo que gira.

SQPOLL: un hilo del kernel vacía tu cola de envío

Con IORING_SETUP_SQPOLL, el kernel crea un hilo dedicado que observa tu submission queue y consume los sqe que vas depositando, sin que tú hagas ninguna syscall. Envías escribiendo en memoria compartida y avanzando la cabeza del anillo; el hilo lo ve.

struct io_uring ring;
struct io_uring_params p = { };
p.flags          = IORING_SETUP_SQPOLL;
p.sq_thread_idle = 2000;                  /* ms sin trabajo antes de que el hilo se duerma */
io_uring_queue_init_params(4096, &ring, &p);
/* Envío sin syscall: rellenas el sqe y avanzas la cola; el hilo del kernel lo recoge */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, /*index=*/0, buf, len, off, /*buf_index=*/0);
sqe->flags |= IOSQE_FIXED_FILE;           /* el hilo del kernel no puede hacer fget: usa fijos */
io_uring_submit(&ring);                   /* si el hilo está despierto, NO llama a io_uring_enter */

Como el hilo corre en contexto del kernel y no en tu tarea, no puede resolver un descriptor arbitrario de tu proceso: por eso SQPOLL casi siempre va de la mano de los descriptores fijos de la lección 1. Ese hilo aparece en top o ps como iou-sqp-<pid>, y verlo consumir CPU es la prueba de que está vaciando tu cola sin que tú entres al kernel.

# Localizar el hilo de sondeo del kernel y medir su consumo bajo carga
ps -eLf | grep iou-sqp          # un hilo iou-sqp-<pid> por grupo de anillos
pidstat -t -p $(pgrep miapp) 1  # el hilo gira mientras haya trabajo pendiente

Cuándo despertar al hilo

Si SQPOLL nunca hiciera syscalls, un hilo del kernel giraría eternamente aunque tu proceso duerma. Para evitarlo, tras sq_thread_idle milisegundos sin trabajo el hilo se duerme y publica el flag IORING_SQ_NEED_WAKEUP en la memoria compartida de la cola. liburing lo consulta en cada envío y solo entonces hace una syscall para despertarlo.

/* Lo que io_uring_submit hace por dentro con SQPOLL */
if (IO_URING_READ_ONCE(*ring.sq.kflags) & IORING_SQ_NEED_WAKEUP)
	io_uring_enter(ring.ring_fd, 0, 0, IORING_ENTER_SQ_WAKEUP, NULL);
/* si el hilo sigue activo, ni una syscall: submit es una simple escritura en memoria */

El ajuste de sq_thread_idle es la palanca clave: demasiado corto y despiertas al hilo sin cesar (más syscalls); demasiado largo y un núcleo gira en vacío gastando energía. Puedes además clavar el hilo a un núcleo aislado para que no compita con tus trabajadores, y compartir un único hilo SQPOLL entre varios anillos con IORING_SETUP_ATTACH_WQ.

struct io_uring_params p = { };
p.flags          = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
p.sq_thread_cpu  = 5;                     /* clavar el hilo a un núcleo dedicado */
p.sq_thread_idle = 1000;
io_uring_queue_init_params(4096, &ring, &p);

Para no dedicar un núcleo por anillo, varios anillos comparten un único hilo de sondeo con IORING_SETUP_ATTACH_WQ, apuntando al descriptor del primer anillo:

/* Segundo anillo que reutiliza el hilo SQPOLL del primero, sin gastar otro núcleo */
struct io_uring_params p2 = { };
p2.flags = IORING_SETUP_SQPOLL | IORING_SETUP_ATTACH_WQ;
p2.wq_fd = ring.ring_fd;                  /* reutiliza el hilo de sondeo ya existente */
io_uring_queue_init_params(4096, &ring2, &p2);
⚠️
SQPOLL no es gratis: un núcleo por anillo activo

El hilo SQPOLL despierto consume un núcleo entero girando sobre tu cola. Con varios anillos cada uno querría el suyo: por eso existe IORING_SETUP_ATTACH_WQ, que comparte un único hilo de sondeo entre anillos, y por eso sq_thread_idle debe afinarse para que el hilo duerma cuando de verdad no hay trabajo. SQPOLL rinde en cargas sostenidas y de baja latencia; para tráfico a ráfagas muy espaciadas, el núcleo girando cuesta más que la syscall que ahorra. Desde 5.11 no requiere privilegios: el hilo ejecuta con las credenciales del creador, así que sus operaciones se autorizan como si las hicieras tú.

IOPOLL: sondear el completado del NVMe

SQPOLL borró la syscall de envío; IORING_SETUP_IOPOLL ataca la otra mitad: la interrupción de completado. En lugar de que el NVMe levante una IRQ al terminar cada E/S, tu llamada de recolección sondea la cola de completado del dispositivo. Exige archivos abiertos con O_DIRECT (nivel 24) sobre un dispositivo de bloque con colas de sondeo habilitadas:

# El bloque debe exponer sondeo para que IOPOLL funcione sobre él
cat /sys/block/nvme0n1/queue/io_poll        # 1 si el sondeo está activo
echo 1 > /sys/block/nvme0n1/queue/io_poll   # habilitarlo si estaba a 0
struct io_uring_params p = { };
p.flags = IORING_SETUP_IOPOLL;            /* solo E/S O_DIRECT de bloque; nada de bufferizado */
io_uring_queue_init_params(256, &ring, &p);

int fd = open("/dev/nvme0n1", O_RDONLY | O_DIRECT);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, offset);
io_uring_submit_and_wait(&ring, 1);       /* la recolección SONDEA la cola de completado */

La recolección no espera una notificación del kernel: entra y sondea activamente la cola de completado del dispositivo hasta encontrar resultados. Cualquier io_uring_wait_cqe o io_uring_submit_and_wait dispara ese sondeo; luego drenas la CQ como siempre.

/* Drenar los completados encontrados por el sondeo, en bloque */
struct io_uring_cqe *cqe;
unsigned head, vistos = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
	/* ...procesar cqe->res, uno por E/S completada... */
	vistos++;
}
io_uring_cq_advance(&ring, vistos);       /* avanza la cola de completado de golpe */
flowchart LR
subgraph Por_interrupcion
  I1[submit] --> I2[el NVMe trabaja] --> I3[IRQ de fin] --> I4[handler despierta la tarea]
end
subgraph IOPOLL
  P1[submit] --> P2[el NVMe trabaja] --> P3[la tarea sondea la cola] --> P4[completado leido sin IRQ]
end

Sin interrupción no hay cambio de contexto ni coste de handler: la latencia baja a microsegundos de un solo dígito y un núcleo puede exprimir millones de IOPS. El precio es que la recolección gira ocupando CPU, y que IOPOLL solo vale para E/S de bloque directa: no puedes mezclarla con E/S bufferizada ni con la mayoría de operaciones que no sean de almacenamiento en el mismo anillo.

ℹ️
IOPOLL necesita colas de sondeo en el bloque

IORING_SETUP_IOPOLL no funciona sobre cualquier disco: el dispositivo de bloque debe tener colas de poll configuradas (parámetro poll_queues del driver NVMe, visible bajo /sys/block/). Sin ellas, el envío falla o cae a interrupciones. Además el sondeo híbrido —dormir la mitad del tiempo estimado y sondear el resto— es un término medio que recorta CPU a cambio de algo de latencia.

Ambos modos son ortogonales y se combinan: un anillo con IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL sobre descriptores fijos y O_DIRECT logra el ideal de un motor de almacenamiento —cero syscalls de envío y cero interrupciones de completado—, a cambio de dedicar núcleos al sondeo. Es exactamente la configuración con la que ScyllaDB o un backend de base de datos exprimen un NVMe hasta su límite físico.

💡
Sondeo híbrido: recupera CPU sin perder toda la latencia

El sondeo puro quema un núcleo aunque no llegue trabajo. El sondeo híbrido del bloque (io_poll_delay) es un punto medio: el kernel estima cuánto tardará la E/S, duerme la primera mitad de ese tiempo y solo sondea la segunda, recuperando gran parte de la CPU a cambio de unos pocos microsegundos de latencia. Mídelo con fio variando --hipri y el retardo para hallar el equilibrio de tu dispositivo.

🧹

SQPOLL

Un hilo del kernel vacía la SQ. Envías escribiendo en memoria; cero io_uring_enter mientras el hilo esté despierto. Exige descriptores fijos.

🎯

IOPOLL

La recolección sondea el completado del NVMe. Sin IRQ, latencia de microsegundos. Solo E/S O_DIRECT de bloque, y un núcleo girando.

El hardware se volvió tan rápido que preguntar y dormir es la ruta lenta

Durante cincuenta años la sabiduría fue unánime: sondear es un pecado, malgasta CPU; lo correcto es pedir la E/S, dormir, y dejar que una interrupción te despierte. Esa doctrina nació cuando un disco tardaba milisegundos y una syscall costaba nanosegundos: dormir era obviamente mejor que girar. io_uring invierte el axioma porque el hardware invirtió las proporciones. Un NVMe de 2026 responde en microsegundos; a esa escala, el cambio de contexto de una interrupción y el trap de una syscall cuestan más que la operación que gestionan. SQPOLL e IOPOLL son la consecuencia lógica: si atender la E/S cuesta más que la E/S, deja de atender y ponte a sondear. Fíjate en la trayectoria completa del nivel: la syscall fue la unidad de E/S de Unix durante medio siglo; io_uring primero la puso en lote, luego SQPOLL la borró del envío, y IOPOLL borró la interrupción del completado. Lo que queda son dos anillos en memoria compartida y, en el extremo, un núcleo que nunca entra al kernel por la puerta clásica. La frontera usuario-kernel, ese trap sagrado que definía el sistema operativo, se disuelve en una estructura de datos compartida. El sondeo no volvió por nostalgia: volvió porque, arriba del todo de la pila, esperar educadamente se convirtió en el derroche.

⚔️ Borra la syscall, luego la interrupción
  1. Levanta un anillo con IORING_SETUP_SQPOLL y descriptores fijos; localiza el hilo iou-sqp-<pid> en ps -eLf.
  2. Con strace -f -e io_uring_enter confirma que, bajo carga sostenida, apenas ves llamadas: el envío ocurre en memoria.
  3. Sube y baja sq_thread_idle y observa cómo cambia la frecuencia de despertares y el uso de CPU del hilo.
  4. Abre un NVMe con O_DIRECT, monta IORING_SETUP_IOPOLL y mide la latencia de una lectura de 4 KiB frente al modo por interrupción.
  5. Explica por qué IOPOLL rechaza la E/S bufferizada y por qué su ganancia de latencia cuesta un núcleo entero girando.