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

La familia de fds de eventos: eventfd, signalfd, timerfd

La culminación del 'todo es un descriptor': eventfd convierte una notificación entre hilos en un contador legible, signalfd convierte señales asíncronas en bytes que se leen sincronamente, y timerfd convierte el tiempo en un fd que reporta expiraciones. Los tres se vigilan con el mismo epoll de forma uniforme, y su techo común anticipa por qué io_uring, en el nivel 40, va más allá del readiness.

⏱ 15 min

Un bucle de eventos maduro no solo espera sockets: espera también temporizadores, señales del sistema y avisos de otros hilos. La tentación clásica era manejar cada uno con su mecanismo propio —manejadores de señal asíncronos, el timeout de epoll_wait, tuberías internas— y coserlos con cuidado. Linux ofrece algo más elegante: convertir cada uno de esos eventos en un descriptor de archivo. Señales, timers y notificaciones se vuelven fds legibles, y un único epoll los vigila a todos sin saber qué son. Es “todo es un fichero” llevado a su conclusión lógica, y también el punto donde el modelo del readiness roza su techo.

🎯 Al terminar esta lección sabrás
  • Convertir notificaciones entre hilos en un fd con eventfd.
  • Convertir señales asíncronas en bytes legibles con signalfd.
  • Convertir temporizadores en un fd con timerfd y leer el número de expiraciones.
  • Ver el principio unificador y por qué el readiness tiene un techo que io_uring supera.

eventfd: un contador que es un descriptor

eventfd crea un descriptor respaldado por un contador de 64 bits mantenido en el kernel. Escribir le suma un valor; leer devuelve el contador y, por defecto, lo pone a cero. El fd se vuelve legible —dispara EPOLLIN— cuando el contador es distinto de cero. Es la vía canónica para que un hilo, o incluso un manejador de señal, despierte un bucle de epoll ajeno.

/* eventfd: notificar a un bucle de eventos desde otro hilo */
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
epoll_ctl(epfd, EPOLL_CTL_ADD, efd, &(struct epoll_event){ .events = EPOLLIN, .data.fd = efd });

/* hilo productor: empuja el bucle sin tuberias ni sockets */
uint64_t uno = 1;
write(efd, &uno, sizeof(uno));      /* suma 1 al contador y despierta epoll_wait */

/* en el bucle, al activarse efd: */
uint64_t cuenta;
read(efd, &cuenta, sizeof(cuenta)); /* devuelve el acumulado y lo pone a cero */

Con la bandera EFD_SEMAPHORE, cada read resta uno en lugar de vaciar el contador, con lo que el fd se comporta como un semáforo contable entre hilos. Antes de eventfd se usaba el self-pipe trick —escribir un byte en una tubería interna—; eventfd es su sustituto a medida, más barato y sin consumir dos descriptores.

signalfd: señales como bytes que se leen

Las señales son el gran enemigo de un bucle de eventos: llegan de forma asíncrona, interrumpen en cualquier punto, y su manejador solo puede llamar a un puñado de funciones seguras. signalfd las domestica. Bloqueas las señales con sigprocmask para que no se entreguen de la forma clásica, y las lees sincronamente desde un fd, dentro del epoll, como estructuras signalfd_siginfo.

/* signalfd: recibir SIGINT y SIGTERM como datos, sin manejador asincrono */
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL);   /* imprescindible: bloquear la entrega clasica */

int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &(struct epoll_event){ .events = EPOLLIN, .data.fd = sfd });

/* en el bucle, al activarse sfd: */
struct signalfd_siginfo si;
read(sfd, &si, sizeof(si));            /* si.ssi_signo trae el numero de senal */
if (si.ssi_signo == SIGTERM)
	iniciar_apagado();

La señal deja de ser una interrupción del flujo para convertirse en un mensaje más en la cola de eventos, atendido en el punto seguro y previsible del bucle. Se acabaron las condiciones de carrera entre el manejador y el estado del programa.

timerfd: el tiempo como descriptor

timerfd_create produce un fd que se vuelve legible cuando un temporizador expira. Lo armas con timerfd_settime a partir de un struct itimerspec que fija el primer disparo y el intervalo de repetición. Al leerlo, devuelve un entero de 64 bits con el número de expiraciones ocurridas, de modo que aunque el bucle vaya con retraso no se pierde ningún tic.

/* timerfd: un temporizador periodico como fd dentro de epoll */
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);

struct itimerspec spec = {
	.it_value    = { .tv_sec = 1, .tv_nsec = 0 },   /* primer disparo al segundo */
	.it_interval = { .tv_sec = 1, .tv_nsec = 0 },   /* y luego cada segundo */
};
timerfd_settime(tfd, 0, &spec, NULL);
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &(struct epoll_event){ .events = EPOLLIN, .data.fd = tfd });

/* en el bucle, al activarse tfd: */
uint64_t expiraciones;
read(tfd, &expiraciones, sizeof(expiraciones));   /* cuantas veces vencio desde la ultima lectura */

La ventaja sobre el argumento timeout de epoll_wait es enorme: en vez de calcular a mano cuánto falta para el próximo vencimiento y pasarlo en cada llamada, el tiempo se vuelve un descriptor listo más, gestionado con la misma lógica que todo lo demás.

