Zero-Copy em Zig: sendfile para Arquivos e Sockets

Para enviar um arquivo por um socket em Zig com menos cópias, use sendfile quando o target e o tipo de conexão permitirem, mas trate a operação como streaming: mantenha um offset, aceite transferências parciais, respeite backpressure e tenha um fallback com read + write. O benefício vem de evitar que a aplicação copie cada bloco do kernel para um buffer no user space e depois de volta para o kernel.

Esse padrão é útil em servidores de arquivos, proxies internos, caches, distribuição de artefatos e endpoints que entregam objetos grandes sem transformá-los. Ele não é um botão universal de performance. TLS, compressão, ranges, arquivos pequenos e diferenças entre Linux, BSD e macOS mudam a decisão. O objetivo deste guia é mostrar o modelo operacional correto, não apenas uma chamada isolada de syscall.

Se você ainda está montando a camada de rede, leia primeiro o guia de sockets TCP e UDP em Zig. Para arquivos grandes acessados aleatoriamente, compare também com mmap em Zig.

Resposta rápida

CenárioMelhor ponto de partida
Arquivo grande para socket TCP sem TLSsendfile com loop e offset
HTTPS com TLS no user spacebuffer reutilizável + escrita pela biblioteca TLS
Conteúdo comprimido ou transformado em tempo realstreaming tradicional
Arquivo pequenoresposta normal; meça antes de otimizar
Socket não bloqueantesendfile + readiness (poll/event loop)
Target multiplataformainterface comum + implementação por plataforma
Resposta com HTTP Rangecalcule offset e quantidade antes do loop
Benchmarkcompare CPU, throughput, p95 e bytes transferidos

O que “zero-copy” significa na prática

O caminho tradicional para enviar um arquivo é conceitualmente simples:

  1. a aplicação chama read;
  2. o kernel coloca os bytes em um buffer visível ao processo;
  3. a aplicação chama write no socket;
  4. o kernel copia ou referencia os dados para a pilha de rede.

Em pseudocódigo Zig:

var buffer: [64 * 1024]u8 = undefined;
while (true) {
    const n = try file.read(&buffer);
    if (n == 0) break;
    try writeAllToSocket(socket, buffer[0..n]);
}

Esse código pode ser perfeitamente bom. É portátil, fácil de instrumentar e permite transformar, comprimir, criptografar ou calcular um hash sobre os bytes. O custo é que cada bloco atravessa o buffer da aplicação.

Com sendfile, você fornece ao kernel um descritor de entrada, um descritor de saída, um offset e uma quantidade. Em plataformas que suportam esse caminho, o kernel consegue aproveitar o page cache e alimentar a pilha de rede sem entregar o conteúdo inteiro a um buffer controlado pela aplicação.

“Zero-copy” não significa literalmente zero movimentação física em qualquer hardware. Drivers, checksums, DMA, fragmentação e a própria implementação do sistema operacional ainda participam. O termo descreve a redução de cópias e transições evitáveis no caminho da aplicação.

A arquitetura recomendada em Zig

Não espalhe chamadas de sendfile pelo handler HTTP. Crie uma operação de domínio pequena, com contrato explícito:

const TransferOptions = struct {
    offset: u64 = 0,
    count: u64,
};

const TransferResult = struct {
    bytes_sent: u64,
    completed: bool,
};

fn transferFile(
    file: *std.fs.File,
    socket: Socket,
    options: TransferOptions,
) !TransferResult {
    // Seleciona sendfile ou fallback conforme plataforma e conexão.
}

Essa separação resolve quatro problemas:

  • a API pública não depende da assinatura de uma syscall específica;
  • Linux pode usar um caminho otimizado sem contaminar o resto do projeto;
  • TLS e plataformas sem suporte usam o fallback;
  • testes conseguem validar offset, limite e falhas sem subir um servidor completo.

Como o Zig ainda evolui antes da versão 1.0, nomes de funções e conversões de handles podem mudar. Fixe a toolchain do projeto e confira a versão da stdlib usada no CI. O contrato acima deve permanecer seu; o adaptador para o sistema operacional é que acompanha a versão.

Loop correto: transferência parcial é normal

O erro mais comum é assumir que uma chamada transfere tudo:

// Conceitualmente incorreto: o retorno pode ser menor que count.
_ = try sendfile(socket_fd, file_fd, offset, count);

A lógica robusta mantém o progresso:

fn sendFileRegion(
    out_fd: FileDescriptor,
    in_fd: FileDescriptor,
    initial_offset: u64,
    count: u64,
) !u64 {
    var offset = initial_offset;
    var remaining = count;

    while (remaining > 0) {
        const sent = sendfilePlatform(out_fd, in_fd, offset, remaining) catch |err| {
            switch (err) {
                error.WouldBlock => {
                    try waitUntilWritable(out_fd);
                    continue;
                },
                error.Interrupted => continue,
                else => return err,
            }
        };

        if (sent == 0) break;
        offset += sent;
        remaining -= sent;
    }

    return count - remaining;
}

