wandres.dev
E/S MULTIPLEXADA · select, poll, epoll

epoll: la multiplexación O(1) de Linux

La respuesta de Linux al muro O(n): epoll_create1 crea un objeto persistente en el kernel, epoll_ctl registra cada descriptor una sola vez en un árbol de interés, y epoll_wait devuelve solo los fds listos con coste proporcional a lo que ocurre y no a lo que se vigila. Por dentro, el árbol rojinegro de epitems, la lista de listos y el ep_poll_callback que convierte el sondeo en notificación por empuje: el motor de nginx, Redis, HAProxy y Node.

⏱ 17 min

Bajo nginx, Redis, HAProxy y el libuv que mueve a Node.js late la misma estructura de datos del kernel de Linux: epoll. No es un poll más rápido, es un cambio de modelo. En vez de preguntarle al kernel en cada llamada “de estos diez mil, ¿cuáles están listos?” —y que él lo averigüe recorriéndolos—, le declaras tu interés una sola vez y dejas que la disponibilidad fluya hacia ti según ocurre. El coste deja de escalar con el tamaño de lo que vigilas y pasa a escalar con el ritmo de lo que pasa, que es la única forma honesta de cobrar por eventos.

🎯 Al terminar esta lección sabrás
  • Crear el conjunto con epoll_create1 y entender que es un objeto persistente del kernel.
  • Registrar fds una sola vez con epoll_ctl y struct epoll_event.
  • Recibir solo los fds listos con epoll_wait, a coste proporcional a los listos y no al total.
  • Conocer la maquinaria interna: árbol de interés, lista de listos y ep_poll_callback.

epoll_create1 y epoll_ctl: declarar el interés una vez

epoll_create1 devuelve un descriptor que representa un objeto vivo dentro del kernel, un struct eventpoll. A partir de ahí, epoll_ctl añade, modifica o quita descriptores de ese conjunto con las órdenes EPOLL_CTL_ADD, EPOLL_CTL_MOD y EPOLL_CTL_DEL. Cada fd se registra una sola vez y permanece en el conjunto hasta que lo quitas.

struct epoll_event {
	__u32     events;   /* EPOLLIN, EPOLLOUT, EPOLLET, EPOLLONESHOT... */
	epoll_data_t data;  /* union: ptr, fd, u32, u64: te lo devuelven tal cual */
};
/* crear el conjunto y registrar el socket de escucha UNA vez */
int epfd = epoll_create1(EPOLL_CLOEXEC);

struct epoll_event ev = {
	.events   = EPOLLIN,
	.data.ptr = &conn_escucha,   /* adjunta tu objeto: lo recuperas sin buscarlo */
};
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);

El campo data es una unión que el kernel te devuelve intacta en cada evento. Guardar ahí data.ptr con el puntero a tu estructura de conexión es la práctica canónica: cuando el fd se activa, recibes directamente el objeto, sin traducir un número de descriptor a través de una tabla hash.

epoll_wait: recoger solo los listos

epoll_wait bloquea hasta que alguno de los fds registrados esté listo y llena tu array únicamente con esos eventos, hasta un máximo de maxevents. Su coste es proporcional al número de listos, no al tamaño del conjunto: ahí muere el muro O(n).

/* bucle de eventos completo: el esqueleto de todo servidor epoll */
struct epoll_event eventos[MAX_EVENTOS];

for (;;) {
	int n = epoll_wait(epfd, eventos, MAX_EVENTOS, -1);  /* solo devuelve los listos */

	for (int i = 0; i < n; i++) {
		struct conn *c = eventos[i].data.ptr;    /* tu objeto, sin busqueda */

		if (c->fd == lfd) {
			/* nueva conexion: accept no bloqueante y alta en el conjunto */
			int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
			struct conn *nc = conn_nueva(cfd);
			struct epoll_event nev = { .events = EPOLLIN, .data.ptr = nc };
			epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &nev);
		} else if (eventos[i].events & EPOLLIN) {
			atender_lectura(c);
		}
	}
}

Un solo hilo, un solo bucle, y todo el trabajo concentrado en los descriptores que de verdad tienen algo que decir. Añadir la conexión número diez mil no encarece ni un ápice la atención de las otras: el registro con EPOLL_CTL_ADD es una operación puntual, no un peaje que se repita en cada vuelta.

El parámetro maxevents acota cuántos listos recoges por llamada. Si hay más listos que ese tope, los sobrantes no se pierden: permanecen en la lista de listos del kernel y salen en la siguiente vuelta, y epoll los rota para que ningún descriptor quede postergado sin fin. Dimensionar bien el array —ni tan pequeño que multiplique las llamadas, ni tan grande que dispare la latencia de la primera atención— es un ajuste real de todo servidor bajo carga.

Por dentro: árbol de interés, lista de listos y el callback

La eficiencia no es magia, es una elección de estructuras de datos en fs/eventpoll.c. El struct eventpoll guarda dos colecciones. El árbol rojinegro (rbr) contiene un struct epitem por cada descriptor registrado: es el conjunto de interés persistente, que sobrevive entre llamadas. La lista de listos (rdllist) contiene solo los epitems cuyo fd está disponible ahora mismo.

