← Sistemas de Archivos y E/SBloque 13 · Dispositivos de bloque y de caracteres · Tema 47 de 50

Tema 47

E/S no bloqueante, poll e io_uring

Esperar disponibilidad y recibir una operación terminada son modelos diferentes. Estudiaremos O_NONBLOCK, poll y epoll, y después enviaremos lecturas con io_uring. Dos programas en C, un laboratorio y dos simuladores permitirán seguir estados, lecturas parciales, identificadores y vida útil de los búferes sin depender del orden de finalización.

1. Tres formas de relacionarse con la espera

En el tema 46, pread entregaba bytes o un error cuando retornaba. Ahora queremos administrar varias fuentes de trabajo sin quedar esperando una sola de ellas. Para razonar sobre ello conviene separar tres contratos.

ModeloQué hace la aplicaciónQué recibe
Llamada bloqueanteSolicita una operación que puede esperar dentro de la llamada.Su resultado al retornar.
No bloqueante y disponibilidadIntenta operar, y espera eventos cuando todavía no puede avanzar.Una indicación para volver a intentar read o write.
Envío y finalizaciónDescribe una operación y conserva sus recursos hasta su resultado.Una finalización asociada a esa solicitud.

Disponibilidad: condición que permite intentar una operación sin la espera ordinaria de esa fuente. Finalización: resultado de una operación ya solicitada. Un evento de lectura de epoll no es una lectura terminada; un CQE de io_uring para una lectura sí informa su resultado.

Una interfaz asíncrona no elimina todas las esperas ni garantiza ejecución simultánea en hardware. La aplicación puede esperar finalizaciones, y el envío y procesamiento tienen costes. El objetivo es administrar trabajo pendiente y dependencias con un contrato explícito.

2. O_NONBLOCK y las banderas del descriptor

Para los objetos que lo soportan, O_NONBLOCK modifica qué ocurre cuando una operación no puede avanzar de inmediato. En lugar de esperar por disponibilidad puede retornar -1 con EAGAIN o EWOULDBLOCK. El comportamiento depende del tipo de objeto y del controlador.

Conservar las banderas existentes al activar O_NONBLOCK
int banderas = fcntl(fd, F_GETFL);
if (banderas == -1) {
    /* Tratar el error; no usar banderas como si fueran validas. */
} else if (fcntl(fd, F_SETFL, banderas | O_NONBLOCK) == -1) {
    /* Tratar el error de configuracion. */
}

Estas son banderas de estado de la descripción de archivo abierta: descriptores duplicados pueden compartirlas. FD_CLOEXEC pertenece a otro conjunto de banderas y no se configura con F_SETFL. En el ejemplo de temporizadores elegiremos el modo no bloqueante al crearlos.

O_NONBLOCK no convierte automáticamente un acceso a disco en una operación asíncrona. En Linux no evita la espera de E/S de archivos regulares y dispositivos de bloque. La referencia open(2) delimita su efecto.

3. Datos, EOF, EAGAIN y EINTR

Resultado de read con cantidad positivaInterpretaciónPróximo paso habitual
n > 0Se recibieron n bytes; pueden ser menos que los pedidos.Procesar solo ese rango y actualizar el progreso.
n = 0EOF según el contrato de la fuente.Finalizar o aplicar la regla correspondiente al objeto.
n = -1, EAGAIN/EWOULDBLOCKNo hay progreso disponible ahora en modo no bloqueante.Esperar disponibilidad; no confundirlo con EOF.
n = -1, EINTRLa llamada fue interrumpida.Reintentar o atender la política de señales y cancelación.
Otro errorLa operación falló.Registrar y resolver el estado; no usar el búfer como resultado.

Una notificación no reserva datos para un consumidor determinado. El estado puede cambiar antes de nuestra lectura; por eso el intento sigue tratando EAGAIN. El ejemplo usa fuentes propias sin otros lectores, pero conserva esa comprobación.

Una escritura no bloqueante también puede ser parcial. Se conserva el tramo pendiente, y se solicita interés de escritura solo mientras haya datos por enviar; vigilar permanentemente una salida que siempre está lista puede despertar el bucle sin trabajo útil.

4. poll: consultar varios descriptores a la vez

poll recibe un arreglo de pollfd con fd, events y revents. events expresa el interés; revents contiene lo observado. El retorno positivo cuenta elementos con algún evento, no bytes ni necesariamente solo los eventos que solicitamos.

Estructura de un bucle de disponibilidad
preparar descriptores e intereses
mientras quede trabajo:
    esperar eventos con poll
    si EINTR: aplicar la politica y recalcular el plazo si corresponde
    si timeout: no inventar una lectura completada
    por cada descriptor con revents:
        tratar disponibilidad, cierre y errores
        intentar la operacion y revisar su retorno

