Quase todo projeto em Python passa por uma situação tipo essa: O código funciona, a leitura é agradável, os testes passam. Aí chega o mundo real, banco lento, API instável, arquivo pesado, CPU em 100 por cento. Nesse instante muita gente conclui que Python não aguenta o tranco. Quase nunca é esse o diagnóstico. O que costuma estar errado é o modelo mental.
Quando alguém diz que quer deixar o programa mais rápido, vale interromper a empolgação por cinco segundos e fazer uma pergunta menos glamourosa. Rápido em quê. Em calcular. Em esperar. Em atender muita coisa ao mesmo tempo. Em terminar um lote antes do café esfriar. Parece detalhe, mas é aqui que threads, asyncio e processos deixam de parecer três jeitos parecidos de fazer a mesma coisa e passam a ter personalidade própria.

Concorrência não é um botão de desempenho. É uma forma de organizar o tempo do programa. Essa frase parece abstrata até você perceber que um serviço web pode passar boa parte da vida esperando resposta de rede, enquanto um script de análise passa a maior parte do tempo moendo CPU. Os dois podem estar lentos, só que por razões completamente diferentes.
O relógio do programa sempre entrega o culpado
Se a tarefa fica parada esperando disco, rede, banco ou qualquer recurso externo, você está diante de um gargalo de espera. O programa não está trabalhando duro, ele está olhando para a parede com educação. Nessa hora, trocar o desenho do fluxo costuma render mais do que sair micro otimizando função.
Se a tarefa faz conta sem parar, percorre milhões de registros, comprime dados, transforma imagem, calcula hash atrás de hash, o gargalo muda de rosto. Agora o programa realmente quer tempo de processador. A solução que brilhou no cenário anterior talvez aqui só acrescente complexidade e decepção.
Esse tipo de distinção parece didático demais até o dia em que uma equipe inteira empilha sintaxe assíncrona em volta de uma rotina puramente numérica e depois se pergunta por que nada aconteceu. Já vi isso mais de uma vez. O curioso é que o erro raramente nasce de preguiça. Ele nasce de intuição mal calibrada. Python é amigável demais no começo, então muita gente espera que concorrência também seja amigável por padrão.
Threads continuam ótimas quando o problema mora fora do processo
A biblioteca threading trabalha com múltiplas threads dentro do mesmo processo. Isso traz uma vantagem deliciosa no dia a dia, a memória é compartilhada. Você não precisa serializar metade da vida para passar contexto adiante. Dá para dividir trabalho sem montar toda uma infraestrutura de comunicação. Para tarefas ligadas a entrada e saída, isso é excelente.
Aí um detalhe que sempre reaparece nessa conversa, a GIL. Em CPython tradicional, ela impede que várias threads executem bytecode Python ao mesmo tempo. Esse é o ponto que costuma ser mal repetido em textos apressados. Gente demais aprende a frase thread não serve em Python e para por aí. O que fica de fora é justamente o que importa. Quando uma thread está bloqueada esperando rede ou arquivo, outra pode avançar. O ganho aparece porque o mundo externo é lento e porque seu programa não precisa ficar parado junto com ele.
Quando o gargalo é espera, threads continuam sendo uma resposta muito boa. Não porque elas derrotam a GIL no braço, mas porque a GIL não é o centro do problema naquele momento.
Um exemplo simples ajuda mais do que dez slogans.
from concurrent.futures import ThreadPoolExecutor
import timedef io_lento(segundos):
time.sleep(segundos)
return segundoswith ThreadPoolExecutor(max_workers=4) as executor:
resultados = list(executor.map(io_lento, [1, 1, 1, 1]))print(sum(resultados))
Esse código é pequeno, quase bobo, e ainda assim mostra a ideia central. Inclusive, no curso de python do Didatica Tech eles ensinam a importar módulos, funções e a rodar scripts como esse acima. Então isso significa que qualquer iniciante consegue entender até essa parte. Cada chamada passa boa parte do tempo esperando. Distribuir isso entre threads evita que uma espera precise terminar para a próxima começar. Em uma execução real, o relógio tende a ficar perto de um segundo e não de quatro. Em produção, esse papel costuma ser ocupado por leitura de arquivo, chamada HTTP, acesso a armazenamento remoto, consulta em banco, qualquer operação que segura o fluxo por causa de algo que vive fora do seu processo.
Nessa família de problemas, vale lembrar de um módulo que muita gente esquece quando começa a brincar com concorrência, queue. Ele existe para troca segura de dados entre threads e resolve um problema real sem teatro. Em vez de dividir estruturas compartilhadas por todo lado e descobrir tarde demais que o estado ficou inconsistente, você empurra unidades de trabalho para uma fila e deixa as threads consumirem com bem menos atrito.
Aí entra a parte menos glamourosa e mais útil da conversa. Thread que compartilha memória demais vira convite para condição de corrida. Não é preciso demonizar thread por causa disso, basta respeitar a ferramenta. Compartilhe o mínimo necessário, prefira dados imutáveis quando der, use fila para coordenação, trate estado global como algo caro. A maioria dos bugs esquisitos em código concorrente nasce menos da tecnologia escolhida e mais da vontade de economizar desenho.
Asyncio não é turbo, é orquestra
Asyncio costuma ser vendido como velocidade. Eu prefiro outra palavra, orquestra. O modelo inteiro gira em torno de um laço de eventos que distribui tempo entre tarefas cooperativas. Elas não são interrompidas do nada. Elas cedem controle quando encontram um ponto aguardável, e isso muda bastante a forma de pensar.
Tem uma sutileza aqui que separa quem decorou sintaxe de quem realmente entendeu o modelo. Chamar uma função assíncrona não executa seu corpo. Você recebe uma corrotina. E mais, fazer await direto em uma corrotina não cria concorrência por si só. Em muitos casos você só está escrevendo um fluxo sequencial com roupa nova.
Olhe a diferença.
import asyncioasync def consulta(segundos):
await asyncio.sleep(segundos)
return segundosasync def sequencial():
a = await consulta(1)
b = await consulta(1)
return a + basync def concorrente():
async with asyncio.TaskGroup() as grupo:
t1 = grupo.create_task(consulta(1))
t2 = grupo.create_task(consulta(1))
return t1.result() + t2.result()print(asyncio.run(sequencial()))
print(asyncio.run(concorrente()))
No primeiro caso, uma espera começa depois que a anterior termina. No segundo, as duas tarefas são agendadas e o laço de eventos administra o progresso de ambas. Esse trecho parece simples demais para ser valioso, mas ele explica boa parte das confusões com código assíncrono. Tem gente que enche uma base de código de await e continua rodando tudo em fila indiana.
Foi por isso que TaskGroup caiu tão bem. Ele não é só um jeito elegante de juntar tarefas. Ele traz um estilo mais seguro de concorrência estruturada. Em vez de tarefas soltas escapando para todos os lados, você cria um grupo, espera o grupo terminar e deixa o bloco delimitar responsabilidade. Na prática, o código fica menos sujeito a tarefa esquecida, erro que aparece tarde e comportamento misterioso. Não é que gather tenha ficado errado. Ele continua útil. Só que, para tarefas claramente relacionadas, TaskGroup costuma comunicar melhor a intenção e segurar melhor a casa.
Existe um ponto menos óbvio, mas muito importante. Em asyncio, cancelamento não é detalhe de implementação, ele faz parte do desenho. Recursos como TaskGroup e tempo limite usam cancelamento internamente. Quando alguém captura CancelledError e engole a exceção sem entender o que está fazendo, o programa começa a se comportar de um jeito meio sobrenatural. Tarefa que parecia encerrada continua por ali, limpeza não roda como deveria, timeout deixa rastros. Esse tipo de bug é péssimo porque não explode sempre. Ele prefere aparecer no ambiente mais caro.
Outra ponte útil é asyncio.to_thread. Ela é excelente quando você já está em código assíncrono e precisa chamar uma função bloqueante ligada a entrada e saída. O laço de eventos continua livre enquanto essa função roda em uma thread separada. O detalhe importante é que isso não transforma uma rotina pesada de CPU em milagre. Em CPython comum, essa ponte serve sobretudo para entrada e saída bloqueante. Ainda assim, saber usar essa passagem evita aquele reflexo de reescrever biblioteca inteira só porque um pedaço trava o loop.
Já que estamos aqui, vale separar duas filas que muita gente mistura no início. queue.Queue foi feita para threads e é segura para esse contexto. asyncio.Queue pertence ao mundo assíncrono e não é segura para thread. Parece uma observação pequena, mas muita dor de cabeça nasce exatamente desse cruzamento indevido. E no universo assíncrono, quando você quer impor tempo limite em operações de fila, a solução normalmente entra por asyncio.wait_for. A fila em si não carrega esse parâmetro nos métodos. Esse é o tipo de detalhe que quase nunca aparece no tutorial fofinho, mas aparece no código real.
Processo cobra pedágio, mas entrega CPU de verdade
Quando o problema é cálculo pesado, processos entram em cena com uma honestidade admirável. Eles não compartilham o mesmo espaço de memória como threads fazem. Isso dói um pouco porque passa a existir custo de serialização, cópia de dados e coordenação. Em compensação, você realmente consegue usar múltiplos núcleos para executar Python ao mesmo tempo.
É aqui que ProcessPoolExecutor costuma brilhar. Ele encaixa bem quando você tem uma função claramente presa à CPU e um conjunto razoável de trabalhos independentes. Hashing pesado, transformação massiva de dados, partes numéricas de um pipeline, geração de relatórios que realmente mastigam informação, tudo isso conversa melhor com processo do que com thread.
Um exemplo enxuto:
from concurrent.futures import ProcessPoolExecutordef soma_quadrados(limite):
return sum(i * i for i in range(limite))with ProcessPoolExecutor() as executor:
resultados = list(executor.map(soma_quadrados, [3_000_000, 3_000_000, 3_000_000, 3_000_000]))print(sum(resultados))
Esse tipo de desenho funciona porque cada processo recebe um trabalho grande o bastante para justificar o pedágio da comunicação. E pedágio existe mesmo. Mandar uma tarefa minúscula para outro processo pode sair mais caro do que executá-la ali mesmo. Esse é um daqueles momentos em que o bom senso vale mais do que a ânsia de paralelizar tudo.
Tem outra nuance que separa uso casual de uso maduro. Em ProcessPoolExecutor, os argumentos e resultados precisam ser serializáveis, e o módulo principal precisa poder ser importado pelos subprocessos. Traduzindo isso para a vida real, nem toda função bonita do seu ambiente interativo viaja bem para outro processo. Função local, lambda improvisada, objeto cheio de estado difícil de serializar, conexão aberta, nada disso costuma envelhecer bem quando atravessa essa fronteira. Muita frustração com multiprocessing nasce menos da ideia e mais desse choque cultural entre o código que parece confortável no processo atual e o código que realmente pode ser enviado para outro.
Tem ainda um ajuste de desempenho que quase sempre passa despercebido. Em iterações muito longas, o parâmetro chunksize pode fazer diferença sensível com ProcessPoolExecutor, justamente porque agrupa submissões e reduz overhead. Em ThreadPoolExecutor, esse parâmetro não muda nada. É um detalhe pequeno na documentação, mas desses que pagam aluguel quando o volume cresce.
O desenho certo quase nunca é puro
Projetos reais raramente cabem em dogmas. Um serviço pode usar asyncio para lidar com milhares de conexões abertas, chamar to_thread para conversar com alguma biblioteca bloqueante de arquivo, e ainda despachar uma etapa pesada de CPU para um pool de processos. Isso não é bagunça. Bagunça é escolher um único martelo e fingir que o sistema inteiro tem formato de prego.
Gosto de pensar em fronteiras. O núcleo assíncrono coordena espera. A thread absorve integrações bloqueantes que você não quer ou não pode reescrever. O processo assume o que realmente precisa de paralelismo em CPU. Quando essas fronteiras ficam nítidas, o código melhora até em legibilidade. E isso é pouco comentado em textos sobre desempenho. Concorrência bem escolhida também reduz fadiga mental. Você para de discutir ferramenta no abstrato e passa a perguntar qual parte do sistema está esperando, qual parte está calculando e qual parte está servindo de ponte.
Tem coisa pior do que um sistema lento. É um sistema lento e misterioso. Aquele em que ninguém sabe dizer por que existe uma thread aqui, um await ali, dois pools acolá e uma fila no meio só porque sim. O segredo não é usar mais mecanismos. É usar o menor número de mecanismos que respeite a natureza do gargalo.
A conversa sobre GIL ficou mais interessante
Durante anos, o papo sobre concorrência em Python parecia preso numa mesma frase. Existe GIL, então pronto, assunto encerrado. Só que o cenário começou a mudar. A construção de Python com free threading, sem GIL, apareceu no 3.13 e no 3.14 passou a ser oficialmente suportada, ainda como opção e não como padrão. Isso muda o horizonte da linguagem sem invalidar o que a gente aprendeu até aqui.
O ponto mais sensato aqui é evitar dois exageros. O primeiro é agir como se nada tivesse mudado. Mudou, e bastante. O segundo é imaginar que agora thread virou solução universal. Ainda não. O caminho sem GIL traz custo extra, diferenças de comportamento e novas preocupações práticas. Em outras palavras, a história ficou melhor, não mágica.
O lado bonito dessa transição é que ela obriga a comunidade a amadurecer a conversa sobre segurança de thread em vez de terceirizar tudo para um detalhe do interpretador. Isso é saudável. Muita lógica concorrente já precisava ser bem desenhada antes. Agora essa necessidade fica mais explícita, que no fundo é um ganho de honestidade técnica.
O mapa mental que realmente ajuda
Se o seu programa passa o dia esperando rede, banco, disco ou API, pense em threads ou asyncio. A escolha entre um e outro depende mais do estilo do sistema do que de uma guerra santa. Threads costumam ser excelentes quando você quer encaixar concorrência em código já síncrono com pouco atrito. Asyncio brilha quando o sistema inteiro vive de muitas operações de espera coordenadas e você quer um modelo coerente de ponta a ponta.
Se o seu problema é CPU, vá de processo ou de alguma solução nativa que realmente use vários núcleos. Thread em CPython tradicional não foi feita para salvar algoritmo pesado escrito em puro Python. Pode até ajudar em casos específicos, sobretudo quando bibliotecas externas liberam a GIL, mas tratar isso como regra é pedir para a realidade corrigir o entusiasmo.
Se você já está num mundo assíncrono e precisa chamar algo bloqueante, to_thread costuma ser a ponte certa para entrada e saída. Se a etapa é computacional, use processo. Se há troca de itens entre threads, queue.Queue. Se a troca acontece só no universo assíncrono, asyncio.Queue. Parece simples quando dito assim. E é justamente esse tipo de simplicidade que a gente deveria preservar antes de complicar o desenho.
No fim, desempenho é uma conversa sobre escolha
Tem algo muito humano em tentar resolver lentidão com a ferramenta mais nova, mais falada ou mais elegante. Eu entendo. Todo mundo gosta de sentir que está modernizando o código. Só que concorrência em Python recompensa outro tipo de maturidade. Ela favorece quem observa o comportamento real do programa, aceita que nem todo atraso é o mesmo atraso, e escolhe a ferramenta que combina com a natureza do trabalho.
Seu código não fica melhor quando parece sofisticado. Ele fica melhor quando cada parte sofre do jeito certo. Espera vai para um modelo que sabe esperar sem desperdiçar tempo. CPU vai para um modelo que realmente usa CPU. Coordenação vai para estruturas feitas para coordenar. Quando isso encaixa, Python deixa de parecer limitado e volta a ser o que ele sempre foi nas mãos certas, uma linguagem muito boa para construir sistemas claros, produtivos e surpreendentemente capazes.
Se eu precisasse resumir tudo em uma imagem mental, seria esta. Concorrência não é colocar mais gente para falar ao mesmo tempo. É organizar quem pode trabalhar enquanto outro está inevitavelmente esperando. Quando você enxerga isso, muita decisão técnica para de parecer religiosa e passa a parecer apenas lúcida.