⏱️ Lectura: 16 min

Un servidor Nginx con un solo proceso worker puede sostener decenas de miles de conexiones simultáneas sin que la CPU se dispare. El secreto no es magia ni hilos ocultos: es epoll en Linux, la llamada al sistema que le dice al kernel que avise solo cuando algo cambia, en vez de obligar a la aplicación a preguntar socket por socket.

📑 En este artículo
  1. TL;DR
  2. ¿Qué es epoll en Linux?
  3. Por qué importa
  4. Cómo funciona epoll por dentro
  5. Ejemplos prácticos y cómo empezar
    1. Ejemplo 1: el programa más simple con epoll
    2. Ejemplo 2: un servidor TCP que atiende varios clientes con un solo hilo
    3. Verificar que un proceso usa epoll
    4. El equivalente en Python
  6. Casos de uso reales
  7. Errores comunes y buenas prácticas
  8. Comparativa con alternativas
  9. Profundizando: edge-triggered y el detalle interno
  10. Preguntas frecuentes
    1. ¿Epoll en Linux funciona igual en macOS o Windows?
    2. ¿Qué diferencia hay entre epoll y io_uring?
    3. ¿Necesito usar epoll directamente en mi código?
    4. ¿Cuántos descriptores puede vigilar una sola instancia de epoll?
    5. ¿Qué pasa si manejo mal el modo edge-triggered de epoll?
  11. Referencias

Entender la interfaz epoll explica por qué Redis, Node.js y Nginx eligen un modelo de un solo hilo para la entrada y salida de datos, y por qué ese diseño escala mejor que lanzar un hilo por cada cliente conectado.

TL;DR

  • Epoll en Linux permite que un solo hilo vigile miles de sockets sin recorrerlos uno por uno en cada ciclo.
  • La llamada epoll_wait() devuelve solo los descriptores con actividad real, nunca la lista completa.
  • Nginx, Redis y el libuv de Node.js usan epoll como motor de entrada y salida en Linux.
  • EPOLLET activa el modo edge-triggered: hay que vaciar el buffer completo o se pierden eventos.
  • select y poll recorren todos los descriptores en cada llamada; epoll solo consulta una lista de listos.

¿Qué es epoll en Linux?

Epoll es la interfaz de notificación de eventos del kernel de Linux para vigilar miles de descriptores de archivo sin recorrerlos uno por uno. Es la pieza detrás del mecanismo epoll en servidores como Nginx y Redis, que reemplaza el sondeo constante por una lista de descriptores listos.

La necesidad surgió a comienzos de los 2000, cuando los servidores web empezaron a manejar miles de conexiones persistentes y el viejo modelo select() se quedó corto por su límite de 1024 descriptores y su recorrido lineal. El kernel 2.5.44, liberado en 2002, incorporó epoll como respuesta directa a ese cuello de botella, documentado hoy en la página de manual epoll(7).

Por qué importa

La razón de fondo es aritmética. Si un servidor atiende 10.000 conexiones con select(), cada vez que el programa pregunta si hay algo nuevo, el kernel tiene que revisar las 10.000 una por una, aunque solo tres tengan datos esperando. Ese costo crece con cada cliente nuevo y se paga en cada ciclo del programa, no una sola vez.

Pensalo como la diferencia entre revisar el buzón de correo cada cinco minutos, aunque esté vacío la mayoría de las veces, y que el cartero toque la puerta solo cuando efectivamente dejó una carta. La primera estrategia gasta tiempo revisando en vano; la segunda solo interrumpe cuando hay algo real que atender. Epoll aplica esa segunda lógica a miles de sockets a la vez, sin que el costo de vigilar cada uno crezca con el total.

Con la interfaz epoll el kernel invierte el problema: en lugar de que la aplicación pregunte, el propio kernel anota qué sockets cambiaron de estado en una lista de listos. Cuando el programa llama a epoll_wait(), recibe directamente esa lista, sin recorrer nada que no haya cambiado.