POLLIN indica disponibilidad para lectura. POLLOUT se refiere a escritura. POLLERR, POLLHUP y POLLNVAL requieren atención y pueden aparecer sin solicitarlos. Un fd negativo se ignora, lo que permite retirar entradas del arreglo.

En fuentes de flujo pueden coexistir POLLIN y POLLHUP: aún puede quedar contenido antes de EOF. Cerrar por ver HUP sin consumir lo que corresponde puede perder datos. La referencia poll(2) describe eventos y retorno.

5. Plazos, espera activa y reparto del trabajo

timeout=0 consulta sin esperar. Repetirlo continuamente consume CPU mientras no ocurre nada útil. Un timeout positivo permite esperar, y -1 expresa espera sin límite de esa llamada.

Si una aplicación tiene un plazo total, conviene expresarlo como instante límite del reloj monotónico y recalcular lo restante tras cada interrupción. Repetir el timeout completo después de cada EINTR extiende el presupuesto original.

Plazo total frente a tiempo de cada intento
limite = monotonic_now + presupuesto
antes de cada espera:
    restante = limite - monotonic_now
    si restante ≤ 0: terminar por plazo
    esperar hasta restante

El timeout de poll es milisegundos; convertir y redondear con criterio.

Un bucle también necesita repartir trabajo: una fuente muy activa no debe monopolizar la CPU. Si se limita su procesamiento por turno, hay que conservar el estado pendiente y volver a atenderlo. Con notificación por flancos esto será especialmente importante.

6. epoll y la notificación por nivel

epoll mantiene una lista de interés en el kernel. Se crea una instancia con epoll_create1, se agregan o modifican fuentes con epoll_ctl y se esperan notificaciones mediante epoll_wait. La aplicación no vuelve a pasar todo el arreglo de interés en cada espera.

El modo por defecto es por nivel, level-triggered: mientras persista una condición de disponibilidad, puede volver a notificarse. Si una fuente tiene 12 bytes y solo se consumen 4, aún quedan 8 que justifican disponibilidad en una espera posterior.

El campo data permite asociar una identidad de aplicación. El número de descriptor puede reutilizarse después de close, por lo que la identidad de una operación no debería depender de un entero viejo sin controlar su ciclo de vida.

La documentación de epoll(7) explica las listas y modos. epoll ayuda con muchos descriptores, pero no elimina trabajo de registro, procesamiento ni sincronización y no ofrece un multiplicador universal de rendimiento frente a poll.

7. Flancos, drenaje y EAGAIN

Con EPOLLET se elige notificación por flancos, edge-triggered. No se debe esperar que una condición que sigue lista se notifique de nuevo simplemente porque quedó trabajo sin consumir.

La regla de un consumidor sencillo es utilizar el descriptor no bloqueante y continuar intentando la operación hasta EAGAIN, EOF o error. Si se impone un presupuesto por turno, la aplicación conserva el descriptor en su propia lista de trabajo listo hasta terminar de drenarlo.

Flujo conceptual para una lectura de bytes
al recibir el evento:
    repetir:
        n = read(fd, buffer, capacidad)
        n > 0: procesar esos n bytes y continuar
        n == 0: resolver EOF y retirar la fuente cuando corresponda
        EINTR: reintentar segun la politica
        EAGAIN/EWOULDBLOCK: esperar una nueva notificacion
        otro error: resolver fallo

EPOLLONESHOT es otra opción: desactiva el interés tras una notificación hasta rearmarlo con EPOLL_CTL_MOD. No es sinónimo de EPOLLET. En el programa usaremos temporizadores de un único vencimiento; eso describe al temporizador, no el uso de EPOLLONESHOT.

8. Qué cambia con archivos regulares

En Linux, poll suele considerar los archivos regulares disponibles para lectura y escritura, aunque una operación posterior pueda esperar por el almacenamiento. Esa disponibilidad no significa que sus datos ya estén residentes en RAM.

Registrar un archivo regular en epoll normalmente falla con EPERM por no admitir ese tipo de notificación. No se resuelve ese fallo agregando O_NONBLOCK. La referencia epoll_ctl(2) describe el error para objetivos sin soporte.

NecesidadHerramienta del temaQué no supone
Esperar vencimientos de varios temporizadores.poll o epoll sobre timerfd.No se están leyendo bloques de disco.
Recibir resultados de lecturas de un archivo.Solicitudes READ de io_uring.No dependen de registrar el archivo en epoll.
Consultar sin esperar un evento de disponibilidad.poll con timeout cero.No transforma una lectura regular en asíncrona.

