Threads são unidades de execução dentro de um mesmo processo, permitindo que um programa execute várias tarefas concorrentemente. Em Python, o módulo threading fornece uma API de alto nível para trabalhar com threads, abstraindo os detalhes do sistema operacional. Nesta aula, vamos explorar os conceitos fundamentais de threading, quando usar (e quando não usar), como sincronizar threads com locks e as implicações do GIL no desempenho.

Entender threading é essencial para escrever programas eficientes, especialmente em aplicações que lidam com muitas operações de entrada/saída, como requisições web, leitura/escrita de arquivos ou comunicação com bancos de dados. No entanto, o GIL do CPython limita a execução paralela de threads para tarefas CPU-bound, o que exige uma análise cuidadosa do tipo de tarefa antes de optar por threads.

Threads em Python

Uma thread é a menor unidade de execução que pode ser gerenciada de forma independente pelo agendador do sistema operacional. Em Python, o módulo threading permite criar e gerenciar threads de maneira simples. A forma mais comum de criar uma thread é instanciar a classe Thread e passar uma função alvo (target) que será executada na nova thread.

O exemplo a seguir mostra como criar e iniciar uma thread simples:

import threading
import time

def tarefa(nome, tempo):
    print(f"Thread {nome} iniciando")
    time.sleep(tempo)
    print(f"Thread {nome} finalizada")

# Criando threads
thread1 = threading.Thread(target=tarefa, args=("A", 2))
thread2 = threading.Thread(target=tarefa, args=("B", 1))

# Iniciando as threads
thread1.start()
thread2.start()

# Aguardando as threads terminarem
thread1.join()
thread2.join()

print("Programa principal finalizado")

Neste código, duas threads são criadas e executam a função tarefa com argumentos diferentes. O método start() inicia a execução da thread, e join() faz o programa principal esperar até que a thread termine. Sem o join(), o programa principal pode terminar antes das threads, o que pode ser indesejado.

Além disso, é possível criar threads como subclasses da classe Thread, sobrescrevendo o método run(). Essa abordagem é útil quando a lógica da thread é mais complexa e precisa de estado interno.

import threading

class MinhaThread(threading.Thread):
    def __init__(self, nome):
        super().__init__()
        self.nome = nome

    def run(self):
        print(f"Thread {self.nome} executando")

thread = MinhaThread("X")
thread.start()
thread.join()

Quando usar (I/O-bound)

Threads são mais eficazes para tarefas que passam a maior parte do tempo esperando por operações de entrada/saída (I/O). Isso inclui leitura/escrita de arquivos, requisições HTTP, consultas a bancos de dados ou comunicação com dispositivos externos. Durante essas operações, a thread fica bloqueada aguardando a resposta, liberando o processador para outras threads.

Em tarefas I/O-bound, o uso de threads pode melhorar significativamente o desempenho, pois o programa pode realizar várias operações de I/O simultaneamente, reduzindo o tempo total de espera. Por exemplo, se você precisa buscar dados de 100 URLs, fazer isso sequencialmente levaria a soma dos tempos de cada requisição; com threads, as requisições podem ser feitas em paralelo, reduzindo o tempo total para aproximadamente o tempo da requisição mais lenta.

Vamos ver um exemplo prático de download de múltiplas URLs usando threads:

import threading
import requests

def baixar(url):
    resposta = requests.get(url)
    print(f"Baixado {url} com status {resposta.status_code}")

urls = [
    "https://httpbin.org/delay/2",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/3"
]

threads = []
for url in urls:
    t = threading.Thread(target=baixar, args=(url,))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print("Todas as requisições concluídas")

Neste exemplo, as três requisições são feitas em paralelo, e o tempo total será aproximadamente o maior atraso (3 segundos), em vez de 6 segundos se fossem sequenciais.

Locks

Quando múltiplas threads acessam recursos compartilhados (como variáveis globais, arquivos ou estruturas de dados), pode ocorrer uma condição de corrida (race condition), onde o resultado final depende da ordem de execução das threads, levando a resultados incorretos. Para evitar isso, usamos mecanismos de sincronização, como Lock.

Um Lock garante que apenas uma thread por vez execute uma seção crítica do código, bloqueando outras threads até que a seção seja liberada. O exemplo clássico é um contador que é incrementado por várias threads:

import threading

contador = 0
lock = threading.Lock()

def incrementar():
    global contador
    for _ in range(100000):
        with lock:
            contador += 1

threads = []
for _ in range(10):
    t = threading.Thread(target=incrementar)
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print(f"Contador final: {contador}")

Sem o lock, o valor final do contador pode ser menor que 1.000.000 (10 threads × 100.000) devido a operações de leitura/escrita não atômicas. Com o lock, garantimos que apenas uma thread por vez executa contador += 1, resultando no valor correto.

Além de Lock, o módulo threading oferece outros mecanismos como RLock (lock reentrante), Semaphore e Event, que podem ser úteis em diferentes cenários de sincronização.

Limitações pelo GIL

O GIL (Global Interpreter Lock) é um mutex que protege o interpretador CPython, garantindo que apenas uma thread execute bytecode por vez. Isso significa que, mesmo em sistemas com múltiplos núcleos, threads Python não executam código Python em paralelo real. Consequentemente, threads não trazem ganhos de desempenho para tarefas CPU-bound (que exigem muito processamento), pois o GIL impede a execução simultânea de operações de CPU.

