TCP_NODELAY em Zig: Nagle, REUSEADDR e Opções de Socket

Para reduzir a latência de mensagens TCP pequenas em Zig, você pode habilitar TCP_NODELAY no socket com std.posix.setsockopt, desativando o algoritmo de Nagle naquela conexão. Mas essa opção não é um botão universal de “mais performance”: ela troca eficiência de pacotes por menor espera em certos padrões interativos. Para servidores reiniciáveis, SO_REUSEADDR resolve outro problema; para throughput, SO_SNDBUF e SO_RCVBUF tratam filas do kernel; para detectar peers desaparecidos, a ferramenta é keepalive. Cada opção responde a uma pergunta diferente.

A melhor política é configurar sockets em um módulo pequeno, documentar o motivo de cada valor e medir o efeito no tráfego real. Uma API de chat, um banco de dados, um jogo e um download de arquivo usam TCP, mas não têm o mesmo tamanho de mensagem, orçamento de latência ou comportamento sob pressão.

Este guia complementa o tutorial de sockets TCP e UDP em Zig, o artigo sobre TCP keepalive e conexões mortas e o padrão de graceful shutdown com SIGTERM. Como Zig ainda evolui antes do 1.0, confirme nomes, tipos e constantes de std.posix na versão fixada pelo projeto.

Resposta rápida

ObjetivoOpção ou técnicaCuidado principal
reduzir atraso de mensagens pequenasTCP_NODELAYpode aumentar a quantidade de pacotes
reiniciar listener sem esperar conexões antigasSO_REUSEADDRsemântica varia entre sistemas
múltiplos listeners na mesma portaSO_REUSEPORTdistribuição e segurança exigem projeto explícito
detectar peer desaparecidoSO_KEEPALIVE + opções TCPnão substitui deadline da aplicação
aumentar fila de envioSO_SNDBUFmais memória e possível bufferbloat
aumentar fila de recepçãoSO_RCVBUFpode esconder consumidor lento
limitar bloqueio de I/Otimeout/deadlineAPIs variam por plataforma
definir fechamento abortivoSO_LINGERconfiguração errada perde dados
reduzir syscalls e pacotesbatching na aplicaçãoacrescenta espera controlada
validar melhoriabenchmark + captura de pacotesmédia sozinha esconde caudas

O que é o algoritmo de Nagle

Uma aplicação pode chamar write várias vezes com poucos bytes:

write("O")
write("K")
write("\n")

Se cada chamada virasse imediatamente um segmento TCP, a rede carregaria muitos pacotes cujo cabeçalho é maior que o payload. O algoritmo de Nagle reduz esse desperdício. Em termos simplificados, quando já existem dados enviados e ainda não confirmados, o kernel pode aguardar para combinar novas escritas pequenas num segmento maior.

Isso costuma ser bom para eficiência. O problema aparece em protocolos do tipo pergunta-resposta com mensagens curtas. A aplicação envia alguns bytes, espera a resposta, o peer também envia poucos bytes e os mecanismos de agrupamento e confirmação podem introduzir uma espera perceptível. A interação entre Nagle, delayed ACK, buffering da aplicação e agenda do sistema operacional é mais importante que a opção isolada.

TCP_NODELAY diz ao kernel para não aplicar Nagle naquele socket. O nome parece significar “TCP sem qualquer atraso”, mas não é isso. A conexão ainda pode atrasar por:

  • DNS e handshake;
  • congestionamento ou perda de pacotes;
  • escalonamento de threads;
  • buffering na aplicação;
  • proxy ou load balancer;
  • fila cheia no kernel;
  • coleta ou processamento do peer;
  • retransmissão TCP;
  • algoritmo do próprio protocolo.

Portanto, ative TCP_NODELAY para um padrão de mensagens, não para compensar uma arquitetura que nunca foi medida.

Quando TCP_NODELAY faz sentido

Bons candidatos incluem:

  • terminal remoto ou shell interativo;
  • jogo com comandos pequenos e frequentes;
  • RPC com requests e respostas curtos;
  • protocolo de controle entre agente e servidor;
  • conexão com banco que alterna frames pequenos;
  • WebSocket interativo com baixa tolerância a atraso;
  • replicação em que cada confirmação libera a próxima etapa.

