Por qué construir un event loop

Todos usan event loops — Node.js, Python asyncio, Ruby async, Nginx. Pero pocos saben qué pasa realmente dentro de ellos. La mejor forma de entender es construir uno desde cero.

evix es exactamente eso: un event loop escrito en C con bindings nativos para Ruby, creado para entender cada decisión que bibliotecas como libuv y libev toman bajo el capó.


La estructura central

Un event loop es, en el fondo, un while que coordina tres tipos de trabajo: callbacks inmediatos, timers y eventos de I/O. La estructura central en evix:

struct evix_loop {
  int running;
  evix_cb_node_t *head;      // cola de callbacks inmediatos
  evix_timer_t *timers;      // lista de timers
  evix_io_t *ios;            // lista de I/O watchers
  evix_backend_t *backend;   // kqueue o epoll
  void *backend_data;        // estado interno del backend
};

Cada campo es una linked list. Los callbacks inmediatos forman una cola FIFO. Los timers guardan un timestamp de expiración. Los I/O watchers asocian un file descriptor con un callback.


El ciclo principal

El loop sigue un orden preciso en cada iteración:

int evix_loop_run(evix_loop_t *loop) {
  loop->running = 1;
  while (loop->running && has_work(loop)) {
    // 1. Drena todos los callbacks inmediatos
    drain_callbacks(loop);

    // 2. Calcula timeout a partir del timer más cercano
    int timeout_ms = /* ... */;

    // 3. Poll en el backend (kqueue/epoll) con el timeout
    loop->backend->poll(loop, timeout_ms);

    // 4. Dispara timers expirados
    /* ... */
  }
  return 0;
}

El orden importa. Los callbacks inmediatos siempre ejecutan primero, antes de cualquier timer — incluso timers con delay cero. El timeout del poll se calcula a partir del timer más cercano, garantizando que el loop no duerma más de lo necesario.

has_work verifica si existe algún trabajo pendiente. Si no hay callbacks, ni timers, ni I/O watchers, el loop termina naturalmente.


Timers

Los timers son simples: guardan un timestamp de expiración (expire_at) y, opcionalmente, un intervalo de repetición (repeat_ms):

struct evix_timer {
  uint64_t expire_at;
  uint64_t repeat_ms;
  evix_callback_fn callback;
  void *data;
  struct evix_timer *next;
};

Después de que poll retorna, el loop recorre la lista de timers, compara expire_at con el reloj actual (CLOCK_MONOTONIC), y dispara los expirados. Timers one-shot se remueven y liberan. Timers recurrentes tienen su expire_at recalculado:

if (timer->repeat_ms > 0) {
  timer->expire_at = current + timer->repeat_ms;
} else {
  // one-shot: remover de la lista y free
  *prev = timer->next;
  free(timer);
}

CLOCK_MONOTONIC es esencial aquí. Nunca uses gettimeofday() o CLOCK_REALTIME para timers — son afectados por ajustes de NTP y pueden causar timers que disparan en el momento equivocado o que nunca disparan.


I/O multiplexing: kqueue y epoll

La parte más interesante es cómo el loop espera por eventos de I/O sin bloquear. En vez de hacer read() bloqueante en cada file descriptor, usamos una syscall del SO que monitorea múltiples FDs a la vez.

evix abstrae esto con una interfaz de backend:

struct evix_backend {
  int  (*init)(evix_loop_t *loop);
  void (*destroy)(evix_loop_t *loop);
  int  (*poll)(evix_loop_t *loop, int timeout_ms);
  int  (*io_add)(evix_loop_t *loop, int fd, int events);
  int  (*io_del)(evix_loop_t *loop, int fd);
};

En macOS, el backend usa kqueue. En Linux, epoll. La selección es automática en tiempo de compilación:

#ifdef __linux__
#define EVIX_DEFAULT_BACKEND evix_epoll_backend
#else
#define EVIX_DEFAULT_BACKEND evix_kqueue_backend
#endif

La implementación de kqueue, por ejemplo, registra filtros de lectura/escritura y hace poll con timeout:

static int kqueue_poll(evix_loop_t *loop, int timeout_ms) {
  struct kevent events[64];
  struct timespec ts = {
    .tv_sec = timeout_ms / 1000,
    .tv_nsec = (timeout_ms % 1000) * 1000000L,
  };

  int n = kevent(state->kq, NULL, 0, events, 64, &ts);

  for (int i = 0; i < n; i++) {
    int fd = (int)events[i].ident;
    // encuentra el watcher correspondiente y dispara el callback
  }
  return n;
}

El timeout viene del cálculo de timers. Si no hay timers, el poll bloquea indefinidamente hasta que un evento de I/O llegue (timeout_ms = -1). Si hay un timer cercano, el poll espera como máximo hasta que el timer expire. Así es como los event loops son eficientes: nunca hacen busy-waiting, pero tampoco pierden un timer.


Watchers one-shot vs persistentes

Los I/O watchers pueden ser persistentes (disparan cada vez que hay datos) o one-shot (disparan una vez y se remueven):

evix_io_t *io = calloc(1, sizeof(evix_io_t));
io->fd = fd;
io->events = events;
io->oneshot = (events & EVIX_IO_ONESHOT) ? 1 : 0;
io->callback = callback;

