Paralelismo em shell
LidoNesta aula, você aprenderá a executar tarefas em paralelo em shell scripts, desde o básico de jobs em background e o comando wait até ferramentas mais avançadas como o GNU parallel, além de técnicas para controlar o nível de concorrência e evitar sobrecarga do sistema.
Em scripts shell, muitas vezes precisamos executar várias tarefas que são independentes entre si. Fazer isso sequencialmente pode desperdiçar tempo e recursos, especialmente em máquinas com múltiplos núcleos de CPU. O paralelismo permite executar essas tarefas simultaneamente, reduzindo drasticamente o tempo total de execução. Nesta aula, vamos explorar as principais técnicas para paralelizar comandos e scripts em Bash, desde o simples uso de processos em background até o poderoso utilitário GNU parallel, que simplifica a execução paralela de comandos em lote.
Antes de mergulharmos nas ferramentas, é importante entender que o shell Bash já oferece mecanismos nativos para gerenciar processos: o operador & para colocar um comando em background, o comando wait para aguardar a conclusão desses processos, e o comando jobs para listá-los. Essas são as bases do paralelismo em shell. A partir delas, podemos construir soluções mais robustas e controladas, como usar xargs ou o GNU parallel para limitar o número de processos simultâneos. Vamos começar pelo fundamento: os jobs em background.
Jobs em background
Quando você executa um comando no shell, ele roda em foreground, ou seja, o terminal fica bloqueado até que o comando termine. Para executar um comando em background (em segundo plano), basta adicionar o operador & ao final da linha de comando. Isso faz com que o shell retorne imediatamente ao prompt, enquanto o processo continua rodando por baixo. O shell atribui um job ID (como [1]) e um PID (process ID) ao processo.
Exemplo simples:
sleep 10 &
O comando acima inicia um processo sleep de 10 segundos em segundo plano. O shell exibe algo como [1] 12345, onde 1 é o job ID e 12345 é o PID. Você pode verificar os jobs em execução com o comando jobs:
jobs -l
O -l mostra também os PIDs. Para trazer um job de volta ao foreground, use fg %1 (o %1 refere-se ao job ID). Para enviar um job para background novamente, use bg %1 (útil quando você pressiona Ctrl+Z para pausar).
É possível executar vários comandos em background de uma vez, inclusive redirecionando a saída para arquivos para evitar que se misturem no terminal:
(sleep 5; echo "tarefa 1 concluída") > saida1.txt &
(sleep 3; echo "tarefa 2 concluída") > saida2.txt &
Note que usamos parênteses para agrupar comandos em um subshell, mas poderíamos usar sleep 5; echo "..." > saida1.txt & também. O ponto principal é que cada comando em background é um processo separado e o shell não espera por eles a menos que usemos wait.
Uma dica importante: se você sair do terminal, os processos em background podem ser mortos (dependendo do shell e das configurações). Para evitar isso, use nohup ou disown. Por exemplo, nohup comando & ignora o sinal SIGHUP, e disown %1 remove o job da lista do shell, evitando que ele receba o sinal quando o shell terminar.
wait
O comando wait é essencial para sincronizar processos em background. Ele faz o script esperar até que todos os jobs em background terminem (ou um específico). Sem ele, o script continuaria imediatamente, o que poderia causar problemas se você precisa dos resultados dos processos para continuar.
Você pode usar wait sem argumentos para esperar todos os jobs, ou com um PID ou job ID para esperar um específico. Exemplo:
#!/bin/bash
# Executa duas tarefas em paralelo
tarefa1 () {
sleep 5
echo "Tarefa 1 concluída"
}
tarefa2 () {
sleep 3
echo "Tarefa 2 concluída"
}
tarefa1 &
tarefa2 &
# Espera todos os jobs terminarem
wait
echo "Todas as tarefas terminaram"
Ao executar, o script imprime as mensagens das tarefas conforme elas terminam (primeiro a tarefa 2, depois a tarefa 1) e, ao final, a mensagem "Todas as tarefas terminaram". Sem o wait, a última linha seria impressa antes das tarefas terminarem, o que geralmente não é o desejado.
O wait também retorna o código de saída do processo esperado. Se você quiser capturar o status de cada processo, pode fazer:
comando1 &
pid1=$!
comando2 &
pid2=$!
wait $pid1
status1=$?
wait $pid2
status2=$?
echo "Status 1: $status1, Status 2: $status2"
O $! expande para o PID do último processo em background. Isso é útil para verificar se cada tarefa foi bem-sucedida.
Uma pegadinha: se você usar wait sem argumentos, ele espera todos os jobs, mas não permite capturar o status individual. Para maior controle, prefira esperar por PID específicos.
GNU parallel (visão geral)
O GNU parallel é uma ferramenta poderosa para executar comandos em paralelo, especialmente quando você tem uma lista de itens (arquivos, URLs, etc.) e deseja aplicar o mesmo comando a cada um, mas limitando o número de execuções simultâneas. Ele é mais conveniente do que gerenciar manualmente jobs em background, pois cuida de filas, distribuição de carga, e coleta de saída de forma organizada.
Para instalar no Ubuntu/Debian: sudo apt install parallel. No CentOS/RHEL: sudo yum install parallel (ou dnf). No macOS com Homebrew: brew install parallel.
O uso básico é: parallel 'comando {}' ::: item1 item2 item3. O {} é substituído por cada item. Exemplo:
parallel echo "Processando {}" ::: arquivo1.txt arquivo2.txt arquivo3.txt
Isso executará três comandos echo em paralelo. Por padrão, o número de jobs simultâneos é igual ao número de núcleos da CPU. Você pode alterar isso com -j ou --jobs. Por exemplo, -j 2 limita a dois processos simultâneos.
Outro uso comum é com arquivos de entrada:
parallel -a urls.txt curl -O {}
Isso baixa todos os arquivos listados em urls.txt em paralelo. O GNU parallel também permite combinar múltiplas entradas, usar funções shell, e até mesmo distribuir tarefas em múltiplas máquinas (com SSH). É uma ferramenta extremamente versátil.
Um exemplo mais elaborado, usando uma função:
#!/bin/bash
processa() {
echo "Processando $1"
sleep 1
echo "Terminei $1"
}
export -f processa
parallel -j 3 processa ::: 1 2 3 4 5
Note que é necessário exportar a função com export -f para que ela esteja disponível nos subshells que o parallel cria. O -j 3 limita a 3 execuções simultâneas, então a saída será algo como:
Processando 1
Processando 2
Processando 3
Terminei 1
Terminei 2
Terminei 3
Processando 4
Processando 5
Terminei 4
Terminei 5
O GNU parallel também oferece opções para controlar a saída, como --keep-order para manter a ordem dos resultados, e --progress para mostrar uma barra de progresso. É uma ferramenta indispensável para scripts que processam muitos arquivos ou dados.
Controle de concorrência
Quando usamos & e wait, o número de processos simultâneos é igual ao número de comandos que colocamos em background. Se tivermos, por exemplo, 1000 itens, não podemos lançar 1000 processos de uma vez, pois isso sobrecarregaria o sistema. Precisamos controlar o número máximo de processos simultâneos (concorrência). Existem várias técnicas para isso, desde as mais artesanais até o uso de ferramentas como xargs e parallel.
Uma abordagem manual é usar uma fila e o comando wait dentro de um loop, mas isso é complicado. Uma técnica comum é usar o xargs com a opção -P (parallel). Por exemplo:
seq 1 100 | xargs -P 10 -I {} bash -c 'echo "Executando {}..."; sleep 1'
Aqui, -P 10 define o número máximo de processos simultâneos (10). O -I {} substitui {} pelo item. O comando bash -c é usado para executar uma string de comando. Isso é uma forma simples de limitar a concorrência.
Outra técnica é usar um semáforo com mkfifo (named pipe) e read, mas é mais avançada. O GNU parallel já resolve isso de forma elegante com a opção -j. Portanto, para scripts mais complexos, recomenda-se usar o parallel.
Vamos ver um exemplo de script que processa vários arquivos com limite de concorrência usando parallel:
#!/bin/bash
# Processa todos os arquivos .txt em paralelo, no máximo 5 por vez
processa_arquivo() {
echo "Processando $1"
# Simula algum trabalho
sleep 2
echo "Finalizado $1"
}
export -f processa_arquivo
ls *.txt | parallel -j 5 processa_arquivo
Isso garante que nunca haja mais de 5 processos simultâneos, evitando sobrecarga de CPU e memória. Além disso, o parallel cuida da coleta de saída, evitando que as mensagens se misturem de forma bagunçada.
Se você não quiser usar dependências externas, pode implementar um controle manual usando um contador e o comando wait, mas exige cuidado. Um exemplo simples:
#!/bin/bash
MAX_JOBS=5
pids=()
for i in $(seq 1 20); do
# Se atingiu o máximo, espera um processo terminar
while [ ${#pids[@]} -ge $MAX_JOBS ]; do
# Espera o primeiro PID terminar
wait ${pids[0]}
# Remove o PID da lista
unset pids[0]
pids=("${pids[@]}")
done
echo "Iniciando tarefa $i"
sleep 1 &
pids+=($!)
done
# Espera todos os restantes
wait
echo "Todas as tarefas concluídas"
Esse script funciona, mas tem a limitação de que wait espera um processo específico e não o que terminar primeiro. Para uma solução mais robusta, seria necessário usar wait -n (no Bash 4.3+) que espera o próximo processo a terminar. Exemplo:
#!/bin/bash
MAX_JOBS=5
pids=()
for i in $(seq 1 20); do
# Se temos MAX_JOBS, espera o próximo terminar
if [ ${#pids[@]} -ge $MAX_JOBS ]; then
wait -n ${pids[@]}
# Remove o PID que terminou (não trivial, mas simplificado)
fi
sleep 1 &
pids+=($!)
done
wait
echo "Concluído"
Na prática, usar parallel é muito mais simples e confiável. Portanto, a recomendação é: para scripts simples, use & e wait; para processamento em lote, use xargs -P ou GNU parallel.
Boas práticas e observações finais
Ao trabalhar com paralelismo em shell, algumas boas práticas ajudam a evitar problemas:
- Limite o número de processos: nunca lance mais processos do que o número de núcleos (ou um pouco mais). Isso evita thrashing e degradação de desempenho.
- Redirecione a saída: quando muitos processos escrevem no terminal, as saídas se misturam. Redirecione para arquivos ou use ferramentas que capturem a saída de forma organizada (como
parallelcom--line-buffer). - Capture códigos de saída: sempre verifique se cada tarefa foi bem-sucedida, usando
waitcom PIDs ou as opções do parallel. - Use
trappara limpeza: se o script for interrompido, processos em background podem ficar órfãos. Usetrappara matá-los. - Teste com um número pequeno de itens: antes de executar em escala, teste com poucos itens para validar a lógica.
O paralelismo pode trazer ganhos enormes de desempenho, mas deve ser usado com cuidado. Sempre monitore o uso de recursos do sistema para garantir que você não está sobrecarregando a máquina.
Referências
- Bash Reference Manual: Job Control
- Bash Builtins: wait
- GNU Parallel Official Website
- GNU Parallel Manual
- xargs(1) - Linux manual page
- Advanced Bash-Scripting Guide: Background Jobs
Exercícios
- Escreva um script que execute três comandos
sleepem background (com durações diferentes), espere todos terminarem e imprima "Todos os sleeps terminaram".
#!/bin/bash
sleep 5 &
sleep 3 &
sleep 7 &
wait
echo "Todos os sleeps terminaram"
wait sem argumentos e usar wait $PID, e dê um exemplo onde capturar o status de cada processo é importante.
wait sem argumentos espera todos os jobs em background e retorna 0 se todos tiverem sucesso. wait $PID espera apenas o processo com aquele PID e retorna o código de saída desse processo. Isso é importante quando você precisa saber se uma tarefa específica falhou, por exemplo, ao baixar múltiplos arquivos e quer continuar mesmo se um falhar, mas registrar o erro.
#!/bin/bash
curl -O http://exemplo.com/arquivo1 &
pid1=$!
curl -O http://exemplo.com/arquivo2 &
pid2=$!
wait $pid1
status1=$?
wait $pid2
status2=$?
echo "Arquivo1: $status1, Arquivo2: $status2"
echo "Processando {}" para os itens a b c d e com no máximo 2 jobs simultâneos. Mostre o comando e explique a saída esperada.
parallel -j 2 echo "Processando {}" ::: a b c d e
A saída será algo como (a ordem pode variar):
Processando a
Processando b
Processando c
Processando d
Processando e
Como o echo é rápido, os processos são lançados em rajadas de 2, mas a saída pode aparecer em qualquer ordem. Para manter a ordem, use --keep-order.
xargs -P para executar o comando sleep 1 para os números de 1 a 10, com no máximo 3 processos simultâneos, e meça o tempo total de execução com time. Qual é o tempo esperado e por quê?
#!/bin/bash
seq 1 10 | xargs -P 3 -I {} sleep 1
O tempo total será aproximadamente 4 segundos (na verdade, 3.33...). Com 3 processos simultâneos, temos 10 tarefas: os primeiros 3 executam em paralelo (t=0 a t=1), depois os próximos 3 (t=1 a t=2), depois os próximos 3 (t=2 a t=3), e os últimos 4 (t=3 a t=4). Como cada sleep leva 1 segundo, o tempo total é o teto de 10/3 = 3.33, arredondado para 4 segundos. Para medir, use time ./script.sh.
processa_arquivo que imprime "Processando -j 4 para executar. Mostre o script completo.
#!/bin/bash
processa_arquivo() {
echo "Processando $1"
sleep 0.5
}
export -f processa_arquivo
seq 1 20 | parallel -j 4 processa_arquivo
Isso executará 20 tarefas, no máximo 4 simultâneas. O tempo total será aproximadamente 20/4 * 0.5 = 2.5 segundos.