Para tarefas CPU-bound, a recomendação é usar multiprocessamento (módulo multiprocessing), que cria processos separados, cada um com seu próprio interpretador e GIL, permitindo verdadeiro paralelismo em múltiplos núcleos. No entanto, a comunicação entre processos é mais cara e complexa do que entre threads.

O GIL afeta principalmente operações que envolvem muitas instruções Python puras, como loops com cálculos. Operações de I/O, que liberam o GIL durante a espera, podem se beneficiar de threads. Além disso, bibliotecas escritas em C (como NumPy) podem liberar o GIL durante operações longas, permitindo que outras threads executem.

Exemplo de uma tarefa CPU-bound que não ganha com threads:

import threading
import time

def calcular_soma(n):
    soma = 0
    for i in range(n):
        soma += i
    return soma

# Executando sequencialmente
inicio = time.time()
calcular_soma(10**7)
calcular_soma(10**7)
print(f"Sequencial: {time.time() - inicio:.2f} segundos")

# Executando com threads
inicio = time.time()
threads = []
for _ in range(2):
    t = threading.Thread(target=calcular_soma, args=(10**7,))
    t.start()
    threads.append(t)
for t in threads:
    t.join()
print(f"Com threads: {time.time() - inicio:.2f} segundos")

Você notará que o tempo com threads é similar ou até maior que o sequencial, devido à contenção pelo GIL. Para essas tarefas, o módulo multiprocessing é mais adequado.

Boas práticas e observações finais

Ao trabalhar com threads, é importante seguir algumas boas práticas para evitar problemas:

  • Evite compartilhar estado sempre que possível; use filas (queue.Queue) para comunicação entre threads.
  • Use locks para proteger seções críticas, mas evite manter locks por muito tempo para não causar deadlocks.
  • Prefira with lock: para garantir que o lock seja liberado mesmo em caso de exceção.
  • Considere usar ThreadPoolExecutor do módulo concurrent.futures para gerenciar um pool de threads de forma mais simples e segura.
  • Teste cuidadosamente a sincronização, pois erros de concorrência são difíceis de depurar.

Threads são uma ferramenta poderosa para melhorar a eficiência de programas I/O-bound, mas não são adequadas para CPU-bound no CPython. Ao entender o GIL e as técnicas de sincronização, você pode decidir quando usar threads ou alternativas como multiprocessing.

Referências

Exercícios

  1. Exercício 1: Crie um programa que inicie 5 threads, cada uma imprimindo seu nome e um número de 1 a 5 (com um pequeno atraso aleatório). Use join() para garantir que todas terminem antes de imprimir "Fim".
  2. ✓ Resposta:
    import threading
    import time
    import random
    
    def tarefa(nome):
        for i in range(1, 6):
            print(f"{nome}: {i}")
            time.sleep(random.uniform(0.1, 0.5))
    
    threads = []
    for n in range(5):
        t = threading.Thread(target=tarefa, args=(f"Thread-{n}",))
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
    
    print("Fim")
  3. Exercício 2: Escreva um programa que baixe 10 URLs (use https://httpbin.org/delay/1 para simular atraso) usando threads e meça o tempo total. Compare com a versão sequencial (sem threads).
  4. ✓ Resposta:
    import threading
    import requests
    import time
    
    urls = ["https://httpbin.org/delay/1"] * 10
    
    def baixar(url):
        requests.get(url)
    
    # Sequencial
    inicio = time.time()
    for url in urls:
        baixar(url)
    print(f"Sequencial: {time.time() - inicio:.2f} s")
    
    # Com threads
    inicio = time.time()
    threads = []
    for url in urls:
        t = threading.Thread(target=baixar, args=(url,))
        t.start()
        threads.append(t)
    for t in threads:
        t.join()
    print(f"Com threads: {time.time() - inicio:.2f} s")
  5. Exercício 3: Implemente um contador seguro usando Lock e verifique que o valor final é correto quando 10 threads incrementam 100.000 vezes cada uma.
  6. ✓ Resposta:
    import threading
    
    contador = 0
    lock = threading.Lock()
    
    def incrementar():
        global contador
        for _ in range(100000):
            with lock:
                contador += 1
    
    threads = []
    for _ in range(10):
        t = threading.Thread(target=incrementar)
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
    
    print(f"Contador final: {contador}")  # Deve ser 1.000.000
  7. Exercício 4: Explique por que threads não melhoram o desempenho de tarefas CPU-bound no CPython e sugira uma alternativa.
  8. ✓ Resposta: O GIL (Global Interpreter Lock) impede que múltiplas threads executem bytecode Python simultaneamente, mesmo em máquinas com múltiplos núcleos. Portanto, para tarefas que exigem muita CPU, threads não trazem ganho e podem até piorar o desempenho devido à contenção. A alternativa é usar o módulo multiprocessing, que cria processos separados, cada um com seu próprio GIL, permitindo execução paralela real.
  9. Exercício 5: Crie um pequeno programa que use ThreadPoolExecutor para processar uma lista de números e calcular o quadrado de cada um, usando 4 threads. Imprima os resultados.
  10. ✓ Resposta:
    from concurrent.futures import ThreadPoolExecutor
    
    def quadrado(x):
        return x * x
    
    numeros = [1, 2, 3, 4, 5, 6, 7, 8]
    
    with ThreadPoolExecutor(max_workers=4) as executor:
        resultados = list(executor.map(quadrado, numeros))
    
    print(resultados)  # [1, 4, 9, 16, 25, 36, 49, 64]