Arquitetura Event-Driven em Zig: Event Loop, Filas e Backpressure

Arquitetura Event-Driven em Zig: Event Loop, Filas e Backpressure

Arquitetura orientada a eventos aparece em quase todo sistema que precisa reagir rápido sem desperdiçar recursos: servidores HTTP, gateways de rede, processadores de filas, pipelines de dados, telemetria, automação industrial e ferramentas locais que observam arquivos ou sockets. Em vez de uma sequência linear de chamadas bloqueantes, o programa recebe eventos, decide o que fazer e agenda trabalho para handlers pequenos.

Zig combina bem com esse estilo porque deixa alocação, vida útil de buffers, concorrência e I/O explícitos. A linguagem não força um runtime assíncrono específico. Você pode começar com uma fila em memória, evoluir para threads e poll, integrar io_uring no Linux, ou delegar parte da mensageria para NATS, Redis Streams, Kafka ou outro broker. O ponto é desenhar a fronteira entre evento, fila e handler antes de escrever código demais.

Este guia atualiza a visão prática de event-driven em Zig: quando usar, como montar um event loop simples, onde aplicar backpressure, como tratar erros, como observar o sistema e quais limites importam em produção.

Quando event-driven faz sentido em Zig

Use uma arquitetura event-driven quando o sistema recebe muitos estímulos independentes e precisa manter latência previsível. Exemplos comuns:

  • servidor HTTP que transforma cada requisição em trabalho interno;
  • daemon que observa conexões TCP, arquivos, timers e sinais do sistema;
  • pipeline que consome mensagens de uma fila e grava em banco;
  • gateway que multiplexa milhares de conexões com pouco consumo de memória;
  • agente de observabilidade que coleta métricas, logs e eventos de rede;
  • sistema embarcado que reage a interrupções, timers e sensores.

Evite event-driven quando o fluxo é pequeno, batch, linear e fácil de testar como uma sequência simples. Zig não recompensa arquitetura acidentalmente complexa. Se um main com três funções resolve, mantenha simples. Event-driven vale quando fila, isolamento de handlers e controle de concorrência reduzem acoplamento ou protegem o sistema contra picos.

Modelo mental: evento, fila, handler e efeito

Um desenho saudável separa quatro responsabilidades:

  1. Evento: fato pequeno e imutável, como requisicao_http, timer_expirado, mensagem_recebida ou socket_pronto.
  2. Fila: buffer que guarda eventos até serem processados, com política clara para quando enche.
  3. Handler: função curta que interpreta um evento e produz efeito ou novos eventos.
  4. Efeito: I/O, gravação em banco, chamada externa, resposta HTTP, log ou métrica.

Essa separação evita o erro mais comum: deixar o callback fazer tudo, bloquear por tempo indeterminado e alocar memória sem limite. Em Zig, a assinatura do handler deve deixar dependências explícitas: allocator, logger, cliente HTTP, pool de banco, config, arena temporária e contexto de shutdown.

socket/timer/broker
  evento pequeno
 fila com limite ── backpressure/drop/retry
 handler curto
      ├── efeito externo
      └── novos eventos

Event loop mínimo em Zig

Um event loop didático pode ser apenas uma fila em memória. Ele não substitui poll, epoll, kqueue ou io_uring, mas ajuda a organizar contratos.

const std = @import("std");

const TipoEvento = enum {
    requisicao_http,
    mensagem_recebida,
    timer_expirado,
    conexao_fechada,
    shutdown,
};

const Evento = struct {
    tipo: TipoEvento,
    payload: []const u8,
    created_at_ms: i64,
};

const Handler = *const fn (ctx: *Contexto, evento: Evento) anyerror!void;

const Contexto = struct {
    allocator: std.mem.Allocator,
    logger: std.log.Level = .info,
    shutting_down: bool = false,
};

const EventLoop = struct {
    allocator: std.mem.Allocator,
    fila: std.ArrayList(Evento),
    handlers: std.AutoHashMap(TipoEvento, Handler),
    max_queue: usize,

    pub fn init(allocator: std.mem.Allocator, max_queue: usize) EventLoop {
        return .{
            .allocator = allocator,
            .fila = std.ArrayList(Evento).init(allocator),
            .handlers = std.AutoHashMap(TipoEvento, Handler).init(allocator),
            .max_queue = max_queue,
        };
    }

    pub fn deinit(self: *EventLoop) void {
        self.handlers.deinit();
        self.fila.deinit();
    }

    pub fn on(self: *EventLoop, tipo: TipoEvento, handler: Handler) !void {
        try self.handlers.put(tipo, handler);
    }

    pub fn emitir(self: *EventLoop, evento: Evento) !void {
        if (self.fila.items.len >= self.max_queue) return error.QueueFull;
        try self.fila.append(evento);
    }

    pub fn tick(self: *EventLoop, ctx: *Contexto) !void {
        if (self.fila.items.len == 0) return;

        const evento = self.fila.orderedRemove(0);
        if (evento.tipo == .shutdown) {
            ctx.shutting_down = true;
            return;
        }

        if (self.handlers.get(evento.tipo)) |handler| {
            handler(ctx, evento) catch |err| {
                std.log.err("handler failed: tipo={s} err={s}", .{
                    @tagName(evento.tipo),
                    @errorName(err),
                });
            };
        }
    }
};

