quinta-feira, 14 de março de 2019

Python, paralelismo e GIL - Nem tudo funciona como você pensa


#python #multiprocessing #thread #OBP8anos #oBomProgramador
Bom, neste artigo vou mostrar a você um pouco das idiossincrasias do Python e seus efeitos no seu projeto de software. Veremos uma característica curiosa, o GIL - Global Interpreter Lock, e como podemos contorná-lo. Este artigo é original do meu blog de cultura Python: http://pythondrops.com




Processamento paralelo

Hoje em dia, sequer paramos para pensar nesse assunto, mas a maioria das aplicações que desenvolvemos são baseadas em processamento paralelo, que pode ser Multiprogramação, Multiprocessamento ou uma combinação de ambas.

  • Multiprogramação: temos mais de uma tarefa sendo executada concorrentemente pelo computador;
  • Multiprocessamento: temos mais de uma tarefa sendo executada simultaneamente pelo computador;
Concorrentemente significa que as tarefas competem pelo tempo do processador, utilizando-o e liberando-o de tempos em tempos. Já simultaneamente significa que temos mais de um núcleo na CPU e temos várias tarefas realmente sendo executadas ao mesmo tempo.

Nos sistemas operacionais modernos, temos o conceito de Processos, que são um conjunto formado por código e recursos em execução. Cada Processo pode ter mais de uma linha de execução (ou Thread) simultânea, portanto, partes do processo podem estar sendo executadas por núcleos diferentes.

Java EE - Máquina de salsichas

Quem trabalha com aplicações Java EE sequer se preocupa com isso. A especificação garante que o programador ficará livre de preocupações com Processos e Threads, pois o Servidor (container Java EE) toma conta disso para ele.

É como se fosse uma máquina de fazer salsichas (minha analogia preferida): Você enfia um burro de um lado e saem salsichas de outro. Como ela funciona? Não nos interessa.

Mas, em outras linguagens, como Python, as coisas são um pouco diferentes.

Python e o GIL

Não adianta espernear! Python tem muitas vantagens sobre Java, exceto uma: Performance! Não acredita? Bem, eu não culpo você, mas dê uma olhada nesse site de benchmark: 


Existem algumas razões para essa diferença de velocidade. Uma delas é a arquitetura da Máquina Virtual Java, que realmente é fantástica, a outra é o GIL - Global Interpreter Lock.

Trocando em miúdos, os principais interpretadores Python (CPython e PyPy) possuem um Mutex que impede que múltiplos threads executem o seu bytecode simultaneamente. Por quê? Porque partes do código C do CPython não são "thread safe".

Os únicos interpretadores que estão livres disso são o Jython (JVM) e o IronPython (.NET).

O GIL não é um grande problema para a maioria das aplicações, e vou explicar o motivo.

CPU Bound vs I/O Bound

As aplicações CPU Bound são aquelas que fazem pouco ou nenhum I/O, segurando a CPU (o núcleo) o tempo todo. Para largar o "osso", elas têm que sofrer "preempção". I/O Bound é a maioria das aplicações, que recebe o request, faz algum processamento de validação, acessa bancos de dados, gera logs, grava arquivos, acessa servidores REST e depois gera uma resposta. Viu? A maioria das operações é de I/O, durante as quais o Thread entra em estado de espera (não ocupada) liberando os recursos para outros threads. 

Este é o princípio do paralelismo concorrente.

Aplicações CPU Bound são, geralmente, monousuário, certo? Errado! As modernas técnicas de Blockchain e Deep Learning criaram uma nova categoria de aplicações CPU Bound e Multiusuário! Então temos um grande problema nas mãos.

Contornando o GIL

Se você vai criar uma app CPU Bound e Multiusuário, eu recomendaria de cara: Java. E, se for o caso, Java com rotinas em C++ usando JNI (Java Native Interface). Porém, pode ser que a arquitetura do sistema preveja que seja em Python, portanto, você tem que pensar em outra saída.

É possível contornar o GIL em Python sim. Há várias alternativas, dentre elas, cito duas muito comuns: 
  • Usar paralelismo de processos (multiprocessamento);
  • Compilar seu programa com Cython.
Cython é um compilador estático para Python, que gera código em C, o qual você pode compilar usando o GCC ou equivalente. 

Usar processos ou "workers" é relativamente simples, com as bibliotecas do Python 3. Um worker é um processo "forked" a partir de outro. Fork é a operação de criar uma nova instância de um processo. 