Los watchers one-shot son fundamentales para el modelo de Fibers. Cuando una fiber necesita esperar datos, registra un watcher one-shot y hace yield. Cuando el dato llega, el watcher dispara, hace resume en la fiber, y es automáticamente removido.


Bindings para Ruby: protegiendo del GC

La extensión C nativa para Ruby necesita resolver un problema sutil: el garbage collector. Cuando registramos un bloque Ruby como callback en el event loop C, necesitamos garantizar que el GC no recolecte el bloque mientras el event loop aún pueda necesitarlo.

La solución es un array Ruby que mantiene referencias a todos los objetos vivos:

typedef struct {
  evix_loop_t *loop;
  VALUE callbacks; // Array Ruby: previene GC de los procs
} evix_rb_loop_t;

static void loop_mark(void *ptr) {
  evix_rb_loop_t *rb_loop = ptr;
  rb_gc_mark(rb_loop->callbacks); // marca para el GC
}

Cada callback, timer o I/O watcher registrado se empuja a ese array. loop_mark es llamado por el GC durante la fase de marcado, garantizando que ningún objeto vivo sea recolectado.


Concurrencia cooperativa con Fibers

Con el event loop y los bindings listos, la capa Ruby agrega concurrencia cooperativa usando Fibers. La idea: código que parece síncrono pero no bloquea.

module Evix
  class IO
    def read(maxlen = 4096)
      wait_readable
      @ruby_io.read_nonblock(maxlen)
    end

    private

    def wait_readable
      fiber = Fiber.current
      @loop.add_io(@fd, IO_READ | IO_ONESHOT) { fiber.resume }
      Fiber.yield
    end
  end
end

Cuando se llama read, el wrapper registra un watcher one-shot de lectura en el file descriptor y llama Fiber.yield. La fiber se suspende. Cuando llegan datos, el event loop dispara el callback que hace fiber.resume, y la ejecución continúa exactamente donde se detuvo.

Desde la perspectiva del que usa, el código es lineal:

loop.spawn do
  data = wrapped_io.read   # yield interno
  wrapped_io.write(data)   # yield si buffer lleno
end

Pero por debajo, ningún thread está bloqueado. El event loop está libre para procesar otras fibers, timers y callbacks mientras esta fiber espera I/O.


Señales como eventos

Manejar señales en un event loop es complicado porque los signal handlers tienen restricciones severas — no pueden asignar memoria ni llamar la mayoría de las funciones. La solución clásica es el self-pipe trick:

def add_signal(sig, &block)
  rd, wr = ::IO.pipe
  Signal.trap(sig) { wr.write_nonblock("\x00") rescue nil }

  add_io(rd.fileno) do
    rd.read_nonblock(256) rescue nil
    block.call
  end
end

El signal handler hace solo una cosa: escribe un byte en un pipe. El event loop monitorea el extremo de lectura del pipe como cualquier otro I/O watcher. Cuando el byte llega, el callback se ejecuta en el contexto seguro del loop, no dentro del handler.


TCP server: una fiber por conexión

Con todas las piezas en su lugar, un TCP server concurrente queda simple:

server = Evix::TCPServer.new(loop, "127.0.0.1", 3000) do |client|
  while (data = client.read)
    client.write(data)
  end
end

Internamente, el server monitorea el socket con un watcher de lectura persistente. Cuando una conexión llega, hace accept_nonblock y crea una fiber para la conexión:

def accept_connections
  loop do
    client = @server.accept_nonblock
    @loop.spawn do
      wrapped = @loop.wrap(client)
      @handler.call(wrapped)
    ensure
      wrapped&.close
    end
  rescue ::IO::WaitReadable
    break
  end
end

Cada conexión corre en su propia fiber, con código lineal y legible, pero sin threads del sistema operativo. El event loop coordina todo.


Un bug sutil: connect non-blocking

TCPClient.connect reveló una trampa clásica de sockets non-blocking. Cuando connect() retorna EINPROGRESS, el socket eventualmente se vuelve “writable.” Pero writable no significa conectado.

Si el servidor no está corriendo, el socket se vuelve writable porque el connect terminó — con un error. Sin verificar SO_ERROR, el siguiente write() causa EPIPE (broken pipe), enmascarando el error real (ECONNREFUSED).

La corrección:

err = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
raise Errno::ECONNREFUSED, "#{host}:#{port}" unless err.zero?

Este es el tipo de bug que solo aparece cuando el servidor no está accesible — exactamente el escenario que quieres manejar con gracia, no con un error críptico.


Lo que queda como aprendizaje

Construir un event loop desde cero te fuerza a entender decisiones que normalmente quedan ocultas:

  • Por qué los callbacks inmediatos necesitan una cola separada de los timers
  • Cómo el timeout del poll conecta timers e I/O en un único mecanismo
  • Por qué CLOCK_MONOTONIC existe y cuándo CLOCK_REALTIME falla
  • Cómo el GC de lenguajes de alto nivel interactúa con callbacks en C
  • Por qué el self-pipe trick es la forma estándar de manejar señales en event loops
  • Por qué SO_ERROR es obligatorio después de connect non-blocking

Cada una de estas decisiones tiene una razón concreta, y la única forma de realmente internalizarlas es tropezar con ellas al implementar.


Referencias