Por que o uso de um loop de shell para processar o texto é considerado uma má prática?

196

O uso de um loop while para processar o texto geralmente é considerado uma má prática nos shells POSIX?

Como Stéphane Chazelas apontou , algumas das razões para não usar o loop de shell são conceitual , confiabilidade , legibilidade , desempenho e segurança .

Esta resposta explica os aspectos de confiabilidade e legibilidade :

while IFS= read -r line <&3; do
  printf '%s\n' "$line"
done 3< "$InputFile"

Para desempenho , o whileloop e a leitura são tremendamente lentos ao ler de um arquivo ou canal, porque o shell de leitura interno lê um caractere de cada vez.

E quanto aos aspectos conceituais e de segurança ?

cuonglm
fonte
Relacionado (o outro lado da moeda): Como a yesgravação no arquivo é tão rápida?
Curinga
1
O shell de leitura interno não lê um único caractere de cada vez, lê uma única linha de cada vez. wiki.bash-hackers.org/commands/builtin/read
A.Danischewski
@ A.Danischewski: Depende do seu shell. Em bash, lê um tamanho de buffer de cada vez, tente dashpor exemplo. Veja também unix.stackexchange.com/q/209123/38906
cuonglm

Respostas:

256

Sim, vemos várias coisas como:

while read line; do
  echo $line | cut -c3
done

Ou pior:

for line in `cat file`; do
  foo=`echo $line | awk '{print $2}'`
  echo whatever $foo
done

(não ria, eu já vi muitos deles).

Geralmente de iniciantes em scripts de shell. Essas são traduções literais ingênuas do que você faria em linguagens imperativas como C ou python, mas não é assim que você faz as coisas em shells, e esses exemplos são muito ineficientes, completamente não confiáveis ​​(potencialmente levando a problemas de segurança) e, se você conseguir, para corrigir a maioria dos erros, seu código fica ilegível.

Conceitualmente

Em C ou na maioria dos outros idiomas, os blocos de construção estão apenas um nível acima das instruções do computador. Você diz ao seu processador o que fazer e depois o que fazer em seguida. Você pega seu processador manualmente e o administra de maneira micro: você abre esse arquivo, lê muitos bytes, faz isso, faz isso com ele.

Os reservatórios são uma linguagem de nível superior. Pode-se dizer que nem é uma língua. Eles estão antes de todos os intérpretes de linha de comando. O trabalho é realizado por esses comandos que você executa e o shell serve apenas para orquestrá-los.

Uma das grandes coisas que o Unix introduziu foi o pipe e os fluxos stdin / stdout / stderr padrão que todos os comandos manipulam por padrão.

Em 45 anos, não achamos melhor que essa API para aproveitar o poder dos comandos e fazê-los cooperar em uma tarefa. Essa é provavelmente a principal razão pela qual as pessoas ainda usam conchas hoje.

Você tem uma ferramenta de corte e uma ferramenta de transliteração e pode simplesmente:

cut -c4-5 < in | tr a b > out

O shell está apenas fazendo o encanamento (abra os arquivos, configure os canos, chame os comandos) e, quando estiver pronto, ele flui sem que o shell faça qualquer coisa. As ferramentas realizam seu trabalho simultaneamente, eficientemente em seu próprio ritmo, com buffer suficiente, para não bloquear um ao outro, é simplesmente bonito e ao mesmo tempo tão simples.

Invocar uma ferramenta tem um custo (e vamos desenvolvê-la no ponto de desempenho). Essas ferramentas podem ser escritas com milhares de instruções em C. Um processo deve ser criado, a ferramenta deve ser carregada, inicializada e, em seguida, limpa, destruída e aguardada.

Invocar cuté como abrir a gaveta da cozinha, pegue a faca, use-a, lave-a, seque-a e coloque-a de volta na gaveta. Quando você faz:

while read line; do
  echo $line | cut -c3
done < file

É como em cada linha do arquivo, pegar a readferramenta na gaveta da cozinha (muito desajeitada porque não foi projetada para isso ), ler uma linha, lavar a ferramenta de leitura e recolocá-la na gaveta. Em seguida, agende uma reunião para a ferramenta echoe cut, pegue-a na gaveta, chame-a, lave-a, seque-a, coloque-a de volta na gaveta e assim por diante.

