Zig e io_uring: I/O Assíncrono de Alta Performance no Linux

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 é:

  1. A aplicação preenche um SQE com a operação desejada (read, write, accept, etc.)
  2. Submete a fila para o kernel
  3. O kernel processa as operações de forma assíncrona
  4. 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:

Aspectoepollio_uring
ModeloNotificação de prontidãoConclusão de operação
Syscalls por operação2+ (epoll_wait + read/write)0-1 (batch submit)
Operações suportadasApenas socket/fd readinessRead, write, accept, connect, send, recv, fsync, fallocate, etc.
Buffer managementManual por operaçãoBuffer pools compartilhados
Overhead por operação~1-2us~0.1-0.5us
Batching nativoNãoSim

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.

Continue aprendendo Zig

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