Performance em scripts
LidoEsta aula aborda técnicas para melhorar a performance de scripts Bash, focando em evitar subshells desnecessários, escolher built-ins em vez de comandos externos, escrever loops eficientes e medir o desempenho com ferramentas como time.
Escrever scripts que executam rapidamente é essencial em ambientes de produção, onde scripts podem ser executados milhares de vezes. Nesta aula, vamos explorar técnicas práticas para otimizar o desempenho de scripts Bash, desde evitar subshells desnecessários até medir o tempo de execução. Você aprenderá a identificar gargalos e aplicar soluções simples que podem reduzir drasticamente o tempo de execução.
O Bash é uma linguagem interpretada, e cada comando externo invocado tem um custo de inicialização de processo. Portanto, entender como minimizar o uso de subshells e comandos externos é crucial. Vamos analisar cada técnica com exemplos práticos e medir o impacto usando o comando time.
Evitando subshells desnecessários
Um subshell é um processo filho criado pelo Bash para executar comandos em um contexto separado. Subshells são criados quando usamos parênteses (comando), quando encadeamos comandos com pipe (|), ou quando executamos comandos em segundo plano com &. Cada subshell tem custo adicional de criação e comunicação. Em loops e funções, o uso excessivo de subshells pode degradar o desempenho.
Um exemplo comum é usar cat arquivo | while read linha, que cria um subshell para o loop while. Uma alternativa mais eficiente é usar redirecionamento: while read linha; do ...; done < arquivo. Isso evita o subshell do pipe e permite que as variáveis definidas dentro do loop persistam após o loop.
Outra situação é quando usamos parênteses para agrupar comandos sem necessidade. Por exemplo, (cd /tmp; ls) cria um subshell, mas se não precisamos de um subshell, podemos usar cd /tmp && ls ou simplesmente cd /tmp; ls no contexto atual. Isso evita a criação de um processo filho.
# Ineficiente: subshell via pipe
cat dados.txt | while read linha; do
echo "$linha"
done
# Eficiente: redirecionamento sem subshell
while read linha; do
echo "$linha"
done < dados.txt
Outra técnica é usar mapfile (ou readarray) para ler um arquivo inteiro em um array, evitando loops desnecessários. Isso é mais rápido e usa menos subshells.
# Lendo arquivo em array eficientemente
mapfile -t linhas < dados.txt
for linha in "${linhas[@]}"; do
echo "$linha"
done
Em funções, tenha cuidado com o uso de pipes e subshells. Se você precisar modificar uma variável global dentro de uma função, evite usar um pipe que crie um subshell.
Built-ins vs comandos externos
O Bash possui built-ins (comandos internos) que são executados diretamente pelo shell, sem criar um novo processo. Comandos externos, como grep, awk, sed, cat, etc., são executados como processos separados, o que tem custo de inicialização. Para scripts com muitas chamadas, usar built-ins pode melhorar significativamente a performance.
Por exemplo, em vez de usar grep para verificar se uma string contém uma substring, podemos usar [[ $var == *substring* ]]. Para substituição de strings, ${var//padrão/substituição} é mais rápido que sed. Para obter o tamanho de uma string, ${#var} é um built-in, enquanto wc -c é externo.
# Externos: grep, sed, wc
if echo "$str" | grep -q "padrão"; then :; fi
novo=$(echo "$str" | sed 's/foo/bar/')
tamanho=$(echo -n "$str" | wc -c)
# Built-ins equivalentes
if [[ $str == *padrão* ]]; then :; fi
novo=${str//foo/bar}
tamanho=${#str}
Outros built-ins úteis: printf (em vez de echo + formatação externa), read (para ler variáveis), test ou [ ] (para comparações), e let ou (( )) (para aritmética).
Para operações de arquivo, como contar linhas, wc -l é um comando externo, mas se você já está lendo o arquivo em um loop, pode contar as linhas dentro do próprio loop, evitando uma chamada externa.
# Contando linhas com wc (externo)
count=$(wc -l < dados.txt)
# Contando linhas com loop (built-in)
count=0
while IFS= read -r line; do
((count++))
done < dados.txt
No entanto, para arquivos grandes, wc é otimizado em C e pode ser mais rápido que um loop Bash. Portanto, é importante medir e escolher a abordagem mais adequada.
Loops eficientes
Loops são comuns em scripts, mas podem ser lentos se não forem bem escritos. Evite criar subshells dentro de loops, minimize o uso de comandos externos e prefira operações em lote quando possível.
Uma técnica é usar for em vez de while quando você sabe o número de iterações ou está iterando sobre uma lista. for é geralmente mais rápido porque não depende de entrada externa. Além disso, evite usar cat para ler arquivos; use redirecionamento ou mapfile.
Outra otimização é reduzir o número de comandos dentro do loop. Por exemplo, em vez de chamar echo várias vezes, acumule a saída em uma variável e imprima uma vez no final. Isso reduz a quantidade de chamadas de sistema.
# Loop ineficiente: muitos echos
for i in {1..1000}; do
echo "Número: $i"
done
# Loop eficiente: acumulando saída
saida=""
for i in {1..1000}; do
saida+="Número: $i
"
done
echo -e "$saida"
Para processar arquivos grandes, considere usar ferramentas como awk ou sed que são otimizadas em C, em vez de um loop Bash. Por exemplo, para extrair uma coluna de um CSV, awk -F',' '{print $1}' é muito mais rápido que um loop while read.
# Loop Bash para extrair primeira coluna
while IFS=',' read -r col1 rest; do
echo "$col1"
done < dados.csv
# Awk (muito mais rápido)
awk -F',' '{print $1}' dados.csv
Se você precisar usar um loop, tente usar for com listas geradas por expansão de chaves ou por seq, mas cuidado com listas muito grandes que podem causar estouro de memória. Para intervalos grandes, um loop for com seq é eficiente.
Medindo
Para otimizar, você precisa medir. A ferramenta mais comum é o comando time, que mostra o tempo real, de usuário e de sistema. Use time com o script ou comando para ver o desempenho.
time ./meu_script.sh
O Bash também tem o built-in time que pode ser usado para medir blocos de código. Para medições mais detalhadas, use time com a opção -p para saída POSIX.
time -p comando
Outra ferramenta é o date para marcar tempos de início e fim, mas time é mais simples. Para análise de desempenho mais avançada, você pode usar strace para ver chamadas de sistema, ou perf para profiling, mas isso é mais complexo.
Exemplo de medição de um loop:
time for i in {1..10000}; do :; done
Para comparar duas abordagens, execute cada uma com time e compare os tempos. Lembre-se de que o tempo pode variar devido a outros processos, então execute várias vezes e use a mediana.
Além disso, você pode usar date +%s%N para obter nanossegundos e medir manualmente:
inicio=$(date +%s%N)
# seu código
fim=$(date +%s%N)
echo "Tempo: $(( (fim - inicio) / 1000000 )) ms"
Para scripts complexos, crie funções de medição reutilizáveis e use trap para medir o tempo de cada função.
Boas práticas
Aqui estão algumas boas práticas para manter a performance em scripts:
- Prefira built-ins a comandos externos sempre que possível.
- Evite subshells desnecessários, especialmente em loops.
- Use
mapfilepara ler arquivos grandes. - Acumule saída em variáveis em vez de imprimir muitas vezes.
- Use ferramentas como
awkesedpara processamento de texto pesado. - Meça o desempenho antes e depois de otimizações para garantir melhorias.
Referências
- Bash Reference Manual
- Advanced Bash-Scripting Guide
- ShellCheck - shell script analysis tool
- Bash Pitfalls
- time(1) - Linux manual page
- GNU Coreutils: time invocation
Exercícios
- Exercício 1: Explique por que usar
cat arquivo | while read linhaé ineficiente e como você pode reescrever isso para evitar o subshell. - Exercício 2: Escreva dois trechos de código para contar o número de linhas de um arquivo: um usando
wc -le outro usando um loopwhilecom redirecionamento. Explique qual é mais rápido para arquivos pequenos e para arquivos grandes. - Exercício 3: Dado o seguinte comando:
for i in $(seq 1 1000); do echo $i; done. Escreva uma versão mais eficiente usando expansão de chaves e acumulando a saída. - Exercício 4: Use o comando
timepara medir o tempo de execução de um script simples que imprime os números de 1 a 10000. Execute duas versões: uma usandoechodentro do loop e outra usando acumulação de saída. Compare os tempos e explique a diferença. - Exercício 5: Escreva um script que use
awkpara extrair o segundo campo de um arquivo CSV e compare com um loop Bash que faz o mesmo. Usetimepara medir ambos e discuta os resultados.
cat arquivo | while read linha cria um subshell para o loop while, porque o pipe é executado em um subshell. Isso significa que variáveis definidas dentro do loop não persistem após o loop. Além disso, há o custo de criar um processo extra para o cat. Para evitar, use redirecionamento: while read linha; do ...; done < arquivo. Isso executa o loop no shell atual, sem subshell, e é mais eficiente.# Usando wc
count=$(wc -l < arquivo)
# Usando loop
count=0
while IFS= read -r _; do
((count++))
done < arquivo
Para arquivos pequenos, o loop pode ser mais rápido porque evita a criação de um processo externo, mas para arquivos grandes, wc é muito mais rápido porque é implementado em C e otimizado, enquanto o loop Bash interpreta cada linha. Na prática, wc é quase sempre mais rápido para arquivos com mais de algumas centenas de linhas.saida=""
for i in {1..1000}; do
saida+="$i\n"
done
echo -e "$saida"
Isso evita a chamada externa a seq e reduz o número de chamadas a echo, acumulando a saída em uma variável.time para cada um. A versão com acumulação será mais rápida porque reduz o número de chamadas de sistema de escrita (write). O echo é um built-in, mas cada chamada ainda envolve uma operação de escrita no terminal, que é relativamente custosa. Acumular a saída e imprimir uma única vez reduz drasticamente o número de syscalls, resultando em um desempenho muito melhor.# Loop Bash
while IFS=',' read -r _ campo2 _; do
echo "$campo2"
done < dados.csv
# Awk
awk -F',' '{print $2}' dados.csv
O awk será significativamente mais rápido para arquivos grandes porque é uma ferramenta especializada implementada em C, otimizada para processamento de texto. O loop Bash interpreta cada linha e faz múltiplas operações, o que é mais lento. Para arquivos pequenos, a diferença pode não ser perceptível, mas para grandes volumes, awk é a escolha certa.