Algumas dessas ferramentas ( reade echo) são construídos na maioria das conchas, mas que dificilmente faz a diferença aqui desde echoe cutainda precisam ser executados em processos separados.

É como cortar uma cebola, mas lavar a faca e colocá-la de volta na gaveta da cozinha entre cada fatia.

Aqui, a maneira mais óbvia é tirar a cutferramenta da gaveta, cortar sua cebola inteira e recolocá-la na gaveta após todo o trabalho.

IOW, em shells, especialmente para processar texto, você invoca o menor número possível de utilitários e os coopera com a tarefa, não executa milhares de ferramentas em sequência, esperando que cada um inicie, execute, limpe antes de executar o próximo.

Leitura adicional na boa resposta de Bruce . As ferramentas internas de processamento de texto de baixo nível em shells (exceto talvez zsh) são limitadas, pesadas e geralmente não são adequadas para o processamento geral de texto.

atuação

Como dito anteriormente, executar um comando tem um custo. Um custo enorme se esse comando não estiver embutido, mas mesmo se estiverem embutidos, o custo será alto.

E os shells não foram projetados para funcionar assim, não têm pretensão de serem linguagens de programação com desempenho. Eles não são, são apenas intérpretes de linha de comando. Portanto, pouca otimização foi feita nessa frente.

Além disso, os shells executam comandos em processos separados. Esses componentes não compartilham uma memória ou estado comum. Quando você faz um fgets()ou fputs()em C, isso é uma função no stdio. O stdio mantém buffers internos para entrada e saída para todas as funções do stdio, para evitar fazer chamadas dispendiosas do sistema com muita freqüência.

Os correspondentes até mesmo utilitários de shell builtin ( read, echo, printf) não pode fazer isso. readdestina-se a ler uma linha. Se ele ler além do caractere de nova linha, isso significa que o próximo comando que você executar perderá. Portanto, readé necessário ler a entrada um byte de cada vez (algumas implementações têm uma otimização se a entrada for um arquivo regular, pois eles lêem pedaços e procuram novamente, mas isso só funciona para arquivos regulares e, bashpor exemplo, lê apenas pedaços de 128 bytes, o que é ainda muito menos do que os utilitários de texto).

O mesmo no lado da saída, echonão pode apenas armazenar sua saída em buffer, ele precisa enviá-la imediatamente, porque o próximo comando que você executar não compartilhará esse buffer.

Obviamente, executar comandos sequencialmente significa que você precisa esperar por eles; é uma pequena dança do agendador que fornece controle do shell e das ferramentas e vice-versa. Isso também significa (em oposição ao uso de instâncias de ferramentas de execução longa em um pipeline) que você não pode aproveitar vários processadores ao mesmo tempo, quando disponíveis.

Entre esse while readloop e o (supostamente) equivalente cut -c3 < file, no meu teste rápido, há uma taxa de tempo de CPU de cerca de 40000 nos meus testes (um segundo versus meio dia). Mas mesmo se você usar apenas os recursos internos do shell:

while read line; do
  echo ${line:2:1}
done

(aqui com bash), isso ainda é cerca de 1: 600 (um segundo vs 10 minutos).

Confiabilidade / legibilidade

É muito difícil acertar esse código. Os exemplos que dei são vistos com muita frequência na natureza, mas eles têm muitos bugs.

readé uma ferramenta útil que pode fazer muitas coisas diferentes. Pode ler a entrada do usuário, dividi-la em palavras para armazenar em diferentes variáveis. read linese não ler uma linha de entrada, ou talvez ele lê uma linha de uma maneira muito especial. É realmente lê palavras a partir da entrada aquelas palavras separadas por $IFSe onde barra invertida pode ser usado para escapar dos separadores ou o caractere de nova linha.

Com o valor padrão de $IFS, em uma entrada como:

   foo\/bar \
baz
biz

read linearmazenará "foo/bar baz"em $line, não " foo\/bar \"como seria de esperar.

Para ler uma linha, você realmente precisa:

IFS= read -r line

Isso não é muito intuitivo, mas é assim que é, lembre-se de que as conchas não foram feitas para serem usadas assim.

Mesmo para echo. echoexpande seqüências. Você não pode usá-lo para conteúdos arbitrários, como o conteúdo de um arquivo aleatório. Você precisa printfaqui em vez disso.

