Threads em Python? é claro!

Posted by Diego Garcia on Thu 18 February 2016

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 será 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 benefícios 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 arquivos) 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: múltiplos 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 fictício de uma aplicação de linha de comando que imprime a cotação do real em relação à algumas moedas estrangeiras. Essas cotações serão recuperadas do site Dólar Hoje e sites similares.

Recuperando as cotações

Recuperaremos as cotações das seguintes moedas: dólar, 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 fará 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:

import re
from urllib.request import urlopen


def exchange_rate(url):
    response = urlopen(url).read().decode('utf-8')
    result = re.search(DEFAULT_REGEX, response)
    if result:
        return result.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 síncrono) recomendo o uso da biblioteca requests

Execução serial

Para recuperar a cotação de todas as urls listadas no dicionário 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.

for currency, url in CURRENCY.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 itens do dicionário.

Execução multithread

Para executar essa mesma rotina mas de forma paralela, utilizaremos a forma mais moderna de se trabalhar com concorrência em python, o módulo concurrent.futures. Esse módulo permite através de um Executor executar tarefas assíncronas através de threads ou sub processos.

O módulo concurrent.futures está disponível a partir da versão 3.2 do python, porém, possui o backport futures compatível com python 2.7

O módulo concurrent.futures possui 2 principais componentes:

  • Executor: Interface que possui métodos para executar rotinas de forma assíncrona.
  • Future: Interface que encapsula a execução assíncrona de uma rotina.

Para executarmos nossa função exchange_rate de forma assíncrona 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 assíncrona 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 encapsulará a execução assíncrona 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.

from concurrent.futures import as_completed, ThreadPoolExecutor


with ThreadPoolExecutor(max_workers=len(CURRENCY)) as executor:
    waits = {
        executor.submit(exchange_rate, url): currency
        for currency, url in CURRENCY.items()
    }
    for future in as_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 assíncronas, 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 número 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 execuçã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

tags: python, threads,



Comments !