Modelos de Entrada/Salida de Red: Bloqueante, No Bloqueante, Multiplexación y Asíncrona

I/O Bloqueante

En este modelo, una llamada como recvfrom() transfiere el control al kernel. Si los datos aún no están disponibles, el hilo se suspende inmediatamente y no devuelve el control hasta que el paquete ha sido recibido y copiado al búfer del proceso.

Esta simplicidad tiene un costo elevado bajo carga: un solo socket inactivo puede detener toda la lógica de aceptación de nuevas conexiones. Aunque se pueden emplear hilos o procesos dedicados por conexión, dicha estrategia no escala bien — cada nueva conexión consume memoria, tiempo de planificación y recursos del sistema. Pools de hilos o conexiones mitigarían parcialmente el problema, pero no eliminan la sobrecarga inherente ni resuelven los cuellos de botella cuando el volumen de solicitudes supera la capacidad del pool.

Ejemplo de servidor bloqueante en Python:

cliente_id = 0
while True:
    conexion, direccion = self.sock_servidor.accept()
    cliente_id += 1
    print(f"Conexión desde {direccion}")
    print(f"Total de clientes atendidos: {cliente_id}")

    try:
        mensaje = conexion.recv(1024)  # Se bloquea aquí si no hay datos
        if mensaje:
            conexion.sendall(mensaje)
    finally:
        conexion.close()

</div>I/O No Bloqueante
-----------------

Al configurar un socket con `setblocking(False)`, las operaciones de lectura devuelven inmediatamente, ya sea con datos válidos o con una excepción como `BlockingIOError`. Esto permite que el proceso continúe ejecutando otras tareas sin esperar.

No obstante, su uso típico implica sondeo activo (*busy-waiting*): repetir llamadas a `recv()` en bucle, lo cual consume ciclos de CPU innecesariamente. Además, existe latencia entre el momento en que los datos llegan al kernel y el siguiente ciclo de sondeo, retrasando su procesamiento.

Servidor no bloqueante básico:

<div>```
def iniciar_servidor_no_bloqueante(self):
    self.sock_servidor = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    self.sock_servidor.bind(('', 8080))
    self.sock_servidor.listen(5)
    
    cliente_id = 0
    while True:
        try:
            conexion, direccion = self.sock_servidor.accept()
            conexion.setblocking(False)
            cliente_id += 1
            print(f"Conexión desde {direccion}")
            
            try:
                mensaje = conexion.recv(1024)
                if mensaje:
                    conexion.sendall(mensaje)
            except BlockingIOError:
                pass  # No hay datos aún, continuar
            finally:
                conexion.close()
        except BlockingIOError:
            time.sleep(0.01)  # Pequeña pausa para evitar consumo excesivo de CPU

Este enfoque permite supervisar múltiples descriptores de archivo simultáneamente desde un único hilo, evitando tanto el bloqueo como el sondeo constante. En Linux, se implementa mediante tres interfaces: select(), poll() y epoll().

select()

Requiere pasar conjuntos de bits (fd_set) que indican qué descriptores observar para lectura, escritura o errores. El sistema bloquea hasta que al menos uno esté listo o expire el tiempo de espera.

Sus limitaciones inculyen: copia redundante de estructuras entre espacios de usuario y kernel, recorrido lineal de todos los descriptores en cada invocación y un límite fijo de 1024 FDs por defecto.

poll()

Mejora sobre select() al usar una matriz dinámica de estructuras pollfd, eliminando el límite de descriptores. Sin embargo, conserva las ineficiencias de copia y recorrido completo en cada llamada.

epoll()

Ofrece una arquitectura orientada a eventos. En lugar de listar todos los FDs en cada llamada, se registra cada descriptor una sola vez mediante epoll_ctl(). Internamente, el kernel mantiene una lista de eventos listos y notifica mediante callbacks cuando ocurren cambios — esto elimina el recorrido innecesario y reduce drásticamente la sobrecarga.

Soporta dos modos: disparo por nivel (Level-Triggered, predeterminado) y disparo por flanco (Edge-Triggered). Este último reduce aún más las notificaciones redundantes, ideal para aplicaciones de alto rendimiento.

Ejemplo simplificado con epoll:

epoll_instancia = select.epoll()
epoll_instancia.register(servidor.fileno(), select.EPOLLIN)

fd_a_socket = {servidor.fileno(): servidor}
colas_mensajes = {}

while True:
    eventos = epoll_instancia.poll(timeout=5)
    if not eventos:
        continue

    for fd, evento in eventos:
        sock = fd_a_socket[fd]

        if evento & select.EPOLLIN:
            if sock is servidor:
                nueva_conn, addr = servidor.accept()
                nueva_conn.setblocking(False)
                fd_a_socket[nueva_conn.fileno()] = nueva_conn
                epoll_instancia.register(nueva_conn.fileno(), select.EPOLLIN)
                colas_mensajes[nueva_conn] = queue.Queue()
            else:
                try:
                    datos = sock.recv(1024)
                    if datos:
                        colas_mensajes[sock].put(datos)
                        epoll_instancia.modify(sock.fileno(), select.EPOLLIN | select.EPOLLOUT)
                    else:
                        epoll_instancia.unregister(fd)
                        sock.close()
                        del colas_mensajes[sock]
                except BlockingIOError:
                    pass
        elif evento & select.EPOLLOUT:
            try:
                msg = colas_mensajes[sock].get_nowait()
                sock.send(msg)
                epoll_instancia.modify(sock.fileno(), select.EPOLLIN)
            except queue.Empty:
                epoll_instancia.modify(sock.fileno(), select.EPOLLIN)

</div>E/S Asíncrona (AIO)
-------------------

A diferencia de los modelos anteriores —todos síncronos—, la E/S asíncrona delega completamente la operación al kernel. Al llamar a `aio_read()` o `aio_write()`, la función retorna inmediatamente; el kernel gestiona toda la transferencia y notifica al proceso mediante señal o callback cuando finaliza.

El núcleo de esta funcionaldiad es la estructura `aiocb` (Asynchronous I/O Control Block), que encapsula el descriptor, el búfer de destino, el tamaño y los mecanismos de notificación. Gracias a su diseño, permite lanzar múltiples operaciones simultáneas sin interferencia, manteniendo contexto explícito para cada una.

Fragmento de ejemplo en C usando notificación por señal:

<div>```
#include <aio.h>
#include <signal.h>

