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.
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.
- Configurar
IORING_SETUP_SQPOLLy entender el hiloiou-sqpdel kernel. - Saber cuándo hay que despertar a ese hilo con
IORING_SQ_NEED_WAKEUP. - Activar
IORING_SETUP_IOPOLLpara sondear el completado de un NVMe conO_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);
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.
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.
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.
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.
- Levanta un anillo con
IORING_SETUP_SQPOLLy descriptores fijos; localiza el hiloiou-sqp-<pid>enps -eLf. - Con
strace -f -e io_uring_enterconfirma que, bajo carga sostenida, apenas ves llamadas: el envío ocurre en memoria. - Sube y baja
sq_thread_idley observa cómo cambia la frecuencia de despertares y el uso de CPU del hilo. - Abre un NVMe con
O_DIRECT, montaIORING_SETUP_IOPOLLy mide la latencia de una lectura de 4 KiB frente al modo por interrupción. - Explica por qué IOPOLL rechaza la E/S bufferizada y por qué su ganancia de latencia cuesta un núcleo entero girando.