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:
- Evento: fato pequeno e imutável, como
requisicao_http,timer_expirado,mensagem_recebidaousocket_pronto. - Fila: buffer que guarda eventos até serem processados, com política clara para quando enche.
- Handler: função curta que interpreta um evento e produz efeito ou novos eventos.
- 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/selectpodem bastar para começar. - No Linux,
epollé um caminho conhecido para muitos sockets. - Em BSD/macOS,
kqueueé o equivalente comum. - No Linux moderno,
io_uringpermite 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
- Zig HTTP Server em Produção — limites, logs, health check e deploy.
- Observabilidade em Zig — métricas, logs, traces e alertas.
- Networking com Sockets TCP e UDP — base de I/O de rede.
- Error Handling em Zig — erros, cleanup e retries.
- Sistemas de Tempo Real com Zig — latência previsível.
- Concorrência em Zig — threads e atomics.
- io_uring com Zig — I/O assíncrono no Linux.
- Zig em Telecomunicações — event-driven em telecom.