Como o 'sim' escreve no arquivo tão rapidamente?

54

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 yes escreve 11504640 linhas em um segundo enquanto eu posso escrever apenas 1953 linhas em 5 segundos usando for e echo do bash.

Como sugerido nos comentários, existem vários truques para torná-lo mais eficiente, mas nenhum chega 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

Estes podem escrever até 20 mil linhas em um segundo. E eles podem ser melhorados 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 de yes , que pode escrever cerca de 11 milhões de linhas em um segundo!

Então, como o yes escreve para o arquivo tão rapidamente?

    
por Pandya 24.01.2016 / 06:22

3 respostas

61

resumo:

yes exibe um comportamento semelhante à maioria dos outros utilitários padrão que, em geral, escrevem para um FILE STREAM com saída em buffer pela libC via stdio . Eles só fazem o syscall write() a cada 4kb (16kb ou 64kb) ou qualquer que seja o bloco de saída BUFSIZ . echo é write() por GNU . Isso é um muito de modo de comutação (que não é, aparentemente, tão caro como um interruptor de contexto ) .

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

mas eu estava errado:

Quando eu disse antes que yes usava o stdio, eu apenas supus que isso acontecia porque se comporta muito como aqueles que fazem. Isso não estava correto - apenas emula o comportamento deles dessa maneira. O que ele realmente faz é muito parecido com um analógico para a coisa que fiz abaixo com o shell: ele primeiro faz um loop para combinar seus argumentos (ou y se nenhum) até que eles não possam crescer mais sem exceder BUFSIZ .

Um comentário da fonte imediatamente anterior ao loop for relevante afirma:

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

yes faz o seu próprio write() s depois disso.

digressão:

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

I've tried timeout 1 $(while true; do echo "GNU">>file2; done;) but unable to stop loop.

O problema timeout que você tem com a substituição de comando - acho que entendi agora e posso explicar por que ele não para. timeout não inicia porque sua linha de comando nunca é executada. Seu shell bifurca um shell filho, abre um pipe em seu stdout e o lê. Ele parará de ler quando o filho sair e interpretará tudo o que o filho escreveu para $IFS mangling e glob expansionions e, com os resultados, ele substituirá tudo de $( pelo ) correspondente.

Mas se o filho for um loop infinito que nunca grava no pipe, ele nunca pára de fazer loop e a linha de comandos do timeout nunca é concluída antes (como eu suponho) você faz CTRL-C e mata o loop filho. Portanto, timeout pode nunca matar o loop que precisa ser concluído antes que possa ser iniciado.

outro timeout s:

... simplesmente não são tão relevantes para seus problemas de desempenho quanto a quantidade de tempo que o seu programa shell deve passar alternando entre o modo de usuário e o modo kernel para manipular a saída. O timeout , no entanto, não é tão flexível quanto um shell pode ser para essa finalidade: onde os shells se destacam em sua capacidade de manipular argumentos e gerenciar outros processos.

Como é notado em outro lugar, simplesmente mover seu redirecionamento [fd-num] >> named_file para o destino de saída do loop, em vez de apenas direcionar a saída para o comando em loop, pode melhorar substancialmente o desempenho porque, pelo menos, o open() syscall só precisa ser feito uma vez. Isso também é feito abaixo com o canal | direcionado como saída para os loops internos.

comparação direta:

Você pode gostar de:

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

Qual é tipo semelhante ao sub-relacionamento de comando descrito anteriormente, mas não há nenhum canal e o filho é em segundo plano até matar o pai. No caso yes o pai foi realmente substituído desde que o filho foi gerado, mas o shell chama yes sobrepondo seu próprio processo com o novo e assim o PID permanece o mesmo e seu filho zumbi ainda sabe quem matar afinal de contas.

buffer maior:

Agora vamos ver como aumentar o buffer write() 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 sequências de saída de mais de 1kb estavam sendo divididas em write() 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 é 300 vezes a quantidade de dados gravados pelo shell no mesmo período de tempo que o último. Não é muito pobre. Mas não é yes .

relacionado:

Conforme solicitado, há uma descrição mais detalhada do que os simples comentários de código sobre o que é feito aqui em este link .

    
por 24.01.2016 / 06:25
18

Uma pergunta melhor seria por que seu shell está escrevendo o arquivo tão lentamente. Qualquer programa compilado auto-contido que use syscalls de escrita de arquivo responsavelmente (não liberando cada caractere de cada vez) faria isso razoavelmente rápido. O que você está fazendo é escrever linhas em uma linguagem interpretada (o shell) e, além disso, você faz um lote de operações de saída de entrada desnecessárias. O que yes faz:

  • abre um arquivo para escrever
  • chama funções otimizadas e compiladas para escrevendo para um fluxo
  • o fluxo é armazenado em buffer, então 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, fazendo várias operações extras para realmente 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 comando date external 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)
    • teste se a condição de finalização do loop é atendida
    • abre um arquivo no modo de anexação
    • parse echo comando, reconheça-o (com algum código de correspondência de padrão) como um shell embutido, chame a expansão de parâmetro e tudo mais no argumento "GNU" e, finalmente, escreva a linha no arquivo aberto
    • fecha o arquivo novamente
    • repita o processo

As partes caras: toda a interpretação é extremamente cara (o bash está fazendo uma grande quantidade de pré-processamento de toda a entrada - sua cadeia poderia conter substituição variável, substituição de processo, expansão de chave, caracteres de escape e mais), um builtin é provavelmente uma instrução switch com redirecionamento para uma função que lida com o builtin, e muito importante, você abre e fecha um arquivo para cada linha de saída. Você poderia colocar >> file fora do loop while para torná-lo muito mais rápido , mas você ainda está em uma linguagem interpretada. Você tem muita sorte que echo seja 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 atrapalharia o processo - você viu como isso é caro quando você tinha o comando date no loop.

    
por 24.01.2016 / 21:53
10

As outras respostas abordaram os principais pontos. Em uma nota lateral, você pode aumentar o rendimento de 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
    
por 25.01.2016 / 01:10