O detalhe importante é o limite da fila. Sem max_queue, o sistema pode transformar pico de tráfego em consumo ilimitado de memória. Em produção, fila sem limite é dívida técnica disfarçada.

Backpressure: o que acontece quando a fila enche

Todo sistema event-driven precisa responder a uma pergunta desconfortável: o que fazer quando chegam mais eventos do que os handlers conseguem processar? Há quatro respostas típicas:

  • rejeitar: retornar 429/503, fechar conexão ou recusar mensagem;
  • degradar: processar só eventos essenciais e descartar telemetria secundária;
  • bufferizar fora do processo: usar broker ou disco para absorver picos;
  • aumentar workers: paralelizar handlers quando o gargalo permite.

A pior resposta é fingir que não existe limite. Em Zig, prefira tornar o limite visível no tipo ou na configuração: max_queue, max_body_bytes, max_inflight, max_retries, timeout_ms. O mesmo raciocínio vale para servidores HTTP em produção: limite de corpo, timeout e health check são partes da arquitetura, não detalhes de deploy.

Pub/sub interno sem virar framework

Para muitos serviços pequenos, um pub/sub interno basta. Eventos de domínio podem acionar logs, métricas, atualização de cache ou persistência sem acoplar tudo ao handler principal.

const Assinante = struct {
    tipo: TipoEvento,
    handler: Handler,
};

fn publicar(ctx: *Contexto, assinantes: []const Assinante, evento: Evento) void {
    for (assinantes) |sub| {
        if (sub.tipo != evento.tipo) continue;
        sub.handler(ctx, evento) catch |err| {
            std.log.warn("subscriber failed: tipo={s} err={s}", .{
                @tagName(evento.tipo),
                @errorName(err),
            });
        };
    }
}

Mantenha esse mecanismo pequeno. Quando precisa de durabilidade, fan-out entre processos, reprocessamento, ordenação por chave ou auditoria, use uma fila externa. O Zig continua útil como consumidor eficiente e previsível, mas o broker passa a carregar semânticas que não deveriam ficar em memória local.

Filas lock-free: use com critério

Zig permite escrever estruturas lock-free com atomics, mas isso não deve ser o primeiro passo. Uma fila protegida por mutex pode ser suficiente e muito mais fácil de auditar. Lock-free faz sentido quando você mediu contenção e sabe que o custo de complexidade compensa.

Um buffer circular single-producer/single-consumer ilustra a ideia:

fn RingSpsc(comptime T: type, comptime capacidade: usize) type {
    return struct {
        buffer: [capacidade]T = undefined,
        write_pos: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
        read_pos: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),

        const Self = @This();

        pub fn push(self: *Self, value: T) bool {
            const wp = self.write_pos.load(.monotonic);
            const next = (wp + 1) % capacidade;
            if (next == self.read_pos.load(.acquire)) return false;
            self.buffer[wp] = value;
            self.write_pos.store(next, .release);
            return true;
        }

        pub fn pop(self: *Self) ?T {
            const rp = self.read_pos.load(.monotonic);
            if (rp == self.write_pos.load(.acquire)) return null;
            const value = self.buffer[rp];
            self.read_pos.store((rp + 1) % capacidade, .release);
            return value;
        }
    };
}

Documente o contrato: esse exemplo é SPSC, não MPMC. Se dois produtores chamarem push simultaneamente, a estrutura deixa de ser segura. Para MPMC, use desenho específico, testes agressivos e benchmarks reais. A página de code review em Zig é uma boa checklist para revisar atomics, ownership e cleanup.

I/O: poll, epoll, kqueue e io_uring

O loop acima organiza eventos internos. Para I/O real, você precisa observar descritores, timers ou callbacks do sistema operacional.

  • Em projetos portáveis, poll/select podem bastar para começar.
  • No Linux, epoll é um caminho conhecido para muitos sockets.
  • Em BSD/macOS, kqueue é o equivalente comum.
  • No Linux moderno, io_uring permite submeter operações assíncronas com menos overhead em alguns cenários.

Zig não obriga você a escolher uma abstração única. Isso é poder e responsabilidade. Se o produto precisa rodar em Linux apenas, io_uring pode ser atraente. Se precisa compilar para vários ambientes, mantenha uma camada fina que esconda o backend de I/O e teste cada plataforma separadamente.

Para networking básico antes de entrar em event loops sofisticados, comece pelo guia de sockets TCP e UDP em Zig.

