Capítulo 46, Avançado
Concorrência: GIL, threads e processos
Escolher o modelo de concorrência errado é o erro de desempenho mais caro em Python. A decisão depende de uma pergunta: o seu gargalo é esperar ou calcular?
Código deste capítulo: avancado/cap46_concorrencia.py
O GIL
No CPython, uma trava global (o GIL) permite que apenas uma thread execute bytecode Python por vez. Duas consequências práticas:
- Para trabalho que espera (rede, disco, banco), as threads funcionam bem, porque o GIL é liberado enquanto a thread espera.
- Para trabalho que calcula (laços, contas), threads não aceleram nada, porque só uma roda por vez. Aqui você precisa de processos.
Existe um build opcional do interpretador sem GIL (o executável python3.14t no 3.14). Eu o trato como algo a testar com a sua carga de trabalho, e não como padrão: o ecossistema de extensões ainda está se adaptando.
| O gargalo é... | Use |
|---|---|
| Espera de I/O, poucas dezenas de tarefas | ThreadPoolExecutor |
| Espera de I/O, milhares de conexões | asyncio (próximo capítulo) |
| Cálculo pesado em Python puro | ProcessPoolExecutor |
| Cálculo numérico em arrays | Bibliotecas em C, como NumPy |
Proteja a execução com __name__ == "__main__"
No Windows e no macOS, os processos filhos são criados importando o arquivo principal de novo. Sem a proteção
if __name__ == "__main__", cada filho executaria o programa inteiro e criaria mais filhos. Por isso, neste capítulo, todo código que faz algo fica dentro dessa condição. As funções que os processos executam precisam estar no nível do módulo, e os argumentos e resultados precisam ser serializáveis (picklable).
Threads para I/O
O concurrent.futures oferece uma interface única para threads e processos. Aqui, cinco "downloads" de 0,2 segundo cada. Em sequência levam um segundo. Em cinco threads, cerca de 0,2:
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def baixar(identificador):
time.sleep(0.2)
return f"recurso {identificador}"
def contar_primos(limite):
total = 0
for n in range(2, limite):
for divisor in range(2, int(n ** 0.5) + 1):
if n % divisor == 0:
break
else:
total += 1
return total
if __name__ == "__main__":
inicio = time.perf_counter()
sequencial = [baixar(i) for i in range(5)]
t_seq = time.perf_counter() - inicio
inicio = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
concorrente = list(pool.map(baixar, range(5)))
t_conc = time.perf_counter() - inicio
print(sequencial == concorrente)
print("threads foram mais rápidas:", t_conc < t_seq)
True
threads foram mais rápidas: True
Processos para CPU
Contar primos é cálculo puro. Cada processo tem o seu próprio interpretador e o seu próprio GIL, então eles rodam em núcleos diferentes de verdade. O custo é criar os processos e copiar os dados entre eles, por isso só compensa quando cada tarefa é pesada:
if __name__ == "__main__":
limites = [20_000, 20_000, 20_000, 20_000]
with ProcessPoolExecutor() as pool:
resultados = list(pool.map(contar_primos, limites))
print(resultados)
[2262, 2262, 2262, 2262]
Estado compartilhado exige trava
Threads compartilham memória. Quando duas alteram o mesmo dado, o resultado pode se perder, porque contador += 1 não é uma operação indivisível (ler, somar e gravar são passos separados). O Lock garante que só uma thread entre na região crítica por vez:
import threading
contador = 0
trava = threading.Lock()
def incrementar(vezes):
global contador
for _ in range(vezes):
with trava:
contador += 1
if __name__ == "__main__":
threads = [threading.Thread(target=incrementar, args=(10_000,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(contador)
40000
Comunicar por fila, em vez de compartilhar
A forma mais segura de coordenar threads é não compartilhar estado: uma queue.Queue entrega os itens de um produtor para um consumidor, e já vem com a sincronização embutida:
import queue
def produtor(fila, n):
for i in range(n):
fila.put(i)
fila.put(None)
def consumidor(fila, resultados):
while (item := fila.get()) is not None:
resultados.append(item * item)
if __name__ == "__main__":
fila = queue.Queue()
resultados = []
t1 = threading.Thread(target=produtor, args=(fila, 5))
t2 = threading.Thread(target=consumidor, args=(fila, resultados))
t1.start()
t2.start()
t1.join()
t2.join()
print(resultados)
[0, 1, 4, 9, 16]
O None no final é uma sentinela: o produtor avisa ao consumidor que acabou. É o padrão mais simples para encerrar uma fila.
Exercício 1
Baixar vários recursos com um limite de threads
Escreva baixar_todos(ids, max_workers=4) que use ThreadPoolExecutor e devolva os resultados na ordem dos ids.