2 pontos por GN⁺ 2024-11-30 | 1 comentários | Compartilhar no WhatsApp
  • Pipelines que encadeiam várias comandos com uma saída que chega lentamente, como tail -f /some/log/file | grep thing1 | grep thing2, podem parecer vazios não porque realmente travaram, mas porque um comando intermediário acumula a saída em um buffer
  • grep e muitos programas verificam se stdout é um terminal com isatty; se for um terminal, usam buffering por linha, e se for um pipe ou arquivo, usam buffering em blocos de aproximadamente 8 KB
  • tail, cat e tee são exemplos que não fazem buffering de saída, mas opções para reduzir buffering variam por comando, como grep --line-buffered, sed -u, tcpdump -l, jq -u e tr -u
  • Se você interromper um pipeline com Ctrl-C, a saída que estava no buffer de programas como tcpdump pode desaparecer; se encerrar com kill -TERM $PID, o buffer pode ser liberado e a saída pode aparecer
  • Soluções práticas incluem trocar por comandos que terminam rapidamente, grep --line-buffered, um único awk ou um grep mais complexo, stdbuf e unbuffer, mas é preciso verificar as condições de funcionamento e os efeitos colaterais de cada uma

Por que pipes parecem travar

  • Quando linhas são adicionadas lentamente a um arquivo de log, o pipeline abaixo pode não mostrar saída mesmo que haja resultados correspondentes
    • tail -f /some/log/file | grep thing1 | grep thing2
  • A causa não é o pipe em si, mas o grep thing1 intermediário, que não escreve os resultados imediatamente e os armazena em um buffer
  • Se um programa escrevesse imediatamente a cada vez, aumentaria o número de chamadas de sistema; por desempenho, ele junta uma certa quantidade de dados antes de escrever em um pipe ou arquivo
  • Nesse exemplo, o grep thing1 pode esperar até acumular cerca de 8 KB de saída e, em um log lento, essa condição pode, na prática, nunca acontecer

A forma de saída muda entre terminal e pipe

  • tail -f file | grep thing funciona bem, mas, ao adicionar um segundo grep depois, a saída pode parecer travar
  • grep e muitos programas verificam se stdout é um terminal usando a função isatty
    • Se stdout for um terminal, usam buffering por linha e imprimem imediatamente linha a linha
    • Se stdout for um pipe ou arquivo, usam buffering em blocos e só imprimem quando uma certa quantidade de dados é acumulada
  • Portanto, quando grep escreve diretamente no terminal, a linha aparece na hora; quando escreve em um pipe ligado ao próximo comando, ela pode não aparecer
  • O tamanho do buffer varia de programa para programa
    • Em grep, a libc cuida do buffering, e o tamanho da libc é definido pela variável BUFSIZ
    • A definição na glibc fica em stdio.h
  • Não usar um buffer de saída de 8 KB ao escrever em um terminal não é uma lei da física; um programa poderia implementar isso se quisesse, mas seria um comportamento muito estranho

Comportamentos de buffering variam por comando

  • O que torna o buffering de saída complicado é que o usuário precisa lembrar quais comandos fazem buffering ao escrever em um pipe
  • Exemplos de comandos que não fazem buffering de saída:
    • tail
    • cat
    • tee
  • Comandos comuns que fazem buffering de saída ao escrever em um pipe e formas de mitigação:
    • grep: --line-buffered
    • sed: -u
    • awk: função fflush()
    • tcpdump: -l
    • jq: -u
    • tr: -u
    • cut: não é possível desativar o buffering
  • Para comandos como sort, que só conseguem trabalhar depois de receber toda a entrada, a questão do buffering não é realmente importante
  • Houve uma tentativa de testar tanto o Mac OS quanto as versões GNU, mas, como há muitas variações, pode haver alguns erros

A saída padrão de linguagens de programação também faz buffering

  • A saída do print padrão em algumas linguagens de programação também faz buffering ao escrever em um pipe
  • Formas de desativar por linguagem:
    • C: setvbuf
    • Python: python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby: STDOUT.sync = true
    • Perl: $| = 1
  • Esse comportamento padrão parece ser um design para tornar a função de saída padrão mais rápida em processamento em lote
  • A existência de buffering pode variar conforme a forma de saída
    • Em C++, cout << "hello\n" faz buffering ao escrever em um pipe
    • cout << "hello" << endl libera a saída