9. Una fuente local de eventos: timerfd

timerfd permite representar vencimientos de un temporizador mediante un descriptor. Se crea con un reloj, se arma y luego se lee un contador de expiraciones como uint64_t. El tamaño de esa lectura es parte de su contrato: no es una fuente arbitraria de bytes.

Usaremos dos temporizadores locales con un solo vencimiento y sin intervalo periódico. Antes de armarlos, una lectura no bloqueante devuelve EAGAIN; después del vencimiento, entrega el contador. Esta práctica no requiere comunicación entre procesos.

Un temporizador periódico podría acumular varias expiraciones antes de leerlo. Por eso en general una notificación no equivale a «un único vencimiento». En nuestro contrato de una sola expiración por temporizador, el contador esperado es uno. Véase timerfd_create(2).

10. Programa en C: poll, epoll y EPOLLET

Guarda esperar-eventos.c. Recibe modo poll|epoll|epollet y dos demoras de 1 a 1000 ms. Las identidades 0 y 1 son estables durante la ejecución; no se presupone orden de entrega. Devuelve 0 al observar ambos vencimientos, 1 ante error y 2 por uso incorrecto.

esperar-eventos.c · dos fuentes propias, sin espera activa
#define _POSIX_C_SOURCE 200809L
#include <sys/timerfd.h>
#include <sys/epoll.h>
#include <poll.h>
#include <unistd.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

static int demora(const char *s)
{
    unsigned n = 0;
    if (!*s || strlen(s) > 4) return -1;
    for (; *s; ++s) {
        if (*s < '0' || *s > '9') return -1;
        n = n * 10 + (unsigned)(*s - '0');
    }
    return n >= 1 && n <= 1000 ? (int)n : -1;
}
int main(int argc, char **argv)
{
    int ms[2];
    if (argc != 4 || (strcmp(argv[1], "poll") && strcmp(argv[1], "epoll") &&
        strcmp(argv[1], "epollet")) || (ms[0] = demora(argv[2])) < 0 ||
        (ms[1] = demora(argv[3])) < 0) {
        fprintf(stderr, "Uso: %s poll|epoll|epollet ms0 ms1 (1..1000)\n", argv[0]);
        return 2;
    }
    int usar_epoll = strcmp(argv[1], "poll") != 0;
    int flancos = !strcmp(argv[1], "epollet");
    int fd[2] = {-1, -1}, ep = -1, completos = 0, estado = 1;
    if (usar_epoll && (ep = epoll_create1(EPOLL_CLOEXEC)) == -1) goto fallo;
    for (unsigned i = 0; i < 2; ++i) {
        fd[i] = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
        if (fd[i] == -1) goto fallo;
        uint64_t contador;
        ssize_t n;
        do { n = read(fd[i], &contador, sizeof contador); } while (n == -1 && errno == EINTR);
        if (n != -1 || (errno != EAGAIN && errno != EWOULDBLOCK)) { errno = EIO; goto fallo; }
        printf("INICIAL_%u=EAGAIN\n", i);
        if (usar_epoll) {
            struct epoll_event e = {0};
            e.events = EPOLLIN | (flancos ? EPOLLET : 0);
            e.data.u32 = i;
            if (epoll_ctl(ep, EPOLL_CTL_ADD, fd[i], &e)) goto fallo;
        }
    }
    for (unsigned i = 0; i < 2; ++i) {
        struct itimerspec t = {0};
        t.it_value.tv_sec = ms[i] / 1000;
        t.it_value.tv_nsec = (long)(ms[i] % 1000) * 1000000L;
        if (timerfd_settime(fd[i], 0, &t, NULL)) goto fallo;
    }
    while (completos < 2) {
        unsigned ids[2];
        int nids = 0, n;
        if (usar_epoll) {
            struct epoll_event eventos[2];
            n = epoll_wait(ep, eventos, 2, 2000);
            if (n == -1 && errno == EINTR) continue;
            if (n < 0) goto fallo;
            for (int j = 0; j < n; ++j) {
                if (eventos[j].events & (EPOLLERR | EPOLLHUP)) { errno = EIO; goto fallo; }
                if (eventos[j].events & EPOLLIN) ids[nids++] = eventos[j].data.u32;
            }
        } else {
            struct pollfd p[2] = {{fd[0], POLLIN, 0}, {fd[1], POLLIN, 0}};
            n = poll(p, 2, 2000);
            if (n == -1 && errno == EINTR) continue;
            if (n < 0) goto fallo;
            for (unsigned j = 0; j < 2; ++j) {
                if (p[j].revents & (POLLERR | POLLHUP | POLLNVAL)) { errno = EIO; goto fallo; }
                if (p[j].revents & POLLIN) ids[nids++] = j;
            }
        }
        if (n == 0) { fprintf(stderr, "Timeout sin ambos vencimientos\n"); goto limpiar; }
        for (int j = 0; j < nids; ++j) {
            unsigned id = ids[j];
            if (id >= 2 || fd[id] == -1) { errno = EIO; goto fallo; }
            uint64_t contador;
            ssize_t r;
            do { r = read(fd[id], &contador, sizeof contador); } while (r == -1 && errno == EINTR);
            if (r == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) continue;
            if (r == -1) goto fallo;
            if (r != (ssize_t)sizeof contador || contador != 1) { errno = EIO; goto fallo; }
            printf("EVENTO=%u EXPIRACIONES=%" PRIu64 "\n", id, contador);
            if (usar_epoll && epoll_ctl(ep, EPOLL_CTL_DEL, fd[id], NULL)) goto fallo;
            int viejo = fd[id]; fd[id] = -1;
            if (close(viejo)) goto fallo;
            ++completos;
        }
    }
    puts("COMPLETOS=2");
    estado = 0;
    goto limpiar;
fallo:
    perror("eventos");
limpiar:
    for (unsigned i = 0; i < 2; ++i) if (fd[i] != -1 && close(fd[i])) estado = 1;
    if (ep != -1 && close(ep)) estado = 1;
    if (fflush(stdout) == EOF) estado = 1;
    return estado;
}