Esa diferencia es la que permite que Nginx atienda miles de clientes con un puñado de procesos worker, que Redis sirva todas sus operaciones desde un único hilo sin locks, y que Node.js ejecute JavaScript de un solo hilo sin bloquearse esperando una respuesta de red.

Cómo funciona epoll por dentro

El ciclo de vida de epoll tiene tres llamadas al sistema. Primero, epoll_create1() crea una instancia de epoll y devuelve un descriptor de archivo que la representa; ese descriptor aparece en /proc/<pid>/fd como anon_inode:[eventpoll], documentado en epoll_create(2).

Segundo, epoll_ctl() agrega, modifica o quita descriptores de la instancia con las operaciones EPOLL_CTL_ADD, EPOLL_CTL_MOD y EPOLL_CTL_DEL. Internamente el kernel guarda esos descriptores en un árbol rojo-negro, así que buscarlos o quitarlos es rápido incluso con decenas de miles de entradas.

Tercero, epoll_wait() bloquea al proceso hasta que al menos un descriptor tenga actividad, y devuelve solo esos descriptores listos. Esa lista de listos vive aparte del árbol de descriptores registrados: cuando un socket recibe datos, el controlador de red del kernel lo mueve a la lista de listos mediante un callback, sin que la aplicación tenga que preguntar nada.

El kernel 2.5.44 introdujo epoll en 2002 como respuesta al límite de 1024 descriptores de select(). Foto de Mohammad Rahmani en Unsplash
flowchart TD
    A["epoll_create1()"] --> B["epoll_ctl: EPOLL_CTL_ADD"]
    B --> C{"Llegan datos al socket?"}
    C -->|"No"| D["El kernel espera"]
    D --> C
    C -->|"Sí"| E["El kernel mueve el fd a la lista de listos"]
    E --> F["epoll_wait() devuelve los fd listos"]
    F --> G["La aplicación procesa cada fd"]
    G --> B
sequenceDiagram
    participant App as "Aplicación"
    participant K as "Kernel"
    participant S as "Socket"
    App->>K: epoll_ctl ADD fd
    S->>K: llegan datos
    K->>K: mueve fd a la lista de listos
    App->>K: epoll_wait()
    K-->>App: devuelve fd listo
    App->>S: read(fd)
    Note over App,K: el ciclo se repite sin recorrer fds inactivos

El resultado práctico es que agregar el descriptor número 50.000 no hace más lenta la llamada epoll_wait() para los otros 49.999: solo importa cuántos están realmente listos en ese instante, no cuántos están registrados en total.

Ejemplos prácticos y cómo empezar

Para probar estos ejemplos necesitás un Linux real (no macOS ni Windows: epoll no existe fuera del kernel de Linux) con el compilador gcc instalado. En Debian o Ubuntu alcanza con sudo apt install build-essential.

Ejemplo 1: el programa más simple con epoll

Este primer programa crea una instancia de epoll, registra la entrada estándar (descriptor 0) y espera a que el usuario escriba algo:

#include <stdio.h>
#include <sys/epoll.h>
#include <unistd.h>

int main() {
    int epfd = epoll_create1(0);
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = 0 };
    epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &ev);

    struct epoll_event events[1];
    printf("Esperando que escribas algo (Ctrl+D para salir)...\n");
    while (epoll_wait(epfd, events, 1, -1) > 0) {
        char buf[128];
        int n = read(0, buf, sizeof(buf) - 1);
        if (n <= 0) break;
        buf[n] = '\0';
        printf("Llegó: %s", buf);
    }
    return 0;
}

Compilalo con gcc epoll_demo.c -o epoll_demo y ejecutalo. Al escribir “hola” y presionar Enter, la salida literal es:

Esperando que escribas algo (Ctrl+D para salir)...
hola
Llegó: hola

Ejemplo 2: un servidor TCP que atiende varios clientes con un solo hilo

