Programação concorrente utilizando múltiplas threads em Python

Programação concorrente utilizando múltiplas threads em Python

Dois conceitos importantes que todo programador deve conhecer são: concorrência e paralelismo. Estes dão a habilidade de aumentar a velocidade que os programas são executados ao rodar mais de uma tarefa ao mesmo tempo.

Neste artigo, falaremos um pouco a respeito e depois aplicaremos na prática a concorrência utilizando o módulo threading em Python.

Primeiramente, o que é concorrência?

A programação concorrente consiste em utilizar múltiplas threads (unidades mínimas de processamento que um processador vai executar) para rodar diferentes tarefas de um programa ao mesmo tempo, mas ainda dentro de um mesmo processo.

E o que é paralelismo?

Já no paralelismo, utiliza-se de múltiplos processos para rodar os programas em paralelo, ou seja, podendo executar diferentes tarefas do programa simultaneamente.

A diferença entre os dois basicamente é que, enquanto na concorrência as tarefas têm um certo tempo para rodar e vão sendo alternadas pelo chamado scheduler do sistema operacional, no paralelismo as duas tarefas vão executar exatamente ao mesmo tempo. O que vai determinar qual é a melhor abordagem para o seu programa vai ser o contexto, pois cada um tem suas vantagens e desvantagens.

Apesar de múltiplos processos executarem mais rápido, o custo de iniciar um novo processo é maior quando comparado ao de iniciar uma nova thread.

Programação concorrente ou sequencial?

Para melhor entender o benefício das threads em relação à programação sequencial, um exemplo clássico é de um programa que lê informações em um arquivo e envia para uma impressora. Na programação sequencial, o arquivo é lido, armazenado na memória para só depois iniciar a impressora e começar a imprimir. Dessa forma, enquanto o disco está sendo utilizado para ler o arquivo, a impressora está parada.

Agora imagine colocar o disco e a impressora para trabalharem ao mesmo tempo, o disco começa a ler, vai colocando na memória o que já foi lido e depois de um certo tempo o contexto muda e agora a impressora começa a trabalhar, imprimindo o que está na memória.

Esse ciclo vai se repetindo até que todo o arquivo tenha sido impresso. Ao fazer ambos trabalharem ao mesmo tempo, o total de tempo gasto para a execução do programa será menor.

Porém existe um custo benefício, pois para criar uma nova thread é exigido mais memória e para mudar o contexto de uma thread para outra existe um custo de processamento. Então, a quantidade de threads vai depender da capacidade da sua máquina.

Se você estiver no Windows, pode abrir o gerenciador de tarefas e ver quantas threads e processos estão sendo executados neste momento:

Esse processador é um I5-8300H, que possui 4 núcleos e 8 threads, então esses 254 processos e 2816 threads no gerenciador de tarefas estão todos na memória, sendo executados de forma concorrente, ou seja, o contexto vai mudando para que todas as threads sejam executadas.

Apesar de existirem muito mais threads para executarem do que existem fisicamente, os computadores de hoje conseguem rodar bilhões de operações por segundo, então nem é perceptível que não estejam sendo executados ao mesmo tempo até certo ponto e isso é o que nos permite executar vários programas simultaneamente.

Manipulando threads

Para começar, vamos criar um arquivo chamado main.py e vamos importar as bibliotecas nativas time e threading:

Agora vamos criar uma função simples para contar até 10 e passá-la como parâmetro para uma variável que será um objeto de uma thread:

Note que ao passar a função para o parâmetro target do objeto ela não está sendo chamada, apenas passamos o nome da função que será o alvo sem o parênteses na frente.

Para que a thread comece a ser executada, precisamos chamar a função start do objeto, mas antes vamos executar a função active_count do módulo threading para saber quantas threads estão sendo executadas e depois que executarmos a thread, vamos chamar essa mesma função de novo:

Ao executar o programa, você verá no início que a função active_count vai retornar 1, o que significa que apenas uma thread está rodando, ou seja, a thread principal que todos os seus programas em Python já criam normalmente, então verá no console a thread sendo executada e depois o resultado do active_count que será 2, o que significa que agora temos 2 threads sendo executadas, a principal e a que criamos.

Quando iniciamos uma nova thread, ela não bloqueia a principal a não ser que elas precisem usar um mesmo recurso, então diferentemente da programação sequencial onde o seu código vai executando linha a linha de cima para baixo, na concorrente a thread será iniciada, começará a fazer o seu trabalho e os comandos que estiverem abaixo da inicialização da thread começaram a serem executados.

Então mesmo nesse exemplo simples que fizemos, pode ser que a função active_count execute antes que a thread finalize o seu trabalho, isso fica mais evidente se colocarmos para que ela conte até 100.

Também é possível passar argumentos para as funções nas threads, para isso utilizamos o parâmetro args. Vamos criar agora uma função que recebe como parâmetro um nome e mostra no terminal quando estiver fazendo a contagem e depois duas threads em variáveis diferentes e com nomes diferentes:

Analisando o resultado, verá que a execução da thread 1 estará acontecendo ao mesmo tempo (de forma concorrente) da thread 2, então a saída no terminal será uma mistura de ambas. Mas dá para atrelar uma thread de volta com a principal utilizando o comando join:

Se você chamar o essa função na thread 1 antes de iniciar a thread 2, o programa vai esperar a execução da primeira terminar para começar o da segunda.

Sincronizando threads