E, claro, há o típico esquecimento de citar sua variável na qual todos caem. Então é mais:

while IFS= read -r line; do
  printf '%s\n' "$line" | cut -c3
done < file

Agora, mais algumas advertências:

  • exceto zsh, isso não funcionará se a entrada contiver caracteres NUL enquanto pelo menos os utilitários de texto GNU não tiverem o problema.
  • se houver dados após a última nova linha, eles serão ignorados
  • dentro do loop, o stdin é redirecionado, portanto você precisa prestar atenção para que os comandos nele não sejam lidos no stdin.
  • para os comandos nos loops, não estamos prestando atenção se eles são bem-sucedidos ou não. Geralmente, as condições de erro (disco cheio, erros de leitura ...) serão mal tratadas, geralmente mais mal do que com o equivalente correto .

Se quisermos abordar alguns desses problemas acima, isso se tornará:

while IFS= read -r line <&3; do
  {
    printf '%s\n' "$line" | cut -c3 || exit
  } 3<&-
done 3< file
if [ -n "$line" ]; then
    printf '%s' "$line" | cut -c3 || exit
fi

Isso está se tornando cada vez menos legível.

Existem vários outros problemas ao passar dados para comandos por meio dos argumentos ou recuperar sua saída em variáveis:

  • a limitação no tamanho dos argumentos (algumas implementações de utilitário de texto também têm um limite lá, embora o efeito daquelas que são alcançadas seja geralmente menos problemático)
  • o caractere NUL (também é um problema com os utilitários de texto).
  • argumentos tomados como opções quando começam com -(ou +às vezes)
  • diversas peculiaridades de vários comandos normalmente utilizados nesses loops como expr, test...
  • os operadores de manipulação de texto (limitados) de vários shells que manipulam caracteres de vários bytes de maneiras inconsistentes.
  • ...

Considerações de segurança

Quando você começa a trabalhar com variáveis ​​de shell e argumentos para comandos , está inserindo um campo minado.

Se você esquecer de citar suas variáveis , esquecer o marcador de fim de opção , trabalhar em locais com caracteres de vários bytes (a norma atualmente), certamente introduzirá erros que mais cedo ou mais tarde se tornarão vulnerabilidades.

Quando você pode querer usar loops.

TBD

