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ário | Melhor ponto de partida |
|---|---|
| Arquivo grande para socket TCP sem TLS | sendfile com loop e offset |
| HTTPS com TLS no user space | buffer reutilizável + escrita pela biblioteca TLS |
| Conteúdo comprimido ou transformado em tempo real | streaming tradicional |
| Arquivo pequeno | resposta normal; meça antes de otimizar |
| Socket não bloqueante | sendfile + readiness (poll/event loop) |
| Target multiplataforma | interface comum + implementação por plataforma |
| Resposta com HTTP Range | calcule offset e quantidade antes do loop |
| Benchmark | compare CPU, throughput, p95 e bytes transferidos |
O que “zero-copy” significa na prática
O caminho tradicional para enviar um arquivo é conceitualmente simples:
- a aplicação chama
read; - o kernel coloca os bytes em um buffer visível ao processo;
- a aplicação chama
writeno socket; - 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:
- Retorno curto não é erro. Continue a partir do novo offset.
EINTRnão reinicia seu estado. Repita preservando o progresso.EAGAINnão autoriza busy loop. Espere o socket ficar gravável viapoll,epoll,kqueueou 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:
offsetnã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;
countdeve caber nos tipos aceitos pela plataforma;- o arquivo pode mudar entre o
state 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:
sendfilepode 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:
- arquivo vazio;
- arquivo menor que um page size;
- arquivo maior que o buffer do fallback;
- range no começo, meio e fim;
- cliente que lê devagar;
- desconexão no meio;
- socket não bloqueante que retorna
WouldBlock; - arquivo truncado durante a transferência;
countmaior que os bytes restantes;- 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;
-
Interruptedrepete sem perder estado; -
WouldBlockespera 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.