sábado, 28 de junho de 2014

Python asyncio - Métodos assíncronos em Python

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.