Stéphane Chazelas
fonte
24
Claro (vividamente), legível e extremamente útil. Agradeço novamente. Esta é realmente a melhor explicação que eu já vi em qualquer lugar da Internet sobre a diferença fundamental entre scripts e programação de shell.
Wildcard
2
São posts como esses que ajudam os iniciantes a aprender sobre os Shell Scripts e ver suas diferenças sutis. Deve adicionar a variável de referência como $ {VAR: -default_value} para garantir que você não obtenha um nulo. e defina -o nounset para gritar com você ao referenciar um valor não definido.
Unsignedzero
6
@ A.Danischewski, acho que você está perdendo o objetivo. Sim, cutpor exemplo, é eficiente. cut -f1 < a-very-big-fileé eficiente, o mais eficiente possível, se você o escrever em C. O que é terrivelmente ineficiente e suscetível a erros é invocado cutpara cada linha de um a-very-big-fileloop em um shell, que é o ponto a ser destacado nesta resposta. Isso concorda com sua última afirmação sobre a criação de códigos desnecessários, o que me faz pensar que talvez eu não entenda seu comentário.
Stéphane Chazelas
5
"Em 45 anos, não achamos melhor que essa API para aproveitar o poder dos comandos e fazê-los cooperar em uma tarefa". - na verdade, o PowerShell, por exemplo, resolveu o temido problema de análise, passando dados estruturados em vez de fluxos de bytes. A única razão pela qual os shells ainda não o usam (a idéia já existe há algum tempo e basicamente se cristalizou em algum momento em torno de Java quando os tipos de contêineres de lista e dicionário agora padrão se tornaram populares) é que seus mantenedores ainda não conseguiam concordar com o formato de dados estruturado comum a ser usado (.
ivan_pozdeev 11/11
6
@OlivierDulac Eu acho que é um pouco de humor. Essa seção será para sempre TBD.
muru 13/05/19
43

No que diz respeito ao conceito e à legibilidade, os shells normalmente estão interessados ​​em arquivos. A "unidade endereçável" é o arquivo e o "endereço" é o nome do arquivo. Os shells têm todos os tipos de métodos de teste para existência de arquivos, tipo de arquivo, formatação de nome de arquivo (começando com globbing). Os shells têm muito poucas primitivas para lidar com o conteúdo do arquivo. Os programadores de shell precisam chamar outro programa para lidar com o conteúdo do arquivo.

Por causa da orientação do arquivo e do nome do arquivo, a manipulação de texto no shell é muito lenta, como você notou, mas também requer um estilo de programação pouco claro e distorcido.

Bruce Ediger
fonte
25

Existem algumas respostas complicadas, dando muitos detalhes interessantes para os geeks entre nós, mas é realmente bastante simples - processar um arquivo grande em um loop de shell é muito lento.

Eu acho que o questionador é interessante em um tipo típico de script de shell, que pode começar com algumas análises de linha de comando, configuração do ambiente, verificação de arquivos e diretórios e um pouco mais de inicialização, antes de iniciar seu trabalho principal: passar por uma grande arquivo de texto orientado a linhas.

Para as primeiras partes ( initialization), geralmente não importa que os comandos do shell sejam lentos - ele está executando apenas algumas dezenas de comandos, talvez com alguns loops curtos. Mesmo se escrevermos essa parte de maneira ineficiente, geralmente levará menos de um segundo para fazer toda essa inicialização, e tudo bem - isso só acontece uma vez.

Porém, quando processamos o arquivo grande, que pode ter milhares ou milhões de linhas, não é bom que o script do shell leve uma fração significativa de segundo (mesmo que sejam apenas algumas dezenas de milissegundos) para cada linha, pois isso pode levar horas.

É quando precisamos usar outras ferramentas, e a beleza dos scripts de shell do Unix é que eles tornam muito fácil fazer isso.

Em vez de usar um loop para examinar cada linha, precisamos passar o arquivo inteiro por um pipeline de comandos . Isso significa que, em vez de chamar os comandos milhares ou milhões de vezes, o shell os chama apenas uma vez. É verdade que esses comandos terão loops para processar o arquivo linha por linha, mas eles não são scripts de shell e foram projetados para serem rápidos e eficientes.

O Unix possui muitas ferramentas maravilhosas, que vão do simples ao complexo, que podemos usar para construir nossos pipelines. Eu normalmente começaria com os mais simples, e só usava os mais complexos quando necessário.

Eu também tentava usar as ferramentas padrão disponíveis na maioria dos sistemas e tentava manter meu uso portátil, embora isso nem sempre seja possível. E se o seu idioma favorito for Python ou Ruby, talvez você não se importe com o esforço extra de garantir que ele esteja instalado em todas as plataformas em que o seu software precisa rodar :-)

Ferramentas simples incluem head, tail, grep, sort, cut, tr, sed, join(ao mesclar 2 arquivos) e awkone-liners, entre muitos outros. É incrível o que algumas pessoas podem fazer com correspondência de padrões e sedcomandos.

Quando fica mais complexo, e você realmente precisa aplicar alguma lógica a cada linha, awké uma boa opção - seja uma linha (algumas pessoas colocam scripts awk inteiros em 'uma linha', embora isso não seja muito legível) ou em um script externo curto.

Como awké uma linguagem interpretada (como o seu shell), é incrível que ele possa executar o processamento linha a linha com tanta eficiência, mas foi desenvolvido especificamente para isso e é realmente muito rápido.

Além disso, há Perlum grande número de outras linguagens de script que são muito boas no processamento de arquivos de texto e também vêm com muitas bibliotecas úteis.

E, finalmente, há o bom e velho C, se você precisar de velocidade máxima e alta flexibilidade (embora o processamento de texto seja um pouco entediante). Mas é provavelmente um uso muito ruim do seu tempo escrever um novo programa C para cada tarefa de processamento de arquivos que você se deparar. Como trabalho muito com arquivos CSV, escrevi vários utilitários genéricos em C que podem ser reutilizados em muitos projetos diferentes. Na verdade, isso expande o leque de 'ferramentas simples e rápidas do Unix' que posso chamar dos meus scripts de shell, para que eu possa lidar com a maioria dos projetos apenas escrevendo scripts, o que é muito mais rápido do que escrever e depurar códigos C personalizados sempre!