void manejador_finalizacion(int sig, siginfo_t *info, void *ctx) {
    struct aiocb *req = (struct aiocb *)info->si_value.sival_ptr;
    if (aio_error(req) == 0) {
        ssize_t bytes = aio_return(req);
        printf("Lectura completada: %zd bytes\n", bytes);
    }
}

int main() {
    int fd = open("datos.bin", O_RDONLY);
    struct aiocb cb;
    memset(&cb, 0, sizeof(cb));
    cb.aio_fildes = fd;
    cb.aio_buf = malloc(4096);
    cb.aio_nbytes = 4096;
    cb.aio_offset = 0;

    // Configurar notificación por señal SIGUSR1
    cb.aio_sigevent.sigev_notify = SIGEV_SIGNAL;
    cb.aio_sigevent.sigev_signo = SIGUSR1;
    cb.aio_sigevent.sigev_value.sival_ptr = &cb;

    struct sigaction sa;
    sa.sa_sigaction = manejador_finalizacion;
    sa.sa_flags = SA_SIGINFO;
    sigaction(SIGUSR1, &sa, NULL);

    aio_read(&cb);
    pause(); // Esperar señal
    return 0;
}

  • Bloqueante: Fácil de implementar, adecuado para entornos con pocas conexiones concurrentes.
  • No bloqueante: Evita la suspensión total, pero requiere gestión manual del sondeo y latencia inevitable.
  • Multiplexación: Escalablee y eficiente. epoll es la opción preferida en sistemas Linux modernos para servidores de alta concurrencia.
  • Asíncrona: Máxima desacoplación y rendimiento teórico óptimo, aunque con mayor complejidad de uso y soporte limitado en algunas plataformas.

Los modelos basados en señales (signal-driven I/O) son poco utilizados en la práctica debido a la ambigüedad en la identificación del descriptor asociado a la señal, especialmente en contextos multi-descriptor.

Etiquetas: Socket epoll async-io Linux-Networking i-o-multiplexing

Publicado el 8-9 21:23