epoll, select y poll: por qué el kernel de Linux aguanta 100.000 conexiones
Cómo el kernel pasó de recorrer cada socket en cada llamada a mantener un árbol rojo-negro y una lista de listos, y por qué select y poll se quedaron atrás.

El paper C10K que Dan Kegel publicó en 1999 sigue siendo la mejor forma de explicar por qué el kernel de Linux multiplexa E/S como lo hace. La pregunta era cómo servir 10.000 conexiones concurrentes en una sola máquina, y la respuesta que acabó ganando fue epoll, el mecanismo que hoy sostiene NGINX, Redis, Netty y Node.js a través de libuv.
Por qué select y poll no escalan
select() trabaja con un fd_set, una máscara de bits de tamaño fijo. En Linux, FD_SETSIZE son 1024 bits definidos en <sys/select.h>. Cada llamada copia esa máscara al kernel, recorre los descriptores uno a uno hasta nfds-1, registra el proceso en la cola de espera de cada socket, duerme y, cuando llega un paquete, vuelve a recorrer todo para construir la máscara de salida. El problema añadido es que sobrescribe la máscara de entrada, así que el programa tiene que reconstruirla entera con FD_ISSET antes de la siguiente llamada.
poll() cambió la máscara fija por un array de struct pollfd, con los campos events y revents separados. Eso quitó el techo de 1024 descriptores y la sobrescritura destructiva. Pero la arquitectura O(N) es idéntica: si vigilas 50.000 sockets, cada llamada copia al kernel un array de 50.000 estructuras, unos 400 KB, y el kernel las recorre una por una. El bucle en espacio de usuario para comprobar revents & POLLIN tampoco desaparece. Y tanto select como poll son sin estado: el kernel no recuerda entre llamadas qué estabas vigilando.
Lo que cambia epoll
Davide Libenzi lo metió en el kernel 2.5.44 y separó el registro del sondeo en tres llamadas: epoll_create1, epoll_ctl y epoll_wait. Dentro, en fs/eventpoll.c, cada instancia es un struct eventpoll con dos estructuras que explican el cambio de escala. Por un lado, un árbol rojo-negro que guarda todos los descriptores vigilados como nodos epitem, indexados por (struct file *, int fd), con inserción y borrado en O(log N) y persistencia en memoria del kernel entre ciclos de sondeo. Por otro, la rdllist, una lista doblemente enlazada que solo contiene los epitems con E/S activa y sin consumir. Cuando no hay datos entrantes, esa lista está vacía y epoll_wait no tiene nada que recorrer.
El modelo de un hilo por conexión
Antes de llegar aquí, cada conexión tenía su propio proceso o hilo, como en el Apache prefork. Cada hilo reserva su pila, entre 2 y 8 MB de memoria virtual por defecto según la configuración histórica, de modo que 10.000 hilos suponen decenas de gigas reservados solo para mantener vivos sockets ociosos. Y cuando miles de hilos compiten por CPU, el planificador se pasa más tiempo cambiando contextos, invalidando entradas de la TLB y machacando las cachés L1, L2 y L3 que ejecutando lógica de la aplicación.
Nada de esto es nuevo: epoll lleva más de veinte años en el kernel y el paper de Kegel es de 1999. Pero sigue siendo la primera decisión que conviene justificar al diseñar un servicio con muchas conexiones persistentes, no la última. Si el cuello de botella está en cómo se registran y se despiertan los sockets, cambiar de framework no arregla nada.