Casos em que o ganho tende a ser menor:

  • upload ou download de arquivos grandes;
  • streaming com chunks naturalmente grandes;
  • replicação em lotes;
  • envio de logs acumulados;
  • resposta HTTP já montada num buffer razoável;
  • workload limitado por CPU, disco ou banco de dados.

A pergunta útil não é “TCP_NODELAY é mais rápido?”, mas: o tempo de espera causado pelo agrupamento de segmentos é relevante para o percentil de latência que importa ao produto?

Configurando TCP_NODELAY em Zig

Opções específicas do socket normalmente passam por std.posix.setsockopt. Um helper conceitual para valores inteiros pode ser escrito assim:

const std = @import("std");

fn setIntSocketOption(
    fd: std.posix.socket_t,
    level: u32,
    option: u32,
    enabled: bool,
) !void {
    const value: c_int = if (enabled) 1 else 0;
    try std.posix.setsockopt(
        fd,
        level,
        option,
        std.mem.asBytes(&value),
    );
}

fn enableTcpNoDelay(stream: std.net.Stream) !void {
    try setIntSocketOption(
        stream.handle,
        std.posix.IPPROTO.TCP,
        std.posix.TCP.NODELAY,
        true,
    );
}

Dependendo da versão de Zig, as constantes podem estar organizadas de outra forma e o tipo aceito por setsockopt pode mudar. Procure na stdlib instalada em vez de copiar cegamente um snippet:

zig env
rg "pub fn setsockopt|NODELAY|REUSEADDR|SNDBUF|RCVBUF" /caminho/da/lib/std

Mantenha a variação de plataforma confinada. O restante da aplicação deveria chamar uma função como configureInteractiveTcp(stream) e não repetir níveis, constantes e conversões em cada handler.

Aplicando no cliente e no servidor

No cliente, configure logo depois de conectar:

const address = try std.net.Address.parseIp("192.0.2.10", 9000);
const stream = try std.net.tcpConnectToAddress(address);
errdefer stream.close();

try enableTcpNoDelay(stream);

try stream.writeAll("PING\n");

No servidor, configure a conexão aceita, não apenas o listener:

while (true) {
    const connection = try server.accept();

    enableTcpNoDelay(connection.stream) catch |err| {
        std.log.err("tcp_nodelay_failed error={}", .{err});
        connection.stream.close();
        continue;
    };

    // Entregue o stream configurado ao handler ou ao pool.
}

Algumas opções pertencem ao listener; outras, a cada conexão. TCP_NODELAY é uma propriedade do socket TCP conectado. Se você configurar apenas o socket de escuta e presumir herança sem validar a plataforma, pode colocar em produção uma política diferente da esperada.

Uma fábrica de conexões é o lugar mais seguro para centralizar isso:

const TcpProfile = enum {
    interactive,
    bulk_transfer,
    internal_rpc,
};

fn configureTcp(stream: std.net.Stream, profile: TcpProfile) !void {
    switch (profile) {
        .interactive, .internal_rpc => try enableTcpNoDelay(stream),
        .bulk_transfer => {},
    }
}

O enum torna a intenção revisável. Um número solto em main.zig não explica por que a opção existe.

TCP_NODELAY não substitui batching

Desabilitar Nagle não transforma três write minúsculos numa boa estratégia. Se os bytes já estão disponíveis juntos, envie-os juntos:

var buffer: [128]u8 = undefined;
const message = try std.fmt.bufPrint(
    &buffer,
    "{s} {d}\n",
    .{ "STATUS", request_id },
);
try stream.writeAll(message);

Isso reduz syscalls, simplifica captura de pacotes e mantém a mensagem coerente. TCP_NODELAY é útil quando você não quer que o kernel espere por dados futuros; batching é útil quando a aplicação já conhece os dados agora.

Para protocolos com alto volume, use uma fila por conexão e um limite pequeno de batching, por quantidade de bytes ou por janela de microssegundos. O limite é essencial: lote sem prazo vira latência; envio imediato sem lote vira tempestade de syscalls.

