Mochileiro T.I
Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors

Threads Python – Funcionamento e Manipulação

Primordialmente as threads em Python tem uma política de gerenciamento diferenciada pelo interpretador oficial da linguagem (CPython) mantido pela Python Software Foundation.

Visando tornar a linguagem “Thread Safe”, ou seja, evitar as chamadas “Condições de Corrida” onde várias threads tem a possibilidade acessar recursos de memória ao mesmo tempo, o CPython implementa um recurso chamado GIL.

Este recurso, abreviação de Global Interpreter Lock (GIL), dentre outras funções, age a princípio como um limitador de threads. Dessa forma, ele impede que múltiplas threads executem ao mesmo tempo, facilitando o gerenciamento de memória e evitando inconsistências nos dados.

Uma vez que somente uma thread pode ser executada por vez, tarefas CPU-bound, isto é, tarefas de processamento intenso, a limitação das threads impacta negativamente a performance do programa.

Em contrapartida, tarefas IO-bound (que fazem bastante uso de leitura e escrita de arquivos ou chamadas de rede) performam melhor.

O uso de threads certamente é um recurso valioso em qualquer linguagem de programação e em Python não é diferente. Portanto, apesar das limitações implicadas pelo interpretador, elas ainda são muito úteis para nossos programas Python.

Neste sentido, veremos ao decorrer deste artigo como criar threads Python, como manipulá-las, os impactos de desempenho bem como formas de contorná-los.

Criação de Threads Python

A linguagem oferece por padrão a biblioteca “threading”, portanto não precisamos de pacotes adicionais para esse propósito. Como certamente é uma linguagem muito amigável e fácil de aprender, criar threads Python não poderia ser diferente:

import threading
import time

# Apenas define uma função com tenha operação de entrada/saída
def alguma_operacao_IO():
    print(input("Digite seu nome:"))

# Cria o objeto thread
t1 = threading.Thread(target=alguma_operacao_IO)
# Cria a thread de fato
t1.start()

# Restante do código, apenas um contador de 1 a 10 com pausa de 1 segundo
# entre as contagens
for i in range(10):
    print(i)
    time.sleep(1)

O código acima demonstra a criação de uma thread bem como seu comportamento perante o restante do código abaixo dela. Perceba que, embora haja uma função que pausaria a execução do programa até que o usuário digite seu nome, isso não acontece.

Em outras palavras, funciona como se houvessem duas execuções ao mesmo tempo: o código dentro da função indicada no atributo “target” do objeto thread e o restante do código do programa.

Por outro lado, se quisermos que o nosso loop “for” aguarde para ser executado até que a thread colete o nome do usuário, indicamos ao Python tal comportamento através da instrução join():

import threading
import time

# Apenas define uma função com tenha operação de entrada/saída
def alguma_operacao_IO():
    print(input("Digite seu nome:"))

# Cria o objeto thread
t1 = threading.Thread(target=alguma_operacao_IO)
# Cria a thread de fato
t1.start()

# Agora, daqui para baixo, todo código aguardará o término da função alguma_operacao_IO()
t1.join()

# Restante do código, apenas um contador de 1 a 10 com pausa de 1 segundo
# entre as contagens
for i in range(10):
    print(i)
    time.sleep(1)

Nomeando Threads Python

Com o intuito de facilitar a identificação das threads para efeitos de depuração, podemos passar o parâmetro “name” e acessá-lo através do objeto thread:

import threading


def tarefa():
    print(f"Thread {threading.current_thread().name} está rodando.")


# Criando threads com nomes personalizados
t1 = threading.Thread(target=tarefa, name="Thread-1")
t2 = threading.Thread(target=tarefa, name="Thread-2")

t1.start()
t2.start()

Verificando o status das threads

Obter o status de uma thread é muito importante em alguns cenários, assim sendo, o método is_alive() retorna um valor boleano indicando se ela está rodando:

import threading
import time

def tarefa():
    time.sleep(2)
    print("Tarefa concluída.")

t = threading.Thread(target=tarefa)
t.start()

print(f"A thread está rodando? {t.is_alive()}")  # Deve ser True
t.join()
print(f"A thread está rodando? {t.is_alive()}")  # Agora deve ser False

Tornando uma thread “daemon”

Com efeito de encerrarmos todas as threads em execução caso o programa principal termine, existe a possibilidade de adicionar o atributo “daemon” conforme abaixo:

import threading
import time

def alguma_operacao_IO():
    print(input("Digite seu nome:"))

# Definição do atributo DAEMON
t1 = threading.Thread(target=alguma_operacao_IO, daemon=True)
t1.start()

for i in range(10):
    print(i)
    time.sleep(1)

# Neste ponto, caso o usuário não tenha digitado seu nome,
# o programa será encerrado junto com a thread.

Passando argumentos para threads

Sem dúvida, frequentemente precisamos passar argumentos para vários destinos (métodos, classes, Api’s) e com as threads não é diferente. Dessa forma utilizamos o atributo “args”:

import threading

def saudacao(nome):
    print(f"Olá, {nome}!")

t = threading.Thread(target=saudacao, args=("Alice",))
t.start()
t.join()

Acima de tudo, repare que os argumentos são passados dentro de uma tupla.

Evitando problemas de concorrência

Imagine um cenário onde temos o estoque de produtos sendo atualizado por dois funcionários. Nesse sentido, para garantir a consistência da quantidade adicionada, precisamos de um mecanismo que permita apenas um funcionário por vez adicionando produtos.

