A principios de esta década, la infraestructura de servidores web chocó contra un muro físico que ninguna ampliación de memoria parecía solucionar. El síntoma era universal: un servidor Apache con arquitectura prefork o hilos POSIX convencionales funcionaba sin fricción gestionando cientos de peticiones simultáneas, pero colapsaba de inmediato cuando diez mil clientes mantenían sus sockets abiertos en reposo1 Dan Kegel documentó formalmente el problema C10K en 1999. Para 2002, la adopción masiva de conexiones HTTP/1.1 persistentes (Keep-Alive) convirtió lo que parecía un ejercicio de laboratorio en una pesadilla operativa en entornos de producción..
El cuello de botella no radicaba en el ancho de banda ni en la potencia de cálculo del procesador, sino en la trampa arquitectónica del modelo síncrono de un hilo por conexión (thread-per-connection).
La factura invisible de la pila de llamadas
En los sistemas operativos tipo Unix clásicos, asignar un hilo o un proceso a cada cliente parece el modelo más intuitivo: el código se escribe de forma lineal, bloqueando la ejecución en la llamada read() hasta que llegan bytes por la red. La ilusión de simplicidad, sin embargo, oculta un coste de memoria insostenible.
Cada hilo del sistema operativo requiere su propia pila de llamadas (stack memory). En arquitecturas x86 de 32 bits, el valor por defecto oscilaba habitualmente entre 2 MB y 8 MB de espacio de direcciones reservado:
10.000 conexiones × 2 MB por hilo = 20 GB de espacio virtual
En un entorno donde los servidores de producción rara vez contaban con más de 2 GB o 4 GB de RAM física y el espacio de direccionamiento virtual total de 32 bits para espacio de usuario estaba acotado a 3 GB, el kernel simplemente no podía instanciar las pilas. La memoria se agotaba antes de procesar una sola línea de HTML, provocando fallos de reserva de memoria (OOM killer) o ahogando al subsistema de memoria virtual en un intercambio masivo contra el disco (thrashing)2 W. Richard Stevens detalló esta sobrecarga estructural en Unix Network Programming. El cambio de contexto (context switch) no solo gasta ciclos salvando registros de CPU, sino que destruye la localidad de las cachés L1 y L2 del hardware al alternar entre miles de espacios de direcciones distintos..
A esto se sumaba la contención en el planificador de tareas (scheduler) del kernel. Intercalar la ejecución de miles de hilos activos degrada el rendimiento de la CPU: la mayor parte del tiempo de cómputo se disipa guardando y restaurando el estado de los registros, cargando tablas de páginas en la MMU e invalidando las líneas de caché de datos e instrucciones.
La ineficiencia O(N) de select() y poll()
Para eludir el coste de los hilos, los desarrolladores acudían a las llamadas de multiplexación síncrona select() y poll(). La premisa era agrupar todos los descriptores de fichero (file descriptors, FD) en un único hilo y consultar al kernel cuáles estaban listos para lectura o escritura:
/* Estructura clásica con poll() */
struct pollfd fds[10000];
/* ... registrar descriptores en el array ... */
int ready = poll(fds, 10000, timeout_ms);
if (ready > 0) {
for (int i = 0; i < 10000; i++) {
if (fds[i].revents & POLLIN) {
handle_read(fds[i].fd);
}
}
}
La trampa de poll() y select() es su complejidad algorítmica $O(N)$. En cada invocación, el programa debe copiar un vector masivo de estructuras de datos desde el espacio de usuario hacia el espacio de kernel. Acto seguido, el kernel debe iterar secuencialmente sobre los diez mil descriptores para comprobar el estado de sus buffers de red3 select() impone además un límite fijo compilado en el kernel mediante la macro FD_SETSIZE, que en Linux y BSD solía restringir el monitoreo a 1.024 descriptores por proceso, obligando a parchear cabeceras y recompilar el sistema si se pretendía superar ese umbral..
Si de esos 10.000 descriptores solo 5 tienen actividad real en un instante dado —un escenario típico de clientes inactivos manteniendo la conexión—, la CPU gasta el 99,9% de sus ciclos recorriendo un vector de conexiones ociosas. El coste escala con el número total de clientes conectados, no con la cantidad de tráfico que fluye por la red.
La revolución reactiva: epoll y kqueue
La ruptura definitiva con el paradigma de sondeo lineal llegó con la introducción de mecanismos de notificación de eventos basados en el estado del kernel: kqueue en FreeBSD 4.1 y la maduración de epoll en el árbol de Linux 2.5:
[Sockets con datos listos] ──> [Interrupción de red] ──> [Añadir a lista enlazada ready_list]
│
▼
Usuario invoca epoll_wait() ◄──────────────────────── Devuelve solo descriptores activos: O(1)
A diferencia de poll(), epoll separa el registro del descriptor de la notificación del evento a través de tres primitivas:
epoll_create(): Crea una instancia en el kernel respaldada internamente por un árbol rojo-negro (red-black tree) para almacenar los descriptores vigilados.epoll_ctl(): Registra, modifica o elimina un descriptor del árbol. La adición se realiza una única vez con coste $O(\log N)$, evitando transferir vectores de datos en cada ciclo.epoll_wait(): Suspende el hilo hasta que al menos un descriptor cambie de estado. El kernel mantiene una lista enlazada de descriptores listos (ready_list) alimentada por las propias rutinas de interrupción de los drivers de red.
Cuando el hilo despierta, epoll_wait() no devuelve un array disperso de diez mil elementos: devuelve únicamente una lista compacta con los descriptores que tienen eventos pendientes. La complejidad del ciclo pasa de $O(N)$ a $O(K)$, donde $K$ es el número de conexiones que realmente tienen tráfico.
El precio de la asincronía
El cambio de paradigma no fue gratuito. Abandonar el hilo por conexión exigió desmantelar el modelo mental de ejecución imperativa. Las operaciones de red debían configurarse obligatoriamente en modo no bloqueante (O_NONBLOCK). Si un socket no puede escribir todo su payload, el desarrollador ya no puede detener el flujo: debe guardar el buffer sobrante en memoria de usuario, registrar el descriptor para recibir notificaciones de escritura (EPOLLOUT) y ceder el control al bucle de eventos.
La lógica de negocio se fragmentó en máquinas de estados explícitas, dando origen a la arquitectura que años después definiría a motores como Nginx, HAProxy o Node.js.
El problema C10K dejó una lección que trasciende al hardware de su época: cuando una abstracción de programación oculta el coste del recurso físico subyacente —en este caso, fingir que la concurrencia es infinita asignando pilas de memoria independientes—, el sistema operativo termina cobrando la factura de golpe en el peor escenario posible de producción.