Se você leu o post anterior sobre threads em python, muito provavelmente percebeu que o fato do CPython
ser otimizado para executar códigos singlethread, não é um impeditivo para execução de tarefas paralelas com alto desempenho.
Porém, threads resolvem com maestria a execução de tarefas de I/O Bound paralelas, mas quando o assunto é CPU Bound, existe uma outra forma de ser efetivo no paralelismo com python.
Ainda o GIL
Assim como na execução singlethread, o GIL
NÃOé liberado para tarefas do tipo CPU Bound (ou seja, que dependem do uso massivo do processador e não de I/O).
Mesmo que sejam criadas inúmeras threads para execução desse tipo de tarefa, o desempenho final não seria satisfatório, pelo contrário, o desempenho singlethread seria melhor do que o multithread.
Porém, existe outra forma de lidar com esse problema, processos. Veja como isso funciona.
Encontrando os números primos
Para demonstrar o uso de multi processamento no python partiremos para um exemplo totalmente didático. Faremos uma função que retorna uma lista com o números primos até um determinado número limite.
defprimes_until(num):result=[]forpinrange(2,num+1):foriinrange(2,p):ifp%i==0:breakelse:result.append(p)returnresult
Por exemplo, ao executar a função primes_until
passando o número 10
como argumento, teremos o seguinte retorno:
>>>primes_until(10)[2,3,5,7]
Números primos são os números naturais que têm apenas dois divisores diferentes: o 1 e ele mesmo. fonte
Como essa função não exige muito poder computacional para ser executada, daremos uma forçada na barra para que a execução fique lenta o suficiente a ponto de compensar o multi processamento.
Executaremos a função primes_until
14 vezes passando como número limite o range de 1000 até 15000 saltando de 1000 em 1000.
TO_CALCULATE=range(1000,15000,1000)
Execução Serial
Para realizar esses cálculos de forma serial, iremos iterar sobre o gerador TO_CALCULATE
que especificamos anteriormente e para cada número gerado iremos executar a função primes_until
.
defrun_serial():print({i:primes_until(i)foriinTO_CALCULATE})
Como escrevo esses exemplos baseados no Python 3 a função built-in
range
se tornou um gerador. Para utiliza-la como gerador no Python 2 utilize a funçãoxrange
Execução multiprocess
Faremos o mesmo para realizar a execução multiprocess, porém, iremos distribuir cada execução em um processo diferente.
Assim como no post sobre threads em python, usaremos o módulo concurrent.futures
, com a diferença que desta vez utilizaremos o ProcessPoolExecutor
como nosso executor.
Criaremos Futures
para cada execução (através do método executor.submit()
) e depois através do gerador as_completed()
iteraremos sobre as futures (no caso nossos processos) que já estejam concluídas.
fromconcurrent.futuresimportas_completed,ProcessPoolExecutordefrun_multiprocess():waits={}withProcessPoolExecutor()asexecutor:waits={executor.submit(primes_until,i):iforiinTO_CALCULATE}print({waits[future]:future.result()forfutureinas_completed(waits)})
Caso não seja especificado o parâmetro
max_workers
na criação da instancia do ProcessPoolExecutor, por padrão o python assume como sendo o número de processadores da máquina.
Ao realizar o submit
da função primes_until para o nosso ProcessPoolExecutor, um fork do processo principal é criado e a execução é feita nesse processo separado de forma paralela.
Dessa forma, conseguimos dividir a execução em processo separados (com o GIL independente para cada um) e com isso não temos o efeito do lock do GIL para cada requisição ao processador.
$ ps aux | grep python3
diego-g+ 10074 6.0 0.2 19472012404 pts/24 Sl+ 13:01 0:00 python3 primes_numbers.py multiprocess
diego-g+ 10075121 0.1 472567936 pts/24 R+ 13:01 0:01 python3 primes_numbers.py multiprocess
diego-g+ 10076121 0.1 472567932 pts/24 R+ 13:01 0:01 python3 primes_numbers.py multiprocess
diego-g+ 10077119 0.1 472567940 pts/24 R+ 13:01 0:01 python3 primes_numbers.py multiprocess
diego-g+ 10078121 0.1 472567936 pts/24 R+ 13:01 0:01 python3 primes_numbers.py multiprocess
Comparando a execução
Como disse no começo desse post, a função primes_until não requer um grande poder de processamento para ser executada, mas como esse post tem fins didáticos, forçamos um conjudo de execuções pesadas a ponto de ficar muito demorado a excução singlethread. Obviamente a execução multiprocess executa todos os calculos de uma só vez de forma paralela e sem intervenção do GIL (por se tratar de processos separados), com isso, conseguimos alcançar uma maior velocidade na execução.
- Execução serial
$ time python primes_numbers.py
real 0m6.366s
user 0m6.285s
sys 0m0.076s
- Execução multiprocess
$ time python primes_numbers.py multiprocess
real 0m3.588s
user 0m12.186s
sys 0m0.055s
Se trocassamos o executor de ProcessPoolExecutor para ThreadPoolExecutor teriamos sérios problemas de performance, devido ao bloqueio do GIL a ponto de a execução singlethread ter um desempenho melhor.
Veja o código completo desse exemplo neste gist.
O custo do uso de multi processamento
Apesar da execução multiprocess do exemplo anterior ter sido concluída em praticamente metade do tempo quando comparada a execução serial, não podemos encarar o multiprocess como a solução de todos os problemas em python. Multiprocess não é uma bala de prata, muito pelo contrário, o seu uso deve ser muito ponderado.
O multiprocess tem um custo no python que muitas vezes não paga o seu uso, como por exemplo, o tempo de fork
do processo, serialização
(via pickle) dos dados, comunicação
entre processos, etc.
A minha sugestão é, teste e compare antes de tomar uma decisão, se uma arquitetura multiprocess não for muito superior em termos de desempenho para o seu problema, não vale a pena manter essa complexidade.
Alternativas
Antes de pensar em uma solução baseada em paralelismo, você pode executar o seu código em outros interpretadores do python como por exemplo o pypy que promete ser um interpretador extremamente rápido e otimizado ou o Cython que tem uma relação mais amigavel com o GIL.
Referências
Launching parallel tasks