Como o `yes` grava no arquivo tão rapidamente?

58

Deixe-me dar um exemplo:

$ timeout 1 yes "GNU" > file1
$ wc -l file1
11504640 file1

$ for ((sec0=`date +%S`;sec<=$(($sec0+5));sec=`date +%S`)); do echo "GNU" >> file2; done
$ wc -l file2
1953 file2

Aqui você pode ver que o comando yesescreve 11504640linhas em um segundo, enquanto eu posso escrever apenas 1953linhas em 5 segundos usando bash fore echo.

Conforme sugerido nos comentários, existem vários truques para torná-lo mais eficiente, mas nenhum chega nem perto de corresponder à velocidade de yes:

$ ( while :; do echo "GNU" >> file3; done) & pid=$! ; sleep 1 ; kill $pid
[1] 3054
$ wc -l file3
19596 file3

$ timeout 1 bash -c 'while true; do echo "GNU" >> file4; done'
$ wc -l file4
18912 file4

Eles podem escrever até 20 mil linhas em um segundo. E eles podem ser melhorados ainda mais para:

$ timeout 1 bash -c 'while true; do echo "GNU"; done >> file5' 
$ wc -l file5
34517 file5

$ ( while :; do echo "GNU"; done >> file6 ) & pid=$! ; sleep 1 ; kill $pid
[1] 5690
$ wc -l file6
40961 file6

Isso nos leva a 40 mil linhas em um segundo. Melhor, mas ainda muito longe do yesqual pode escrever cerca de 11 milhões de linhas em um segundo!

Então, como yesgravar no arquivo tão rapidamente?

Pandya
fonte
9
No segundo exemplo, você tem duas invocações de comandos externos para cada iteração do loop e dateé um pouco pesado, além disso, o shell precisa reabrir o fluxo de saída echopara cada iteração do loop. No primeiro exemplo, existe apenas uma chamada de comando com um redirecionamento de saída único, e o comando é extremamente leve. Os dois não são de forma alguma comparáveis.
um CVn
@ MichaelKjörling você está certo datepode ser muito pesado, veja editar a minha pergunta.
Pandya
1
timeout 1 $(while true; do echo "GNU">>file2; done;)é a maneira errada de usar, timeout pois o timeoutcomando será iniciado apenas quando a substituição do comando for concluída. Use timeout 1 sh -c 'while true; do echo "GNU">>file2; done'.
muru
1
resumo das respostas: gastando apenas o tempo da CPU em write(2)chamadas do sistema, não em cargas de barco de outros syscalls, sobrecarga de shell ou até mesmo criação de processo no seu primeiro exemplo (que executa e aguarda datepor cada linha impressa no arquivo). Um segundo de gravação é suficiente para afunilar a E / S do disco (em vez da CPU / memória), em um sistema moderno com muita RAM. Se for permitido executar mais, a diferença seria menor. (Dependendo do grau de implementação ruim do bash e da velocidade relativa da CPU e do disco, você pode nem saturar a E / S do disco com o bash).
Peter Cordes

Respostas:

65

casca de noz:

yesexibe comportamento semelhante à maioria dos outros utilitários padrão que normalmente gravam em um FILE STREAM com saída em buffer pelo libC via stdio . Eles fazem o syscall somente a write()cada 4kb (16kb ou 64kb) ou qualquer que seja o bloco de saída BUFSIZ . echoé um write()por GNU. É muita troca de modo (o que aparentemente não é tão caro quanto uma troca de contexto ) .

E isso não é nada para mencionar que, além de seu loop de otimização inicial, yesé um loop C muito simples, minúsculo e compilado e seu loop de shell não é de forma alguma comparável a um programa otimizado para compilador.


mas eu estava errado:

Quando eu disse antes que o yesstdio usado, eu apenas assumi que sim porque se comporta muito como aqueles que o fazem. Isso não estava correto - apenas emula o comportamento deles dessa maneira. Na verdade, o que ele faz é muito parecido com o que fiz abaixo com o shell: primeiro ele faz um loop para confundir seus argumentos (ou ynenhum) até que eles não cresçam mais sem exceder BUFSIZ.

