Por que construir um event loop

Todo mundo usa event loops — Node.js, Python asyncio, Ruby async, Nginx. Mas poucos sabem o que acontece dentro deles. A melhor forma de entender é construir um do zero.

O evix é exatamente isso: um event loop escrito em C com bindings nativos para Ruby, criado para entender cada decisão que bibliotecas como libuv e libev tomam por baixo dos panos.


A estrutura central

Um event loop é, no fundo, um while que coordena três tipos de trabalho: callbacks imediatos, timers e eventos de I/O. A estrutura central do evix:

struct evix_loop {
  int running;
  evix_cb_node_t *head;      // fila de callbacks imediatos
  evix_timer_t *timers;      // lista de timers
  evix_io_t *ios;            // lista de I/O watchers
  evix_backend_t *backend;   // kqueue ou epoll
  void *backend_data;        // estado interno do backend
};

Cada campo é uma linked list. Callbacks imediatos são uma fila FIFO. Timers guardam o timestamp de expiração. I/O watchers associam um file descriptor a um callback.


O ciclo principal

O loop segue uma ordem precisa a cada iteração:

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

    // 2. Calcula timeout a partir do timer mais próximo
    int timeout_ms = /* ... */;

    // 3. Poll no backend (kqueue/epoll) com o timeout
    loop->backend->poll(loop, timeout_ms);

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

A ordem importa. Callbacks imediatos sempre executam primeiro, antes de qualquer timer — mesmo timers com delay zero. O timeout do poll é calculado a partir do timer mais próximo, garantindo que o loop não dorme mais do que deveria.

O has_work verifica se existe qualquer trabalho pendente. Se não há callbacks, nem timers, nem I/O watchers, o loop termina naturalmente.


Timers

Timers são simples: guardam um timestamp de expiração (expire_at) e, opcionalmente, um intervalo de repetição (repeat_ms):

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

Após o poll retornar, o loop percorre a lista de timers, compara expire_at com o relógio atual (CLOCK_MONOTONIC), e dispara os expirados. Timers one-shot são removidos e liberados. Timers recorrentes têm seu expire_at recalculado:

if (timer->repeat_ms > 0) {
  timer->expire_at = current + timer->repeat_ms;
} else {
  // one-shot: remove da lista e free
  *prev = timer->next;
  free(timer);
}

CLOCK_MONOTONIC é essencial aqui. Nunca use gettimeofday() ou CLOCK_REALTIME para timers — eles são afetados por ajustes de NTP e podem causar timers que disparam no momento errado ou nunca disparam.


I/O multiplexing: kqueue e epoll

A parte mais interessante é como o loop espera por eventos de I/O sem bloquear. Em vez de fazer read() bloqueante em cada file descriptor, usamos uma syscall do SO que monitora múltiplos FDs de uma vez.

O evix abstrai isso com uma interface 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);
};

No macOS, o backend usa kqueue. No Linux, epoll. A seleção é automática em tempo de compilação:

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

A implementação do kqueue, por exemplo, registra filtros de leitura/escrita e faz poll com 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;
    // encontra o watcher correspondente e dispara o callback
  }
  return n;
}

O timeout vem do cálculo de timers. Se não há timers, o poll bloqueia indefinidamente até um evento de I/O chegar (timeout_ms = -1). Se há um timer próximo, o poll espera no máximo até o timer expirar. Isso é como event loops conseguem ser eficientes: nunca fazem busy-waiting, mas também nunca perdem um timer.


Watchers one-shot vs persistentes

I/O watchers podem ser persistentes (disparam toda vez que há dados) ou one-shot (disparam uma vez e são removidos):

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;

Watchers one-shot são fundamentais para o modelo de Fibers. Quando uma fiber precisa esperar dados, ela registra um watcher one-shot e faz yield. Quando o dado chega, o watcher dispara, faz resume na fiber, e é automaticamente removido.


Bindings para Ruby: protegendo do GC

A extensão C nativa para Ruby precisa resolver um problema sutil: o garbage collector. Quando registramos um bloco Ruby como callback no event loop C, precisamos garantir que o GC não colete o bloco enquanto o event loop ainda pode precisar dele.

A solução é um array Ruby que segura referências a todos os objetos vivos:

typedef struct {
  evix_loop_t *loop;
  VALUE callbacks; // Array Ruby: previne GC dos 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 o GC
}

Cada callback, timer ou I/O watcher registrado é empurrado para esse array. O loop_mark é chamado pelo GC durante a fase de marcação, garantindo que nenhum objeto vivo seja coletado.


Concorrência cooperativa com Fibers

Com o event loop e os bindings prontos, a camada Ruby adiciona concorrência cooperativa usando Fibers. A ideia: código que parece síncrono mas não bloqueia.

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

Quando read é chamado, o wrapper registra um watcher one-shot para leitura no file descriptor e faz Fiber.yield. A fiber suspende. Quando dados chegam, o event loop dispara o callback que faz fiber.resume, e a execução continua exatamente de onde parou.

Do ponto de vista de quem usa, o código é linear:

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

Mas por baixo, nenhuma thread é bloqueada. O event loop está livre para processar outras fibers, timers e callbacks enquanto essa fiber espera I/O.


Sinais como eventos

Tratar sinais em um event loop é complicado porque signal handlers têm restrições severas — não podem alocar memória, nem chamar a maioria das funções. A solução clássica é o 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

O signal handler faz apenas uma coisa: escreve um byte em um pipe. O event loop monitora a ponta de leitura do pipe como qualquer outro I/O watcher. Quando o byte chega, o callback é executado no contexto seguro do loop, não dentro do handler.


TCP server: uma fiber por conexão

Com todas as peças no lugar, um TCP server concorrente fica simples:

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

Internamente, o server monitora o socket com um watcher de leitura persistente. Quando uma conexão chega, ele faz accept_nonblock e cria uma fiber para a conexão:

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 conexão roda em sua própria fiber, com código linear e legível, mas sem threads do sistema operacional. O event loop coordena tudo.


Um bug sutil: connect non-blocking

O TCPClient.connect revelou uma armadilha clássica de sockets non-blocking. Quando connect() retorna EINPROGRESS, o socket eventualmente fica “writable”. Mas writable não significa conectado.

Se o servidor não está rodando, o socket fica writable porque o connect terminou — com erro. Sem verificar SO_ERROR, o próximo write() causa EPIPE (broken pipe), mascarando o erro real (ECONNREFUSED).

A correção:

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

Esse é o tipo de bug que só aparece quando o servidor não está acessível — exatamente o cenário que você quer tratar com graça, não com um erro críptico.


O que fica de aprendizado

Construir um event loop do zero força você a entender decisões que normalmente ficam escondidas:

  • Por que callbacks imediatos precisam de uma fila separada de timers
  • Como o timeout do poll conecta timers e I/O em um único mecanismo
  • Por que CLOCK_MONOTONIC existe e quando CLOCK_REALTIME falha
  • Como o GC de linguagens de alto nível interage com callbacks em C
  • Por que o self-pipe trick é a forma padrão de lidar com sinais em event loops
  • Por que SO_ERROR é obrigatório após connect non-blocking

Cada uma dessas decisões tem uma razão concreta, e a única forma de realmente internalizar é tropeçar nelas ao implementar.


Referências