El segundo ejemplo escala la idea a un servidor real. El socket de escucha se registra en la misma instancia de epoll que los sockets de los clientes, y un único bucle de epoll_wait() atiende a todos:

epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

for (;;) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        if (fd == listen_fd) {
            int client_fd = accept(listen_fd, NULL, NULL);
            struct epoll_event cev = { .events = EPOLLIN, .data.fd = client_fd };
            epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &cev);
            printf("Cliente conectado, fd=%d\n", client_fd);
        } else {
            char buf[256];
            int r = read(fd, buf, sizeof(buf));
            if (r <= 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); }
            else write(fd, buf, r);
        }
    }
}

Si conectás dos clientes con nc localhost 9000 en dos terminales distintas, la salida del servidor muestra algo como:

Cliente conectado, fd=4
Cliente conectado, fd=5

Ningún hilo nuevo se crea por cliente: el mismo bucle y la misma instancia de epoll atienden a los dos sockets.

Verificar que un proceso usa epoll

No hace falta compilar nada para comprobar que un servidor en producción usa epoll. El comando ls -la /proc/$(pgrep -o nginx)/fd lista los descriptores abiertos del proceso, y la entrada de epoll aparece como un enlace simbólico a anon_inode:[eventpoll], tal como documenta epoll_create(2):

lrwx------ 1 www-data www-data 64 oct 16 10:02 6 -> anon_inode:[eventpoll]

Otra forma es rastrear las llamadas al sistema en vivo con strace -f -e trace=epoll_wait -p $(pgrep -o redis-server) (si strace no está instalado, sudo apt install strace lo agrega en Debian y Ubuntu), que muestra una salida similar a:

epoll_wait(7, [{EPOLLIN, {u32=12, u64=12}}], 128, -1) = 1

El 1 al final confirma que llegó exactamente un descriptor listo de los 128 que el proceso vigilaba en esa llamada.

El equivalente en Python

Python no expone epoll de forma directa en la mayoría de los scripts, pero el módulo estándar selectors lo usa por debajo en Linux. Podés confirmarlo así:

import selectors
print(selectors.DefaultSelector)

En cualquier Linux moderno con Python 3, la salida literal es:

<class 'selectors.EpollSelector'>

Eso significa que cualquier servidor async de Python (asyncio, Tornado, aiohttp) que corra sobre Linux termina apoyándose en epoll aunque el código nunca mencione la palabra.

Casos de uso reales

Nginx usa un modelo de pocos procesos worker, cada uno con su propia instancia de epoll, para atender miles de conexiones HTTP sin crear un hilo por cliente. Es la razón por la que un servidor con pocos núcleos puede sostener decenas de miles de conexiones keep-alive con un uso de memoria predecible.

Redis sirve todas sus operaciones desde un único hilo de ejecución, lo que elimina la necesidad de locks internos para proteger sus estructuras de datos. Ese diseño solo es viable porque la espera de red no bloquea ese hilo: epoll le avisa cuándo hay datos y el hilo sigue libre mientras tanto.

Node.js no implementa epoll directamente en JavaScript, pero su runtime libuv lo usa en Linux para resolver las operaciones de red y de archivos de forma no bloqueante, lo que permite que un programa de un solo hilo atienda miles de peticiones HTTP concurrentes.

HAProxy y Envoy, los proxies que enrutan tráfico en la mayoría de las plataformas de microservicios, también dependen de epoll para multiplexar las conexiones entrantes y salientes sin gastar un hilo del sistema operativo por cada una.

La bandera EPOLLEXCLUSIVE, agregada en el kernel 4.5, evita que varios procesos despierten a la vez por una sola conexión nueva. Foto de Chris Ried en Unsplash

Errores comunes y buenas prácticas

El error más frecuente es confundir el modo level-triggered, el predeterminado, con edge-triggered (EPOLLET). En level-triggered, epoll_wait() sigue avisando mientras queden datos sin leer en el buffer, así que leer parcialmente no rompe nada. En edge-triggered, el aviso llega una sola vez por cada cambio de estado: si no vaciás el buffer por completo, esos datos quedan invisibles hasta que llegue algo nuevo.

