Nesta aula, vamos explorar o módulo threading do Python, que permite a criação e gerenciamento de threads. Threads são unidades de execução dentro de um mesmo processo, que podem rodar concorrentemente, compartilhando o mesmo espaço de memória. Isso é particularmente útil para tarefas que envolvem espera, como operações de I/O (entrada/saída), onde a CPU fica ociosa aguardando respostas de dispositivos ou redes.

Vamos entender quando usar threads, como sincronizá-las com locks e as limitações impostas pelo GIL, que é um mecanismo do CPython que permite apenas uma thread executando bytecode por vez. Apesar disso, threads ainda são eficazes para melhorar a responsividade e o desempenho em aplicações I/O-bound.

Threads em Python

Uma thread é a menor unidade de execução que pode ser gerenciada de forma independente pelo sistema operacional. Em Python, o módulo threading fornece uma API de alto nível para criar e gerenciar threads. Cada thread pode executar uma função ou um método, e múltiplas threads podem rodar 'ao mesmo tempo', embora na prática o GIL limite a execução paralela de bytecodes.

Para criar uma thread, você pode instanciar a classe Thread e passar a função alvo (target) e os argumentos (args). Veja um exemplo simples:

import threading
import time

def tarefa(nome, atraso):
    print(f"Iniciando {nome}")
    time.sleep(atraso)
    print(f"Finalizando {nome}")

# Cria duas threads
thread1 = threading.Thread(target=tarefa, args=("Thread 1", 2))
thread2 = threading.Thread(target=tarefa, args=("Thread 2", 3))

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

# Aguarda a conclusão
thread1.join()
thread2.join()

print("Todas as threads concluídas")

O método start() inicia a execução da thread, e join() faz com que o programa principal espere a thread terminar antes de continuar. Sem join(), o programa principal pode terminar antes das threads, dependendo da ordem de execução.

Quando usar (I/O-bound)

Threads são especialmente úteis para tarefas que são limitadas por I/O (input/output), ou seja, quando o programa passa a maior parte do tempo esperando por operações externas, como leitura de arquivos, requisições de rede, acesso a bancos de dados, etc. Nesses casos, a CPU fica ociosa e outras threads podem aproveitar esse tempo para executar outras tarefas.

Um exemplo clássico é baixar múltiplos arquivos da internet. Sem threads, você baixaria um arquivo por vez, esperando cada download terminar antes de começar o próximo. Com threads, você pode iniciar vários downloads simultaneamente, reduzindo o tempo total.

import threading
import requests

def baixar(url):
    print(f"Baixando {url}")
    response = requests.get(url)
    print(f"Baixado {url} - {len(response.content)} bytes")

urls = [
    "https://example.com/file1",
    "https://example.com/file2",
    "https://example.com/file3",
]

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

for t in threads:
    t.join()

print("Downloads concluídos")

Em contraste, para tarefas que são CPU-bound (limitadas pela CPU), como cálculos matemáticos intensivos, threads não trazem ganho de desempenho devido ao GIL, e pode até piorar a performance por causa da sobrecarga de troca de contexto. Nesses casos, o módulo multiprocessing é mais adequado.

Locks

Quando múltiplas threads acessam e modificam dados compartilhados, há o risco de condições de corrida (race conditions), onde o resultado final depende da ordem de execução das threads. Para evitar isso, utilizamos mecanismos de sincronização, como locks.

Um lock (trava) garante que apenas uma thread por vez possa executar um bloco de código protegido. As outras threads que tentarem adquirir o lock ficarão bloqueadas até que ele seja liberado. Em Python, podemos usar threading.Lock.

import threading

contador = 0
lock = threading.Lock()

def incrementar():
    global contador
    for _ in range(100000):
        # Adquire o lock
        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"Valor final do contador: {contador}")

Sem o lock, o valor final poderia ser menor que 1.000.000 (10 threads × 100.000) devido a condições de corrida. O uso do with lock: garante que o incremento seja atômico em relação ao acesso concorrente.

É importante usar locks com cuidado para evitar deadlocks (quando duas threads esperam uma pela outra indefinidamente) e para não proteger código desnecessariamente, o que reduziria a concorrência.

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, em um programa com múltiplas threads, elas não podem executar código Python em paralelo em múltiplos núcleos da CPU. O GIL é necessário para proteger a memória do interpretador, mas limita o desempenho de aplicações CPU-bound com threads.

Na prática, isso significa que threads são eficazes para tarefas I/O-bound, pois a thread que está esperando por I/O libera o GIL, permitindo que outras threads executem. No entanto, para tarefas que exigem processamento intensivo, as threads não trazem benefício de paralelismo real, e o uso de multiprocessing (que cria processos separados, cada um com seu próprio GIL) é a alternativa recomendada.