El timeout es de cada llamada, no un plazo global: el ejemplo permite reintentar tras EINTR. Cada fuente es de un solo vencimiento y se retira después de consumir su contador completo; no queda contenido pendiente que exija otra notificación por flanco. Un consumidor de un flujo reutilizable necesitaría el bucle de drenaje de la sección 7.

ERR/HUP se tratan aquí como fallos del contrato de estos temporizadores propios. Ese camino no se copia como regla de cierre inmediato para fuentes de flujo que puedan conservar datos después de HUP.

11. io_uring: SQE, CQE y propiedad del búfer

io_uring utiliza una cola de envío, SQ, y una de finalización, CQ. La aplicación prepara una SQE describiendo la operación; un CQE posterior identifica la solicitud y su resultado. La biblioteca liburing encapsula el manejo de los anillos.

Envío y recepción de resultadosLa aplicación prepara SQE con identidad, descriptor, offset y búfer. El kernel ejecuta las operaciones y publica CQE con identidad y resultado. La aplicación procesa cada resultado y reconoce la entrada consumida. Preparar SQEfd, offset, búferuser_data Enviar y ejecutarorden final no supuesto Procesar CQEuser_data, resreconocer consumoEl búfer debe seguir válido mientras su operación está pendiente.Una operación puede finalizar antes o después de otras enviadas.
Identificar resultados es parte del contrato, no un detalle del orden de impresión.

En una lectura, cqe->res contiene bytes si es positivo, cero para EOF o un código -errno si la operación falla. El retorno de esperar el CQE describe la espera, no el resultado de la lectura. Se consume la entrada con io_uring_cqe_seen una vez copiados y procesados sus datos relevantes.

Los búferes y el estado referenciado permanecen vivos hasta que termina la operación correspondiente. Enviar una solicitud no los libera. Tampoco se supone copia cero universal ni que todas las operaciones se ejecuten fuera del hilo que envía. La referencia io_uring(7) describe el modelo.

Se usarán offsets explícitos para evitar competir por una posición de archivo compartida. io_uring_prep_read(3) explica la diferencia respecto de offset=-1.

12. Programa en C: dos lecturas con liburing

Guarda dos-lecturas-uring.c. Requiere liburing y un archivo regular estable de al menos 128 bytes. Envía lecturas de 64 bytes en offsets 0 y 64. Conserva búferes estáticos, identifica CQE mediante user_data y reenvía el tramo faltante si una lectura es corta y positiva.

Devuelve 0 al completar ambos rangos, 1 ante fallo, 2 por uso incorrecto y 3 si no logra inicializar el anillo. Estado 3 puede deberse a política del entorno o recursos, no solo a ausencia de soporte del kernel.

dos-lecturas-uring.c · solicitudes identificadas y contenido hexadecimal
#define _POSIX_C_SOURCE 200809L
#define _FILE_OFFSET_BITS 64
#include <liburing.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