O snippet é deliberadamente uma camada de adaptação: FileDescriptor, sendfilePlatform e waitUntilWritable devem mapear para as APIs da versão e do target adotados pelo projeto.

Três regras são obrigatórias:

  1. Retorno curto não é erro. Continue a partir do novo offset.
  2. EINTR não reinicia seu estado. Repita preservando o progresso.
  3. EAGAIN não autoriza busy loop. Espere o socket ficar gravável via poll, epoll, kqueue ou event loop.

Em um servidor bloqueante simples, a própria chamada pode esperar espaço no socket. Em um servidor com milhares de conexões, bloquear uma thread por cliente pode destruir a escalabilidade. O modelo de concorrência precisa combinar com a operação.

Como obter offset e tamanho com segurança

Antes de transferir, descubra o tamanho do arquivo e valide o intervalo solicitado. Para uma resposta completa:

offset = 0
count = tamanho_do_arquivo

Para um range HTTP inclusivo bytes=1000-1999:

offset = 1000
count = 1999 - 1000 + 1 = 1000

Valide antes de fazer qualquer cast:

  • offset não pode ser maior que o tamanho do arquivo;
  • o fim do range não pode ser menor que o início;
  • a soma deve ser checada contra overflow;
  • count deve caber nos tipos aceitos pela plataforma;
  • o arquivo pode mudar entre o stat e a transferência.

Se os arquivos são mutáveis, decida a semântica. Uma opção é abrir o arquivo e usar os metadados daquele handle, evitando resolver o caminho novamente. Para artefatos imutáveis e nomes por hash, o problema fica menor.

Nunca concatene um caminho recebido do usuário diretamente ao diretório de arquivos. Resolva a rota para um identificador interno ou use operações relativas a um diretório já aberto. Isso reduz path traversal e condições de corrida com links simbólicos.

Fallback portátil com buffer reutilizável

O fallback não é código de segunda classe. Ele cobre TLS, transformações e targets sem sendfile, portanto precisa de testes e métricas.

fn copyRange(
    allocator: std.mem.Allocator,
    file: *std.fs.File,
    writer: anytype,
    offset: u64,
    count: u64,
) !u64 {
    try file.seekTo(offset);

    const buffer = try allocator.alloc(u8, 64 * 1024);
    defer allocator.free(buffer);

    var remaining = count;
    var total: u64 = 0;

    while (remaining > 0) {
        const wanted: usize = @intCast(@min(remaining, buffer.len));
        const n = try file.read(buffer[0..wanted]);
        if (n == 0) break;

        try writer.writeAll(buffer[0..n]);
        remaining -= n;
        total += n;
    }

    return total;
}

Em um servidor, evite alocar um novo buffer para cada bloco. Aloque uma vez por transferência, reutilize buffers de um pool limitado ou associe um buffer a cada worker. Um pool sem limite apenas troca pressão no heap por retenção excessiva de memória.

O tamanho ideal não é universal. Blocos de 16 KiB, 64 KiB e 256 KiB podem se comportar de forma diferente conforme armazenamento, rede e TLS. Meça com arquivos representativos.

sendfile e HTTP

Em HTTP/1.1, envie os headers antes do corpo:

HTTP/1.1 200 OK\r\n
Content-Length: 7340032\r\n
Content-Type: application/octet-stream\r\n
ETag: "..."\r\n
\r\n

Depois, transfira exatamente Content-Length bytes. Se a conexão cair após parte do corpo, não tente corrigir o status HTTP: os headers já foram enviados. Registre quantos bytes saíram e encerre a conexão.

Para 206 Partial Content, inclua Content-Range e use o range calculado. Para requisição HEAD, envie os mesmos headers que enviaria no GET, mas não transfira o corpo.

Evite sendfile quando o corpo exige:

  • compressão gzip ou Brotli em tempo real;
  • template ou substituição de conteúdo;
  • criptografia feita no processo;
  • checksum calculado naquele momento;
  • multiplexação por um protocolo que precisa enquadrar os bytes no user space.

Uma alternativa eficiente é pré-comprimir arquivos estáticos e selecionar a representação pronta conforme Accept-Encoding. Assim, o servidor pode enviar o .br ou .gz já existente sem transformar o conteúdo durante a requisição.

O limite do TLS

No HTTPS tradicional, OpenSSL, BoringSSL, mbedTLS ou outra biblioteca no user space precisa receber o plaintext para produzir registros criptografados. Um sendfile direto do arquivo para o socket pula justamente essa etapa, então não serve para o caminho comum.

Existem tecnologias como kernel TLS em alguns ambientes, mas elas exigem suporte coordenado entre kernel, biblioteca, cifras e operação. Não trate isso como default portátil.

Arquiteturas práticas:

  • Nginx/Caddy termina TLS e serve arquivos: seu processo Zig só cuida de autorização ou metadados;
  • proxy termina TLS, Zig envia por conexão interna: avalie se o proxy pode buscar o arquivo por localização interna;
  • Zig termina TLS: use o writer da biblioteca TLS e o fallback com buffer;
  • rede interna sem TLS: sendfile pode ser adequado se o modelo de ameaça permitir.

