2 pontos por GN⁺ 2024-05-10 | 1 comentários | Compartilhar no WhatsApp
  • Problemas de latência em sistemas distribuídos continuam sendo resolvidos apenas com a ativação de TCP_NODELAY, o que indica que o comportamento padrão do TCP pode não combinar com cargas de trabalho modernas
  • O algoritmo de Nagle foi criado na RFC896, em 1984, para reduzir o custo de cabeçalhos de pequenos pacotes TCP, impedindo o envio de um novo segmento antes de receber um ACK
  • Quando usado junto com delayed ACK, um lado espera pelo ACK e o outro espera por dados de resposta ou por um temporizador, o que prejudica aplicações em pipeline sensíveis à latência
  • Mesmo que o RTT dentro de um datacenter seja de cerca de 500 μs, servidores modernos conseguem fazer muito nesse intervalo, então o benefício de atrasar o envio em um RTT não é claro
  • Em sistemas distribuídos modernos, TLS, encoding, serialização e o tamanho das mensagens de aplicação reduziram o problema de pacotes de um único byte; em ambientes sensíveis à latência, desativar o algoritmo de Nagle é mais natural

A configuração a verificar primeiro ao depurar latência

  • Quando surge um problema de latência em sistemas distribuídos, a primeira coisa a verificar costuma ser se TCP_NODELAY está ativado
  • Muitos desenvolvedores de sistemas distribuídos já perderam tempo com problemas que se resolviam com essa simples opção de socket
  • Essa repetição mostra que o comportamento padrão do TCP talvez não seja adequado aos sistemas distribuídos atuais, ou que o próprio algoritmo de Nagle pode estar ultrapassado

O problema que o algoritmo de Nagle tentava resolver

  • RFC896 é um documento de 1984 que tratava do problema dos pacotes pequenos
  • Na época, ao enviar via TCP dados que chegavam caractere por caractere, como entrada de teclado, havia a ineficiência de anexar um cabeçalho de 40 bytes a cada 1 byte de dados
    • Isso gerava 4000% de overhead, com 40 bytes de cabeçalho para cada 1 byte de dados úteis
    • Mesmo que fosse tolerável sob carga leve, era ruim para a vazão da rede
  • O objetivo do algoritmo de Nagle era amortizar melhor o custo dos cabeçalhos TCP e aumentar a vazão
    • Pacotes pequenos surgiam principalmente em aplicações de interação humana, como shells, e em implementações que entregavam dados ao kernel aos poucos por meio de várias chamadas write
  • O comportamento central é não enviar imediatamente novos dados de transmissão como um segmento TCP separado quando os dados enviados anteriormente ainda não receberam ACK
  • O algoritmo de Nagle é frequentemente explicado junto com um temporizador, mas a própria RFC896 não usa um temporizador separado além do tempo de ida e volta (RTT) da rede

A latência que surge quando combinado com delayed ACK

  • Delayed ACK é uma técnica que não envia imediatamente a confirmação de recebimento de um pacote, esperando até que haja dados a devolver ou que um temporizador expire
  • RFC813 é um documento inicial de 1982 que propôs atrasar ACKs e trata da possibilidade de o receptor, em certas situações, adiar o envio do ACK e configurar um temporizador para enviá-lo depois
  • RFC1122 formaliza melhor o delayed ACK
  • As duas funcionalidades são razoáveis separadamente, mas podem criar latência quando usadas juntas
    • O algoritmo de Nagle espera pelo recebimento do ACK antes de enviar mais dados
    • Delayed ACK adia o envio do ACK até que dados de resposta estejam prontos ou o temporizador expire
    • Isso ajuda a preencher pacotes, mas não é bom para aplicações em pipeline sensíveis à latência
  • Um comentário de John Nagle no Hacker News também vê o problema não como a prevenção de tinygrams, mas como a combinação de atraso de ACKs com um temporizador fixo
  • É um caso em que duas funcionalidades de protocolo razoáveis se combinam para produzir um comportamento indesejado, e esse tipo de interação dificulta o design de protocolos

