⏱️ 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
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.
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.
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 queread()devuelvaEAGAIN, nunca asumir que un soloread()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ón | Cuándo usarla | Ventaja | Limitación |
|---|---|---|---|
| select() | Pocos descriptores (menos de 1024) y máxima portabilidad | Disponible en casi cualquier sistema POSIX | Límite fijo de descriptores, recorrido O(n) en cada llamada |
| poll() | Más de 1024 descriptores sin límite fijo | Sin límite artificial de descriptores | Sigue recorriendo la lista completa en cada llamada |
| epoll | Miles de conexiones simultáneas en Linux | Complejidad O(1) amortizada, solo entrega descriptores listos | Exclusivo de Linux, no portable a otros sistemas |
| kqueue | El mismo escenario en BSD y macOS | Filosofía similar a epoll, más genérico | No existe en Linux |
| io_uring | Alto volumen de E/S con mínimo overhead de llamadas al sistema | Colas compartidas entre kernel y aplicación, agrupa operaciones | Curva 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
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
0 Comentarios