Algumas dicas finais:

  • não se esqueça de iniciar seu shell principal export LANG=C, ou muitas ferramentas tratarão seus arquivos ASCII simples como Unicode, tornando-os muito mais lentos
  • considere também a configuração export LC_ALL=Cse desejar sortproduzir pedidos consistentes, independentemente do ambiente!
  • se você precisar dos sortseus dados, isso provavelmente levará mais tempo (e recursos: CPU, memória, disco) do que tudo o resto; tente minimizar o número de sortcomandos e o tamanho dos arquivos que eles estão classificando
  • um único pipeline, quando possível, geralmente é mais eficiente - executar vários pipelines em sequência, com arquivos intermediários, pode ser mais legível e passível de depuração, mas aumentará o tempo que o programa leva
Laurence Renshaw
fonte
6
Os pipelines de muitas ferramentas simples (especificamente as mencionadas, como cabeça, cauda, ​​grep, classificação, corte, tr, sed, ...) são frequentemente usados ​​desnecessariamente, especificamente se você já tiver uma instância awk nesse pipeline, o que pode fazer as tarefas dessas ferramentas simples também. Outra questão a ser considerada é que, em pipelines, você não pode transmitir informações de estado de maneira simples e confiável dos processos na parte frontal de um pipeline para os processos que aparecem na parte traseira. Se você usar para esses pipelines de programas simples um programa awk, terá um único espaço de estado.
Janis
14

Sim mas...

A resposta correta de Stéphane Chazelas é baseado em conceito de delegação de cada operação de texto para binários específicos, como grep, awk, sede outros.

Como o é capaz de fazer muitas coisas sozinho, soltar os garfos pode se tornar mais rápido (mesmo que executar outro intérprete para fazer todo o trabalho).

Por exemplo, dê uma olhada neste post:

https://stackoverflow.com/a/38790442/1765658

e

https://stackoverflow.com/a/7180078/1765658

testar e comparar ...

Claro

Não há consideração sobre a entrada e segurança do usuário !

Não escreva aplicação web sob !!

Porém, para muitas tarefas de administração de servidor, nas quais o poderia ser usado no lugar do , o uso do builtins bash poderia ser muito eficiente.

Meu significado:

Escrever ferramentas como bin utils não é o mesmo tipo de trabalho que a administração do sistema.

Então não são as mesmas pessoas!

Onde os administradores de sistemas precisam saber shell, eles podem escrever protótipos usando sua ferramenta preferida (e mais conhecida).

Se esse novo utilitário (protótipo) for realmente útil, outras pessoas poderão desenvolver uma ferramenta dedicada usando uma linguagem mais apropriada.

F. Hauri
fonte
1
Bom exemplo. Sua abordagem é certamente mais eficiente que a do lololux, mas observe como a resposta do tensibai (a maneira correta de fazer essa IMO, sem usar loops de shell) é de magnitude superior a sua. E o seu é muito mais rápido se você não usar bash. (mais de três vezes mais rápido com o ksh93 no meu teste no meu sistema). bashgeralmente é o shell mais lento. Even zshé duas vezes mais rápido nesse script. Você também tem alguns problemas com variáveis ​​não citadas e com o uso de read. Então você está ilustrando muitos dos meus pontos aqui.
Stéphane Chazelas
@ StéphaneChazelas Eu concordo, o bash é provavelmente o shell mais lento que as pessoas poderiam usar hoje, mas o mais amplamente usado de qualquer maneira.
F. Hauri
@ StéphaneChazelas Publiquei uma versão perl na minha resposta
F. Hauri
1
@Tensibai, você vai encontrar POSIXsh , Awk , Sed , grep, ed, ex, cut, sort, join... tudo com mais confiabilidade do que Bash ou Perl.
Curinga
1
O @Tensibai, de todos os sistemas envolvidos em U&L, a maioria deles (Solaris, FreeBSD, HP / UX, AIX, a maioria dos sistemas Linux embutidos ...) não é bashinstalado por padrão. bashé encontrada principalmente apenas em Apple MacOS e sistemas GNU (eu suponho que é o que você chama principais distribuições ), embora muitos sistemas também tê-lo como um pacote opcional (como zsh, tcl, python...)
Stéphane Chazelas