Vale notar que o GIL existe apenas no CPython (a implementação padrão). Outras implementações como Jython ou IronPython não possuem GIL, e o Python 3.13 introduziu um modo experimental sem GIL. Mas para a maioria dos usos, o GIL é uma realidade a considerar.

import threading
import time

def trabalho_cpu():
    inicio = time.time()
    # Simula trabalho pesado
    soma = sum(range(10**7))
    fim = time.time()
    print(f"Tempo: {fim - inicio:.2f}s")

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

for t in threads:
    t.join()

Você notará que o tempo total é semelhante ao de executar as tarefas sequencialmente, porque o GIL impede a execução paralela das threads CPU-bound.

Boas Práticas e Observações Finais

  • Use threads para tarefas I/O-bound, como downloads, leitura/escrita de arquivos, chamadas de API, etc.
  • Para tarefas CPU-bound, prefira multiprocessing ou bibliotecas como asyncio para I/O assíncrono.
  • Sempre sincronize o acesso a dados compartilhados com locks ou outros mecanismos (RLock, Semaphore, Condition) para evitar condições de corrida.
  • Evite criar muitas threads; cada thread consome recursos do sistema. Considere usar um pool de threads (concurrent.futures.ThreadPoolExecutor).
  • Teste bem o código com threads, pois erros de concorrência podem ser intermitentes e difíceis de reproduzir.

Referências

Exercícios

  1. Crie um programa que use duas threads para imprimir os números de 1 a 10 cada uma, com um pequeno atraso entre as impressões. O programa deve iniciar as threads e esperar que ambas terminem.

    ✓ Resposta:
    import threading
    import time
    
    def imprimir_numeros(nome):
        for i in range(1, 11):
            print(f"{nome}: {i}")
            time.sleep(0.1)
    
    t1 = threading.Thread(target=imprimir_numeros, args=("Thread A",))
    t2 = threading.Thread(target=imprimir_numeros, args=("Thread B",))
    
    t1.start()
    t2.start()
    t1.join()
    t2.join()
  2. Escreva um código que simule o download de três arquivos com tempos diferentes usando threads. Utilize a função time.sleep para simular o tempo de download e imprima mensagens de início e fim.

    ✓ Resposta:
    import threading
    import time
    
    def download(arquivo, tempo):
        print(f"Iniciando download de {arquivo}")
        time.sleep(tempo)
        print(f"Download de {arquivo} concluído")
    
    threads = []
    for arquivo, tempo in [("arquivo1", 2), ("arquivo2", 3), ("arquivo3", 1)]:
        t = threading.Thread(target=download, args=(arquivo, tempo))
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
  3. Implemente um contador compartilhado protegido por um lock. Crie 5 threads, cada uma incrementando o contador 1000 vezes, e imprima o valor final.

    ✓ Resposta:
    import threading
    
    contador = 0
    lock = threading.Lock()
    
    def incrementar():
        global contador
        for _ in range(1000):
            with lock:
                contador += 1
    
    threads = []
    for _ in range(5):
        t = threading.Thread(target=incrementar)
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
    
    print(f"Valor final: {contador}")  # Deve ser 5000
  4. Escreva um programa que baixe o conteúdo de três URLs diferentes usando threads. Utilize a biblioteca requests (se não tiver instalada, simule com time.sleep).

    ✓ Resposta:
    import threading
    import requests
    
    def baixar(url):
        print(f"Baixando {url}")
        response = requests.get(url)
        print(f"Conteúdo de {url}: {len(response.content)} bytes")
    
    urls = [
        "https://example.com",
        "https://httpbin.org/get",
        "https://jsonplaceholder.typicode.com/todos/1"
    ]
    
    threads = []
    for url in urls:
        t = threading.Thread(target=baixar, args=(url,))
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
  5. Explique com suas palavras por que o GIL impede que threads sejam eficientes para tarefas CPU-bound e sugira uma alternativa.

    ✓ Resposta:O GIL é um mutex global que garante que apenas uma thread execute bytecode Python por vez. Isso significa que, mesmo em sistemas com múltiplos núcleos, as threads não podem executar código Python em paralelo, pois precisam adquirir o GIL. Para tarefas CPU-bound, o tempo de processamento é dominado pela execução de código Python, então o GIL cria um gargalo, fazendo com que as threads executem praticamente de forma sequencial, sem ganho de desempenho. A alternativa é usar o módulo multiprocessing, que cria processos separados, cada um com seu próprio interpretador e GIL, permitindo que o código seja executado em paralelo em múltiplos núcleos.