Diferenças com Ctrl-C e redirecionamento para arquivo

  • Se você conectar a saída de tcpdump a grep como abaixo e esquecer o -l, a saída pode ficar no buffer
    • sudo tcpdump -ni any port 53 | grep example.com
  • Idealmente, ao pressionar Ctrl-C, seria esperado que tcpdump liberasse o buffer e que grep pesquisasse e mostrasse a saída que faltava
  • Na prática, quando os programas terminam, a saída que estava no buffer do tcpdump é perdida
  • Ao verificar com strace, grep recebe SIGINT antes de tcpdump, então, mesmo que tcpdump tente liberar o buffer, grep pode já estar morto
  • Como contorno, se você encontrar o PID do tcpdump e executar kill -TERM $PID, o tcpdump pode liberar o buffer e a saída pode aparecer
  • Redirecionamento para arquivo também faz buffering
    • sudo tcpdump -ni any port 53 > output.txt
  • Porém, diferentemente do problema em que Ctrl-C descarta completamente o conteúdo do buffer, no redirecionamento para arquivo, pela experiência, muitas vezes o conteúdo do buffer é escrito no arquivo antes de o programa terminar
  • Não está claro se esse comportamento pode ser sempre considerado confiável

Cinco formas de evitar buffering

  • Trocar por um programa que termina rapidamente

    • É possível evitar a própria situação de escrever lentamente em um pipe e trocar por um comando que termina rápido
    • Exemplo:
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • Não é o mesmo comportamento do comando original com tail -f, mas evita problemas complexos de buffering
  • Usar a opção de buffering por linha do grep

    • grep tem uma flag para evitar buffering
    • Exemplo:
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • Combinar com awk ou um grep mais complexo

    • Situações com vários grep podem ser convertidas para um único awk
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • Ou podem ser escritas com um grep usando uma expressão regular mais complexa
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • Como awk também faz buffering, para esse método funcionar o awk precisa ser o último comando do pipeline
  • Usar stdbuf

    • stdbuf usa LD_PRELOAD para desativar o buffering da libc
    • Exemplo de desativação do buffering de saída:
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • Como outras soluções baseadas em LD_PRELOAD, sua confiabilidade é limitada
      • Não funciona com binários estáticos
      • Pode não funcionar se o programa não usa o buffering da libc
      • Nem sempre funciona no Mac OS
    • Há uma explicação relacionada em How stdbuf works, de Harry Marr
  • Usar unbuffer

    • unbuffer program força a saída do programa a parecer um TTY, fazendo com que ele use menos buffering e ative coisas como saída colorida, como em um TTY normal
    • Exemplo:
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • Ao contrário de stdbuf, sempre funciona, mas pode ter efeitos colaterais indesejados
      • Por exemplo, grep thing1 pode inserir cores nos resultados correspondentes
    • unbuffer vem no pacote expect

Situações em que o problema aparece com mais frequência e a ideia de uma variável de ambiente

  • Esse problema aparece principalmente em programas que enviam dados lentamente para um pipe
  • Exemplos:
    • tcpdump
    • tail -f
    • Monitoramento de logs no estilo kubectl logs
    • Saída de cálculos lentos
  • Seria bom ter uma variável de ambiente padrão para desativar buffering, como PYTHONUNBUFFERED em Python
  • A ideia veio de um post de blog de 2018 e de um post de acompanhamento, de Mark Dominus
  • Um exemplo de nome possível seria NO_BUFFER, parecido com NO_COLOR
  • O design é complicado
    • O NETBSD tem variáveis de ambiente como STDBUF, STDBUF1 etc., que oferecem bastante controle
    • A maioria dos desenvolvedores talvez não queira implementar várias variáveis de ambiente para um caso de borda relativamente pequeno
  • Também é uma curiosidade se existem programas que liberam automaticamente o buffer de saída em intervalos, como 1 segundo, mas nenhum vem à mente, e isso pode ter desvantagens

Escopo não abordado

  • A diferença entre buffering por linha e saída totalmente sem buffer foi excluída
  • A diferença entre buffering de stderr e buffering de stdout foi excluída
  • Este conteúdo trata apenas de buffering que ocorre dentro dos programas
  • O driver de TTY do sistema operacional às vezes também faz um pouco de buffering
  • Outros motivos para liberar a saída além da situação de escrever em um pipe foram excluídos