SO_REUSEADDR: reinício previsível do servidor

Depois que uma conexão TCP fecha, estados como TIME_WAIT continuam existindo por algum tempo. Isso protege a rede contra segmentos atrasados de uma conexão antiga. Durante deploys rápidos, um novo processo pode tentar abrir o mesmo endereço e receber AddressInUse.

SO_REUSEADDR costuma permitir que o listener seja recriado sem esperar todo o estado antigo desaparecer. Configure antes do bind/listen. O desenho conceitual em API POSIX é:

const fd = try std.posix.socket(
    std.posix.AF.INET,
    std.posix.SOCK.STREAM,
    std.posix.IPPROTO.TCP,
);
errdefer std.posix.close(fd);

try setIntSocketOption(
    fd,
    std.posix.SOL.SOCKET,
    std.posix.SO.REUSEADDR,
    true,
);

// bind(fd, ...), listen(fd, ...)

As abstrações de std.net.Address.listen podem oferecer uma opção equivalente na struct de configuração da sua versão. Prefira a API de alto nível quando ela expressar exatamente a política necessária; desça para std.posix quando precisar controlar a ordem ou uma opção não exposta.

SO_REUSEADDR não significa “qualquer processo pode compartilhar esta porta”. O comportamento exato difere entre Linux, BSD, macOS e Windows. Faça teste de integração no target de produção, inclusive com um listener antigo, conexões em TIME_WAIT e tentativa de segundo bind.

SO_REUSEPORT não é sinônimo de REUSEADDR

SO_REUSEPORT permite, em plataformas que suportam a opção, mais de um socket vinculado ao mesmo endereço e porta sob regras específicas. O kernel pode distribuir conexões entre listeners. Isso pode ser útil para:

  • um listener por thread ou processo;
  • rolling restart com sobreposição controlada;
  • redução de contenção em um accept loop central.

Mas ele também muda o modelo operacional. Você precisa saber:

  • como o kernel distribui conexões;
  • se todos os processos têm a mesma configuração e versão;
  • como readiness remove uma réplica;
  • o que acontece durante deploy parcial;
  • quais usuários podem abrir socket compatível;
  • se métricas agregam todos os listeners.

Não ative SO_REUSEPORT só porque o nome parece uma versão melhor de SO_REUSEADDR. Em muitos serviços, um listener e um pool de workers são mais simples e suficientemente rápidos.

Buffers de envio e recepção

SO_SNDBUF controla a capacidade associada à fila de envio do socket. SO_RCVBUF faz o mesmo para a recepção. Os sistemas modernos aplicam autotuning, limites globais e, em alguns casos, multiplicam internamente o valor solicitado para metadados.

Buffers maiores podem ajudar quando o produto banda-latência é grande: muita largura de banda e RTT alto exigem dados suficientes em voo. Porém, aumentar buffers indiscriminadamente cria custos:

  • mais memória por conexão;
  • filas mais longas antes de a aplicação perceber saturação;
  • maior latência de cauda;
  • pressão de memória com milhares de clientes;
  • sensação falsa de que backpressure foi resolvido.

Imagine 20 mil conexões com buffers efetivos de centenas de kilobytes. Mesmo que poucas encham ao mesmo tempo, a capacidade potencial importa. Meça RSS, memória de socket no host e quantidade de conexões, não apenas throughput.

No Linux, ss ajuda a observar filas:

ss -tinm '( sport = :9000 or dport = :9000 )'

Para parâmetros globais:

sysctl net.core.rmem_max
sysctl net.core.wmem_max
sysctl net.ipv4.tcp_rmem
sysctl net.ipv4.tcp_wmem

Não altere sysctl como primeira resposta. Descubra primeiro se o socket realmente está limitado, se há perda, se o consumidor é lento ou se o protocolo produz mensagens pequenas demais.

Timeouts, keepalive e deadlines

