Muito provavelmente você já deve ter ouvido a mesma lenda que me foi contada quando estava apreendendo python:
Python não é bom com threads
Isso não é totalmente mentira, mas a questão é, threads só não serão efetivas com o python, se você não usar da forma correta.
O temível GIL
O CPython
(interpretador padrão do python) possui o Global Interpreter Lock, também conhecido como GIL
.
Um mecanismo (presente também em outras linguagens como o Ruby) responsável por prevenir o uso de paralelismo, fazendo com que apenas uma thread seja executada no interpretador por vez.
Isso faz com que, mesmo criando inúmeras threads, o desempenho de uma rotina singlethread sejá melhor do que o desempenho de uma rotina multithread, já que internamente, apenas uma thread estará fazendo uso da cpu por vez (mesmo em um ambiente multicore).
Existem vários beneficios e malefícios relacionados ao GIL e talvez esse seja o motivo pelo qual muitas pessoas acreditam que em python, threads não são efetivas.
Porém, o que nem todos sabem é que para operações de I/O
(network/socket e escrita em arquvios) o GILé liberado, ou seja, sempre que uma tarefa de I/O for executada (por exemplo, consultar um servidor externo) o GILé liberado para que outro processo seja executado de forma paralela até essa primeira chamada retornar resultado.
Vamos ver isso na prática.
Problema do mundo real: multiplos requests http
Uma situação muito comum no dia a dia de um desenvolvedor é ter que lidar com multiplos requests externos na mesma rotina. Usaremos um exemplo ficticio de uma aplicação de linha de comando que imprimi a cotação do real em relação a algumas moedas estrangeiras. Essas cotações serão recuperadas do site Dolar Hoje e sites similares.
Recuperando as cotações
Recuperaremos as cotações das seguintes modeas: dolar
, euro
, libra
e peso
CURRENCY={'dolar':'http://dolarhoje.com/','euro':'http://eurohoje.com/','libra':'http://librahoje.com/','peso':'http://pesohoje.com/'}
Como esses sites utilizam o mesmo formato, utilizaremos uma regex padrão para processa-los:
DEFAULT_REGEX=r'<input type="text" id="nacional" value="([^"]+)"/>'
Para recuperar essas cotações, faremos uma espécie de web crawler que fara um GET
na página e via RegEx
será recuperada a informação sobre a cotação monetária.
O método para realizar esse request é extremamente simples:
importrefromurllib.requestimporturlopendefexchange_rate(url):response=urlopen(url).read().decode('utf-8')result=re.search(DEFAULT_REGEX,response)ifresult:returnresult.group(1)
Um simples get
e decode
do conteúdo de uma url através da urlib
e a busca do padrão de uma regex através da função search
do pacote re
nativo do python para lidar com regex.
Utilizei a
urllib
por ser uma biblioteca nativa do python, porém, para esse tipo de operação (e qualquer outro tipo de request sincrono) recomendo o uso da bibliotéca requests
Execução serial
Para recuperar a cotação de todas as urls listadas no dicionáro CURRENCY
de forma serial, basta iterar pelos items
(chave, valor) desse dicionário, executando a função exchange_rate
para cada um passando a url como parâmetro.
forcurrency,urlinCURRENCY.items():print('{}: R${}'.format(currency,exchange_rate(url)))
Cada iteração desse for só será finalizada após a função exchange_rate
processar a url informada, ou seja, o tempo demorado será algo em torno do tempo do primeiro request vezes o número de items do dicionário.
Execução multithread
Para executar essa mesma rotina mas de forma paralela, utilizaremos a forma mais moderna de se trabalhar com concorrencia em python, o módulo concurrent.futures
.
Esse módulo permite através de um Executor
executar tarefas assincronas através de threads ou sub processos.
O módulo concurrent.futures está disponivel apartir da versão 3.2 do python, porém, possui o backport futures compatível com python 2.7
O módulo concurrent.futures possuí 2 principais componentes:
Executor
: Interface que possui métodos para executar rotinas de forma assincrona.Future
: Interface que encapsula a execução assincrona de uma rotina.
Para executarmos nossa função exchange_rate
de forma assincrona deveremos executar o método submit
do executor (em nosso caso, uma instância de ThreadPoolExecutor
).
Esse método aceita como parâmetro a função que será executada de forma assincrona e seus *args
e **kwargs
, no nosso caso devemos passar a função exchange_rate e a url.
O método submit retorna uma instância de Future que encapsulara a execução assincrona da rotina.
Em nosso problema, precisamos iniciar todos os requests e aguardar até que todos sejam concluídos, para que isso seja possível basta criar futures dessas rotinas e processar as que forem concluídas.
fromconcurrent.futuresimportas_completed,ThreadPoolExecutorwithThreadPoolExecutor(max_workers=len(CURRENCY))asexecutor:waits={executor.submit(exchange_rate,url):currencyforcurrency,urlinCURRENCY.items()}forfutureinas_completed(waits):currency=waits[future]print('{}: R${}'.format(currency,future.result()))
O gerador as_completed
do módulo concurrent.futures retorna as futures que forem concluídas na ordem em que forem concluídas.
Após a future estar concluída, basta recuperar seu resultado através do método result()
.
Repare que na criação do executor foi necessário especificar o número de workers que serão utilizados para executar as rotinas assincronas, porém, na versão 3.5 do python esse parâmetro não é mais obrigatório e caso ele seja omitido, o python assume o número de processadores na máquina
Comparando a execução
Apesar de o mesmo numero de requests externos estarem sendo executados em ambos os casos, a execução serial executa um request por vez, enquanto que a execução multithread executa todos os requests de uma só vez, de forma paralela (sem intervenção do GIL) diminuindo assim o tempo de execução da aplicação de forma exponencial
- Execução serial
$ time python multi_requests.py
libra: R$5,78
dolar: R$4,00
euro: R$4,47
peso: R$0,27
real 0m3.476s
user 0m0.129s
sys 0m0.004s
- Execução multithread
$ time python multi_requests.py threads
dolar: R$4,00
euro: R$4,47
libra: R$5,78
peso: R$0,27
real 0m1.433s
user 0m0.122s
sys 0m0.025s
Note que a execução serial demorou em torno de 3.47 segundos contra 1.43 segundos da excução multithread. Essa diferença tende a crescer de acordo com a quantidade de requests feitos.
Veja o código completo desse exemplo neste gist.
Conclusão
Em resumo, toda vez que alguém enche a boca para me dizer "python não é bom com threads" essa é a minha reação:
(╯°□°)╯︵ ┻━┻
Referências
Launching parallel tasks
Understanding the Python GIL