Um comentário da fonte imediatamente anterior ao forloop relevante indica:

/* Buffer data locally once, rather than having the
large overhead of stdio buffering each item.  */

yesfaz o que faz a si próprio write()depois disso.


digressão:

(Como originalmente incluído na pergunta e retido por contexto para uma explicação possivelmente informativa já escrita aqui) :

Eu tentei, timeout 1 $(while true; do echo "GNU">>file2; done;)mas incapaz de parar o loop.

O timeoutproblema que você tem com a substituição de comando - acho que entendi agora e posso explicar por que não para. timeoutnão inicia porque sua linha de comando nunca é executada. Seu shell bifurca um shell filho, abre um tubo no stdout e o lê. Ele irá parar de ler quando a criança sair e, em seguida, interpretará toda a criança escrita para $IFSexpansões desconcertantes e globais e, com os resultados, substituirá tudo, desde $(a correspondência ).

Mas se o filho é um loop sem fim que nunca grava no canal, ele nunca para de fazer um loop e timeouta linha de comando nunca é concluída antes (como eu acho) de você fazer CTRL-Ce matar o loop. Portanto, nunca étimeout possível eliminar o loop que precisa ser concluído antes de iniciar.


outros timeouts:

... simplesmente não são tão relevantes para seus problemas de desempenho quanto a quantidade de tempo que seu programa shell deve gastar alternando entre os modos de usuário e kernel para lidar com a saída. timeout, no entanto, não é tão flexível quanto um shell pode ser para esse propósito: onde os shells se destacam tem a capacidade de manipular argumentos e gerenciar outros processos.

Como observado em outro lugar, simplesmente mover o [fd-num] >> named_fileredirecionamento para o destino de saída do loop em vez de direcionar a saída para o comando em loop pode melhorar substancialmente o desempenho, pois dessa forma pelo menos o open()syscall precisa ser feito apenas uma vez. Isso também é feito abaixo, com o |tubo direcionado como saída para os loops internos.


comparação direta:

Você pode fazer como:

for cmd in  exec\ yes 'while echo y; do :; done'
do      set +m
        sh  -c '{ sleep 1; kill "$$"; }&'"$cmd" | wc -l
        set -m
done

256659456
505401

Que é tipo de como a relação de comando sub descrito antes, mas não há nenhuma tubulação e a criança está em segundo plano até que ele mata o pai. No yescaso, o pai foi realmente substituído desde que a criança foi criada, mas o shell chama yessobrepondo seu próprio processo com o novo e, assim, o PID permanece o mesmo e seu filho zumbi ainda sabe quem matar, afinal.


buffer maior:

Agora vamos ver como aumentar o write()buffer do shell .

IFS="
";    set y ""              ### sets up the macro expansion       
until [ "${512+1}" ]        ### gather at least 512 args
do    set "$@$@";done       ### exponentially expands "$@"
printf %s "$*"| wc -c       ### 1 write of 512 concatenated "y\n"'s  

1024

Eu escolhi esse número porque as seqüências de saída com mais de 1kb foram divididas em write()s separadas para mim. E aqui está o loop novamente:

for cmd in 'exec  yes' \
           'until [ "${512+:}" ]; do set "$@$@"; done
            while printf %s "$*"; do :; done'
do      set +m
        sh  -c $'IFS="\n"; { sleep 1; kill "$$"; }&'"$cmd" shyes y ""| wc -l
        set -m
done

268627968
15850496

Isso representa 300 vezes a quantidade de dados gravados pelo shell na mesma quantidade de tempo para este teste que o último. Não é muito pobre. Mas não é yes.


relacionados:

Conforme solicitado, há uma descrição mais completa do que os meros comentários de código sobre o que é feito aqui neste link .