Erros, retries e dead letters

Eventos falham. Broker cai, banco retorna timeout, JSON vem inválido, downstream responde 500, handler encontra bug. A arquitetura precisa diferenciar erro transitório de erro permanente.

Um padrão prático:

  • erro de validação: rejeite e registre contexto mínimo;
  • timeout externo: retry com backoff e jitter;
  • falha repetida: mande para dead letter ou estado de inspeção;
  • bug interno: logue com correlação, alerte e evite loop infinito;
  • shutdown: pare de aceitar novos eventos e drene o que for seguro.

Em Zig, use error sets para expressar essas categorias. Não transforme todo erro em anyerror até a borda sem registrar intenção. O guia de error handling em Zig cobre try, catch, errdefer e cleanup em mais detalhe.

Observabilidade para sistemas event-driven

Sem observabilidade, event-driven vira caixa-preta. Métricas mínimas:

  • tamanho atual da fila;
  • eventos recebidos por tipo;
  • eventos processados por tipo;
  • erros por handler;
  • latência entre criação e processamento;
  • número de retries;
  • eventos descartados por backpressure;
  • tempo de shutdown/drain.

Logs devem carregar um identificador de correlação quando o evento nasceu de requisição externa. Traces ajudam quando um evento gera outros eventos ou chama serviços diferentes. Para um desenho completo de logs, métricas, traces e health checks, veja observabilidade em Zig.

Deploy: processo único, workers ou broker?

A escolha de deploy depende do tipo de garantia que você precisa.

Processo único com fila em memória é simples, rápido e bom para ferramentas locais, agentes pequenos e serviços em que perder eventos em restart é aceitável.

Processo com workers internos aumenta throughput, mas exige cuidado com ordem, concorrência, compartilhamento de allocator, shutdown e idempotência.

Broker externo adiciona operação, mas oferece durabilidade, replay, fan-out e escala horizontal. É o caminho natural quando eventos representam pedidos de cliente, cobrança, integração importante ou processamento caro.

Para serviços cloud native, combine esse desenho com readiness/liveness, limites de memória, logs estruturados e deploy reversível. O guia de Zig em ambientes cloud native e o de Kubernetes Operators em Zig mostram os trade-offs de operar binários Zig em cluster.

Checklist de produção

Antes de colocar um sistema event-driven em produção, confirme:

  • cada fila tem limite explícito;
  • existe política para fila cheia;
  • handlers têm timeout ou orçamento de trabalho;
  • eventos grandes usam referência ou armazenamento externo, não cópia ilimitada;
  • retry tem limite, backoff e jitter;
  • erro permanente não entra em loop infinito;
  • shutdown para entrada nova e drena trabalho seguro;
  • métricas expõem fila, latência, erro e drop;
  • logs têm correlação por requisição/evento;
  • testes cobrem ordem, duplicidade, falha e reprocessamento;
  • benchmarks medem throughput e p95/p99, não só média.

Zig vs Go e Rust para event-driven

Para comparar modelos de concorrência event-driven, Go oferece goroutines e channels como primitivos de alto nível. É excelente para serviços de rede comuns e times que querem produtividade imediata.

Rust conta com Tokio e um ecossistema assíncrono maduro, com garantias fortes de ownership e concorrência. O custo é aprender o modelo async, lifetimes e bibliotecas.

Zig fica no meio: menos framework, mais controle direto. É uma boa escolha quando o sistema valoriza binário pequeno, previsibilidade, integração C, controle de memória e um desenho que a equipe consegue auditar sem runtime mágico.

FAQ

Zig tem async/await estável para esse tipo de arquitetura?

O histórico de async em Zig mudou ao longo das versões, então trate exemplos antigos com cuidado. Em produção, prefira desenhar filas, workers, threads e backends de I/O explícitos conforme a versão do compilador e o alvo. Não dependa de tutorial antigo sem validar no Zig atual.

Event-driven em Zig precisa de io_uring?

Não. io_uring é uma opção poderosa no Linux, mas muitos sistemas funcionam bem com threads, poll, epoll, broker externo ou fila em memória. Use io_uring quando a necessidade de I/O assíncrono e o ambiente Linux justificarem a complexidade.

Como testar um sistema event-driven?

Separe handlers puros de efeitos externos. Teste handlers com eventos sintéticos, injete relógio/cliente/banco falsos, cubra duplicidade e ordem fora do esperado, e rode testes de carga para fila cheia. Para bugs difíceis, combine logs estruturados com métricas de fila e traces.

Posso usar Kafka, NATS ou Redis Streams com Zig?

Sim, mas a maturidade das bibliotecas varia. Em muitos casos, vale integrar por protocolo simples, cliente C, processo sidecar ou uma ponte em outra linguagem. O importante é manter idempotência, backpressure e observabilidade no consumidor Zig.

Conteúdo relacionado

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.