Com a finalidade de proteger a parte crítica do código que pode gerar inconsistência, utilizamos o método “Lock()” com a instrução “with”:

import threading
import time

# Estoque inicial
estoque = 100
lock = threading.Lock()

def adicionar_estoque(quantidade, nome_thread):
    global estoque
    for _ in range(quantidade):
        with lock:  # Garante que apenas uma thread acesse `estoque` por vez
            estoque += 1
            print(f"{nome_thread}: Adicionou 1 item. Estoque atual: {estoque}")
        time.sleep(0.01)  # Simula tempo de processamento

# Criando duas threads que adicionam produtos ao estoque
t1 = threading.Thread(target=adicionar_estoque, args=(10, "Fornecedor-1"))
t2 = threading.Thread(target=adicionar_estoque, args=(10, "Fornecedor-2"))

# Iniciar as threads
t1.start()
t2.start()

# Esperar as threads finalizarem
t1.join()
t2.join()

print(f"Estoque final: {estoque}")

No exemplo acima, a parte crítica é a soma da variável “estoque”. Portanto, colocamos esta soma dentro da instrução “with lock” garantindo desse modo que apenas uma thread acesse a variável por vez.

De maneira idêntica, Python oferece estruturas de dados que já lidam com gerenciamento de concorrência. Assim sendo, você não precisa se preocupar em definir manualmente o “lock” em seu código.

Por exemplo a estrutura de fila “Queue” é thread-safe e por isso não necessita de trabalho adicional:

import threading
import queue

def processar_fila(q):
    while not q.empty():
        item = q.get()
        print(f"Processando item: {item}")
        q.task_done()

q = queue.Queue()
for i in range(5):
    q.put(i)

t1 = threading.Thread(target=processar_fila, args=(q,))
t2 = threading.Thread(target=processar_fila, args=(q,))

t1.start()
t2.start()
t1.join()
t2.join()

Em suma, é uma ótima forma de realizar comunicação entre threads Python.

Impactos de desempenho das threads Python

Conforme dito anteriormente, o GIL impede que múltiplas threads executem ao mesmo tempo. Isso impacta negativamente a performance de tarefas que usam CPU intensamente, como por exemplo tarefas com muitos cálculos matemáticos.

Em outras palavras, o GIL impede que seu programa utilize outros núcleos do seu processador ao mesmo tempo.

Certamente uma ótima forma de contornar este problema é escrever o código crítico em linguagem C e integrá-lo com Python. Nesse sentido, o artigo Rodando Bibliotecas C/C++ em Python dá uma visão geral e algumas formas de fazer isso.

No entanto, ainda que inferior ao C/C++, existe uma forma que utiliza exclusivamente Python e aumenta a performance de programas que precisam de mais poder de processamento.

Trata-se do “multiprocessing”, biblioteca nativa do Python que faz paralelismo real criando processos independentes. Dessa maneira, é capaz de utilizar múltiplos núcleos ao mesmo tempo.

Primeiramente, veja o código abaixo que executa tarefas com e sem threads bem como o resultado de sua execução:

import threading
import time

def calcular():
    total = sum(i * i for i in range(10**7))  # Cálculo pesado

# Medir tempo com threads
t1 = threading.Thread(target=calcular)
t2 = threading.Thread(target=calcular)

inicio = time.time()

t1.start()
t2.start()
t1.join()
t2.join()

fim = time.time()
print(f"Tempo com threads: {fim - inicio:.2f} segundos")

# Medir tempo sem threads (execução sequencial)
inicio = time.time()

calcular()
calcular()

fim = time.time()
print(f"Tempo sem threads: {fim - inicio:.2f} segundos")

Saída:

Tempo com threads: 1.77 segundos
Tempo sem threads: 1.74 segundos

Note que conforme não existe paralelismo, o tempo de execução é praticamente o mesmo tanto com threads quanto sem elas. Neste caso, como trata-se de uma tarefa CPU-bound, a utilização de threads é inútil.

Por outro lado, o “multiprocessing” ameniza este cenário:

import multiprocessing
import time

def calcular():
    total = sum(i * i for i in range(10**7))

if __name__ == "__main__":
    p1 = multiprocessing.Process(target=calcular)
    p2 = multiprocessing.Process(target=calcular)

    inicio = time.time()

    p1.start()
    p2.start()
    p1.join()
    p2.join()

    fim = time.time()
    print(f"Tempo com multiprocessing: {fim - inicio:.2f} segundos")

Saída:

Tempo com multiprocessing: 1.00 segundos

Uma vez que agora são dois processos, a execução neste caso levou quase a metade do tempo, fazendo jus ao paralelismo.

Conclusão

Embora Python seja uma linguagem de baixa performance, quando utilizadas corretamente, suas threads dão resultados de desempenho satisfatórios. Além disso, é relativamente simples lidar com as mesmas através do pacote “threading”.

Se o código faz apenas cálculo pesados, threading não funciona. Em contrapartida, o paralelismo com o multiprocessing é uma escolha bastante viável caso não seja possível a utilização de linguagens mais performáticas como C/C++ ou Rust.

Em conclusão, threads Python fazem mais sentido para tarefas que envolvem espera de dados (IO-bound), visto que podemos liberar a execução das demais partes do programa enquanto tal espera acontece.

Espero ter ajudado!

Até a pŕoxima!