Onde isso não combina com sistemas distribuídos modernos

  • Mesmo sem delayed ACK, o comportamento do algoritmo de Nagle pode diferir do que sistemas distribuídos modernos desejam
  • No ambiente atual, o próprio RTT é um custo difícil de ignorar
    • Um único RTT dentro de um datacenter costuma ser de cerca de 500 μs
    • O RTT entre datacenters na mesma região fica em alguns ms
    • Rotas globais podem chegar a centenas de ms
  • Servidores modernos conseguem processar muito trabalho mesmo em centenas de μs, então é difícil ver como claramente vantajosa a decisão de atrasar o envio dos dados por um RTT
  • A justificativa original do algoritmo de Nagle era reduzir o overhead de cabeçalho de 40 vezes causado por pacotes de um único byte
  • Bancos de dados distribuídos e sistemas distribuídos modernos, em geral, não enviam pacotes de um único byte
    • Os próprios dados enviados pela aplicação são maiores
    • Overheads de protocolos como TLS são adicionados
    • Também há overhead de encoding e serialização
  • O problema de evitar mensagens pequenas continua importante, mas essa responsabilidade foi efetivamente deslocada para a camada de aplicação
  • Enviar dados encapsulados em JSON um byte por vez não é eficiente, independentemente do algoritmo de Nagle

Por que ver TCP_NODELAY como a escolha padrão

  • Se você está construindo um sistema distribuído sensível à latência em hardware moderno de nível de datacenter, pode ativar TCP_NODELAY para desativar o algoritmo de Nagle
  • Considerando o tráfego, a composição das aplicações e o desempenho do hardware em sistemas modernos, o algoritmo de Nagle pode não ser mais necessário
  • Também é possível defender que TCP_NODELAY deveria ser o padrão
  • Código que chama write a cada byte pode ficar mais lento com TCP_NODELAY como padrão
  • Se eficiência é importante, esse código deveria corrigir a implementação da aplicação, em vez de depender do algoritmo de Nagle

TCP_QUICKACK é mais uma opção auxiliar

  • TCP_QUICKACK pode ser citado como alternativa, mas sua falta de portabilidade e sua semântica peculiar dificultam escolhê-lo primeiro
  • É preciso conferir diretamente o significado na página man do tcp no Linux
  • O problema maior é que TCP_QUICKACK não resolve a questão fundamental de o kernel manter dados por mais tempo do que a intenção do programa
  • Se o programa chamou write(), ele espera que o write() de fato seja executado