mikeserv
fonte
@heemayl - talvez? Eu não tenho certeza se entendi o que você está perguntando? quando um programa usa o stdio para gravar a saída, ele faz sem buffer (como stderr por padrão) ou buffer de linha (para terminais por padrão) ou buffer de bloco (basicamente a maioria das outras coisas é definida dessa maneira por padrão) . eu sou um pouco incerto sobre o que define o tamanho do buffer de saída - mas geralmente é de 4kb. e assim as funções stdio lib reunirão sua saída até que possam escrever um bloco inteiro. ddé uma ferramenta padrão que definitivamente não usa stdio, por exemplo. a maioria dos outros faz.
mikeserv
3
A versão do shell está executando AND open(existente) writeAND close(que eu acredito que ainda aguarda liberação), AND criando um novo processo e executando date, para cada loop.
dave_thompson_085
@ dave_thompson_085 - vá para / dev / chat . e o que você diz não é necessariamente verdade, como você pode ver lá. Por exemplo, fazer esse wc -lloop bashcomigo obtém 1/5 da saída do shloop - bashgerencia um pouco mais de 100k writes()a dash500k de s.
mikeserv
Desculpe, eu era ambígua; Eu quis dizer a versão do shell na pergunta, que no momento em que o li tinha apenas a versão original com o for((sec0=`date +%S`;...controle do tempo e o redirecionamento no loop, não as melhorias subseqüentes.
precisa saber é o seguinte
@ dave_thompson_085 - tudo bem. de qualquer maneira, a resposta estava errada sobre alguns pontos fundamentais e deve estar praticamente correta agora, como espero.
precisa saber é
20

Uma pergunta melhor seria por que o seu shell está gravando o arquivo tão lentamente. Qualquer programa compilado independente que use syscalls de gravação de arquivo de forma responsável (sem liberar todos os caracteres de uma vez) faria isso razoavelmente rápido. O que você está fazendo é escrever linhas em uma linguagem interpretada (o shell) e, além disso, você realiza muitas operações desnecessárias de saída de entrada. O que yesfaz:

  • abre um arquivo para gravação
  • chama funções otimizadas e compiladas para gravar em um fluxo
  • o fluxo é armazenado em buffer; portanto, um syscall (uma mudança cara para o modo kernel) acontece muito raramente, em grandes blocos
  • fecha um arquivo

O que seu script faz:

  • lê em uma linha de código
  • interpreta o código, realizando muitas operações extras para analisar sua entrada e descobrir o que fazer
  • para cada iteração do loop while (que provavelmente não é barato em uma linguagem interpretada):
    • chame o datecomando externo e armazene sua saída (somente na versão original - na versão revisada, você ganha um fator de 10 por não fazer isso)
    • testar se a condição de término do loop é atendida
    • abrir um arquivo no modo de acréscimo
    • echocomando parse , reconheça-o (com algum código de correspondência de padrões) como um shell embutido, chame a expansão de parâmetros e tudo mais no argumento "GNU" e, finalmente, escreva a linha no arquivo aberto
    • feche o arquivo novamente
    • repita o processo

As partes caras: toda a interpretação é extremamente cara (o bash está realizando uma enorme quantidade de pré-processamento de todas as entradas - sua string pode conter substituição de variáveis, substituição de processos, expansão de chaves, caracteres de escape e muito mais), todas as chamadas de um built-in são provavelmente uma instrução switch com redirecionamento para uma função que lida com o built-in e, o que é mais importante, você abre e fecha um arquivo para cada linha de saída. Você pode colocar >> filefora do loop while para torná-lo muito mais rápido , mas ainda está em uma linguagem interpretada. Você tem muita sorte queechoé um shell embutido, não um comando externo - caso contrário, seu loop envolveria a criação de um novo processo (fork & exec) em cada iteração. O que interromperia o processo - você viu o quanto isso custaria quando você tinha o datecomando no loop.

orion
fonte
11

As outras respostas abordaram os pontos principais. Em uma nota lateral, você pode aumentar a taxa de transferência do seu loop while gravando no arquivo de saída no final do cálculo. Comparar:

$ i=0;time while  [ $i -le 1000 ]; do ((++i)); echo "GNU" >>/tmp/f; done;

real    0m0.080s
user    0m0.032s
sys     0m0.037s

com

$ i=0;time while  [ $i -le 1000 ]; do ((++i)); echo "GNU"; done>>/tmp/f;

real    0m0.030s
user    0m0.019s
sys     0m0.011s
Apoorv Gupta
fonte
Sim, isso importa e a velocidade de gravação (pelo menos) dobra no meu caso #
Pandya