Para desenho de borda, veja Zig por trás de Nginx e o guia de TLS, HTTPS e mTLS em Zig.

Backpressure, cancelamento e timeouts

Um cliente lento não deve manter recursos para sempre. Defina:

  • timeout para começar a escrever;
  • timeout de inatividade entre progressos;
  • deadline total opcional para a resposta;
  • limite de transferências simultâneas;
  • política de cancelamento quando o cliente desconecta.

O timeout mais útil não é “o download deve terminar em 10 segundos”, porque um arquivo grande em rede móvel pode ser legítimo. Prefira um prazo de progresso: se nenhum byte sair durante uma janela, cancele.

Em I/O não bloqueante, registre interesse em escrita somente enquanto houver dados pendentes. Manter todo socket sempre inscrito como gravável causa wakeups desnecessários, pois sockets saudáveis costumam estar prontos para escrita a maior parte do tempo.

Portabilidade: Linux, BSD e macOS não são iguais

A ideia de sendfile existe em várias plataformas, mas assinaturas e semânticas variam. A posição dos descritores, a forma de passar offset, o retorno de bytes e o suporte a headers/trailers não são idênticos.

Estruture o código por capacidade:

switch (builtin.os.tag) {
    .linux => return transferLinux(...),
    .freebsd, .macos => return transferBsdLike(...),
    else => return transferBuffered(...),
}

Não force a mesma syscall em todos os targets com condicionais espalhadas. Mantenha uma interface comum e implementações pequenas por plataforma. Seu pipeline deve compilar cada branch relevante; veja como montar isso no guia de cross-compilation em Zig.

Também diferencie “compila” de “funciona”. Testes de integração precisam criar um arquivo temporário, abrir um par de sockets, transferir um range e comparar os bytes recebidos.

Como testar sem depender de benchmark manual

Crie uma matriz mínima:

  1. arquivo vazio;
  2. arquivo menor que um page size;
  3. arquivo maior que o buffer do fallback;
  4. range no começo, meio e fim;
  5. cliente que lê devagar;
  6. desconexão no meio;
  7. socket não bloqueante que retorna WouldBlock;
  8. arquivo truncado durante a transferência;
  9. count maior que os bytes restantes;
  10. fallback forçado mesmo em Linux.

Valide conteúdo e contagem, não apenas “não retornou erro”. Para ranges, compare o resultado com arquivo[offset .. offset + count].

Testes unitários devem cobrir o cálculo do intervalo e os adapters de erro. Testes de integração cobrem descritores reais. Para organizar a suíte, use o guia completo de testes em Zig.

Benchmark: o que medir

Um benchmark útil compara pelo menos dois caminhos:

  • buffer + read/write;
  • sendfile.

Use arquivos de tamanhos variados, conexões persistentes e concorrências próximas da produção. Registre:

  • throughput total em MiB/s;
  • uso de CPU do processo e do sistema;
  • latência p50, p95 e p99;
  • trocas de contexto;
  • memória residente;
  • quantidade de syscalls;
  • erros e desconexões.

Rode com cache quente e frio separadamente. Se o arquivo já está no page cache, você mede principalmente o caminho CPU/rede. Se está frio, o armazenamento domina e pode esconder a diferença.

Não conclua pelo throughput de uma única conexão em localhost. A vantagem pode aparecer sob concorrência, quando a redução de CPU libera capacidade para mais clientes. Também pode desaparecer quando TLS ou o limite da placa de rede domina.

O complemento natural é o tutorial de profiling com perf e flamegraph em Zig.

Checklist de produção

Antes de ativar zero-copy, confirme:

  • o caminho atende somente arquivos autorizados;
  • offset e count são validados contra overflow e tamanho;
  • retornos parciais atualizam o progresso;
  • Interrupted repete sem perder estado;
  • WouldBlock espera readiness sem busy loop;
  • cliente lento tem timeout de progresso;
  • o número de arquivos e sockets abertos é limitado;
  • o fallback com buffer está coberto por testes;
  • TLS e compressão escolhem o caminho correto;
  • logs registram bytes planejados, enviados e motivo do término;
  • testes de range incluem limites e arquivo vazio;
  • benchmarks comparam CPU e latência, não só throughput;
  • cada target suportado compila e roda teste de integração.

Conclusão

sendfile é uma otimização valiosa quando um serviço Zig precisa mover bytes imutáveis de um arquivo para um socket sem inspecioná-los. O desenho correto, porém, começa fora da syscall: interface por plataforma, offset explícito, loop para transferências parciais, integração com backpressure, timeout de progresso e fallback portátil.

Use zero-copy para o caso em que ele realmente reduz trabalho. Para TLS no user space, conteúdo dinâmico ou transformação em trânsito, um buffer reutilizável e uma boa política de streaming tendem a ser mais simples e corretos. Em ambos os caminhos, a vantagem do Zig é a mesma: ownership, erros, descritores e custos ficam visíveis no código — e, portanto, podem ser medidos, testados e operados com previsibilidade.

Continue aprendendo Zig

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