Automação 2

Automação 2

Já tinha escrito há uns tempos como “qualquer um” podia usar Python para automatizar algumas tarefas do dia-a-dia, mas ficou um segredo por contar: a maior parte das tarefas interessantes (ir buscar preços de viagens de avião, melhorar fotografias, sacar legendas para uma série, etc) demora tempo, muito tempo…

Acabei de fazer um teste rápido e ir buscar a página do Skyscanner com as viagens Porto -> Lisboa para um dia demorou 0.6s e fazer o parsing to HTML para encontrar a informação interessante outros 0.5s, ou seja, ~1s para cada destino. E se eu quisesse ver qual era o destino de férias mais barato para passar 3 dias durante Agosto? Estamos a falar de ter que procurar 30 * nº destinos viagens, assumindo que estava disposto a ir para qualquer capital europeia isso faria com que tivesse que escolher entre 900 viagens, o que ia demorar 15 minutos. Quem quer estar 15 minutos à espera de alguma coisa que não seja o início de um jogo do Porto? Exato, ninguém.

Será que podíamos fazer melhor? Claro que sim, e é aí que entram a concorrência e o paralelismo em programação. Antes de mais, acho que tem algum interesse perceber a diferença entre estes dois conceitos:

Concorrência: a capacidade de um programa de alternar entre fluxos de execução. Isto quer dizer que o processador pode num momento estar a exectuar uma função A e, antes de acabar, executar um bocado de uma função B antes de voltar à A. Num programa “normal” (sequencial) isto nunca acontece, ao fazer:

imprimir_folha()
fazer_contas_e_abrir_janela()

A janela nunca seria aberta antes da folha ser impressa. No entanto, há aqui um desperdício enorme da capacidade do meu computador. Assim que envio o ficheiro para a impressora, não há motivo para ficar à espera que a impressão acabe para começar a abrir a janela.

Estar à espera de um recurso externo sem ter que praticamente usar o CPU chama-se estar em I/O (de input/output) e acontece frequentemente (como por exemplo quando estamos a escrever ou ler de um ficheiro ou a fazer um pedido à rede). Durante este período de I/O estar agarrado ao CPU é um desperdício de recursos porque não o estamos a utilizar, o ideal seria ir fazendo outra coisa útil qualquer enquanto estou em I/O. Neste exemplo isso corresponderia a ir fazendo as contas enquanto ainda se está a imprimir.

Paralelismo: em sistemas com vários cores há também a hipótese de atuar em vários fluxos de execução ao mesmo tempo. Isto é um tipo especial de concorrência, já que não tenho garantias da sequência do meu programa: um programa pode ser concorrente e não ser paralelo, mas se for paralelo então é concorrente. Olhando para este exemplo:

fazer_bue_contas()
fazer_bue_contas()
fazer_bue_contas()
fazer_bue_contas()

Facilmente percebemos que não adianta muito ir alternando o fluxo entre as várias funções (concorrência), já que estaríamos a usar o core do CPU atribuído ao nosso programa a 100% em qualquer uma das situações. Não havendo I/O, até é prejudicial ter concorrência, já que há perda de tempo no que se chama mudança de contexto (tirar um core do CPU de uma função para ceder a outra). Neste caso, a única opção de acelerar o programa seria utilizar mais cores do CPU.

Isto é tudo muito bonito, mas como se conseguem estas coisas? Usando várias threads ou processos, que são muito semelhantes sendo que a diferença é que o processo é uma versão mais pesada da thread (consome mais recursos e tem uma mudança de contexto mais lenta) já que tem a sua própria região de memória (ou seja, duas threads podem partilhar variáveis mas dois processos não).

Em Python tudo isto é super simples (nada de coisas esquisitas tipo pthread_create(&thread, NULL, &funcao, NULL);):

import multiprocessing

def fazer_bue_contas(num, result):
  result[num] = num * 2

result = {}
process100 = multiprocessing.Process(target = self.fazer_bue_contas, args = (100, result))
process200 = multiprocessing.Process(target = self.fazer_bue_contas, args = (200, result))
process100.join()
process200.join()

print(result)

Não fica muito mais complicado do que isto para utilizações que não impliquem estado partilhado, ou seja, onde as funções não têm que comunicar umas com as outras e cada uma representa uma unidade isolada de computação (que é o caso da maior parte das tarefas de automação). O que está a acontecer é que começamos dois processos em que cada um faz cálculos com base num valor e devolve o resultado; como os processos têm zonas de memórias independentes temos também que passar uma variável para poder guardar os resultados. No fim, com o join, estamos a esperar que os processos terminem.

Uma questão pertinente é: porque é que decidi usar processos e não threads? Se são uma versão mais leve deviam ser preferíveis na maior parte dos casos. É verdade, mas a maior parte das implementações de Python (incluindo a mais comum, o CPython), sofre de um problema chamado GIL (Global Interpreter Lock), que resumidamente é uma limitação no interpretador que impede que duas threads executem em simultâneo, o que faz com que seja impossível ter paralelismo (só podemos ter concorrência) ao usar threads.

Esta conversa toda só para dizer que ir buscar os vôos ao Skyscanner é uma tarefa que passa um tempo considerável em I/O e por isso beneficia IMENSO de usar várias threads, já que o sistema operativo vai trocando o dono de cada core do CPU de cada vez que eles estão à espera que a resposta do Skyscanner chegue pela rede. Seria algo como:

import thread

threads = [thread.start_new_thread(get_place_prices, (region,)) for region in ["norte", "sul", "este", "oeste"]]
for thread in threads:
  thread.join()

Feito! Imaginando que 70% do tempo é passado em I/O, em vez de demorarmos 15 minutos podemos passar para 5 minutos, yey \o/. Agora a parte propriamente dita de ir buscar os preços pode ficar para outro post, ou então…

The proof is left as an exercise to the reader

Outra questão que fica de fora é: qual é o número ideal de threads que o meu programa deve ter? No exemplo estou a usar 4, mas se uma região tiver muito mais capitais que outra, então posso chegar a um ponto em que só tenho uma thread a executar e estou basicamente com o mesmo problema inicial em que o programa não está a fazer nada enquanto espera a resposta do site. Por outro lado, não adianta ter o número de threads igual ao número de capitais porque o computador tem um número limitado de cores e o ideal é tê-los sempre todos ocupados mas sem perder tempo desnecessário com a mudança de contexto. Mais sobre isto e como chegar a uma solução ótima noutro post :D

comments powered by Disqus