Opções de performance não substituem limites de tempo. Um perfil TCP de produção normalmente combina:

  1. connect timeout para DNS e handshake;
  2. deadline da operação para o request completo;
  3. read/write timeout para I/O bloqueado;
  4. idle timeout para sessão sem atividade útil;
  5. TCP keepalive para peer que desapareceu silenciosamente;
  6. TCP_NODELAY, quando mensagens pequenas são sensíveis à latência;
  7. backpressure para consumidor lento;
  8. retry com backoff, apenas quando a operação permite.

O guia de timeout, retry e circuit breaker em Zig detalha a parte de resiliência. O ponto central é não confundir “pacote saiu sem esperar Nagle” com “operação possui prazo”.

SO_LINGER: use com muito cuidado

SO_LINGER controla parte do comportamento de close quando ainda existem dados pendentes. Uma configuração abortiva pode fazer o fechamento gerar RST, descartando dados não enviados. Isso é útil em casos raros e perigoso como padrão.

Erros comuns:

  • ativar linger para “fechar mais rápido” e perder a resposta final;
  • bloquear o encerramento esperando dados além do prazo do processo;
  • confundir close bem-sucedido localmente com entrega confirmada pela aplicação remota;
  • usar RST para esconder protocolo de shutdown mal definido.

Para a maioria dos servidores, prefira um encerramento de protocolo explícito, pare de aceitar trabalho novo, drene com deadline e então feche o socket. Integre esse fluxo ao readiness/liveness do serviço Zig.

Um módulo de configuração por perfil

Uma aplicação madura pode representar a política sem expor detalhes POSIX ao domínio:

const TcpOptions = struct {
    no_delay: bool = false,
    keepalive: bool = true,
    send_buffer: ?u32 = null,
    receive_buffer: ?u32 = null,
};

fn interactiveProfile() TcpOptions {
    return .{
        .no_delay = true,
        .keepalive = true,
    };
}

fn bulkProfile() TcpOptions {
    return .{
        .no_delay = false,
        .keepalive = true,
    };
}

A função que aplica essas opções deve:

  • ser condicionada por plataforma;
  • retornar erros, não ignorá-los;
  • registrar a política uma vez na inicialização;
  • permitir teste unitário da seleção de perfil;
  • permitir teste de integração do socket real;
  • evitar valores globais mutáveis;
  • documentar unidades e defaults.

Se uma opção é desejável, mas não obrigatória em todos os targets, diferencie “não suportada” de “falhou inesperadamente”. Uma CLI multiplataforma pode continuar sem uma otimização. Um serviço Linux de baixa latência talvez deva falhar no startup se a política essencial não foi aplicada.

Como medir sem se enganar

Faça benchmark com duas configurações idênticas, mudando uma opção por vez. Colete pelo menos:

  • latência p50, p95, p99 e máxima;
  • requests ou mensagens por segundo;
  • uso de CPU do cliente e servidor;
  • syscalls por operação;
  • quantidade e tamanho dos pacotes;
  • retransmissões;
  • bytes em fila;
  • conexões abertas e fechadas;
  • memória por processo e memória de sockets.

Um teste simples de loopback pode ocultar o problema porque o RTT é quase zero e não há perda. Use também uma rede controlada com latência e jitter representativos. Em Linux, namespaces e tc netem ajudam:

sudo tc qdisc add dev eth0 root netem delay 30ms 5ms loss 0.1%
# execute o teste
sudo tc qdisc del dev eth0 root

Capture pacotes:

sudo tcpdump -i any -nn 'tcp port 9000' -w /tmp/zig-tcp-options.pcap

No Wireshark, compare quantidade de segmentos pequenos, intervalos entre request e resposta, retransmissões e encerramentos com RST. Para syscalls:

strace -f -c ./zig-out/bin/servidor

Se TCP_NODELAY reduz p99, mas dobra pacotes e CPU sem melhorar o objetivo do produto, talvez batching explícito seja a solução melhor.

Armadilhas frequentes

Ativar tudo por padrão

Copiar uma lista de opções de um servidor famoso não reproduz o workload dele. Cada flag precisa de hipótese e métrica.

Otimizar a média

Uma média de 3 ms pode esconder p99 de 200 ms. Protocolos interativos vivem na cauda.

