Se você trabalha com servidores de alta performance no Linux, provavelmente já ouviu falar do io_uring. Introduzido no kernel 5.1 (2019), o io_uring revolucionou o I/O assíncrono no Linux ao permitir operações sem syscalls bloqueantes, usando filas compartilhadas entre userspace e kernel. E a linguagem Zig, com seu controle manual de memória e execução em comptime, é uma parceira ideal para extrair o máximo dessa interface.
Neste artigo, vamos explorar como usar io_uring com Zig para construir aplicações de I/O de alta performance — desde os conceitos fundamentais até um servidor TCP funcional.
O que é io_uring
O io_uring é uma interface de I/O assíncrono do kernel Linux baseada em duas filas circulares (ring buffers) compartilhadas entre o espaço do usuário e o kernel:
- SQE (Submission Queue Entry) — onde a aplicação submete requisições de I/O
- CQE (Completion Queue Entry) — onde o kernel entrega os resultados
O fluxo básico é:
- A aplicação preenche um SQE com a operação desejada (read, write, accept, etc.)
- Submete a fila para o kernel
- O kernel processa as operações de forma assíncrona
- Os resultados aparecem na CQE sem necessidade de syscall adicional
Essa arquitetura elimina a principal fonte de overhead do I/O tradicional: o custo de transição entre userspace e kernel. Com io_uring, centenas de operações podem ser submetidas e completadas com uma única syscall — ou até zero, usando o modo SQPOLL.
Por que io_uring é Superior ao epoll
O epoll foi o padrão-ouro do I/O assíncrono no Linux por quase duas décadas. Mas ele tem limitações fundamentais:
| Aspecto | epoll | io_uring |
|---|---|---|
| Modelo | Notificação de prontidão | Conclusão de operação |
| Syscalls por operação | 2+ (epoll_wait + read/write) | 0-1 (batch submit) |
| Operações suportadas | Apenas socket/fd readiness | Read, write, accept, connect, send, recv, fsync, fallocate, etc. |
| Buffer management | Manual por operação | Buffer pools compartilhados |
| Overhead por operação | ~1-2us | ~0.1-0.5us |
| Batching nativo | Não | Sim |
Com epoll, você descobre que um fd está “pronto” e então faz a syscall de read/write. Com io_uring, você diz “leia esses dados” e o kernel faz tudo — incluindo a transferência de dados — antes de notificá-lo. Menos context switches, menos cópias, mais throughput.
Por que Zig é Ideal para io_uring
A combinação Zig + io_uring é poderosa por vários motivos:
Controle de memória — io_uring exige buffers pré-alocados com endereços fixos. Zig oferece alocadores explícitos e controle total sobre layout de memória, perfeito para criar buffer pools alinhados e pinados.
Comptime — Muitos parâmetros de io_uring são constantes conhecidas em tempo de compilação (tamanho das filas, flags, opcodes). Com comptime, essas configurações são resolvidas sem custo em runtime.
Sem runtime oculto — Diferente de Go ou Rust (com tokio), Zig não tem runtime de async escondido. Você controla exatamente quando e como as operações são submetidas e processadas.
Bindings nativos — A stdlib de Zig inclui std.os.linux.io_uring, bindings diretos para a interface do kernel sem wrappers adicionais.
Configurando io_uring em Zig
Vamos começar com a configuração básica de uma instância io_uring:
const std = @import("std");
const linux = std.os.linux;
const IoUring = linux.IoUring;
pub fn main() !void {
// Criar instância com 256 entradas na submission queue
var ring = try IoUring.init(256, .{});
defer ring.deinit();
std.debug.print("io_uring inicializado com sucesso!\n", .{});
std.debug.print("SQ entries: {}\n", .{ring.sq.sqes.len});
std.debug.print("CQ entries: {}\n", .{ring.cq.cqes.len});
}
O parâmetro 256 define o tamanho da submission queue. O kernel pode arredondar para a próxima potência de 2. A completion queue geralmente tem o dobro do tamanho.
Flags de Inicialização
O segundo parâmetro aceita flags que controlam o comportamento da instância:
var ring = try IoUring.init(256, .{
.flags = linux.IORING_SETUP_SQPOLL | // Kernel poll mode
linux.IORING_SETUP_IOPOLL, // Hardware polling
.sq_thread_idle = 2000, // Timeout do thread SQPOLL em ms
});
Com IORING_SETUP_SQPOLL, o kernel cria um thread dedicado que monitora a submission queue continuamente. Isso elimina até a syscall de submit — a aplicação só precisa escrever no ring buffer e o kernel processa automaticamente. Ideal para cenários de latência ultra-baixa.
Operações Básicas: Read e Write
Vamos ver como realizar leitura e escrita assíncrona em arquivos:
const std = @import("std");
const linux = std.os.linux;
const IoUring = linux.IoUring;
pub fn main() !void {
var ring = try IoUring.init(64, .{});
defer ring.deinit();
// Abrir arquivo para leitura
const fd = try std.posix.open("/etc/hostname", .{ .ACCMODE = .RDONLY }, 0);
defer std.posix.close(fd);
// Preparar buffer de leitura
var buffer: [4096]u8 = undefined;
// Submeter operação de leitura
_ = ring.read(
0xDEAD, // user_data: identificador para correlacionar com CQE
fd,
.{ .buffer = &buffer },
0, // offset no arquivo
);
// Submeter para o kernel
_ = try ring.submit();
// Esperar resultado
const cqe = try ring.copy_cqe();
if (cqe.res >= 0) {
const bytes_lidos: usize = @intCast(cqe.res);
std.debug.print("Lidos {} bytes: {s}\n", .{
bytes_lidos,
buffer[0..bytes_lidos],
});
} else {
std.debug.print("Erro: {}\n", .{cqe.res});
}
}
Cada SQE contém um campo user_data de 64 bits que permite identificar qual operação gerou cada CQE. Isso é essencial quando você submete muitas operações simultâneas.
Servidor TCP com io_uring
Agora vamos ao exemplo prático mais completo — um servidor TCP echo de alta performance:
const std = @import("std");
const linux = std.os.linux;
const IoUring = linux.IoUring;
const posix = std.posix;
const net = std.net;
const BUFFER_SIZE = 4096;
const MAX_CONNECTIONS = 1024;
const RING_ENTRIES = 512;
const EventType = enum(u8) {
accept,
read,
write,
};
const UserData = packed struct {
fd: i32,
event_type: EventType,
};
fn encodeUserData(fd: i32, event_type: EventType) u64 {
const ud = UserData{ .fd = fd, .event_type = event_type };
return @bitCast(ud);
}
fn decodeUserData(data: u64) UserData {
return @bitCast(data);
}
pub fn main() !void {
// Inicializar io_uring
var ring = try IoUring.init(RING_ENTRIES, .{});
defer ring.deinit();
// Criar socket do servidor
const server_fd = try posix.socket(
posix.AF.INET,
posix.SOCK.STREAM | posix.SOCK.NONBLOCK,
0,
);
defer posix.close(server_fd);
// Configurar SO_REUSEADDR
try posix.setsockopt(server_fd, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
// Bind e listen
const addr = net.Address.initIp4(.{ 0, 0, 0, 0 }, 8080);
try posix.bind(server_fd, &addr.any, addr.getOsSockLen());
try posix.listen(server_fd, 128);
std.debug.print("Servidor TCP rodando na porta 8080\n", .{});
// Buffers por conexão
var buffers: [MAX_CONNECTIONS][BUFFER_SIZE]u8 = undefined;
// Submeter primeiro accept
_ = ring.accept(
encodeUserData(server_fd, .accept),
server_fd,
null,
null,
0,
);
_ = try ring.submit();
// Event loop principal
while (true) {
const cqe = try ring.copy_cqe();
const ud = decodeUserData(cqe.user_data);
switch (ud.event_type) {
.accept => {
if (cqe.res >= 0) {
const client_fd = cqe.res;
const idx: usize = @intCast(client_fd % MAX_CONNECTIONS);
// Submeter leitura para o novo cliente
_ = ring.read(
encodeUserData(client_fd, .read),
client_fd,
.{ .buffer = &buffers[idx] },
0,
);
}
// Sempre resubmeter accept para novos clientes
_ = ring.accept(
encodeUserData(server_fd, .accept),
server_fd,
null,
null,
0,
);
},
.read => {
if (cqe.res > 0) {
const bytes: usize = @intCast(cqe.res);
const idx: usize = @intCast(ud.fd % MAX_CONNECTIONS);
// Echo: escrever de volta o que foi lido
_ = ring.write(
encodeUserData(ud.fd, .write),
ud.fd,
buffers[idx][0..bytes],
0,
);
} else {
// Conexão fechada ou erro
posix.close(@intCast(ud.fd));
}
},
.write => {
if (cqe.res >= 0) {
const idx: usize = @intCast(ud.fd % MAX_CONNECTIONS);
// Submeter próxima leitura
_ = ring.read(
encodeUserData(ud.fd, .read),
ud.fd,
.{ .buffer = &buffers[idx] },
0,
);
} else {
posix.close(@intCast(ud.fd));
}
},
}
_ = try ring.submit();
}
}
Esse servidor usa io_uring para todas as operações de I/O — accept, read e write — sem nenhuma syscall bloqueante no hot path. Em benchmarks típicos, um servidor io_uring em Zig facilmente ultrapassa 100K requisições/segundo em hardware modesto.
Buffer Pools e Operações Multishot
Para ainda mais performance, io_uring suporta buffer pools registrados que eliminam a cópia de dados entre kernel e userspace:
// Registrar buffers fixos com o kernel
var fixed_buffers: [16][4096]u8 = undefined;
var iovecs: [16]posix.iovec = undefined;
for (&iovecs, &fixed_buffers) |*iov, *buf| {
iov.* = .{
.base = buf,
.len = buf.len,
};
}
try ring.register_buffers(&iovecs);
Com buffers registrados, o kernel mapeia a memória diretamente, evitando cópias em cada operação. Para servidores que processam milhões de requisições, essa otimização faz diferença mensurável.
Multishot Accept
A partir do kernel 5.19, o io_uring suporta multishot accept — uma única submissão que aceita múltiplas conexões sem resubmissão:
// Em vez de resubmeter accept a cada conexão:
_ = ring.accept_multishot(
encodeUserData(server_fd, .accept),
server_fd,
null,
null,
0,
);
Com multishot, o kernel continua preenchendo CQEs para cada nova conexão aceita até que você cancele a operação. Menos submissões, menos overhead, mais throughput.
Comparação com Abordagens Tradicionais
epoll + threads
A abordagem clássica de servidores Linux usa epoll com pool de threads. Funciona bem, mas cada operação exige pelo menos duas syscalls (epoll_wait + read/write) e o gerenciamento de threads adiciona complexidade e overhead de context switch.
async/await (Rust + tokio)
Rust com tokio oferece uma excelente experiência de desenvolvimento com async/await. O runtime Tokio também suporta io_uring via tokio-uring — veja o estado do ecossistema async em Rust no Rust Brasil. Porém, o runtime do tokio adiciona overhead e complexidade — scheduler de tasks, alocações internas, e um modelo de execução que pode ser difícil de raciocinar. Zig com io_uring é mais explícito e previsível.
Go, por sua vez, usa epoll/kqueue internamente no seu runtime de goroutines — para entender como Go lida com networking, confira o Golang Brasil.
O Futuro: std.Io.Evented em Zig
A equipe de Zig está trabalhando em uma abstração de I/O assíncrono na stdlib que usará io_uring no Linux, kqueue no macOS e IOCP no Windows. Quando pronta, essa API oferecerá a mesma performance do io_uring direto com portabilidade automática. Até lá, usar os bindings de io_uring diretamente é a melhor opção para performance máxima no Linux.
Considerações de Deploy
Se você está desenvolvendo um servidor io_uring em Zig, considere que io_uring é específico do Linux. Para deploy em servidores ARM (como AWS Graviton), a cross-compilation do Zig permite compilar de qualquer máquina para o alvo Linux ARM sem toolchains extras:
zig build -Dtarget=aarch64-linux-gnu -Doptimize=ReleaseFast
O binário resultante inclui tudo que precisa — sem dependências externas, sem runtime, pronto para deploy.
Requisitos de Kernel
Certifique-se de que o servidor de produção roda um kernel recente:
- 5.1+ — io_uring básico
- 5.6+ — fixed buffers, timeout, cancel
- 5.19+ — multishot accept
- 6.0+ — multishot receive, zero-copy send
Em distribuições modernas como Ubuntu 22.04+ ou Amazon Linux 2023, o kernel já suporta todas essas funcionalidades.
Performance: O que Esperar
Em benchmarks típicos comparando um echo server Zig + io_uring com alternativas:
- 2-3x mais throughput que epoll + threads para cargas I/O-bound
- Latência p99 50-70% menor que abordagens baseadas em async/await com runtime
- Uso de memória 5-10x menor que servidores Node.js ou Go para a mesma carga
Esses números variam conforme hardware, kernel e carga de trabalho. O ponto chave é que io_uring elimina overhead sistemático que outras abordagens carregam por design.
Para entender melhor como Zig se compara a C em cenários de alta performance, confira nosso artigo sobre Zig vs C moderno. E para ver padrões de concorrência e operações de I/O em Zig, acesse nossos cheatsheets. Também temos conteúdo detalhado sobre o ecossistema de networking em Zig.
Conclusão
A combinação de Zig com io_uring representa o estado da arte em I/O de alta performance no Linux. Zig oferece o controle de baixo nível necessário para usar io_uring de forma ótima, sem a complexidade de C e sem o overhead de runtimes de alto nível.
Se você está construindo servidores, proxies, bancos de dados ou qualquer software que precisa extrair o máximo de performance de I/O no Linux, Zig com io_uring é uma combinação que merece sua atenção séria.