No post anterior falei sobre a diferença entre threads e processos. Nesse aqui vou falar sobre concorrência baseada em eventos (o que todo mundo chama de async) e sobre o GIL do Python.
O problema com threads
Uma thread é uma linha de execução dentro de um processo que compartilha memória com as outras threads. Quando você cria threads e coloca elas pra executar, quem decide quando e onde cada uma roda é o sistema operacional. Você não controla.
O SO pode pausar uma thread no meio de uma operação pra rodar outra. Pode colocar duas threads no mesmo core. Pode decidir que a thread A vai rodar agora e a B vai esperar, mesmo que pra sua aplicação fizesse mais sentido o contrário.
Essa falta de controle é o que gera os problemas de concorrência que vimos no post anterior: race conditions, deadlocks, resultados imprevisíveis. E nem sempre gera a melhor performance pra sua aplicação.
Uma saída pra isso é a concorrência baseada em eventos.
Concorrência baseada em eventos
Em vez de criar múltiplas threads e deixar o SO gerenciar, você roda uma única thread com um loop de eventos (event loop). Esse loop fica rodando continuamente, verificando se algum evento foi disparado, e quando identifica um, chama o handler correspondente pra processar.
Existem duas formas de processar um evento:
A primeira: o loop identifica que um evento foi disparado, processa ele inteiro, e só depois volta pro loop. Enquanto esse evento está sendo processado, todo o resto espera. Isso é processamento síncrono.
A segunda: o loop identifica que um evento foi disparado, começa a processar, e deixa ele processando em background. O loop continua rodando e tratando outros eventos sem pausar. Isso é processamento assíncrono.
Mas como o loop sabe que um processamento em background terminou? Por sinais do sistema operacional e interrupções. O SO avisa o event loop: "aquela operação que você pediu terminou, os dados estão prontos."
Pensa numa máquina de lavar. Você coloca a roupa, liga, e vai fazer outras coisas. Quando termina, a máquina apita. Você não ficou parado do lado dela esperando. O apito é o sinal que te avisa que terminou. O event loop funciona da mesma forma.
asyncio na prática
O asyncio é a implementação desse modelo no Python. Uma única thread, um event loop, e funções async que cedem o controle com await.
async marca uma função como assíncrona. await diz pro event loop: "pode ir fazer outra coisa, eu aviso quando terminar."
import asyncio
import time
async def io_work(task_id, seconds):
"""Simula trabalho de I/O."""
print(f"Tarefa {task_id}: iniciando")
await asyncio.sleep(seconds)
print(f"Tarefa {task_id}: concluída")
async def main():
start = time.perf_counter()
tasks = [io_work(i, 1) for i in range(5)]
await asyncio.gather(*tasks)
elapsed = time.perf_counter() - start
print(f"Tempo total: {elapsed:.2f}s")
asyncio.run(main())
5 tarefas de 1 segundo cada. Quando uma tarefa faz await asyncio.sleep(1), ela cede o controle pro event loop, que inicia a próxima. Quando o sleep termina, o loop retoma a tarefa. Resultado:
Tarefa 0: iniciando
Tarefa 1: iniciando
Tarefa 2: iniciando
Tarefa 3: iniciando
Tarefa 4: iniciando
Tarefa 0: concluída
Tarefa 1: concluída
Tarefa 2: concluída
Tarefa 3: concluída
Tarefa 4: concluída
Tempo total: 1.00s
1 segundo total pra 5 tarefas de 1 segundo. Nenhuma thread foi criada. Nenhuma troca de contexto do SO. Tudo numa única thread.
asyncio vs threading
Se ambos resolvem I/O concorrente, qual a diferença?
import asyncio
import threading
import time
def thread_io_work(seconds):
time.sleep(seconds)
async def async_io_work(seconds):
await asyncio.sleep(seconds)
for n in [100, 1000, 5000]:
# threads
start = time.perf_counter()
threads = [threading.Thread(target=thread_io_work, args=(0.5,)) for _ in range(n)]
for t in threads: t.start()
for t in threads: t.join()
thread_time = time.perf_counter() - start
# asyncio
async def run_async(count):
tasks = [async_io_work(0.5) for _ in range(count)]
await asyncio.gather(*tasks)
start = time.perf_counter()
asyncio.run(run_async(n))
async_time = time.perf_counter() - start
print(f"{n:>5} threads: {thread_time:.2f}s | asyncio: {async_time:.2f}s")
100 threads: 0.53s | asyncio: 0.50s
1000 threads: 0.62s | asyncio: 0.52s
5000 threads: 1.49s | asyncio: 0.56s
Com poucas tarefas a diferença é pequena. Mas conforme o número de tarefas cresce, o consumo de recursos das threads aumenta (cada uma tem seu próprio stack na memória) e a troca de contexto do kernel começa a gargalar o processamento. Existe um teto. O asyncio não tem esse problema porque tudo roda numa única thread.
O trade-off do asyncio: se você chamar uma função bloqueante como time.sleep() dentro de uma função async, ela trava o event loop inteiro. Todas as outras tarefas param até essa chamada terminar. Todo código dentro do loop precisa ser async.
Quando usar cada um
I/O-bound com libs async (aiohttp, asyncpg, aiofiles): asyncio.
I/O-bound com libs síncronas (requests, psycopg2): threading. Funciona porque o GIL é liberado em operações de I/O.
CPU-bound: multiprocessing. É o que deu paralelismo real no post anterior.
O GIL do Python
O sistema operacional tem ferramentas embutidas pra lidar com concorrência entre threads. Uma delas é o semáforo.
Pensa num aeroporto. Os aviões chegam de várias direções e precisam pousar. Se o aeroporto tem uma pista só, apenas um avião pousa por vez. Os outros esperam na fila. Se é um aeroporto grande como Guarulhos, com várias pistas, vários aviões podem pousar ao mesmo tempo, mas se todas as pistas estiverem ocupadas, ainda assim os próximos esperam.
O semáforo funciona da mesma forma. Ele controla quantas threads podem acessar um recurso ao mesmo tempo. Se o semáforo permite 4 acessos simultâneos, a quinta thread espera até uma das quatro liberar.
Um tipo especial de semáforo é o mutex (mutual exclusion). O mutex é um semáforo que permite exatamente um acesso por vez. Uma pista, um avião.
O GIL do Python é basicamente um mutex aplicado no bytecode do interpretador CPython. Apenas uma thread executa bytecode Python por vez. Isso existe porque o gerenciamento de memória do CPython usa contagem de referência, e sem o GIL, duas threads poderiam corromper esses contadores. O GIL é a solução mais simples pra proteger isso.
A consequência: threads em Python não dão paralelismo real pra trabalho CPU-bound. Duas threads rodando cálculo pesado vão ser serializadas pelo GIL.
Python não é a única linguagem com essa limitação. Ruby (CRuby/MRI) tem o GVL (Global VM Lock), que faz a mesma coisa. É uma decisão de implementação do interpretador, não uma limitação da linguagem em si.
Isso está mudando. O Python 3.13 trouxe o modo free-threaded (PEP 703) que permite desabilitar o GIL. É o que usamos nos benchmarks do post anterior com PYTHON_GIL=0. Pra conseguir fazer isso, o CPython precisou trocar o sistema de contagem de referência por uma versão thread-safe e adicionar locks mais granulares em estruturas internas.
Ainda é experimental. Muitas libs C não são thread-safe sem o GIL. Mas é a direção que o Python está caminhando.
Referências: ARPACI-DUSSEAU, R. e A. Operating Systems: Three Easy Pieces, Cap. 33. PEP 703 (https://peps.python.org/pep-0703/). Python 3.13 What's New (https://docs.python.org/3.13/whatsnew/3.13.html). Documentação asyncio (https://docs.python.org/3/library/asyncio.html).