⚠️ Ojo: con EPOLLET hay que leer en un bucle hasta que read() devuelva EAGAIN, nunca asumir que un solo read() alcanza.

Otro error común es olvidar EPOLL_CTL_DEL antes de cerrar un socket que todavía está registrado. Aunque close() lo quita automáticamente de la instancia de epoll, dejarlo así oculta bugs cuando el mismo número de descriptor se reutiliza para una conexión distinta.

Un tercer problema aparece en servidores multiproceso: si varios procesos comparten la misma instancia de epoll sobre el mismo socket de escucha, todos pueden despertar a la vez para un único cliente nuevo, el fenómeno llamado thundering herd. La bandera EPOLLEXCLUSIVE, agregada en el kernel 4.5, limita ese despertar a un solo proceso por evento.

Por último, mezclar epoll con descriptores que no soportan sondeo de eventos puede devolver resultados inesperados: epoll está pensado sobre todo para sockets, pipes y FIFOs, no para cualquier archivo regular.

Comparativa con alternativas

La elección entre select, poll y epoll en Linux depende casi siempre de cuántos descriptores hay que vigilar y de si el código necesita correr en otros sistemas operativos.

OpciónCuándo usarlaVentajaLimitación
select()Pocos descriptores (menos de 1024) y máxima portabilidadDisponible en casi cualquier sistema POSIXLímite fijo de descriptores, recorrido O(n) en cada llamada
poll()Más de 1024 descriptores sin límite fijoSin límite artificial de descriptoresSigue recorriendo la lista completa en cada llamada
epollMiles de conexiones simultáneas en LinuxComplejidad O(1) amortizada, solo entrega descriptores listosExclusivo de Linux, no portable a otros sistemas
kqueueEl mismo escenario en BSD y macOSFilosofía similar a epoll, más genéricoNo existe en Linux
io_uringAlto volumen de E/S con mínimo overhead de llamadas al sistemaColas compartidas entre kernel y aplicación, agrupa operacionesCurva de aprendizaje mayor, requiere kernel 5.1 o superior

En la práctica, la mayoría de los frameworks modernos esconden esta decisión detrás de una capa de abstracción como libuv, asyncio o Tokio, así que el desarrollador casi nunca llama a epoll_ctl() directamente, pero entender qué pasa debajo ayuda a explicar por qué cambiar de sistema operativo puede alterar el rendimiento de un mismo programa.

Profundizando: edge-triggered y el detalle interno

La diferencia entre level-triggered y edge-triggered no es cosmética: cambia el contrato que la aplicación firma con el kernel. En level-triggered, cada llamada a epoll_wait() pregunta si sigue habiendo datos sin leer, y el kernel responde según el estado actual del buffer. En edge-triggered, el kernel solo notifica el instante exacto en que el buffer pasó de vacío a tener datos, y no vuelve a avisar hasta el próximo cambio de estado, aunque los datos viejos sigan ahí sin leer.

Esa diferencia explica por qué edge-triggered exige descriptores no bloqueantes (O_NONBLOCK): si la aplicación intenta leer más datos de los disponibles con un descriptor bloqueante, el proceso se queda colgado esperando algo que nunca llega, porque ya no habrá otra notificación que lo despierte.

Por dentro, la instancia de epoll mantiene dos estructuras separadas: un árbol rojo-negro con todos los descriptores registrados, para que agregar o borrar sea rápido, y una lista enlazada con los descriptores actualmente listos, para que epoll_wait() no tenga que recorrer el árbol completo. Cuando el controlador de red del kernel recibe un paquete para un socket registrado, ejecuta un callback que mueve ese socket a la lista de listos.

La variante epoll_pwait() agrega un parámetro adicional para bloquear temporalmente señales mientras el proceso espera, evitando una ventana de carrera donde una señal llega justo antes de entrar en el bloqueo y se pierde hasta el siguiente ciclo.