La pieza clave es qué ocurre al registrar. Cuando epoll_ctl añade un fd, instala en la cola de espera de ese archivo un callback, ep_poll_callback. A partir de ahí, epoll no vuelve a interrogar el descriptor: espera a que el propio fd lo avise. Cuando llegan datos al socket, el despertar de su cola de espera dispara ep_poll_callback, que enlaza el epitem en la lista de listos y despierta a quien duerme en epoll_wait.

/* fs/eventpoll.c, esencia de ep_poll_callback: se dispara al llegar el dato */
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
	struct epitem *epi = ep_item_from_wait(wait);
	struct eventpoll *ep = epi->ep;

	list_add_tail(&epi->rdllink, &ep->rdllist);  /* el fd listo entra en la lista */
	wake_up(&ep->wq);                            /* despierta a epoll_wait */
	return 1;
}

epoll_wait no recorre nada: transfiere la lista de listos a tu array y regresa. Ningún descriptor inactivo se toca jamás. Ese es el sentido exacto del O(1) por evento: el trabajo sucede en el flanco de disponibilidad, empujado por el callback desde el fd, en lugar de ser arrastrado por un barrido desde el usuario.

flowchart LR
CTL[epoll_ctl ADD] --> RB[arbol rojinegro de epitems]
RB -->|instala callback| WQ[cola de espera del socket]
WQ -->|llega dato y dispara ep_poll_callback| RD[lista de listos]
RD --> WAIT[epoll_wait copia solo los listos]
💡
data.ptr elimina la tabla de traducción

En select y poll el kernel te devuelve números de descriptor y tú los traduces a tus objetos de conexión, casi siempre con una tabla hash. epoll te deja adjuntar data.ptr al registrar, y te lo devuelve idéntico en cada evento. Guarda ahí el puntero a tu struct conn y el bucle de eventos pierde por completo el paso de búsqueda: del evento saltas directo al estado de esa conexión.

⚠️
epoll vigila la descripción de archivo, no el número de fd

El interés se ancla a la open file description subyacente, no al número entero. Si duplicas un fd con dup y cierras el original, la descripción sigue viva y epoll la sigue vigilando aunque el número que registraste ya no exista: el clásico descriptor fantasma que nunca sale del conjunto. La regla es quitar con EPOLL_CTL_DEL antes de close, y recordar que cerrar un fd solo lo saca del conjunto si era la última referencia a esa descripción.

ℹ️
epoll es de Linux: por eso existe libuv

epoll no es portable: es una interfaz propia de Linux. FreeBSD y macOS ofrecen kqueue, con un modelo de cambios y eventos parecido; Windows tiene IOCP, que ya es de compleción y no de disponibilidad. Por eso existen las bibliotecas que sostienen a Node.js con libuv, o a otros entornos con libevent y libev: envuelven epoll, kqueue e IOCP tras una única API. Cuando lees que “Node usa epoll”, en rigor es libuv quien usa epoll en Linux y otro mecanismo en cada sistema.

epoll convierte el sondeo en interrupción: de tirar a empujar

Reconoce la figura, porque ya la viste en otro nivel y aquí reaparece intacta. select y poll operan por tracción: el usuario tira de la información preguntando “¿cuáles están listos?”, y el kernel produce la respuesta recorriendo el conjunto entero, exactamente como la CPU que sondea un registro de estado en un bucle. epoll opera por empuje: declaras tu interés una vez, el kernel cablea un callback en la ruta de despertar de cada descriptor, y la disponibilidad viaja hacia ti en el instante en que sucede, exactamente como el dispositivo que eleva una línea de interrupción en vez de esperar a que lo interroguen. Es la misma transición que estudiaste entre la E/S programada y la interrupción, trasladada de la frontera con el hardware a la frontera entre procesos: dejar de gastar trabajo en preguntar por lo que no ha cambiado y pagar solo por lo que ocurre. Y como toda inversión de tracción a empuje, tiene su precio en estado: hay que crear un objeto en el kernel, mantener un árbol de interés, gestionar un ciclo de vida de registro y borrado que antes no existía. Ese es el trato profundo de la escalabilidad —cambiar recorridos repetidos por estado persistente más notificación— y explica por qué prácticamente todo servidor de alta concurrencia sobre Linux es, por debajo de sus capas de abstracción, esta única estructura de datos respondiendo a callbacks. Cuando lo veas así, epoll dejará de ser una API con tres funciones y se revelará como lo que es: la interrupción, reinventada para los descriptores.

⚔️ Levanta tu propio motor de eventos
  1. Escribe un servidor de eco con epoll_create1, accept4 no bloqueante y un bucle de epoll_wait, adjuntando tu struct conn en data.ptr.
  2. Explica por qué añadir la conexión diez mil no encarece la atención de las demás, citando dónde vive el conjunto de interés.
  3. Provoca el descriptor fantasma: registra un fd, duplícalo con dup, cierra el original y razona por qué el evento sigue llegando.
  4. Describe la cadena exacta desde que un byte llega al socket hasta que epoll_wait regresa, nombrando ep_poll_callback y la lista de listos.
  5. Compara, en tres líneas, el trabajo por evento de poll frente a epoll cuando hay diez mil fds y solo uno activo, y di dónde se fue el O(n).