Se você já precisou processar milhares de arquivos, fazer centenas de requisições HTTP ou acelerar um algoritmo pesado, sabe que o Python tradicional (síncrono) pode ser frustrantemente lento. A solução? Concorrência e paralelismo. Neste guia completo, você vai dominar multithreading e multiprocessamento em Python, entendendo quando e como usar cada técnica para extrair o máximo de performance do seu código.

Vamos explorar desde os conceitos fundamentais — como threads, processos e o famigerado GIL — até implementações práticas com ThreadPoolExecutor, ProcessPoolExecutor, locks e queues. Ao final, você terá um arsenal completo para escrever Python paralelo de verdade.

Entendendo Threads e Processos

Antes de escrevermos código, precisamos entender o que são threads e processos — e por que a diferença entre eles é crucial em Python.

Um processo é uma instância de um programa em execução. Cada processo tem seu próprio espaço de memória, seus próprios recursos e é isolado dos demais. Pense em abrir o Chrome três vezes: cada janela é um processo separado. Processos são pesados de criar, mas totalmente independentes.

Uma thread (ou linha de execução) é a menor unidade de processamento dentro de um processo. Todas as threads de um mesmo processo compartilham o mesmo espaço de memória. Pense em abas dentro de uma mesma janela do Chrome: todas compartilham recursos, mas podem travar umas às outras se não forem bem gerenciadas.

Em Python, essa distinção é ainda mais importante por causa de uma peculiaridade da implementação padrão: o GIL.

O GIL (Global Interpreter Lock)

O Global Interpreter Lock é um mecanismo do CPython (a implementação padrão do Python) que permite que apenas uma thread execute bytecode Python por vez. Isso significa que, em CPU-bound tasks (tarefas pesadas de processamento), múltiplas threads não trazem ganho de performance — na verdade, podem até piorar o desempenho devido ao overhead de alternância entre threads.

Mas então por que usar threads em Python? A resposta está nas I/O-bound tasks: operações de entrada e saída como requisições HTTP, leitura de arquivos ou consultas a bancos de dados. Durante essas operações, a thread fica ociosa esperando a resposta, e o GIL é liberado — permitindo que outras threads executem. É aí que o multithreading brilha.

Para entender mais a fundo o funcionamento do GIL, recomendo a leitura do artigo What Is the Python Global Interpreter Lock (GIL)? no Real Python, que explica o tema de forma clara e aprofundada.

Multithreading com threading.Thread

O módulo threading fornece a API básica para criar e gerenciar threads em Python. Vamos começar com um exemplo simples:

import threading
import time

def tarefa(nome, segundos):
    print(f"Thread {nome}: iniciando")
    time.sleep(segundos)
    print(f"Thread {nome}: concluída após {segundos}s")

threads = []
for i in range(5):
    t = threading.Thread(target=tarefa, args=(f"T{i}", i + 1))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("Todas as threads concluídas!")

Chamamos start() para iniciar cada thread e join() para aguardar que todas terminem antes de prosseguir. Sem o join(), o programa principal poderia encerrar antes das threads filhas completarem seu trabalho.

A documentação oficial do módulo threading traz todos os detalhes sobre locks, semáforos, eventos e outros mecanismos de sincronização disponíveis.

Subclasses de Thread

Uma abordagem mais organizada é criar subclasses de Thread:

import threading

class MeuDownload(threading.Thread):
    def __init__(self, url):
        super().__init__()
        self.url = url
        self.resultado = None

    def run(self):
        # Simula download
        import time
        time.sleep(2)
        self.resultado = f"Dados de {self.url}"

downloads = [MeuDownload(f"https://api.exemplo.com/item/{i}") for i in range(10)]
for d in downloads:
    d.start()
for d in downloads:
    d.join()
    print(d.resultado)

Essa abordagem é especialmente útil quando você precisa encapsular estado e comportamento em objetos reutilizáveis.

ThreadPoolExecutor: A Maneira Moderna

Gerenciar threads manualmente funciona, mas é trabalhoso. O módulo concurrent.futures, introduzido no Python 3.2 e significativamente melhorado ao longo das versões, oferece uma API muito mais elegante através do ThreadPoolExecutor:

from concurrent.futures import ThreadPoolExecutor
import time

def baixar_arquivo(url):
    print(f"Baixando: {url}")
    time.sleep(2)
    return f"Conteúdo de {url}"

urls = [f"https://site.com/arquivo_{i}.zip" for i in range(20)]

with ThreadPoolExecutor(max_workers=5) as executor:
    resultados = list(executor.map(baixar_arquivo, urls))

print(f"Baixados {len(resultados)} arquivos com sucesso!")

O ThreadPoolExecutor gerencia automaticamente um pool de threads reutilizáveis. O parâmetro max_workers define quantas threads serão mantidas no pool. Com o gerenciador de contexto (with), as threads são finalizadas automaticamente ao sair do bloco.

