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:
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:
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);):
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:
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…
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