Com a saída do Python 3.4, eu atualizei o livro de Introdução à Programação com Python. Alguns assuntos fogem ao escopo do livro que é destinado a iniciantes. Eu vou começar a escrever uma série de posts curtos sobre alguns tópicos que acho interessantes e quem sabe até podem virar base para um novo livro.
Uma das novidades do Python 3.4 é o módulo asyncio que traz várias rotinas para chamada de métodos assíncronos em Python. A programação assíncrona é um pouco diferente do que normalmente estamos habituados a escrever em Python, mas é uma excelente alternativa a utilização de threads e uma boa escolha para resolver problemas com muitas entras ou saídas (I/O).
import asyncio def print_and_repeat(loop): print('Hello World') loop.call_later(2, print_and_repeat, loop) loop = asyncio.get_event_loop() loop.call_soon(print_and_repeat, loop) loop.run_forever()
O mecanismo usado no exemplo é bem simples. A variável loop contém o loop de eventos, uma vez que chamamos asyncio.get_event_loop() e esta retorna o loop de eventos atual. Na linha seguinte, chamamos o método call_soon para agendar a chamada de um método. call_soon adiciona a chamada da função print_and_repeat, definida anteriormente. O segundo parâmetro de call_soon é na realidade o parâmetro para print_and_repeat. Desta forma, loop.call_soon(print_and_repeat, loop) adiciona ao loop de eventos uma chamada a função print_and_repeat, passando loop como primeiro parâmetro. Confuso? Vamos ver o que acontece ao executarmos:
Z:\artigos>c:\python34\python asyncio1.py Hello World Hello World Hello World Hello World
O programa executa e fica na linha do loop.run_forever(). O método run_forever() processa os eventos e é necessário para o bom funcionamento do nosso programa. Experimente remover esta linha e veja que nada é impresso na tela, com o programa finalizando logo em seguida. Isto acontece porque sem run_forever, os eventos não são processados e o Python executa o script até o fim, finalizando sem nunca ter chamado print_and_repeat, ou seja, sem processar a lista de eventos.
Voltando ao nosso exemplo, você entendeu como Hello Word foi impresso várias vezes? Como se estivesse dentro de um for ou while? Isto acontece na última linha de print_and_repeat, onde com o método call_later agendamos uma próxima chamada a print_and_repeat depois de 2 segundos. Logo, o primeiro parâmetro de call_later é o tempo a esperar antes de chamar a função, o segundo a função em si, seguido dos parâmetros a passar a esta função, como fizemos na chamada de call_soon anteriormente.
O código equivalente, sem utilizar eventos seria algo como:
import time while True: print("Hello World") time.sleep(2)
Se é tão simples, por que complicar? Eventos facilitam a execução de código alternadamente, algo difícil de realizar sem utilizarmos threads. Basicamente, solicitamos ao loop de eventos para executar nossas funções de acordo com sua disponibilidade, como se passássemos em nosso programa uma lista de tarefas a executar. O loop de eventos atualiza constantemente a lista de tarefas e isto permite a execução de tarefas em ordem diferente da que estas foram incluídas.
Vejamos um outro exemplo, com call_later:
import asyncio import time import random def faz_algo(loop): espera = random.random() print("Fazendo algo... espera = %f" % espera) loop.call_later(espera, faz_algo, loop) def print_and_repeat(loop): global último agora = time.time() print('Alô - Tempo decorrido: %f' % (agora - último)) último = agora loop.call_later(2, print_and_repeat, loop) último = time.time() loop = asyncio.get_event_loop() loop.call_soon(print_and_repeat, loop) loop.call_soon(faz_algo, loop) loop.run_forever()
Que ao executar nos exibe:
Z:\artigos>c:\python34\python asyncio2.py Alô - Tempo decorrido: 0.007002 Fazendo algo... espera = 0.360861 Fazendo algo... espera = 0.411369 Fazendo algo... espera = 0.788518 Fazendo algo... espera = 0.253137 Fazendo algo... espera = 0.193489 Alô - Tempo decorrido: 2.003724 Fazendo algo... espera = 0.142127 Fazendo algo... espera = 0.523892 Fazendo algo... espera = 0.250166 Fazendo algo... espera = 0.467522 Fazendo algo... espera = 0.304402 Fazendo algo... espera = 0.831368 Alô - Tempo decorrido: 1.988947 Fazendo algo... espera = 0.647338 Fazendo algo... espera = 0.378327 Fazendo algo... espera = 0.013393 Fazendo algo... espera = 0.284973 Fazendo algo... espera = 0.368350 Alô - Tempo decorrido: 2.002417 Fazendo algo... espera = 0.257536
Os valores de espera podem variar de uma execução a outra, o importante é observar que chamamos a função faz_algo e print_and_repeat são chamadas alternadamente, ou melhor, quase que ao mesmo tempo, como se estivéssemos utilizando threads. O que o novo programa faz é executar print_and_repeat como no primeiro exemplo, mas também a função faz_algo. A função faz_algo gera um tempo de espera aleatório, usando random.random() para agendar a próxima execução. Como print_and_repeat executa apenas a cada 2 segundos, o loop de eventos fica livre para executar outras tarefas, o que podemos ver na saída de nosso programa.
O programa equivalente, sem o loop de eventos ficaria parecido com:
import time import random def faz_algo(): espera = random.random() print("Fazendo algo... espera = %f" % espera) time.sleep(espera) def print_and_repeat(): global último agora = time.time() print('Alô - Tempo decorrido: %f' % (agora - último)) último = agora último = 0 while True: agora = time.time() if agora - último >= 2: print_and_repeat() else: faz_algo()
Que produz uma saída semelhante:
Z:\artigos>c:\python34\python asyncio3.py Alô - Tempo decorrido: 1403984084.967565 Fazendo algo... espera = 0.233725 Fazendo algo... espera = 0.309948 Fazendo algo... espera = 0.551685 Fazendo algo... espera = 0.835331 Fazendo algo... espera = 0.008247 Fazendo algo... espera = 0.140163 Alô - Tempo decorrido: 2.081037 Fazendo algo... espera = 0.597524 Fazendo algo... espera = 0.004582 Fazendo algo... espera = 0.054279 Fazendo algo... espera = 0.037356 Fazendo algo... espera = 0.951933 Fazendo algo... espera = 0.003549 Fazendo algo... espera = 0.856917 Alô - Tempo decorrido: 2.506460 Fazendo algo... espera = 0.435528 Fazendo algo... espera = 0.599356 Fazendo algo... espera = 0.798355 Fazendo algo... espera = 0.594801
O importante é notar que quanto mais tarefas temos a realizar, mais complicado ficaria escrever o programa equivalente, sem o loop de eventos. Um conceito a também observar é que apenas uma das funções roda de cada vez. Este detalhe permite construir nossos programas como fazemos normalmente, sem nos preocuparmos com threads. Outro detalhe é o módulo asyncio traz várias outras classes que ajudam a trabalhar de forma assíncrona com arquivos e sockets, por exemplo. A programação com loop de eventos não resolve todos os tipos de problema. Vejamos uma função chamada calcula_algo que utiliza o processador para realizar um cálculo relativamente demorado. Veja o programa abaixo:
import asyncio import time import random def calcula_algo(loop, id): limite = random.randint(30000,50000) print("Calculando %d" % id) z=1 for x in range(1,limite): z*=x print("Fim do Cálculo %d" % id) loop.call_soon(calcula_algo, loop, id+2) def faz_algo(loop): espera = random.random() print("Fazendo algo... espera = %f" % espera) loop.call_later(espera, faz_algo, loop) def print_and_repeat(loop): global último agora = time.time() print('Alô - Tempo decorrido: %f' % (agora - último)) último = agora loop.call_later(2, print_and_repeat, loop) último = time.time() loop = asyncio.get_event_loop() loop.call_soon(print_and_repeat, loop) loop.call_soon(faz_algo, loop) loop.call_soon(calcula_algo, loop, 1) loop.call_soon(calcula_algo, loop, 2) loop.run_forever()
Que produz como saída:
Z:\artigos>c:\python34\python asyncio4.py Alô - Tempo decorrido: 0.007003 Fazendo algo... espera = 0.761420 Calculando 1 Fim do Cálculo 1 Calculando 2 Fim do Cálculo 2 Calculando 3 Fim do Cálculo 3 Calculando 4 Fim do Cálculo 4 Fazendo algo... espera = 0.395006 Alô - Tempo decorrido: 6.790526 Calculando 5 Fim do Cálculo 5 Calculando 6 Fim do Cálculo 6 Calculando 7 Fim do Cálculo 7 Calculando 8 Fim do Cálculo 8 Fazendo algo... espera = 0.540093 Alô - Tempo decorrido: 5.859909 Calculando 9 Fim do Cálculo 9 Calculando 10 Fim do Cálculo 10 Calculando 11 Fim do Cálculo 11 Calculando 12 Fim do Cálculo 12 Fazendo algo... espera = 0.174978 Alô - Tempo decorrido: 6.830562 Calculando 13 Fim do Cálculo 13 Calculando 14
Veja que o atraso para chamar as outras funções é agora muito mais importante, ultrapassando os 6 segundos entre as chamadas de print_and_repeat e tendo um atraso considerável também no processamento de faz_algo. Bem, este comportamento é esperado, uma vez que a função calcula_algo é o que se chama de CPU bound, ou seja, é uma função que precisa mais da atenção do processador do computador que uma operação de criação de arquivo (I/O bound).
Para utilizar corretamente seu computador, você deve começar a separar seus problemas em CPU bound e I/O bound. No caso de problemas CPU bound, threads oferecem a melhor performance, pois temos vários processadores no mesmo computador. Já para problemas I/O bound, ou seja, que precisam acessar o disco ou a rede (ou entrada de dados vinda do teclado), o loop de eventos assíncrono é mais rápido e fácil de programar. Em problemas mistos, onde temos código CPU bound e código I/O bound a executar, uma solução mista precisa ser aplicada.
Por exemplo, threads são relativamente caros para serem criados e são difíceis de controlar e programar. Nos próximos posts, abordarei outros detalhes do módulo asyncio. Antes de continuarmos com métodos assíncronos, vamos comparar o tempo de execução entre as soluções assíncronas, múltiplos threads e com múltiplos processos. Vamos avaliar versões modificadas da função calcula_algo para cada um dessas formas de paralelização. O problema testado será o tempo de total de execução de 20 chamadas a calcula_algo.
Antes de começarmos, vamos remover a parte aleatória da função e transformar o valor de limite em uma constante. Desta forma as comparações serão mais justas e não dependerão do número obtido por randint().
import asyncio import time def calcula_algo(loop, id): limite = 40000 print("Calculando %d" % id) z=1 for x in range(1,limite): z*=x print("Fim do Cálculo %d" % id) if id < 20: loop.call_soon(calcula_algo, loop, id+1) else: loop.stop() inicio = time.time() loop = asyncio.get_event_loop() loop.call_soon(calcula_algo, loop, 1) loop.run_forever() fim = time.time() print("Tempo total: %f s" % (fim-inicio))
Execute o programa e veja que o cálculo foi feito de forma sequencial, ou seja, uma chamada após a outra. Em meu computador o teste executou em aproximadamente 30.37 s. O tempo em seu computador pode e vai variar, pois depende do seu processador e do que sua máquina está fazendo durante os testes.
import time import threading def calcula_algo(id): limite = 40000 print("Calculando %d" % id) z=1 for x in range(1,limite): z*=x print("Fim do Cálculo %d" % id) inicio = time.time() ativos = [] for x in range(20): t = threading.Thread(target=calcula_algo, args=(x,)) t.start() ativos.append(t) for t in ativos: t.join() fim = time.time() print("Tempo total: %f s" % (fim-inicio))
Compare a saída do programa com múltiplos threads com a saída do programa assíncrono. Veja que as chamadas foram iniciadas quase que ao mesmo tempo e que terminaram em uma ordem aleatória. Esta falta de previsibilidade de threads é uma das razões de evitarmos seu uso, especialmente em Python. Devido a uma característica do interpretador Python, que utiliza um lock global, o famoso GIL, programas com múltiplos thread em Python não são eficientes, pois apenas um thread executa de cada vez, o que nos faz voltar ao problema do programa assíncrono adicionado ao tempo de criação e execução dos threads. Em meus testes, este programa teve um desempenho um pouco pior que o programa assíncrono, terminando em 30.67 s. Se você executar novamente este programa, verá que a utilização de CPU não chega nem perto de 100%. Em meu sistema Core i7 que possui 8 cores (4 reais + 4 hyperthreaded), a utilização não passou de 20%.
Vejamos uma solução mais Pythonica, utilizando múltiplos processos e o excelente módulo multiprocessing.
import sys import time from multiprocessing import Pool def calcula_algo(id): limite = 40000 print("Calculando %d" % id) z=1 for x in range(1,limite): z*=x print("Fim do Cálculo %d" % id) if __name__ == '__main__': nproc = int(sys.argv[1]) print("Executando com %d processos.") inicio = time.time() processos = Pool(nproc) processos.map(calcula_algo,list(range(20))) fim = time.time() print("Tempo total: %f s" % (fim-inicio))
Execute o programa várias vezes, passando a cada execução, um dos parâmetros 1, 2, 4, 8 e 20. O parâmetro indica quantos processos teremos em nosso pool. Um pool de processos é um conjunto de processos inicializados pelo módulo multiprocessing. Estes processos ficam disponíveis para o nosso programa e são processos do sistema operacional e não simples threads. Toda a comunicação entre processos é gerenciada pelo módulo multiprocessing. Este módulo é muito interessante, pois realizar este tipo de tarefa em outras linguagens é bem mais complicado. Uma das vantagens do multiprocessing é que cada processo roda seu próprio interpretador Python e assim são capazes de rodar simultaneamente, sem os problemas do GIL que falamos anteriormente. Veja no Gerenciador de Tarefas de seu sistema operacional que múltiplos processos python (ou python.exe no Windows) rodam ao mesmo tempo durante a execução de nosso programa e que agora você deve ter obtido 100% de utilização durante alguns momentos. Veja o resultado da execução de todos os testes em meu computador no gráfico abaixo (passe o mouse sobre as colunas para ver seu valor):
A solução com o multiprocessing melhora ao adicionarmos processos, mas esta melhoria se estabiliza em volta do número de processadores de sua máquina e começa a piorar um pouco depois disso.
O mau desempenho do módulo asyncio é apenas um exemplo de má utilização :-D. Os métodos assíncronos devem ser utilizados com funções que não bloqueiam e que terminam rapidamente. Usar métodos assíncronos com funções CPU bound não pode trazer bons resultados. No entanto, devido aos problemas com o GIL, métodos assíncronos podem simplificar o trabalho de programação e manter a performance de múltiplos threads. Pois a execução sequencial das funções evita a necessidade de sincronizar seus dados. Mesmo com os problemas de GIL, programas em Python que usam threads devem se preparar para execução simultânea de funções, pois a execução salta de um thread a outro durante a execução das funções. Veremos uma outra comparação entre threads e métodos assíncronos em outro posto, usando arquivos.
Isto é só uma pequena amostra do que podemos fazer em Python. Em outro post, abordaremos exemplos mais práticos. O importante é saber a diferença entre a execução assíncrona, com threads e com múltiplos processos. Veremos também como usar como usar um pool de threads em Python e assim combinar as vantagens de threads e métodos assíncronos.