Criar processos tem um custo bem mais alto do que criar threads, e você deve ter isso em mente quando tomar sua decisão. 

Prova de conceito

Criei um exemplo bem simples de aplicação para demonstrar o efeito do GIL e como podemos minimizá-lo. Um programa simples, que calcula e retorna um termo da série de Fibonacci. É uma tarefa totalmente CPU Bound.

Fiz a mesma demonstração utilizando Java e Python. Está no repositório do Pythondrops: 


A implementação Java é bem simples e nela eu uso as classes do pacote java.util.concurrent, como: 
  • ExecutorService;
  • Executors;
  • Callable;
  • Future.
Não vou entrar em detalhes explicando o código, mas você pode baixar o repositório e usar o Maven para compilar o projeto (ou o eclipse). 

Se você executar o código sem passar nenhum argumento de linha de comando, ele vai rodar apenas um único thread (é thread mesmo e não processo).


Eu pedi para encontrar o 42o termo da série (267914296) e o programa demorou cerca de 1,17 segundos para isso. Você pode ampliar a imagem anterior e ver o resultado na view "console".

Bom, meu laptop é um I7 oitava geração, com 4 núcleos e 8 threads simultâneos, então, vou rodar utilizando 6 threads, ou seja, como se fossem 6 usuários simultâneos acessando a aplicação.


Se ampliar a imagem, verá que os resultados foram: 
  • Resultado: 267914296 segundos: 1.681
  • Resultado: 267914296 segundos: 1.27
  • Resultado: 267914296 segundos: 1.431
  • Resultado: 267914296 segundos: 1.595
  • Resultado: 267914296 segundos: 1.483
  • Resultado: 267914296 segundos: 1.66
Ligeiramente acima de 1,17, mas ainda bem próximos. Ele escalou corretamente a aplicação, distribuindo os threads pelos núcleos disponíveis.

Java não tem GIL, portanto, não há bloqueio para execução simultânea de Bytecode.

Agora, vamos à versão python...

O código python, que está no mesmo repositório, faz a mesma coisa. Vou mostrar a execução de um só thread. Devido à diferença de performance entre o Python e a JVM, eu tive que diminuir o termo para 34 (em vez de 42), ficando com esse resultado individual: 


Resultado: 5702887 segundos: 1.5392158031463623

O Python em si é mais lento que a JVM, mas suas bibliotecas (Numpy e outras) podem ser até mais rápidas.

Bom, agora vamos testar com 6 threads!


Os tempos subiram muito! Eu formatei um pouco diferente o resultado, mas vou explicar: 

[(5702887, 10.146195888519287), (5702887, 9.989623069763184), (5702887, 7.946202039718628), (5702887, 11.012054443359375), (5702887, 8.588342666625977), (5702887, 10.32898497581482)]

Acima de 7 segundos, com alguns chegando até 11 segundos!

Será que houve um gargalo no sistema? Não... Os núcleos da CPU estavam bem folgados: 


O problema é o enfileiramento interno dos threads, devido ao GIL!

Aumentar a quantidade de threads só faz aumentar mais ainda o tempo de resposta.

Workers

Bom, eu parti para a solução de usar workers, que são processos inteiros, e não apenas threads. O custo de criar processos é enorme, porém, mesmo assim, os tempos caíram bastante. Há duas versões do código python: thread_fibo.py (usando Pool de threads) e worker_fibo.py (usando workers). Veja os resultados com 6 workers: 


[(5702887, 3.785332441329956), (5702887, 2.5944666862487793), (5702887, 3.7585065364837646), (5702887, 2.6021320819854736), (5702887, 3.8519108295440674), (5702887, 3.7991487979888916)]

Um pouco abaixo de 4 segundos. E vemos uma distribuição melhor de trabalho nos núcleos, forçando bem as CPUs: 


Ou seja, estamos utilizando mais intensamente o computador e gerando tempos menores, já que o paralelismo aumentou.

Moral da história

Python tem suas idiossincrasias, assim como Java e qualquer outra linguagem. Taxar Python de "lento" é demonstrar inabilidade para utilizar plenamente esta plataforma tão versátil. Se usarmos com um pouco de cuidado, teremos melhores resultados. 

No caso do GIL, eu mostrei uma das soluções de contorno, mas existem várias. 

E é claro que usei uma app atípica e totalmente CPU Bound. Com apps I/O Bound, o Python 3 pode utilizar pseudo-threads que funcionam como o Node.js, resultando em uma performance muito boa.












Nenhum comentário:

Postar um comentário