struct trabajo {
    unsigned char buffer[64];
    unsigned usados;
    int pendiente, terminado;
};
static struct trabajo trabajos[2];
static _Noreturn void fatal(const char *mensaje, int codigo)
{
    fprintf(stderr, "%s: %s\n", mensaje, strerror(codigo));
    fflush(stderr);
    /* No retornar ni reutilizar memoria con operaciones aun pendientes. */
    _Exit(1);
}
static void preparar(struct io_uring *ring, int fd, unsigned id)
{
    struct trabajo *t = &trabajos[id];
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    if (!sqe) fatal("Sin espacio SQ para el lote acotado", EIO);
    io_uring_prep_read(sqe, fd, t->buffer + t->usados, 64 - t->usados,
                       (uint64_t)id * 64 + t->usados);
    io_uring_sqe_set_data64(sqe, id);
    t->pendiente = 1;
}
static void enviar(struct io_uring *ring, int esperado)
{
    int r = io_uring_submit(ring);
    if (r < 0) fatal("io_uring_submit", -r);
    if (r != esperado) {
        fprintf(stderr, "Envio incompleto: %d de %d SQE\n", r, esperado);
        fatal("El ejemplo no continua tras envio parcial", EIO);
    }
}
int main(int argc, char **argv)
{
    if (argc != 2) { fprintf(stderr, "Uso: %s archivo-regular\n", argv[0]); return 2; }
    int fd = open(argv[1], O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
    if (fd == -1) { perror("open"); return 1; }
    struct stat st;
    if (fstat(fd, &st) == -1) { perror("fstat"); close(fd); return 1; }
    if (!S_ISREG(st.st_mode) || st.st_size < 128) {
        fprintf(stderr, "Se requiere archivo regular estable de al menos 128 bytes\n");
        close(fd); return 1;
    }
    struct io_uring ring;
    int r = io_uring_queue_init(4, &ring, 0);
    if (r < 0) {
        fprintf(stderr, "INIT_ERROR=%d %s\n", -r, strerror(-r)); close(fd); return 3;
    }
    preparar(&ring, fd, 0);
    preparar(&ring, fd, 1);
    enviar(&ring, 2);
    unsigned completos = 0;
    while (completos < 2) {
        struct io_uring_cqe *cqe;
        do { r = io_uring_wait_cqe(&ring, &cqe); } while (r == -EINTR);
        if (r < 0) fatal("io_uring_wait_cqe", -r);
        uint64_t identidad = io_uring_cqe_get_data64(cqe);
        int resultado = cqe->res;
        io_uring_cqe_seen(&ring, cqe);
        if (identidad >= 2) fatal("Identidad CQE invalida", EIO);
        unsigned id = (unsigned)identidad;
        struct trabajo *t = &trabajos[id];
        if (!t->pendiente || t->terminado) fatal("CQE duplicado o fuera de estado", EIO);
        t->pendiente = 0;
        if (resultado < 0) fatal("Resultado READ", -resultado);
        if (resultado == 0) fatal("EOF antes de completar el rango", EIO);
        if ((unsigned)resultado > 64 - t->usados) fatal("Resultado demasiado grande", EIO);
        t->usados += (unsigned)resultado;
        if (t->usados == 64) {
            t->terminado = 1; ++completos;
            printf("ID=%u OFFSET=%u BYTES=64 HEX=", id, id * 64);
            for (unsigned i = 0; i < 64; ++i) printf("%02X", (unsigned)t->buffer[i]);
            putchar('\n');
        } else {
            preparar(&ring, fd, id);
            enviar(&ring, 1);
        }
    }
    /* Ambos rangos terminaron: ya no queda memoria de lectura en uso. */
    io_uring_queue_exit(&ring);
    int estado = close(fd) == -1 ? 1 : 0;
    if (fflush(stdout) == EOF) estado = 1;
    return estado;
}

Los helpers de liburing retornan errores negativos según su contrato: se usa strerror(-r), no un errno posiblemente ajeno. El programa no activa SQPOLL ni opciones avanzadas. Exige el envío completo del pequeño lote; ante envío parcial termina con error en lugar de asumir dos operaciones aceptadas.

Este ejecutable adopta un camino fatal explícito: si falla después de enviar trabajo, _Exit termina el proceso y deja al kernel gestionar su terminación y recursos; no retorna a código que pueda reutilizar los búferes estáticos. No es una función reutilizable de biblioteca ni un ejemplo de cancelación ordenada. En el camino normal sí consume todas las finalizaciones antes de destruir el anillo y cerrar.

13. Errores, cancelación y cierre del ciclo

Preparar, enviar y completar son estados distintos. io_uring_submit(3) informa cuántas SQE se enviaron con las opciones utilizadas. Un programa general conserva las no enviadas y sigue su progreso; no espera CQE por operaciones que nunca quedaron enviadas.

La espera de CQE también puede fallar independientemente de una lectura. io_uring_wait_cqe(3) distingue su retorno de cqe->res. Después de copiar los datos pertinentes se marca el consumo con io_uring_cqe_seen(3); no se conserva el puntero CQE como si siguiera siendo propio indefinidamente.

Solicitar cancelación es una operación adicional y puede competir con una finalización ya producida. Un programa de larga vida debe identificar tanto la solicitud original como la cancelación, interpretar sus resultados y no liberar el búfer original antes de resolver su operación. «Pedí cancelar» no significa «ya puedo reutilizar memoria».

Cerrar un descriptor o destruir un anillo no se usa en este tema como prueba de que la aplicación consumió todas las finalizaciones. io_uring_queue_exit(3) desmonta los recursos del anillo; nuestro camino normal llega allí cuando el contador de trabajo pendiente ya es cero.

La disponibilidad real depende de kernel, biblioteca y política de ejecución. Un entorno puede rechazar la inicialización con EPERM o limitar operaciones. Se registra ese resultado sin presentarlo como una lectura completada ni reemplazar silenciosamente el ensayo por otro.

14. Laboratorio con eventos y archivo propio

En Bash de Linux crea un directorio nuevo y guarda los dos fuentes. El programa de eventos solo necesita el compilador y las interfaces Linux utilizadas. Para el segundo se requieren cabeceras y biblioteca liburing; en entornos Debian/Ubuntu suelen estar en liburing-dev. Comprueba su disponibilidad en tu entorno.

Compilar eventos y observar los tres modos
mkdir laboratorio47
cd laboratorio47
# Guardar aqui esperar-eventos.c y dos-lecturas-uring.c.
cc -std=c11 -O2 -Wall -Wextra -Wpedantic esperar-eventos.c -o esperar-eventos
./esperar-eventos poll 40 90
./esperar-eventos epoll 40 90
./esperar-eventos epollet 40 90
./esperar-eventos epoll 50 50

Cada ejecución debe mostrar INICIAL_0=EAGAIN, INICIAL_1=EAGAIN, un EVENTO por identidad con EXPIRACIONES=1 y COMPLETOS=2. No se exige el orden entre identidades, especialmente si ambas están listas cuando se despierta el proceso. Los tiempos pedidos no son una promesa de planificación en tiempo real.

Preparar bytes conocidos y compilar con liburing
python3 - <<'PY'
from pathlib import Path
with Path('datos47.bin').open('xb') as f:
    f.write(bytes(range(256)) * 4)
PY
cc -std=c11 -O2 -Wall -Wextra -Wpedantic dos-lecturas-uring.c -luring -o dos-lecturas-uring
./dos-lecturas-uring datos47.bin

La referencia io_uring_queue_init(3) explica inicialización y errores. Si falta la biblioteca no se compila este segundo ejecutable; si el anillo no se puede inicializar, el programa informa estado 3. Ninguno de esos casos convierte el ensayo en un éxito de E/S.

La salida puede comenzar por ID=0 o por ID=1. Para ID=0 se esperan los bytes 00 a 3F y para ID=1, 40 a 7F. El archivo solo se abre en lectura; no se exige una mejora de velocidad con 128 bytes que probablemente ya estén en caché.

Como ampliación, repite el experimento bajo carga y registra comportamiento sin usar sus demoras como prueba de que una interfaz sea siempre más rápida. Los dos programas comparan modelos de eventos y finalización, no cargas de trabajo equivalentes para un benchmark.

15. Verificador de identidades y contenido

Desde laboratorio47 ejecuta este bloque con Python 3. Comprueba los tres modos con demoras distintas y coincidentes, rechazos de argumentos y contenido por identidad. El ensayo io_uring se ejecuta si el binario fue compilado; una inicialización rechazada se informa como caso no ejecutado, separado del éxito.

Python 3 · no depender del orden de llegada de eventos o CQE
python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
import hashlib
import os
import re
import subprocess

eventos = str(Path('esperar-eventos').resolve())
uring = Path('dos-lecturas-uring').resolve()
def ejecutar(args, estado=0):
    r = subprocess.run(args, text=True, capture_output=True, timeout=10)
    assert r.returncode == estado, (args, r.returncode, r.stderr)
    return r.stdout
for modo in ('poll', 'epoll', 'epollet'):
    for a, b in ((10, 30), (30, 10), (15, 15)):
        texto = ejecutar([eventos, modo, str(a), str(b)])
        assert 'INICIAL_0=EAGAIN' in texto and 'INICIAL_1=EAGAIN' in texto
        vistos = re.findall(r'^EVENTO=(\d+) EXPIRACIONES=(\d+)$', texto, re.M)
        assert sorted(vistos) == [('0', '1'), ('1', '1')]
        assert texto.splitlines()[-1] == 'COMPLETOS=2'
for args in ([], ['otro', '10', '20'], ['poll', '0', '20'],
             ['epoll', '-1', '20'], ['epollet', '10', '1001'],
             ['poll', '1.5', '20']):
    ejecutar([eventos, *args], 2)
print('OK eventos: identidades, EAGAIN, vencimientos y rechazos')
if not uring.is_file():
    print('NO EJECUTADO io_uring: falta el binario compilado con liburing')
else:
    with TemporaryDirectory(prefix='uring47-') as d:
        archivo = Path(d) / 'propio.bin'
        datos = bytes(range(256)) * 4
        archivo.write_bytes(datos)
        antes = hashlib.sha256(datos).digest()
        r = subprocess.run([str(uring), str(archivo)], capture_output=True, text=True, timeout=10)
        if r.returncode == 3:
            assert 'INIT_ERROR=' in r.stderr
            print('NO EJECUTADO io_uring: inicializacion rechazada:', r.stderr.strip())
        else:
            assert r.returncode == 0, (r.returncode, r.stderr)
            vistos = {}
            for linea in r.stdout.splitlines():
                m = re.fullmatch(r'ID=(\d+) OFFSET=(\d+) BYTES=64 HEX=([0-9A-F]+)', linea)
                assert m, linea
                i, offset = int(m[1]), int(m[2])
                assert i in (0, 1) and i not in vistos and offset == i * 64
                vistos[i] = bytes.fromhex(m[3])
            assert set(vistos) == {0, 1}
            for i in vistos: assert vistos[i] == datos[i * 64:(i + 1) * 64]
            print('OK io_uring: ambos rangos por identidad y contenido')
        assert hashlib.sha256(archivo.read_bytes()).digest() == antes
        ejecutar([str(uring)], 2)
        corta = Path(d) / 'corta.bin'; corta.write_bytes(b'ABC')
        ejecutar([str(uring), str(corta)], 1)
        enlace = Path(d) / 'enlace'; enlace.symlink_to(archivo)
        ejecutar([str(uring), str(enlace)], 1)
        fifo = Path(d) / 'fifo'; os.mkfifo(fifo)
        ejecutar([str(uring), str(fifo)], 1)
PY

Un archivo corto se rechaza antes de enviar solicitudes, y un FIFO no provoca una apertura bloqueante gracias a O_NONBLOCK y la comprobación de tipo. El verificador no fuerza ni presupone el orden de las finalizaciones y no mide un rendimiento mínimo.

16. Simuladores de disponibilidad y finalización

¿Qué ocurre si una notificación por flanco deja datos pendientes?

Hay una única llegada inicial de bytes a una fuente modelada que sigue abierta; después no hay nuevas llegadas ni EOF. Cada lectura entrega hasta el tramo elegido. El modelo compara una lectura por notificación con drenar hasta EAGAIN. Los turnos sin evento representan espera, no llamadas repetidas con timeout cero.

Nivel, flanco y datos sin consumir

TurnoEventoLlamadas readBytes recibidosPendientesEAGAIN observado
Estado de la fuente al cerrar la observación

Seguir resultados y propiedad de dos búferes

Dos lecturas independientes piden 64 bytes en offsets 0 y 64. El orden es elegido para observar que no determina la identidad. En el caso corto, ID=0 entrega primero 20 y se reenvían sus 44 bytes restantes. El caso de error conserva su resultado como fallo y permite observar la otra finalización; el ejecutable C elige terminar el proceso ante un error operativo.

Preparar, enviar, completar y liberar

IDOffset baseBytes acumuladosEstadoBúfer
CQEuser_dataresAcción de la aplicación
Trabajo pendiente y resultados terminales

17. Diagnóstico y resumen

ObservaciónQué revisar
Se cierra una fuente cuando read da EAGAIN.No es EOF: falta disponibilidad en ese momento.
El proceso consume CPU sin eventos.Buscar consultas repetidas con timeout cero e intereses de escritura sin trabajo.
EPOLLET deja datos que ya no vuelven a notificarse.Drenar hasta EAGAIN o mantener una lista propia de trabajo listo.
epoll rechaza un archivo regular con EPERM.Ese tipo no admite la notificación requerida; no se arregla con O_NONBLOCK.
Una lectura se atribuye al offset equivocado.Usar user_data para identificar la solicitud; no inferir por orden del CQE.
El búfer se libera tras submit.Conservarlo hasta resolver la finalización de esa operación.
Se pidió cancelación y se reutilizó memoria inmediatamente.Resolver la carrera y los resultados de la operación original y la cancelación.

18. Preguntas de repaso y práctica

1. ¿EAGAIN es fin de archivo?

No. Indica que no hay progreso inmediato disponible en esa operación no bloqueante.

2. ¿Qué se conserva al activar O_NONBLOCK con fcntl?

Las banderas existentes, obtenidas con F_GETFL antes de combinar O_NONBLOCK.

3. ¿poll realiza la lectura de los datos?

No. Devuelve eventos; la aplicación sigue intentando read y revisando su retorno.

4. ¿Qué cuenta el retorno positivo de poll?

Entradas del arreglo con algún revents, no bytes recibidos.

5. ¿POLLHUP obliga siempre a cerrar sin leer?

No. En algunas fuentes puede coexistir con datos pendientes y POLLIN.

6. ¿Qué riesgo tiene repetir timeout cero?

Convertir el bucle en espera activa sin trabajo útil y consumir CPU.

7. ¿Por qué recalcular un plazo tras EINTR?

Porque repetir el timeout original puede extender el presupuesto total.

8. ¿Qué significa notificación por nivel?

Que una condición de disponibilidad persistente puede volver a notificarse.

9. ¿Cuál es la regla sencilla para EPOLLET?

Usar modo no bloqueante y consumir hasta EAGAIN, EOF o error, conservando trabajo propio si se limita el turno.

10. ¿EPOLLONESHOT es EPOLLET?

No. EPOLLONESHOT desactiva el interés después de notificar hasta rearmarlo.

11. ¿O_NONBLOCK evita toda espera de disco regular?

No en Linux. No transforma ese acceso en una lectura asíncrona.

12. ¿Por qué se usan timerfd en el ejemplo?

Para disponer de dos fuentes locales de eventos sin introducir comunicación entre procesos.

13. ¿Qué devuelve una lectura válida de timerfd?

Un contador uint64_t de expiraciones; su tamaño e interpretación son específicos de esa interfaz.

14. ¿Una notificación de timerfd siempre equivale a una expiración?

No en general. Los periódicos pueden acumular varias; el ejemplo usa temporizadores de un único vencimiento.

15. ¿Qué identifica user_data?

La solicitud o estado que la aplicación asoció a la SQE y reconoce en el CQE.

16. ¿Un retorno cero de wait_cqe significa cero bytes leídos?

No. Describe éxito de la espera; los bytes o error de la operación están en cqe->res.

17. ¿Qué significa cqe->res = -EIO?

La operación terminó con ese error; no se consulta un errno ajeno para interpretar el resultado.

18. ¿Qué hacer con una lectura corta positiva?

Avanzar el progreso y, si el contrato exige completar, enviar o ejecutar el resto con búfer y offset ajustados.

19. ¿Cuándo puede reutilizarse un búfer de lectura pendiente?

Cuando la operación que lo utiliza ya fue resuelta; enviar o solicitar cancelar no basta por sí solo.

20. ¿Este ejemplo prueba que io_uring siempre supera a pread?

No. Verifica identidad y contenido; un benchmark necesita cargas equivalentes y mediciones.

Para practicar

  1. Clasifica read positivo, cero, EAGAIN, EINTR y error en estados distintos.
  2. Ejecuta los tres modos de eventos y verifica identidades sin exigir orden.
  3. Cambia las demoras de los temporizadores y explica por qué no se exige una duración exacta del proceso.
  4. En el simulador, compara 12 bytes en tramos de 4 con LT y ET usando una sola lectura por evento.
  5. Activa drenaje en ET y distingue el último read con EAGAIN de una lectura con EOF.
  6. Propón cómo conservar una lista propia de trabajo listo al limitar el presupuesto de un turno.
  7. Compila el ejemplo liburing si está disponible y contrasta ambos rangos del archivo conocido.
  8. En el simulador de CQE, invierte el orden y comprueba que no cambia la identidad de los datos.
  9. Observa la lectura corta de 20 bytes y calcula offset, dirección de búfer y cantidad para los 44 restantes.
  10. Ejecuta el verificador y distingue éxito, fallo y ensayo io_uring no ejecutado.
  11. Describe qué estados adicionales necesitaría una biblioteca reutilizable que cancela y drena sin terminar el proceso.
  12. Diseña una comparación de rendimiento con la misma carga, concurrencia y condiciones de caché en ambas interfaces.

En el tema 48 integraremos lo aprendido en un mini sistema de archivos sobre una imagen: estructuras propias, asignación y operaciones verificables.