Esa misma lógica explica por qué los servidores basados en epoll mantienen un uso de CPU casi plano en reposo. Un modelo que recorriera cada conexión en un bucle constante consumiría CPU incluso sin tráfico, mientras que un proceso bloqueado en epoll_wait() queda dormido, sin consumir ciclos de CPU, hasta que el kernel lo despierta con trabajo real que hacer.

💭 Clave: epoll no mide actividad de CPU ni memoria: solo responde a cambios de estado en descriptores de archivo, así que no sirve para notificar eventos que no pasen por el sistema de archivos o la red.

Tu próximo paso: compilá el ejemplo del servidor TCP de este artículo, conectate con dos pestañas de nc localhost 9000 a la vez y observá en la terminal del servidor cómo un solo proceso atiende a ambos clientes sin crear ningún hilo nuevo.

📖 Resumen en Telegram: Ver resumen

📬 Recibí lo nuevo en tu email

Te avisamos de artículos grandes (1-2 por mes).

Preguntas frecuentes

¿Epoll en Linux funciona igual en macOS o Windows?

No. Epoll es una interfaz exclusiva del kernel de Linux. macOS y los sistemas BSD usan kqueue, que resuelve el mismo problema con una filosofía parecida pero una API distinta; Windows usa IOCP, un modelo diferente basado en finalización en vez de notificación.

¿Qué diferencia hay entre epoll y io_uring?

Epoll notifica cuándo un descriptor está listo para leer o escribir, pero la operación la sigue haciendo la aplicación con una llamada aparte. io_uring permite encolar la operación completa y que el kernel la resuelva, devolviendo el resultado por una cola compartida, con menos llamadas al sistema en total.

¿Necesito usar epoll directamente en mi código?

Casi nunca. La mayoría de los lenguajes y frameworks, como Node.js con libuv, Python con asyncio, Go con su runtime o Rust con Tokio, ya usan la interfaz epoll por debajo cuando corren en Linux. Llamar a epoll_ctl() y epoll_wait() a mano solo tiene sentido si estás escribiendo el propio runtime.

¿Cuántos descriptores puede vigilar una sola instancia de epoll?

No hay un límite fijo como el de select() con sus 1024 descriptores. El límite real es la cantidad de descriptores de archivo que el proceso puede abrir, configurable con ulimit -n, y la memoria disponible para el árbol rojo-negro interno.

¿Qué pasa si manejo mal el modo edge-triggered de epoll?

Si usás EPOLLET y no leés todo el buffer disponible en cada notificación, esos datos quedan atrapados: el mecanismo epoll no vuelve a avisar sobre datos que ya notificó una vez, hasta que llegue un paquete nuevo que dispare otro cambio de estado.

Referencias

  • man7.org: página de manual oficial de epoll(7), con la documentación completa de la interfaz.
  • man7.org: página de manual de epoll_create(2), incluye el detalle de anon_inode:[eventpoll].
  • docs.python.org: documentación del módulo selectors, que usa EpollSelector en Linux.
  • docs.libuv.org: documentación de libuv, la librería de entrada y salida asíncrona que usa Node.js.
  • nginx.org: documentación oficial de Nginx sobre sus modelos de procesamiento de eventos.

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Imagen destacada: Foto de Manuel Luikenga en Unsplash

¿Te sirvió? ¿Te dio otro error? Contalo abajo: las preguntas se responden y le sirven al siguiente que llegue.

Dejar un comentario

Andrés Morales

Desarrollador e investigador en inteligencia artificial. Escribe sobre modelos de lenguaje, frameworks, herramientas para devs y lanzamientos open source. Cubre papers de ML, ecosistema de startups tech y tendencias de programación.

0 Comentarios

Deja un comentario

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Podés incluir código entre <code>…</code> o, para varias líneas, <pre><code>…</code></pre>.

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.