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 mapfile para ler arquivos grandes.
  • Acumule saída em variáveis em vez de imprimir muitas vezes.
  • Use ferramentas como awk e sed para processamento de texto pesado.
  • Meça o desempenho antes e depois de otimizações para garantir melhorias.

Referências

Exercícios

  1. Exercício 1: Explique por que usar cat arquivo | while read linha é ineficiente e como você pode reescrever isso para evitar o subshell.
  2. ✓ Resposta: O uso de 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.
  3. Exercício 2: Escreva dois trechos de código para contar o número de linhas de um arquivo: um usando wc -l e outro usando um loop while com redirecionamento. Explique qual é mais rápido para arquivos pequenos e para arquivos grandes.
  4. ✓ Resposta:
    # 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.
  5. 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.
  6. ✓ Resposta:
    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.
  7. Exercício 4: Use o comando time para medir o tempo de execução de um script simples que imprime os números de 1 a 10000. Execute duas versões: uma usando echo dentro do loop e outra usando acumulação de saída. Compare os tempos e explique a diferença.
  8. ✓ Resposta: Você deve criar um script com o loop que imprime diretamente e outro que acumula a saída. Use 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.
  9. Exercício 5: Escreva um script que use awk para extrair o segundo campo de um arquivo CSV e compare com um loop Bash que faz o mesmo. Use time para medir ambos e discuta os resultados.
  10. ✓ Resposta:
    # 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.