El despacho uniforme

La recompensa de haber convertido todo en descriptores es que el bucle no distingue orígenes: un solo epoll_wait recoge sockets, temporizadores, señales y avisos, y un switch sobre data.fd los reparte. El evento deja de tener una naturaleza especial; es, simplemente, un fd listo.

/* un unico bucle despacha sockets, timers, senales y avisos por igual */
int n = epoll_wait(epfd, ev, MAX, -1);
for (int i = 0; i < n; i++) {
	int fd = ev[i].data.fd;
	if (fd == sfd)        atender_senal(sfd);     /* signalfd */
	else if (fd == tfd)   atender_tick(tfd);      /* timerfd  */
	else if (fd == efd)   atender_aviso(efd);     /* eventfd  */
	else                  atender_conexion(fd);   /* socket   */
}

No hay una tubería para las señales, otra cola para los timers y un tercer mecanismo para los hilos: hay descriptores, y un bucle que espera sobre ellos. Esa homogeneidad es la que hace mantenible un servidor con decenas de fuentes de eventos heterogéneas.

🔔

eventfd

Un contador de 64 bits legible. Notificaciones entre hilos y despertar de bucles ajenos sin tuberías. Con EFD_SEMAPHORE, un semáforo contable.

✉️

signalfd

Señales bloqueadas que se leen como signalfd_siginfo. Elimina el manejador asíncrono y sus condiciones de carrera.

timerfd

Temporizadores que se vuelven fds legibles y reportan el número de expiraciones. El tiempo, dentro del mismo epoll.

ℹ️
El self-pipe trick, jubilado

Durante años, la única forma portable de despertar un select desde un manejador de señal era el self-pipe trick: crear una tubería y escribir un byte en ella desde el manejador, con el extremo de lectura dentro del conjunto vigilado. Funcionaba, pero gastaba dos descriptores y un búfer de tubería por cada punto de despertar. eventfd fue diseñado exactamente para jubilarlo: un solo fd, un contador, la misma semántica y menos coste.

⚠️
Estos fds exigen lecturas del tamaño exacto

Los tres descriptores rechazan las lecturas con un búfer demasiado pequeño. eventfd y timerfd requieren leer exactamente ocho bytes, un uint64_t; una lectura más corta falla con EINVAL. signalfd exige un búfer de al menos un struct signalfd_siginfo completo, que en Linux ocupa 128 bytes. No son flujos de bytes arbitrarios como una tubería: son objetos con un registro de tamaño fijo, y tratarlos como un socket cualquiera —leyendo un byte o un fragmento— es un error silencioso muy común al integrarlos en un bucle genérico.

Todo es un fd toca su cima aquí, y al hacerlo revela su techo

Contempla lo que acaba de completarse, porque es la apuesta más profunda de Unix cobrando su máximo dividendo. El descriptor de archivo nació como un asa hacia un fichero, se generalizó a sockets y tuberías, y ahora se convierte en la moneda universal de los eventos: una señal, un tic de reloj, un empujón de otro hilo, todos reducidos al mismo objeto legible para que un único bucle los espere sin necesidad de saber qué son. La uniformidad de la interfaz sustituye al casuismo; en vez de un mecanismo distinto por cada clase de suceso, uno solo —esperar sobre fds listos— los abarca a todos. Pero mira con atención el techo que esta misma cima ilumina. El modelo de fd más readiness solo sabe decirte que una operación puede proceder sin bloquear; no la realiza por ti. Cada despertar de epoll todavía te cuesta una llamada al sistema para leer o escribir de verdad, y el modelo se rompe por completo allí donde el readiness es una mentira: un archivo regular está siempre “listo” y sin embargo su lectura duerme sobre el plato del disco o la cola del NVMe. epoll multiplexa la espera, pero no multiplexa la acción. io_uring, el nivel que viene, disuelve esa última separación: en vez de preguntar “¿cuándo puedo actuar?” y luego actuar, deposita la acción misma en un anillo compartido y más tarde recoge su compleción, fundiendo disponibilidad y ejecución en un solo todo asíncrono. Comprende que eventfd, signalfd y timerfd son el fruto último y más fino del árbol del readiness, y que io_uring crece justamente de advertir que el árbol tiene una copa.

⚔️ Unifica los eventos bajo un solo bucle
  1. Monta un epoll que vigile a la vez un socket, un timerfd periódico y un signalfd para SIGTERM, y despacha cada uno en el mismo bucle.
  2. Explica por qué signalfd exige llamar antes a sigprocmask, y qué ocurriría si no bloquearas la señal.
  3. Usa eventfd para que un hilo trabajador despierte el bucle principal, y razona qué le aporta EFD_SEMAPHORE frente al comportamiento por defecto.
  4. Retrasa a propósito tu bucle y comprueba que el read del timerfd devuelve un número de expiraciones mayor que uno; explica por qué así no se pierden tics.
  5. Argumenta, en cuatro líneas, por qué un archivo regular rompe el modelo del readiness y cómo el paso de “avísame cuándo puedo” a “hazlo y avísame cuándo esté” motiva io_uring.