1 comentários

 
GN⁺ 2024-05-10
Opiniões no Hacker News
  • Ao longo da minha carreira, corrigi várias vezes problemas de latência causados pelo algoritmo de Nagle e, hoje, é a primeira coisa de que desconfio.
    A lógica em si faz sentido, mas não serve para algumas cargas de trabalho; acho que, ao criar um socket, o engenheiro deveria escolher explicitamente, em vez de deixar isso para o padrão do sistema operacional.
    O problema não é se é uma opção boa ou ruim, mas que existe uma configuração que muda de forma bastante agressiva o modo como os dados são transmitidos, e muita gente nem sabe que ela existe.

    • Eu também sou assim: sempre que vejo um novo framework de RPC, tenho o hobby de abrir uma issue no GitHub dizendo “vocês consideraram TCP_NODELAY, ou esse framework só consegue fazer 20 chamadas por segundo?”
      Até agora, encontrei um bug todas as vezes.
      Ex.: https://cloud-haskell.atlassian.net/browse/DP-108 ou https://github.com/agentm/curryer/issues/3
      Dito isso, não concordo com “não é uma opção boa/ruim”.
      Trata-se de uma heurística do lado do kernel para “corrigir magicamente” aplicações mal escritas e, como o artigo diz, aplicações normais não fazem chamadas de sistema write() de rede de 1 byte.
      Esse tipo de software deve ser corrigido.
      Acho que esse recurso faz sentido em casos raros em que você é administrador de sistemas do kernel e não consegue corrigir o software que roda na máquina por motivos como política interna da equipe.
      Fora isso, ele torna software normal mais complexo.
      Significa que você precisa desativar explicitamente uma mágica estranha criada para aumentar um pouco a vazão de software mal escrito, e que, em software bem escrito, cria latências grandes e surpreendentes.
      John Nagle diz, em uma thread linkada daqui, que os ACKs atrasados são piores, e concordo com isso.
      Mas o padrão Send/Send/Receive que o algoritmo de Nagle agrava é um caso de uso totalmente válido e comum, aplicável a qualquer coisa que faça RPC com pipeline sobre TCP.
      Acho que tanto os ACKs atrasados quanto o algoritmo de Nagle deveriam vir desativados por padrão.
      O nome também deveria ser algo como TCP_DELAY, e isso só deveria ser ativado quando você não quisesse implementar um buffering básico no espaço de usuário.
      As pessoas não deveriam precisar saber dessas coisas, e o comportamento padrão deveria ser o menos surpreendente possível.
    • Se o objetivo é principalmente corrigir aplicações com mau comportamento de write, uma opção para ativar TCP_DELAY fica bem esquisita.
      Isso exigiria um engenheiro de software inteligente o bastante para conhecer essa opção, mas não inteligente o suficiente para dividir bem as chamadas de write ou criar por conta própria um buffering melhor, ao estilo Nagle, adequado à sua aplicação.
    • Concordo. No mundo de trading de alta frequência/baixa latência, desativar o algoritmo de Nagle já é bem conhecido há bastante tempo, provavelmente há mais de 15 anos, e é uma das primeiras coisas que eu verifico.
    • O que você realmente quer é definir a latência em n microssegundos, mas não há uma boa forma de fazer isso além de colocar buffering no espaço de usuário diretamente antes da chamada de sistema.
      Se não houver algo como io_uring para compensar o custo das chamadas de sistema, o lado do espaço de usuário funciona melhor.
    • Essa lógica foi originalmente pensada para coisas como sessões Telnet.
      Pelo que lembro, essa era a principal motivação.
  • A conclusão é meio estranha. O algoritmo de Nagle era claramente uma tentativa de agrupar escritas, e, independentemente de hardware, rede, aplicação ou caso de uso, em alguns casos agrupar escritas é melhor.
    Ainda hoje, muita computação usa agrupamento de escritas, e aplicações de rede também se beneficiam disso.
    Protocolos de nível mais alto e mais novos, como QUIC, agrupam escritas e, na prática, movem para o espaço de usuário o tratamento independente de conexões e erros do TCP, fazendo com que o protocolo empurre os dados para a aplicação o mais rápido possível, enquanto a conexão e o tratamento de erros de fluxos individuais ficam a cargo da aplicação, não da pilha TCP/IP do host nem dos roteadores.
    Se as redes voltarem a ficar saturadas como antes, o algoritmo de Nagle retornará em uma forma modificada para QUIC, provavelmente mais fundo no código da aplicação, esperando para enviar pacotes QUIC até que algum critério específico seja atingido.
    Tudo em tecnologia é reinventado quando hardware ou software atinge um gargalo. Como o desempenho dos dois não cresce na mesma velocidade, no fim isso sempre acontece.
    Além da largura de banda, o algoritmo de Nagle é útil quando pequenos pacotes levam à saturação do número de pacotes por segundo.

    • A diferença entre QUIC e TCP está no pecado original do TCP e de seus predecessores: imitar uma conexão de porta serial assíncrona sem uma camada de mensagens visível.
      Isso permitia acessar serviços com uma teletipo física, mas fez com que o TCP não conhecesse limites de mensagens; hoje é possível empurrar parte desse conhecimento para dentro, mas o software inicial não conseguia.
      Em contraste, muitos protocolos que não são TCP, como QUIC, SCTP e TP4, oferecem explicitamente limites de mensagens.
      A interface com o sistema não é uma porta serial emulada, mas sim baseada em mensagens, no máximo remontadas.
    • Certo, mas essa implementação específica dependia de uma heurística para decidir a forma de agrupamento, e parece que essa suposição não se confirmou.
    • O agrupamento deve ser controlado pela aplicação, não pelo protocolo.
      O protocolo não tem contexto suficiente para agrupar corretamente.
  • Por outro lado, e se desativássemos o ACK atrasado?
    O problema é o comportamento patológico que surge quando a prevenção de pacotes pequenos interage com o ACK atrasado
    A opção exposta para desativar a prevenção de pacotes pequenos é TCP_NODELAY, mas como desativar o ACK atrasado?
    Digo, quando você quer fazer benchmark das quatro combinações e ver qual se encaixa melhor
    Pesquisando um pouco, no Linux existe a opção de socket TCP_QUICKACK, mas ela precisa ser definida a cada recebimento
    Também existem /proc/sys/net/ipv4/tcp_delack_min e /proc/sys/net/ipv4/tcp_ato_min
    No FreeBSD existem net.inet.tcp.delayed_ack e net.inet.tcp.delacktime

    • TCP_QUICKACK corrige a pior forma, mas não resolve o problema inteiro
      O algoritmo de Nagle ainda pode esperar até um tempo de ida e volta inteiro antes de enviar dados e, seguindo a RFC, isso praticamente só acrescenta atraso sem trazer benefício
    • Exato. Ter que definir TCP_QUICKACK a cada recebimento — o que estavam pensando?
      Por que alguém iria querer deixá-lo desligado só parte do tempo?
    • No CentOS/RedHat, é possível adicionar quickack 1 ao fim da rota para desativar o ACK atrasado nesse caminho
  • Em um mundo em que a largura de banda era limitada e o tamanho mínimo de pacote era 64 bytes, além de exigir o intervalo entre quadros, enviar um pacote TCP para cada byte era um desperdício enorme de banda
    Na maioria das redes Ethernet, o tamanho mínimo ainda é esse, e o mesmo vale para enviar ACK vazio
    Mas minha posição padrão é esta: não é TCP_NODELAY, é simplesmente TCP

    • Seria bom ter um protocolo com um mecanismo embutido para perceber que o pipe do outro lado caiu por algum motivo
    • O QUIC(https://en.wikipedia.org/wiki/QUIC) não deveria resolver problemas do TCP, como latência?
  • Não me convence muito o argumento de que Nagle não é mais necessário
    Telnet não é importante hoje, mas acho que ainda há muitos aplicativos que fazem algo como
    write(fd, "Host: "), write(fd, hostname), write(fd, "\r\n"), write(fd, "Content-type: ") etc.
    Mesmo que isso não seja um overhead de 40 vezes, pode chegar a algo como 5 vezes

    • É só corrigir a aplicação
      Ninguém escreve em um arquivo desse jeito esperando desempenho mágico. Mesmo o sistema operacional tendo seu próprio buffer
      Não há motivo para esperar algo diferente só ao escrever em um socket, e Nagle nem sequer salva você do overhead de chamadas de sistema
    • Ao ver a menção a Telnet, fiquei curioso sobre o que o OpenSSH faz: ele define TCP_NODELAY em todas as conexões, inclusive sessões interativas
      Confirmei tanto lendo o código quanto observando o comportamento com strace
    • Pensando em E/S assíncrona, em vez de bloquear a cada pequeno write(2), a única abordagem que faz sentido é colocar em um buffer, então acho que esse padrão já não é tão comum
      Em servidores, E/S assíncrona costuma ser necessária para escalar bem, e em clientes também é uma experiência ruim bloquear em chamadas de rede
      Especialmente em ambientes como os de hoje, com mudanças frequentes de rede e muitos momentos fora de alcance
    • A internet inteira não deve ser punida porque alguns desenvolvedores escrevem código ruim
    • Para começo de conversa, não se deve fazer isso
      Mesmo deixando de lado o aspecto de rede, chamadas de sistema são bem caras, então isso prejudica o desempenho
  • Fico me perguntando se alguém conhece uma boa forma de ativar TCP_NODELAY em um socket quando não se tem acesso ao código-fonte da aplicação
    Não encontrei uma configuração de kernel para aplicar isso permanentemente, nem um comando para alterar depois
    Consegui desativar o ACK atrasado colocando quickack 1 na tabela de roteamento, mas ativar TCP_NODELAY de fora da aplicação parece especialmente difícil
    Recentemente estou enfrentando exatamente o problema descrito aqui entre uma aplicação que eu possuo e uma aplicação de código fechado com a qual ela interage

    • Algo como uma interceptação via LD_PRELOAD de socket(2) não funcionaria?
      Seria chamar a função real, depois fazer algo como setsockopt e retornar o socket modificado
    • Dependendo da situação concreta, talvez seja possível colocar o socat no meio
      Se originalmente era your_app —> server, ficaria your_app -> localhost_socat -> server
      O socat tem uma opção de linha de comando para definir tcp_nodelay
      Mas você teria que convencer o aplicativo de código fechado a se conectar ao localhost
      Se ele faz consulta DNS, talvez dê para fazê-lo se conectar ao localhost com uma entrada em /etc/hosts
      Como o app se comunica com o socat por um socket local, o tcp_nodelay do lado do app não tem efeito
    • Não daria para anexar um depurador e chamar setsockopt via ptrace?
    • Abrir /proc//fd/ e definir as opções do socket talvez funcione. Não testei
    • LD_PRELOAD
  • Uns 15 anos atrás, joguei um MMO extremamente em tempo real, e toda a comunicação era TCP
    Quando você clicava em um botão, sua ação nem aparecia na tela até o pacote de resposta voltar
    No fim, as crianças que jogavam esse jogo, eu incluído, todas descobriram que ativar TCP_NODELAY deixava o jogo muito mais fluido
    O efeito era especialmente grande para jogadores da Califórnia, perto dos servidores do jogo

    • Não sei se você está falando de WoW, mas por volta dessa época uma atualização do jogo fez exatamente essa mudança, e provavelmente mudou outras coisas também
      Um efeito colateral interessante era que, antes da mudança, quando o stream TCP travava, o jogo congelava por um instante e depois reproduzia muito rapidamente os eventos recebidos que tinham sido perdidos
      Normalmente esse evento era a cena da minha morte
      Depois da mudança, em vez disso, a conexão simplesmente caía
  • Episódio relacionado do podcast Oxide and Friends: https://www.youtube.com/watch?v=mqvVmYhclAg

    • Foi um episódio excelente e mostrou de forma muito forte a importância da visualização
  • Se você usa uma linguagem moderna que ativa TCP_NODELAY por padrão, como Go, isso não se aplica :-)

  • Nem sempre é isso. Às vezes é DNS

    • Uma vez, uma line card defeituosa em um roteador zerava o último bit dos endereços IPv4, o que virou um ticket de “só endereços IPv4 pares são acessíveis”
    • No meu caso, uma vez era vidro sujo
      Em um roteador perto de uma obra, poeira se depositou na fresta entre o laser e a fibra óptica, atenuando o sinal o suficiente, e vimos 40–50% de perda de pacotes
      Depois de encontrar o ponto da perda, o NOC enviou um e-mail para a operadora de transporte correspondente e, no dia seguinte, o técnico enviado respondeu contando essa história
    • Uma vez a cada 50 anos, a 2 bilhões de km de distância, pode ser um chip de memória defeituoso
      Ainda assim, normalmente dá para contornar com um patch, então não é um grande problema
    • Também não dá para esquecer BGP, ou disco enchendo sem aviso
    • Se falha, é DNS; se simplesmente para de se mexer, é TCP_NODELAY ou buffering de streams
      A Web, que é um sistema realmente complexo, também falha por causa de cache