Agora vamos entender como sincronizar as threads. Este é um passo importante, pois quando uma thread estiver tentando acessar uma variável ou um arquivo na memória, se outra tentar acessar ou modificar ao mesmo tempo pode dar erro. Por exemplo, imagine que com uma thread esteja sendo feito um cálculo matemático e gerando números para uma determinada variável, enquanto outra thread está lendo o valor dessa variável para utilizar em algum sistema.

Pode acontecer, dependendo do cenário, que a primeira termine os cálculos mais rápido do que a segunda consegue ler, então um número será perdido. Porém se o contrário ocorrer e a thread que lê conseguir ser mais rápida, um número pode ser lido duas vezes.

Lock

Primeiro criaremos uma variável global com um número e um objeto da classe lock do módulo threading:

Agora criaremos duas funções, uma para dobrar o valor da variável e outra para reduzir pela metade até um certo limite:


No início de cada função, definimos as variáveis globais x e lock, então antes do loop chamaremos a função lock.aquire() e depois do loop chamamos o lock.release(), essas duas funções vão bloquear a variável para que as operações sejam realizadas e outra thread não comece a manipular a mesma variável e ao fim liberar o acesso.

Agora criaremos as threads passando como alvo as funções e depois iniciaremos com a função start:

Ao executar, a função de dividir pela metade vai começar e vai bloquear a variável x, então após seu término, a variável x será liberada e a função do dobro será iniciada. Caso não houvesse o lock, as duas threads ficariam em um loop infinito, pois uma estaria dividindo pela metade enquanto a outra estaria multiplicando por 2.

Semáforo

Outra forma de sincronizar threads é através do uso do semáforo. Diferente do lock que apenas uma thread pode acessar um recurso por vez, no semáforo é possível definir um número máximo de threads.

Primeiro, vamos criar um objeto da classe BoundedSemaphore passando o valor 5, que será o número máximo de threads que poderão acessar um determinado recurso.

Agora criaremos uma função que receberá o número da thread e avisará quando ela estiver tentando acessar, depois chamaremos a função aquire do semáforo, avisaremos quando a thread conseguir o acesso, colocaremos um delay de 10 segundos e por fim o acesso será liberado:


Por fim, criaremos um loop para gerar 10 threads passando a variável access como target e o número da thread como argumento:

Ao executar, quando chegar na thread 5 que é o máximo, a thread 6 e as demais vão ter que esperar as primeiras liberarem o acesso para conseguirem continuar.

Daemon threads

Outro conceito importante é o das daemon threads, quando a thread principal for finalizada as outras continuarão funcionando até que tenham terminado tudo o que tem para fazer, mas se definirmos uma thread como daemon, significa que ela vai estar atrelada à thread principal, ou seja, quando a principal for finalizada, caso só haja threads do tipo daemon todo o programa é finalizado.

Veja o exemplo abaixo, onde temos a função readFile que vai ler um arquivo de texto e outra printLoop que vai mostrar o que está escrito nele:

A primeira função, como é um loop infinito que só vai ficar verificando se o arquivo foi alterado, será definida como uma thread daemon e a segunda que não é um loop infinito é uma thread normal:

Ao executar, quando a segunda thread terminar o seu trabalho, todo o programa será finalizado, apesar da primeira thread ser um loop infinito porque ela é do tipo daemon.

Events

Um problema que as daemon threads tem é que, independente do que elas estejam fazendo, elas serão destruídas quando o processo principal for finalizado. Isso pode causar algum erro caso a thread esteja acessando uma API ou um banco de dados. Nesse caso, é melhor utilizar uma thread normal e utilizar outro recurso do módulo threading que são os events.

Utilizando o mesmo exemplo anterior, se antes de declarar as funções criarmos um evento e no loop da função readFile colocarmos uma verificação para o estado do evento, podemos terminar o loop infinito caso o evento tenha sido definido:

Agora a thread que vai ler o arquivo pode ser normal e a thread principal pode finalizar o loop infinito quando definir o evento:

Dessa forma, após 5 segundos, a thread de ler o arquivo será finalizada e nenhuma alteração que for feita nele será detectada. Esse recurso pode ser usado para quando o seu processo principal tiver que terminar e a execução das outras threads não podem ser terminadas repentinamente, como no caso do banco de dados, que teria que fechar as conexões primeiro. Então, a escolha de usar daemon threads ou events vai depender de cada caso.

Conclusão

A programação utilizando threads fica uma ordem de grandeza mais difícil, pois diferente dos exemplos básicos de receita de bolo que aprendemos quando começamos a estudar lógica de programação, agora estamos lidando com múltiplos sistemas rodando ao mesmo tempo. Porém, dominar a concorrência e o paralelismo  é uma habilidade essencial que vai possibilitar programar sistemas mais complexos como um jogo, por exemplo, onde você precisa pegar os inputs do usuário no teclado e mouse, renderizar os gráficos e tocar o áudio ao mesmo tempo.

Para acessar o código fonte completo deste projeto, confira o repositório no GitHub e se não quiser perder mais projetos como este, dicas de carreira e notícias sobre tecnologia, se inscreva no nosso blog!

⚠️
As opiniões e comentários expressos neste artigo são de propriedade exclusiva de seu autor e não representam necessariamente o ponto de vista da Revelo.

A Revelo Content Network acolhe todas as raças, etnias, nacionalidades, credos, gêneros, orientações, pontos de vista e ideologias, desde que promovam diversidade, equidade, inclusão e crescimento na carreira dos profissionais de tecnologia.