Você também pode usar submit() para obter objetos Future, que permitem acompanhar o progresso de cada tarefa individualmente:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=5) as executor:
    futures = {executor.submit(baixar_arquivo, url): url for url in urls}
    for future in as_completed(futures):
        url = futures[future]
        try:
            resultado = future.result()
            print(f"{url} concluído: {resultado[:30]}...")
        except Exception as e:
            print(f"{url} falhou: {e}")

Consulte a documentação oficial do concurrent.futures para explorar todos os recursos avançados, incluindo callbacks e timeouts.

Sincronização: Locks e Queues

Quando múltiplas threads compartilham dados, surgem condições de corrida (race conditions). Duas threads podem tentar modificar a mesma variável ao mesmo tempo, corrompendo o resultado. A solução clássica é o uso de locks (travar e destravar o acesso a recursos compartilhados):

import threading

contador = 0
lock = threading.Lock()

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

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

print(f"Contador final: {contador}")  # Sempre 1.000.000

Sem o lock, o resultado seria imprevisível — provavelmente menor que 1.000.000 — devido às condições de corrida. O gerenciador de contexto with lock garante que o lock seja sempre liberado, mesmo em caso de exceções.

Para comunicação entre threads, a fila (queue.Queue) é a ferramenta ideal. Ela já é thread-safe, eliminando a necessidade de locks manuais:

from queue import Queue
import threading
import time

def produtor(fila):
    for i in range(10):
        item = f"Item {i}"
        fila.put(item)
        print(f"Produzido: {item}")
        time.sleep(0.5)
    fila.put(None)  # Sinal de fim

def consumidor(fila):
    while True:
        item = fila.get()
        if item is None:
            break
        print(f"Consumido: {item}")
        time.sleep(1)
    print("Consumidor finalizado")

fila = Queue()
t_produtor = threading.Thread(target=produtor, args=(fila,))
t_consumidor = threading.Thread(target=consumidor, args=(fila,))

t_produtor.start()
t_consumidor.start()
t_produtor.join()
t_consumidor.join()

O padrão produtor-consumidor com filas é extremamente versátil e aparece em aplicações reais como processamento de logs, web scraping e ETL. A documentação oficial do módulo queue detalha os tipos de fila disponíveis: FIFO, LIFO e filas com prioridade.

Para se aprofundar em mais padrões de concorrência, veja também nosso guia completo sobre context managers em Python, que mostra como usar o padrão with para gerenciar recursos de forma segura.

Multiprocessamento com ProcessPoolExecutor

Para tarefas que exigem muito processamento da CPU (CPU-bound) — como processamento de imagens, cálculos científicos ou machine learning — o multithreading não ajuda por causa do GIL. A solução é o multiprocessamento, que cria processos separados, cada um com seu próprio interpretador Python e seu próprio GIL.

O ProcessPoolExecutor do módulo concurrent.futures oferece a mesma API elegante do ThreadPoolExecutor, mas usando processos em vez de threads:

from concurrent.futures import ProcessPoolExecutor
import math

def calcular_primos(limite):
    primos = []
    for num in range(2, limite):
        if all(num % i != 0 for i in range(2, int(math.sqrt(num)) + 1)):
            primos.append(num)
    return len(primos)

intervalos = [10000, 20000, 30000, 40000, 50000, 60000]

with ProcessPoolExecutor(max_workers=4) as executor:
    resultados = list(executor.map(calcular_primos, intervalos))

for intervalo, total in zip(intervalos, resultados):
    print(f"Primos até {intervalo}: {total}")

A diferença de performance em tarefas CPU-bound é dramática. Enquanto threads seriam limitadas pelo GIL e executariam o trabalho praticamente de forma sequencial, processos distribuem o trabalho entre núcleos da CPU, alcançando ganhos lineares de desempenho.

É importante notar que ProcessPoolExecutor tem algumas limitações: os argumentos e retornos das funções precisam ser picklable (serializáveis), e o overhead de criação de processos é maior que o de threads. Para tarefas muito pequenas, o custo de criar o processo pode superar o benefício.

A documentação oficial do módulo multiprocessing oferece recursos mais avançados como filas entre processos, memória compartilhada e conexões pipes para cenários que exigem maior controle.

Outro recurso que pode complementar seu conhecimento é o guia sobre args e kwargs em Python, fundamental para criar funções flexíveis que funcionam bem com os executors.

Multithreading vs Multiprocessamento: Quando Usar Cada Um

Escolher entre threads e processos é uma das decisões mais importantes na programação concorrente em Python. Aqui vai um resumo prático:

Use Multithreading quando:

  • A tarefa é I/O-bound (requisições HTTP, leitura de arquivos, consultas a banco de dados)
  • Você precisa compartilhar estado entre tarefas com frequência
  • O número de tarefas simultâneas é muito grande (milhares)
  • O overhead de criação precisa ser mínimo

Use Multiprocessamento quando:

  • A tarefa é CPU-bound (processamento de imagens, cálculos matemáticos)
  • Você precisa aproveitar múltiplos núcleos da CPU
  • A transferência de dados entre tarefas é pequena
  • O isolamento entre processos é desejável (segurança, estabilidade)

