Novo Blog

Novo endereço

https://blog.nilo.pro.br

Mostrando postagens com marcador multiprocessamento. Mostrar todas as postagens
Mostrando postagens com marcador multiprocessamento. Mostrar todas as postagens

sábado, 28 de junho de 2014

Python asyncio - Métodos assíncronos em Python

Leia no novo blog

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.

sexta-feira, 3 de agosto de 2007

Multitarefa e Multiprocessamento

Leia no novo blog

Ao começar a escrever este post, lembro que julho foi um mês realmente atarefado, sem novos posts no JungleCoders. Mas foi um mês onde voltei a ler sobre as questões de hoje sobre multiprocessamento. Alguns anos atrás, havia menos processadores que usuários :-) Era a época dos computadores de grande porte ou mainframes.

Com a chegada do processador pessoal, iniciou-se a época do um para um, porém eram máquinas pequenas, com sistemas operacionais simples. Embora houvesse um processador por usuário, não havia sistema operacional, muito menos recursos no hardware, para suportar a troca de tarefas. Isso claro, para computadores pessoais, pois já existiam os super-micros e sistemas operacionais mais completos, como o Unix entre muitos outros. Com a evolução do Microsoft DOS e do próprio IBM PC, programadores começaram a se utilizar das interrupções de hardware para simular o multiprocessamento, eram os anos dos programas TSR (Terminate and Stay Resident). Quem nunca usou programas como Norton Guides ou Sidekick? Mas os mais populares eram os tais reloginhos que ficam no canto superior da tela. Todo programador DOS tinha que implementar um... Ok, o Mingo pode dizer que o Amiga já tinha multitarefa de verdade, mas poucos felizardos tiveram acesso a estes sistemas.

Para salvar a linhagem do IBM PC, surgiu o Intel 80286. Mas o pobre DOS continuava monotarefa, exceto pelos TSRs... A IBM lançou o OS/2 e renovou as esperanças de um sistema multitarefa para pobres mortais. Para empresas, o Novell Netware e variantes de Unix já existiam. Com a chegada do Intel 80386 a coisa ficou realmente séria e o Windows 3.0 começou a convencer que a multimídia seria o futuro. O novo sistema permitia que vários aplicativos rodassem ao mesmo tempo, mas não podiam ser mal comportados. Se uma tarefa resolvesse tomar conta do sistema operacional para si... descobríamos que se tratava de multitarefa cooperativa... era o fim. Novas versões do OS/2 surgiram com um novo termo, a tal multitarefa preemptiva. Com a preemptividade, o sistema operacional podia recobrar o controle da máquina, mesmo que uma das tarefas não contribuísse muito para isso.

Bom, o OS/2 nunca pegou realmente para usuários domésticos, sendo relegado a pequenos feudos corporativos e segundo lendas urbanas sobrevivendo até hoje em bancos. O Windows 95 trouxe a preemptividade para os sistemas da Microsoft e para nós, pobres usuários. O grande problema é que às vezes o sistema operacional travava... deixa isso pro passado :-) O sistema foi se estabilizando e agora temos outros tipos de problema. Máquinas domésticas passaram a ter 2 ou 4 processadores e sistemas como Windows XP e Linux têm dado conta da tarefa de utilizar os recursos destas máquinas, mas com uma restrição: se a aplicação é desenvolvida com um só thread, esta não pode ser distribuída para mais de um processador :-( Pode parecer pouca coisa, mas se você não tomar cuidado por ter a aplicação rodando mais lentamente numa máquina com dois processadores.

A grande questão hoje é como escrever programas para este novo mundo? Agora temos vários processadores para nossas aplicações, porem programar com threads ainda é muito difícil. Algumas pessoas acreditam que há necessidade de mudarmos a forma que programamos hoje. Outros acreditam em compiladores inteligentes, capazes de decidir que partes do código podem ou não ser executadas em paralelo. Apesar de não parecer grande problema, a Intel sinalizou que já possui processadores com 80 núcleos no laboratório. Imagine um dia ter uma máquina dessas e ter seu pobre programa rodando a apenas 1/80 do poder total da máquina :-) ?

Como nada é aceito por todos ao mesmo tempo, já existem várias abordagens para este problema. Uma delas é utilizar outras linguagens de programação, com paradigmas diferentes como Erlang ou Haskell. Meu pobre Python está longe disso. Segundo o Guido, a solução é utilizar vários processos e morar na casa mal assombrada dos IPC (Inter-process Communication). Pelo pouco que sei, Ruby anda pior ainda neste quesito. Java, C/C++/C# (*.net) estão bem, mas toda a dor de múltiplos threads fica para o programador.

Uma luz vem do mundo dos jogos, pois os processadores gráficos das placas de vídeo possuem mais de 30 pipelines. Rodando a mais de 500 MHz, estes processadores ficam sem uso quando não estamos jogando, um poder de processamento desperdiçado. Tanto a NVidia quanto a ATI prometeram publicar os compiladores para suas GPUs, mas já avisaram que a forma de programar é diferente.

Quem já trabalhou com IPC e threads sabe que o pode ser feito, mas não de graça. Como os softwares tem se tornado cada vez mais complexos, como resolveremos a questão de criar softwares capazes de aproveitar os recursos de multiprocessamento de nossas máquinas? Eu particularmente acredito na utilização de linguagens com novos paradigmas que propiciam a abstração dos threads, mas no fundo desejo que algo de novo surja na área de compiladores e que todos nós possamos continuar a programar como sempre programamos. Como nem só os computadores são multitarefas, eu resolvi acrescentar o estudo deste tipo de problemas a minha já enorme lista :-) Talvez eu troque meu sistema operacional...