Confundir writeAll com entrega remota

writeAll entrega os bytes ao caminho local de I/O ou retorna erro. Não prova que a aplicação remota processou a mensagem. Se você precisa dessa garantia, crie ACK no protocolo.

Usar buffers maiores para esconder fila infinita

Fila cheia deve produzir backpressure observável: pausar leitura, rejeitar, descartar conforme política ou desconectar cliente lento. Memória não é estratégia de controle de carga.

Ignorar proxies

Se existe Nginx ou load balancer, parte da conexão termina nele. A opção aplicada entre Zig e o proxy não altera diretamente o socket entre proxy e cliente. Entenda cada perna da rede. O guia de Zig atrás de Nginx mostra esse contrato.

Não testar o restart

SO_REUSEADDR só prova valor quando você mata, reinicia e observa estados reais. Inclua isso no teste de deploy.

Checklist de produção

Antes de publicar um serviço TCP em Zig:

  • o perfil da conexão está documentado como interativo, RPC ou bulk;
  • TCP_NODELAY tem hipótese mensurável;
  • mensagens disponíveis juntas são enviadas em lote;
  • SO_REUSEADDR é configurado antes do bind, quando necessário;
  • SO_REUSEPORT não foi ativado sem desenho multiprocesso;
  • buffers usam defaults ou valores justificados por medição;
  • há deadline da aplicação além de opções TCP;
  • keepalive e heartbeat têm papéis separados;
  • consumidor lento encontra limite e backpressure;
  • shutdown drena conexões com prazo;
  • benchmark mede percentis, CPU, pacotes e retransmissões;
  • teste roda no mesmo sistema operacional de produção;
  • falha ao aplicar opção essencial não é ignorada;
  • métricas identificam o perfil e a versão do binário.

Perguntas frequentes

Quando devo ativar TCP_NODELAY em Zig?

Quando mensagens pequenas e interativas sofrem com espera de agrupamento: RPC curto, terminal, jogo, controle ou WebSocket sensível à latência. Meça antes e depois; transferências grandes raramente ganham pelo mesmo motivo.

O algoritmo de Nagle sempre aumenta a latência?

Não. Ele pode não ter efeito perceptível quando a aplicação envia buffers grandes, quando não há dados pendentes ou quando outros custos dominam. O atraso depende do padrão de escrita e confirmação.

SO_REUSEADDR permite dois servidores na mesma porta?

Não como regra geral. Ele é usado principalmente para facilitar rebinding após restart. Compartilhamento intencional costuma envolver SO_REUSEPORT, cuja semântica varia e exige projeto específico.

Buffers maiores deixam TCP mais rápido?

Somente quando o tamanho atual limita uma conexão que precisa manter mais dados em voo. Em outros casos, buffers maiores apenas consomem memória e alongam filas.

TCP_NODELAY substitui flush?

Não. Flush atua no buffer da biblioteca ou da aplicação. TCP_NODELAY atua no agrupamento TCP feito pelo kernel. Você pode precisar de ambos, de um ou de nenhum.

Como confirmar que a opção foi aplicada?

Além de tratar o retorno de setsockopt, consulte o socket com getsockopt quando a API da versão permitir e faça um teste de integração. Depois valide o efeito por captura de pacotes, não apenas por log de startup.

Próximos passos

Se você ainda está montando o protocolo, comece por networking com TCP e UDP em Zig. Para conexões longas, adicione TCP keepalive. Se cliente e servidor rodam no mesmo host, compare com Unix domain sockets. Em serviços web, alinhe as opções com Nginx, timeouts e load balancing e finalize o ciclo com observabilidade de logs e métricas Prometheus.

A regra prática é simples: use TCP_NODELAY para latência pequena e medida, SO_REUSEADDR para ciclo de vida do listener, buffers para uma limitação demonstrada e deadlines para controlar o tempo da operação. Opções de socket são ferramentas cirúrgicas. Em Zig, vale preservar essa clareza no código em vez de transformar o startup numa coleção de flags sem contexto.

Continue aprendendo Zig

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