O artigo Difference Between Multithreading vs Multiprocessing in Python no GeeksforGeeks oferece uma tabela comparativa detalhada que pode ajudar na decisão.

Exemplo Prático: Web Scraping Concorrente

Vamos juntar tudo em um exemplo realista: um web scraper que baixa múltiplas páginas simultaneamente:

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import time

def scrape_url(url):
    try:
        resp = requests.get(url, timeout=10)
        return url, resp.status_code, len(resp.text)
    except Exception as e:
        return url, None, str(e)

urls = [
    "https://python.org",
    "https://docs.python.org/3/",
    "https://pypi.org",
    "https://realpython.com",
    "https://github.com/python",
    "https://stackoverflow.com/questions/tagged/python",
    "https://www.geeksforgeeks.org/python-programming-language/",
    "https://pandas.pydata.org",
]

print("Iniciando scraping síncrono...")
inicio = time.time()
for url in urls:
    resultado = scrape_url(url)
    print(f"{resultado[0]}: {resultado[1]}")
print(f"Tempo síncrono: {time.time() - inicio:.2f}s")

print("\nIniciando scraping concorrente...")
inicio = time.time()
with ThreadPoolExecutor(max_workers=4) as executor:
    futures = {executor.submit(scrape_url, url): url for url in urls}
    for future in as_completed(futures):
        url, status, dados = future.result()
        print(f"{url}: {status}")
print(f"Tempo concorrente: {time.time() - inicio:.2f}s")

A diferença de tempo entre a versão síncrona e a concorrente é impressionante. Em testes reais, o scraping com ThreadPoolExecutor costuma ser de 3 a 8 vezes mais rápido, dependendo do número de URLs e da latência da rede.

O guia de concorrência em Python do Real Python expande esses exemplos com benchmarks e comparações detalhadas entre threading, multiprocessing e asyncio.

Boas Práticas e Armadilhas Comuns

Concorrência é poderosa, mas também traz desafios. Aqui estão as armadilhas mais comuns e como evitá-las:

1. Compartilhamento excessivo de estado: Quanto menos estado compartilhado entre threads/processos, melhor. Prefira passar dados como argumentos e retornar resultados, em vez de modificar variáveis globais.

2. Esquecer o join(): Sempre aguarde suas threads/processos terminarem com join() ou usando gerenciadores de contexto.

3. Deadlocks: Quando duas threads esperam uma pela outra liberar um lock. Use with lock e evite adquirir múltiplos locks ao mesmo tempo.

4. Excesso de workers: Criar threads ou processos demais degrada a performance. O número ideal de workers depende do tipo de tarefa: para I/O-bound, o dobro de threads do número de CPUs costuma funcionar bem; para CPU-bound, use o número de núcleos da sua máquina.

5. Ignorar exceções em threads: Exceções dentro de threads não propagam automaticamente para a thread principal. Sempre use future.result() ou try/except dentro da função alvo.

Para um estudo mais aprofundado sobre multithreading em Python, o tutorial Multithreading in Python do GeeksforGeeks cobre desde o básico até tópicos avançados como semáforos e barreiras de sincronização.

Considerações de Performance

Aqui vai uma tabela prática de referência para escolher a ferramenta certa:

Cenário Ferramenta Recomendada Ganho Esperado
100 requisições HTTP ThreadPoolExecutor (10-20 workers) 5x a 10x mais rápido
Processar 50 imagens grandes ProcessPoolExecutor (4-8 workers) 3x a 8x mais rápido
Milhares de tarefas I/O leves asyncio (async/await) 10x a 100x mais rápido
Algoritmo matemático intenso ProcessPoolExecutor (num_cores) Nx mais rápido (N = núcleos)
Pipeline ETL com etapas Queue + Thread/Process 2x a 5x mais rápido

Conclusão

Dominar multithreading e multiprocessamento é essencial para qualquer desenvolvedor Python que deseja escrever aplicações performáticas e escaláveis. Neste guia, você aprendeu:

  • A diferença fundamental entre threads (mesmo espaço de memória) e processos (espaço isolado)
  • Como o GIL impacta cada abordagem
  • A usar ThreadPoolExecutor para I/O-bound tasks
  • A usar ProcessPoolExecutor para CPU-bound tasks
  • Técnicas de sincronização com locks e queues
  • Padrões práticos como produtor-consumidor
  • Exemplos reais de web scraping concorrente

O segredo do código concorrente bem-sucedido está em escolher a ferramenta certa para cada tipo de tarefa. Comece com o ThreadPoolExecutor para operações de I/O e evolua para o ProcessPoolExecutor quando precisar processar dados pesados. E lembre-se: meça sempre o desempenho antes e depois — a otimização prematura é a raiz de todos os males!

Gostou do guia? Explore também outros tutoriais no blog Universo Python para continuar evoluindo suas habilidades em programação concorrente e outros tópicos avançados da linguagem.