1 comentários

 
GN⁺ 2024-11-30
Comentários do Hacker News
  • Uma abordagem com buffer quase sempre deveria ser do tipo “limiar ou timeout”: fazer flush ao atingir um limiar de bytes ou, se houver pelo menos 1 byte, depois de certo tempo
    É uma abordagem comum em interfaces de hardware para resolver problemas semelhantes
    Nesse caso, a biblioteca que faz buffering no espaço do usuário deve configurar um temporizador adequado quando coloca dados no buffer pela primeira vez. O valor do timeout pode ser recebido como argumento, definido em algo curto para uma pessoa, como 1–100 ms, tornado proporcional a {largura de banda / limiar}, ou escolhido de modo que o overhead das chamadas de sistema não ultrapasse 0,1% do tempo total
    Essa abordagem se aplica não só à escrita, mas também à leitura. Se forem feitas leituras em lote ou leituras combinadas, é necessário algo parecido, mas o canal de dados precisa ter uma forma eficiente de consultar ou receber notificações sobre “dados pendentes”, então isso depende mais do desenho do canal. Em hardware, abordagens como coalescência de interrupções são comuns

    • Acho que essa direção está certa, mas, se a libc configurar temporizadores automáticos, o comportamento esperado muda e surgem muitos problemas complicados
      Erros de E/S poderiam ocorrer não só no momento da escrita, mas a qualquer momento, e várias chamadas de sistema poderiam ser interrompidas pelo temporizador, não apenas nos pontos em que o programa definiu seus próprios temporizadores ou onde um sinal chegou
      Também pode haver confusão se a aplicação e a libc configurarem temporizadores ao mesmo tempo. As APIs modernas de temporizador do kernel parecem melhores do que eu lembrava, então talvez isso seja menos relevante, mas, se a aplicação bloquear sinais temporariamente em uma seção crítica, isso também afeta temporizadores de E/S
      Por causa do momento e da forma de tratamento dos sinais, seria preciso ter ainda mais cuidado ao acessar estruturas de E/S
    • Lidar com esses timeouts de forma transparente parece complicado sob as restrições de POSIX e ISO C. Parece necessária alguma cooperação da camada de aplicação
    • Alarmes comuns no Linux são baseados em sinais, portanto são muito difíceis de gerenciar, e, para reagendá-los, é preciso entrar no kernel, o que pode ter impacto de desempenho
      Com io_uring e temporizadores em espaço de usuário, isso escala muito melhor, mas, para dar suporte a muitas escritas pequenas e rápidas, ainda são necessários alguns truques. Por exemplo, acima de cerca de 1 milhão por segundo, o custo de gerenciar temporizadores começa a aparecer, e, para chegar a 100 milhões de escritas por segundo, foram necessárias técnicas bem peculiares
    • É difícil concordar. Aqui, o buffering está fazendo exatamente o que deveria fazer
      A causa do problema está na mistura entre algo que deveria ser interativo e um contrato que não pressupõe interação. Um exemplo é enviar a saída de acompanhamento do tail por um pipe
      Não acho que exista um problema real a resolver. Usando uma analogia de hardware, é como uma cisterna que acumula água da chuva e só a transfere quando está cheia. Não sei que exemplo você tem em mente, mas, até onde sei, flush baseado em tempo não é comum em hardware
      A correção proposta torna o contrato muito mais complexo
    • Acho melhor um footgun previsível. A ideia é boa, mas deveria ser uma flag separada, e aí seria preciso saber que ela existe
      O problema não está tanto na semântica em si, mas em não conhecer a semântica
  • Mesmo lidando com sistemas NIX há mais de 20 anos, eu sei que isso acontece, mas toda vez só me lembro depois de passar um bom tempo intrigado tentando entender por que a saída não aparece

  • Sobre a parte “os posts recentes estão ficando bem longos; será que alguém realmente quer ler um texto de 3000 palavras sobre buffering?”, pessoalmente, eu quero

    • Depende do texto
      Acho que às vezes textos prolixos são enchimento para otimização de busca
    • Acho que resumos por IA ajudam bastante nesse ponto. Dá para pedir um resumo do texto e depois revisar
      Também seria possível ter seções TLDR e NTLDR, isto é, “li apesar de ser longo”
  • Eu gostaria que todos os buffers fossem descarregados sempre que a CPU do sistema inteiro ficasse ociosa
    Buffering, em geral, é uma técnica para economizar CPU. Se a CPU fosse infinita, todos os buffers teriam 1 byte. Buffers reúnem dados para processá-los em lote por eficiência
    Mas, quando a CPU fica ociosa, não deveria sobrar “trabalho para depois”. Assim que o escalonador do kernel ficasse ocioso, ele deveria enviar a todos os processos um sinal para fazer flush dos buffers

    • Ideia interessante. Mas enviar um sinal a todos os processos parece extremamente caro
      Seria como fazer todo esse trabalho e depois ainda obrigar chamadas de sistema para fazer flush dos buffers. Talvez dê para adicionar um mecanismo que permita ao kernel conhecer buffers em espaço de usuário e buscá-los diretamente quando estiver ocioso
      Fico pensando se isso é, em certa medida, algo como io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff...
    • Ideia legal, mas parece melhor não fazer tudo de uma vez. E, em sistemas de baixo consumo, acordar processos adormecidos de forma especulativa também pode reduzir a eficiência
  • Este texto confunde duas coisas diferentes: sem buffer e buffering por linha
    Sem buffer piora o desempenho desnecessariamente e pode gerar saída incorreta quando várias fontes escrevem no mesmo pipe. Linhas suficientemente longas vão se misturar de qualquer forma, mas, na prática, a maioria das linhas de saída tem menos de 4096 bytes, mesmo incluindo caracteres de formatação/controle e caracteres de planos suplementares
    Buffering por linha é o padrão em terminais e, em pipes, normalmente também é o comportamento desejado. Basta executar cada comando sob stdbuf -oL -eL. Os raros programas que desejam atualizar dentro da própria linha já precisam fazer flush manual, então também se comportam corretamente aqui
    O que o stdbuf realmente faz pode ser visto assim:
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • Já escrevi sobre esse problema antes: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    Sobre comandos que não fazem buffering, isso depende da implementação; ou, no caso do cat, talvez esteja errado. Veja https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c... e -u. É uma grande dor o POSIX não ter incluído uma forma oficial de controlar isso.
    Algo que não foi mencionado é o buffering de entrada, que produz resultados estranhos como este:
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    Nesse caso, a solução é usar stdbuf -i0 head -1.

    • Não acho que um processo lendo de algo como um pipe ou socketpair possa impor esse tipo de restrição ao processo que está escrevendo. Exceto usando hacks pesados como ptrace().
      Talvez seja possível ajustar o tamanho do buffer do pipe, mas não conheço nenhuma convenção segundo a qual a E/S padrão do C tenha que obedecer a isso.
      De todo modo, neste caso o stdbuf não parece ajudar:
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • Há bons motivos para existirem buffers. Imprimir saída na tela é relativamente muito lento em comparação com escrever em um buffer.
    Emitir caracteres um por um é extremamente ineficiente.
    É um problema antigo e aparece com frequência ao lidar com UART. Há várias soluções possíveis: uma abordagem baseada em linhas, que marca o fim da saída com um caractere especial como nova linha; uma baseada em tamanho, que espera até atingir um comprimento como 8 KB; e uma baseada em tempo, que emite a saída a cada X milissegundos.
    Cada abordagem tem prós e contras, e o que é ideal depende da aplicação. Acho incorreta a parte do texto que diz que certos programas não usam buffering. Esses programas simplesmente não usam uma abordagem obviamente baseada em tamanho.

    • Funciona melhor quando é possível conhecer as restrições uma ou duas camadas acima da interface.
      A abordagem baseada em linhas é assim, mas exige um acordo sobre qual caractere usar. Normalmente é a nova linha.
    • Não é só uma questão do custo de processar a escrita real no backend. Fazer tantos chamadas de sistema para /dev/null por si só já pode prejudicar bastante o desempenho.
  • Uso Unix há mais de 35 anos, mas nunca entendi completamente como isso funciona.
    Foi bom ver uma explicação geral do comportamento de buffering em vários sistemas e componentes; com certeza aprendi algo.

  • Sobre a parte “ao pressionar Ctrl-C em um pipe, o conteúdo do buffer desaparece”, acho que a maioria dos programas faria flush do buffer em SIGINT.
    Só que, para isso funcionar assim no shell, seria preciso entregar o SIGINT apenas ao primeiro programa do pipeline, e provavelmente não é isso que acontece na prática.

    • Pelo que me lembro, o último processo recebe sigint e os demais recebem sigpipe.