Trabalhando como Paralelismo no Bash usando o GNU Parallel – Blog do Beyeler
Lucas Costa Beyeler 11 de março de 2017 2
O zmbkpose é um shell script descontinuado para backup de e-mails do Zimbra, porém que ainda é relativamente popular e a melhor solução gratuita para backups. Eu poderia pagar pelo Zimbra Plus ou comprar de uma vez a licença da versão Network para resolver meu problema, mas de graça sempre foi mais interessante…
Compreendendo Multiprocessamento
Antes de entrar com o problema, primeiramente deixe-me explicar um pouco sobre multiprocessamento. Caso seja um conceito que você já está familiarizado, pode pular direto para "Entendendo o Problema".
Normalmente, quando criamos um processo, esse processo executará uma atividade na qual requer um certo tempo de CPU para conclui-la. Normalmente esse processo só irá requisitar a uma CPU para executar, e levará X tempo para concluir essa atividade. Esse é o cenário normal de qualquer processo, não importa a linguagem de programação, o gerenciador de inicialização, ou o interpretador de linha de comando.
No modelo de multiprocessamento, o mesmo processo foi programado para dividir sua carga de trabalho com dois outros processos, chamados de thread ou processo filho, tudo depende do material que você for consultar. Esses processos filhos irão interagir com a CPU, porém cada um com uma CPU diferente. O resultado disso é que a atividade que o processo executaria em X tempo, agora executa em X/2 tempo, ou metade do tempo de antes. Claro que isso é teórico e não estou levando em conta diversos fatores, como carga da máquina e a velocidade de processamento da CPU. Mas mesmo que sejam incluídos essas variáveis, o tempo de execução de seu processo ainda será melhor que do modelo thread única.
Para que não seja necessário os programadores desenvolver essa capacidade toda vez que forem desenvolver um software, existem diversas bibliotecas externas para diversas linguagens de programação, como o OpenMPI no caso do C, ou, em alguns casos, uma implementação já existe nativa na linguagem de programação, como a classe multiprocessing do Python. No caso dos interpretadores de linha de comandos, tudo depende de como foi implementado neles o multiprocessamento, seja através do caractere "&" nos interpretadores Unix, ou com uma ferramenta de linha de comando.
O script possui uma rotina de executar um curl para baixar os e-mails de caixa por caixa de e-mail dos usuários do Zimbra. Só por essa breve descrição já da para perceber o problema, correto? O curl pode possuir a implementação que for para multiprocessamento, mas ainda sim você só vai poder executar um curl por vez, tornando o processo de backup assustadoramente lento. Para você ter uma ideia, em um ambiente de 6000 contas com esse fluxo, e no melhor cenário, você vai levar 7 dias para concluir o processo dependendo do tamanho das contas.
Scripts de Bash não possuem essa lógica de multiprocessamento por padrão, porém possuem o caractere "&", que significa que esse processo está sendo jogado para background, liberando assim o script principal para novas tarefas. Porém o "&" não é solução: ele coloca no background, porém você vai ter que controlar o fluxo de processos com seu script, e levará um tempo até você reinventar a roda simplesmente para agilizar seus processos.
O zmbkpose possui também uma implementação de threads que deixa a desejar: o loop inicia 3 processos imediatamente, e ao finalizar 1 deles irá iniciar mais 3 processos ao mesmo tempo, e depois ficará aguardando até o número de curls cairem para menos de 3 processos, levando em conta que na configuração eu ativei o multiprocessamento e informei que terá 3 processos concorrendo por CPUs. Dependendo do ambiente que você está, e o número de CPUs que você tem disponível, seu servidor vai sobrecarregar e não vai concluir tão cedo a atividade.
Trabalhando com o GNU Parallel
Se é ruim criar a própria lógica para o paralelismo, então vamos utilizar algo que já está consolidado no mercado. No caso do Linux existe o GNU Parallel, uma ferramenta de linha de comando que executa um determinado comando N vezes de forma paralela, gerenciando o número de processos que devem ficar concorrendo de forma automática. Antes de começarmos a usar ele e eu explicar como foi aplicado no zmbkpose, primeiro vamos instalar na nossa máquina o programa. Execute os comandos abaixo de acordo com sua distribuição:
Com o parallel instalado no servidor, vamos começar a brincar com ele. Para paralelizar os comandos existem duas maneiras: ou você passa a lista como parte do comando, ou você passa a lista como um resultado de uma operação através do PIPE (|). Para o que eu precisava executar, bastava 2 parâmetros, porém existem outros que podem ser consultados na documentação do GNU Parallel:
–jobs: Esse campo serve para você informar qual é o número máximo de processos concorrentes;
–no-notice: Serve para informar que você não deseja visualizar os termos de contrato do parallel;
Para testarmos, vamos executar um sleep de 5 segundos num total de 6 vezes, sendo um através de um loop comum, e o outro através do parallel:
time for i in {5,5,5,5,5,5}; do sleep $i; done
real     0m30.012s
user     0m0.000s
sys      0m0.000s
time parallel --no-notice --jobs 5 sleep ::: 5,5,5,5,5,5
real     0m10.686s
user     0m0.124s
sys      0m0.020s
O comando sleep é a melhor maneira de ver se a atividade está sendo realmente paralelizada: cada sleep que fiz acima levará 5 segundos para concluir a execução, dando um total de 30 segundos caso eu execute um de cada vez, que nem fiz no for. Com o parallel, como eu mandei executar 5 threads ao mesmo tempo, o tempo acabou saindo menor, pois foi 5 segundos para executar 5 sleeps, e mais 5 segundos para executar o 6º sleep, dando um total de 10 segundos para completar toda a operação.
Os ":::" servem para informar que você irá passar uma lista de objetos para serem percorridos pelo parallel. Você pode passar mais de uma lista dessa forma, basta colocar ":::" após o final da lista anterior. O resultado desse comando será uma lista de objetos formadas pelos itens da lista 1 (A B C) com os da lista 2 (D E F). Por exemplo:
parallel --no-notice --jobs 5 echo ::: A B C ::: D E F
O outro modo, que foi o que eu usei, é você passar o resultado de um comando como entrada para o parallel utilizando o pipe (|). Para esse exemplo crie um arquivo de texto, e em cada linha coloque o número 5 (pode ser 6 vezes que nem o exemplo anterior). Precisa ser em linhas diferentes pois o parallel considera que cada linha será um item da lista que ele precisa percorrer. Feito isso basta executar um cat para abrir um arquivo e depois o "|" para pegar a saída do cat e mandar para o comando seguinte, no caso o parallel:
cat temp.txt | parallel --no-notice --jobs 5 "sleep {}"
As chaves ("{}") serve para você informar os campos que devem ser substituídos pelos itens da lista anterior. Somente uma ressalva sobre o uso do parallel: se você pretende utilizar variáveis no parallel, qualquer variável, lembre-se de exportar elas antes, isso porque cada processo que ele inicia tem seu próprio sub shell.
Aplicando o Parallel na Prática
O que foi feito no zmbkpose com o parallel foi bem simples: foi gerado uma lista contendo o e-mail de todos os usuários do Zimbra que consegui extrair do LDAP chamada $TEMPACCOUNT, que se trata de um arquivo temporário, e passei ele como entrada do parallel para que pudesse executar uma série de comandos usando esse resultado, conforme segue o trecho abaixo:
cat $TEMPACCOUNT | parallel --no-notice --jobs $MAX_PARALLEL_PROCESS \
"wget --quiet -O $TEMPDIR/{}.tgz \
--http-user $ADMINUSER \
--http-passwd $ADMINPASS \
https://$MAILHOST:7071/home/{}/?fmt=tgz \
--no-check-certificate && \
echo $SESSION:{}:$(date +%m/%d/%y) >> $TEMPSESSION"
Aplicando dessa maneira consegui um desempenho melhor do que com o &, sem falar que deixou o código menos poluído, porém não consegui utilizar dessa forma em todas as situações. Em alguns trechos do código, aonde a lógica que eu precisava paralelizar ficou complexa demais para deixar dentro das aspas, eu criei uma função chamada 'loop_X', como por exemplo o loop_inc que segue abaixo:
loop_inc()
AFTER=$(grep $1 $WORKDIR/sessions.txt | tail -1 | awk -F: '{print $3}')
wget --quiet -O $TEMPDIR/$1.tgz \
https://$MAILHOST:7071/home/$1/?fmt=tgz\&query=after:$AFTER \
Chamando a função no código:
cat $TEMPACCOUNT | parallel --no-notice --jobs $MAX_PARALLEL_PROCESS "loop_inc {}"
O loop_inc, no caso, recebe uma conta de e-mail, procura a data de seu ultimo backup, e então baixa do servidor do Zimbra todos os e-mails depois dessa data. Então, em outro trecho do código, eu executo o parallel novamente, porém invés de passar uma lista de comandos para serem executados pelos itens da lista, eu passo somente a função que eu quero que receba esses itens da lista.
E assim concluo esse post sobre o parallel. Dúvidas, sugestões, críticas, deixem nos comentários que irei responder na medida do possível.
zmbackup: https://github.com/lucascbeyeler/zmbackup
zmbkpose: https://github.com/bggo/Zmbkpose
Tutorial do GNU Parallel: https://www.gnu.org/software/parallel/parallel_tutorial.html
Ole Tange 15 de março de 2017 Responder
Is there any reason you do not want to run `parallel –bibtex` once?
Lucas Costa Beyeler 15 de março de 2017 Responder
Actually, no. I completely forgot about this option when I was writing this article. But for my application, I think –no-notice is more simple than –bibtext because I don't want my users to configure all the environment just to run my software.