Concorrência: threads, processos e async/await

Concorrência: threads, processos e async/await

Threads, processos e asyncio resolvem problemas diferentes, e escolher errado custa caro. Veja o GIL e o build sem GIL da 3.14, por que um contador sem Lock passa nos testes, quando o multiprocessing entrega menos que o esperado e como uma fila assíncrona trava sem erro.
Python

• • 23 min de leitura

Todo programa que vimos até aqui executa uma instrução por vez — modelo sequencial. Mas o mundo real exige mais: baixar múltiplos arquivos simultaneamente, processar milhares de requisições por segundo, aproveitar todos os núcleos do processador. Concorrência e paralelismo são os mecanismos que tornam isso possível, e Python oferece três abordagens distintas para isso, cada uma adequada a um tipo de problema.

Conceitos Fundamentais

Antes do código, é essencial distinguir três conceitos que são frequentemente confundidos:

Concorrência — múltiplas tarefas progridem ao mesmo tempo, alternando execução. Não necessariamente simultâneas.

Paralelismo — múltiplas tarefas executam literalmente ao mesmo tempo, em núcleos distintos do processador.

Assincronismo — uma tarefa cede o controle enquanto aguarda algo externo (I/O), permitindo que outras tarefas avancem.

Tipo de problema          Melhor abordagem
──────────────────────────────────────────
I/O intensivo (rede, disco)  → asyncio ou threading
CPU intensivo (cálculo)      → multiprocessing
Misto                        → multiprocessing + asyncio

O GIL: Global Interpreter Lock

Python tem uma limitação importante: o GIL — um mutex que garante que apenas uma thread Python execute bytecode por vez. Isso significa que threads em Python não entregam paralelismo real para tarefas CPU-intensivas.

Threading em Python:
  Thread A: executa → pausa → executa → pausa
  Thread B:         executa →         executa

Multiprocessing em Python:
  Processo A: ████████████████  (núcleo 0)
  Processo B: ████████████████  (núcleo 1)

Para I/O — onde a thread fica bloqueada esperando resposta da rede ou disco — o GIL é liberado, e threads funcionam bem. Para cálculo puro, use multiprocessing.

O GIL, porém, deixou de ser absoluto. A 3.13 trouxe o primeiro build sem GIL, e a 3.14 passou a dar suporte oficial ao modo free-threading — mas, como vimos em Dominando o Python, ele é uma instalação à parte, e não o padrão. O executável costuma se chamar python3.14t, e nele sys._is_gil_enabled() devolve False. Medido na 3.14.7 com a função de primos da seção de multiprocessing, logo adiante: com quatro threads, o build padrão levou 1,86 s contra 1,78 s do laço sequencial — ganho nenhum —, enquanto o build sem GIL desceu de 2,49 s para 1,15 s. A mesma medição mostra o preço: rodando numa thread só, o código ficou cerca de 40% mais lento sem o GIL, e boa parte das extensões em C ainda não publicou versão compatível. Para cálculo pesado, hoje, processos continuam sendo a resposta padrão.

threading: Threads para I/O

import threading
import time
import requests


def baixar_url(url, resultados, indice):
    """Função executada em cada thread."""
    inicio = time.perf_counter()
    try:
        resposta = requests.get(url, timeout=10)
    except requests.RequestException as e:
        # exceção dentro da thread não chega ao join(): trate aqui
        resultados[indice] = {"url": url, "erro": str(e)}
        return
    resultados[indice] = {
        "url":    url,
        "status": resposta.status_code,
        "bytes":  len(resposta.content),
        "tempo":  time.perf_counter() - inicio
    }


urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

# Com threads — ~1 segundo (em sequência seriam ~4)
inicio = time.perf_counter()
resultados = [None] * len(urls)

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

for t in threads:
    t.join()   # aguarda todas terminarem

total = time.perf_counter() - inicio
print(f"Paralelo com threads: {total:.2f}s")
for r in resultados:
    if "erro" in r:
        print(f"  falhou — {r['url']}: {r['erro'][:60]}")
    else:
        print(f"  {r['status']} — {r['bytes']} bytes em {r['tempo']:.2f}s")

O try dentro de baixar_url não é enfeite. Se uma URL falha, a exceção morre dentro da thread: o traceback aparece no terminal, mas o join() retorna normalmente e resultados[i] continua None. Sem o tratamento, quem quebra é o laço de impressão, com TypeError: 'NoneType' object is not subscriptable — longe da causa. O ThreadPoolExecutor, a seguir, resolve isso de fábrica: future.result() relança a exceção na thread que pediu o resultado.

ThreadPoolExecutor: Interface de Alto Nível

A forma moderna e recomendada de usar threads:

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


def buscar_usuario(user_id):
    url      = f"https://jsonplaceholder.typicode.com/users/{user_id}"
    resposta = requests.get(url, timeout=10)
    dados    = resposta.json()
    return {"id": dados["id"], "nome": dados["name"], "email": dados["email"]}


inicio = time.perf_counter()

with ThreadPoolExecutor(max_workers=5) as executor:
    # submit — submete tarefas e retorna futures
    futures = {
        executor.submit(buscar_usuario, uid): uid
        for uid in range(1, 11)
    }

    # as_completed — processa conforme terminam (não necessariamente em ordem)
    for future in as_completed(futures):
        uid = futures[future]
        try:
            resultado = future.result()
            print(f"  [{resultado['id']:2}] {resultado['nome']:25} — {resultado['email']}")
        except Exception as e:
            print(f"  [{uid}] Erro: {e}")

print(f"\nTotal: {time.perf_counter() - inicio:.2f}s")

Lock: Sincronização entre Threads

Quando múltiplas threads acessam o mesmo recurso, use Lock para evitar condições de corrida:

import threading

class ContadorSeguro:
    def __init__(self):
        self._valor = 0
        self._lock  = threading.Lock()

    def incrementar(self):
        with self._lock:   # garante acesso exclusivo
            self._valor += 1

    def valor(self):
        return self._valor


contador = ContadorSeguro()

def incrementar_mil():
    for _ in range(1000):
        contador.incrementar()

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

print(f"Esperado: 10000 | Obtido: {contador.valor()}")  # 10000

Tire o Lock e rode de novo: no Python 3.14 padrão o resultado continua 10000 — e continuou exato em todas as execuções que medimos, mesmo com dez threads somando um milhão cada. Isso não prova que += é seguro; prova que a corrida é rara. O interpretador só troca de thread em pontos específicos, e um self._valor += 1 quase nunca é cortado ao meio. Basta haver uma espera entre a leitura e a gravação — um time.sleep(0), um acesso a disco, uma chamada de rede — para o mesmo contador terminar perto de 1000 de 10000. E no build sem GIL, dez threads somando cem mil cada chegaram a uns 210 mil de 1 milhão. O Lock é o que torna o código correto em qualquer interpretador; a ausência de erro num teste pequeno não diz nada.

multiprocessing: Paralelismo Real

Para tarefas CPU-intensivas, cada processo tem seu próprio interpretador Python — sem GIL:

import multiprocessing
import time
import math


def calcular_primos(limite):
    """Verifica quais números até limite são primos."""
    primos = []
    for n in range(2, limite):
        eh_primo = all(n % i != 0 for i in range(2, int(math.sqrt(n)) + 1))
        if eh_primo:
            primos.append(n)
    return len(primos)


def benchmark():
    limites = [100_000, 200_000, 300_000, 400_000]

    # Sequencial
    inicio = time.perf_counter()
    resultados_seq = [calcular_primos(l) for l in limites]
    tempo_seq = time.perf_counter() - inicio

    # Paralelo
    inicio = time.perf_counter()
    with multiprocessing.Pool(processes=4) as pool:
        resultados_par = pool.map(calcular_primos, limites)
    tempo_par = time.perf_counter() - inicio

    print(f"Sequencial:  {tempo_seq:.2f}s")
    print(f"Paralelo:    {tempo_par:.2f}s")
    print(f"Speedup:     {tempo_seq / tempo_par:.1f}x")


if __name__ == "__main__":   # obrigatório no Windows, no macOS e, desde a 3.14, no Linux
    benchmark()

Não espere 4× com quatro processos. Medido numa máquina de 16 núcleos, o speedup foi de 1,5×, e o motivo está na carga: as quatro tarefas levam 0,12 s, 0,31 s, 0,54 s e 0,80 s, e o pool.map só termina quando a mais longa termina. Com quatro tarefas iguais, de 250 mil cada, o mesmo código chega a 3,6×. Divida o trabalho em pedaços de tamanho parecido — ou em muitos pedaços pequenos — antes de culpar o multiprocessing. E o if __name__ == "__main__" deixou de ser coisa de Windows: desde a 3.14 o Linux inicia os processos com forkserver, cada filho reimporta o módulo, e sem essa guarda o script termina em RuntimeError. O mesmo código rodava sem ela na 3.12.

ProcessPoolExecutor: Interface de Alto Nível

from concurrent.futures import ProcessPoolExecutor, as_completed
import math


def fatorar(n):
    """Fatoração em números primos — CPU intensivo."""
    fatores = []
    d = 2
    while d * d <= n:
        while n % d == 0:
            fatores.append(d)
            n //= d
        d += 1
    if n > 1:
        fatores.append(n)
    return fatores


# Cada um é o produto de dois primos, e o menor fica perto de 10 milhões:
# é o tamanho do laço até achá-lo que dá trabalho de verdade a cada processo.
numeros = [
    9_999_900_370_006_237,
    9_982_350_693_275_171,
    9_999_929_069_999_503,
    9_999_929_930_007_383,
]

if __name__ == "__main__":
    with ProcessPoolExecutor() as executor:
        futures = {executor.submit(fatorar, n): n for n in numeros}
        for future in as_completed(futures):
            n       = futures[future]
            fatores = future.result()
            print(f"  {n} = {' × '.join(map(str, fatores))}")

A escolha dos números importa. Com primos no lugar deles — 999.999.937 ou 1.000.000.007, por exemplo —, cada fatoração termina em 2 milissegundos, e o pool leva 0,078 s contra 0,006 s do laço sequencial: 13× mais lento, porque criar os processos custa mais que o trabalho. Com os produtos de dois primos acima, o laço sequencial leva 3,0 s e o pool, 1,2 s. Processo só compensa quando cada tarefa dura bem mais que o custo de criá-lo e de mandar os dados de ida e volta.

asyncio: Assincronismo Cooperativo

asyncio é ideal para I/O massivo — milhares de conexões simultâneas com um único thread:

import asyncio
import httpx
import time


async def buscar_post(client, post_id):
    resposta = await client.get(
        f"https://jsonplaceholder.typicode.com/posts/{post_id}"
    )
    dados = resposta.json()
    return {"id": dados["id"], "titulo": dados["title"][:40]}


async def buscar_todos(ids):
    async with httpx.AsyncClient(timeout=15.0) as client:
        tarefas    = [buscar_post(client, i) for i in ids]
        resultados = await asyncio.gather(*tarefas)
        return resultados


async def main():
    inicio = time.perf_counter()
    posts  = await buscar_todos(range(1, 21))
    total  = time.perf_counter() - inicio

    for post in posts[:5]:
        print(f"  [{post['id']:2}] {post['titulo']}")
    print(f"  ...")
    print(f"\n20 posts em {total:.2f}s")


asyncio.run(main())

asyncio: Conceitos Essenciais

import asyncio


# Corrotina — função async
async def tarefa(nome, duracao):
    print(f"[{nome}] iniciando...")
    await asyncio.sleep(duracao)   # cede o controle — não bloqueia
    print(f"[{nome}] concluída após {duracao}s")
    return f"resultado_{nome}"


# gather — executa as corrotinas concorrentemente, num único thread
async def demo_gather():
    inicio     = asyncio.get_running_loop().time()
    resultados = await asyncio.gather(
        tarefa("A", 1.0),
        tarefa("B", 1.5),
        tarefa("C", 0.5),
    )
    total = asyncio.get_running_loop().time() - inicio
    print(f"Todos concluídos em {total:.2f}s")   # ~1.5s, não 3.0s
    print(resultados)


# TaskGroup — Python 3.11+ — mais elegante
async def demo_taskgroup():
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(tarefa("X", 1.0))
        t2 = tg.create_task(tarefa("Y", 0.5))

    print(t1.result())
    print(t2.result())


# timeout — cancela se demorar demais
async def com_timeout():
    try:
        async with asyncio.timeout(0.3):   # Python 3.11+
            await tarefa("lenta", 2.0)
    except TimeoutError:   # asyncio.TimeoutError é apelido dele desde a 3.11
        print("Operação cancelada por timeout.")


asyncio.run(demo_gather())
asyncio.run(demo_taskgroup())
asyncio.run(com_timeout())

Queue Assíncrona: Produtor e Consumidor

Padrão clássico para processar itens em pipeline:

import asyncio
import httpx


N_CONSUMIDORES = 3


async def produtor(fila: asyncio.Queue, urls: list):
    for url in urls:
        await fila.put(url)
        print(f"  [Produtor] enfileirou: {url}")
    # Um sinal de fim para CADA consumidor: se faltar um, ele espera para sempre
    for _ in range(N_CONSUMIDORES):
        await fila.put(None)


async def consumidor(nome, fila: asyncio.Queue, client: httpx.AsyncClient):
    while True:
        url = await fila.get()
        if url is None:
            print(f"  [{nome}] encerrando.")
            break
        try:
            r = await client.get(url, timeout=5.0)
            print(f"  [{nome}] {r.status_code} — {url}")
        except Exception as e:
            print(f"  [{nome}] Erro: {e}")
        finally:
            fila.task_done()


async def pipeline():
    urls = [f"https://httpbin.org/status/{code}"
            for code in [200, 201, 204, 400, 404, 500] * 2]

    fila = asyncio.Queue(maxsize=5)

    async with httpx.AsyncClient() as client:
        consumidores = [
            asyncio.create_task(consumidor(f"C{i}", fila, client))
            for i in range(1, N_CONSUMIDORES + 1)
        ]
        await produtor(fila, urls)
        await asyncio.gather(*consumidores)


asyncio.run(pipeline())

O número de sentinelas None tem de ser igual ao de consumidores, e por isso os dois saem da mesma constante. Com quatro consumidores e três sentinelas, o quarto fica parado no fila.get() para sempre: o programa não termina e não mostra erro nenhum.

Escolhendo a Abordagem Certa

# Guia de decisão rápida

def escolher_abordagem(tipo_tarefa, volume):
    if tipo_tarefa == "IO":
        if volume > 100:
            return "asyncio + httpx"         # máxima escalabilidade
        else:
            return "ThreadPoolExecutor"      # simples e eficiente
    elif tipo_tarefa == "CPU":
        return "ProcessPoolExecutor"         # paralelismo real
    elif tipo_tarefa == "misto":
        return "ProcessPoolExecutor + asyncio dentro de cada processo"
Abordagem Melhor para Limitação
threading I/O simples, legado GIL, overhead de threads
ThreadPoolExecutor I/O com interface limpa GIL
multiprocessing CPU intensivo Overhead de processos, IPC
ProcessPoolExecutor CPU com interface limpa Overhead de processos
asyncio I/O massivo, alta escala Curva de aprendizado, single-thread

Exemplo Completo: Crawler Assíncrono

import asyncio
import re
import time
from dataclasses import dataclass, field
from urllib.parse import urljoin, urldefrag, urlparse

import httpx


RE_TITULO = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
RE_LINK   = re.compile(r"""<a\s[^>]*href=["']([^"'#]+)""", re.IGNORECASE)


@dataclass
class ResultadoCrawl:
    url:        str
    status:     int
    titulo:     str = ""
    links:      list = field(default_factory=list)
    erro:       str = ""


class CrawlerAssincrono:
    def __init__(self, max_paginas=10, max_workers=5):
        self.max_paginas  = max_paginas
        self.max_workers  = max_workers
        self._visitados:  set[str] = set()
        self._resultados: list = []
        self._semaforo    = None

    async def _processar_url(self, client, url):
        async with self._semaforo:
            try:
                r = await client.get(url, timeout=10.0, follow_redirects=True)
            except httpx.HTTPError as e:
                return ResultadoCrawl(url=url, status=0, erro=str(e))

            titulo, links = "", []
            if "text/html" in r.headers.get("content-type", ""):
                match  = RE_TITULO.search(r.text)
                titulo = match.group(1).strip() if match else ""
                # links absolutos, sem fragmento, só do mesmo domínio
                base = str(r.url)
                for href in RE_LINK.findall(r.text):
                    absoluto, _ = urldefrag(urljoin(base, href))
                    if urlparse(absoluto).netloc == urlparse(base).netloc:
                        links.append(absoluto)

            return ResultadoCrawl(
                url=url, status=r.status_code, titulo=titulo, links=links
            )

    async def crawlear(self, url_inicial: str):
        self._semaforo = asyncio.Semaphore(self.max_workers)
        fila = asyncio.Queue()
        await fila.put(url_inicial)
        self._visitados.add(url_inicial)

        async with httpx.AsyncClient(
            headers={"User-Agent": "PythonCrawler/1.0"}
        ) as client:
            while not fila.empty() and len(self._resultados) < self.max_paginas:
                vagas   = self.max_paginas - len(self._resultados)
                tarefas = []
                while not fila.empty() and len(tarefas) < min(self.max_workers, vagas):
                    url = await fila.get()
                    tarefas.append(self._processar_url(client, url))

                resultados = await asyncio.gather(*tarefas)
                self._resultados.extend(resultados)

                # o que torna isto um crawler: os links achados entram na fila
                for resultado in resultados:
                    for link in resultado.links:
                        if link not in self._visitados:
                            self._visitados.add(link)
                            await fila.put(link)

        return self._resultados


async def main():
    crawler = CrawlerAssincrono(max_paginas=5, max_workers=3)
    inicio  = time.perf_counter()

    resultados = await crawler.crawlear("https://www.python.org/")
    total      = time.perf_counter() - inicio

    print(f"\n=== Crawl concluído em {total:.2f}s ===")
    for r in resultados:
        if r.erro:
            print(f"  ✗ {r.url[:50]:50} — {r.erro[:30]}")
        else:
            print(f"  ✓ [{r.status}] {r.titulo[:40]:40} — {r.url[:40]}")


asyncio.run(main())

Duas decisões deste crawler valem para qualquer código de rede. A primeira: um link só entra na fila se ainda não estiver em _visitados, e é isso que impede o programa de girar em círculo entre páginas que se citam. A segunda: não há verify=False. Desligar a verificação do certificado faz o erro de TLS sumir, mas também aceita qualquer servidor que se passe pelo site — e, ao contrário do requests, que emite InsecureRequestWarning, o httpx não avisa nada. Para HTML de verdade, com marcação malformada, prefira um parser como o BeautifulSoup; a expressão regular aqui basta para o exemplo.

A pergunta que decide a ferramenta é onde o programa passa o tempo. Se ele espera — rede, disco, banco —, threads e asyncio resolvem, porque o GIL é liberado durante a espera. O ThreadPoolExecutor é o caminho mais curto para código que já usa bibliotecas síncronas como o requests, e ainda devolve as exceções das threads por future.result(). O asyncio escala para milhares de conexões num único thread, desde que tudo no caminho seja assíncrono; o gather não executa nada em paralelo, apenas deixa as corrotinas esperarem juntas. E toda fila com sentinelas precisa de uma para cada consumidor, ou o programa termina sem terminar.

Se o programa calcula, a resposta ainda é o processo. Um pool só entrega o ganho esperado quando as tarefas têm tamanho parecido e duram bem mais que o custo de criar um processo, e desde a 3.14 a guarda if __name__ == "__main__" é obrigatória também no Linux. O build sem GIL já permite threads em paralelo de verdade, ao custo de uma thread isolada mais lenta e de extensões em C que ainda estão chegando. Em qualquer dos modelos, estado compartilhado pede Lock — não porque o erro apareça no primeiro teste, mas justamente porque quase nunca aparece.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um sistema conta os downloads de cada arquivo num dicionário compartilhado entre as threads do servidor. O teste automatizado dispara 10 threads com mil downloads cada e confere o total: passa sempre, em centenas de execuções. Em produção, o painel mostra bem menos downloads do que o log de acesso registra. A única diferença é que, em produção, o incremento ficou assim: atual = contagem[arquivo], depois uma linha de log gravada em disco, depois contagem[arquivo] = atual + 1. Explique por que o teste nunca pegou o problema e o que deveria estar no código.

Ver resposta

✓ Resposta: O teste pegava o caso em que a corrida é quase impossível, não o caso em que ela não existe. No Python com GIL, o interpretador só troca de thread em pontos específicos, e um incremento curto quase nunca é interrompido entre a leitura e a gravação: medido na 3.14, dez threads somando mil cada deram exatamente 10000 em todas as execuções — e continuaram exatas até com um milhão por thread. A gravação do log muda tudo, porque I/O libera o GIL: a thread lê o valor, sai para o disco, outra thread lê o mesmo valor antigo, e as duas gravam o mesmo resultado. Com só um time.sleep(0) nesse intervalo, o contador de 10000 terminou perto de 1000 — nove em cada dez incrementos perdidos. No build sem GIL o problema aparece até sem espera: dez threads somando cem mil chegaram a uns 210 mil de 1 milhão. A correção é pôr a leitura e a gravação dentro do mesmo with self._lock:, deixando o log fora dele para não segurar o lock durante o I/O; ou trocar o contador por uma estrutura que já resolva isso, como uma fila consumida por uma thread só. E o teste precisa exercitar o código real — com o log no meio —, não uma versão simplificada que por acaso é rápida demais para falhar.

Exercício 2

Um analista paraleliza um relatório com multiprocessing.Pool(processes=4) numa máquina de 16 núcleos. São quatro tarefas: contar os primos até 100 mil, 200 mil, 300 mil e 400 mil. O relatório sequencial levava 1,65 s; com o pool, 1,01 s. Ele conclui que o multiprocessing tem overhead demais e aumenta para 16 processos, sem ganho nenhum. O que ele deveria ter olhado, e como tirar mais do mesmo hardware?

Ver resposta

✓ Resposta: O tempo de cada tarefa. Elas são muito desiguais — medidas sozinhas, levam 0,12 s, 0,31 s, 0,54 s e 0,80 s —, e o pool.map só termina quando a mais longa termina. Por mais processos que existam, o relatório nunca fica mais curto que a maior tarefa, e com quatro tarefas os processos de número 5 a 16 ficam parados: aumentar o pool não muda nada. O ganho de 1,6× não é overhead, é o teto daquele particionamento. A saída é dividir o trabalho em pedaços menores e de tamanho parecido. Reescrevendo a contagem para receber um intervalo, contar(a, b), e cortando os quatro limites em faixas de 25 mil números, o mesmo pool de 4 processos recebeu 40 pedaços e terminou em 0,62 s — 2,7× contra o sequencial, com os mesmos totais: 9592, 17984, 25997 e 33860 primos. Somar os resultados parciais é trabalho do processo principal, e é barato. O limite do outro lado é o tamanho mínimo do pedaço: tarefa que dura poucos milissegundos custa mais para ser enviada a outro processo do que para ser feita, e aí o pool fica mais lento que o laço simples.

Exercício 3

Um script de processamento de imagens roda há dois anos num servidor Linux com Python 3.12. Ele cria um multiprocessing.Pool direto no corpo do módulo, sem função main. Depois da atualização para a 3.14, o script passa a falhar logo no início com RuntimeError, e a mensagem fala em "bootstrapping phase". Ninguém mexeu no código. O que mudou, e qual é a correção certa?

Ver resposta

✓ Resposta: Mudou o jeito de criar os processos filhos. Até a 3.13, o padrão no Linux era fork: o filho nasce como cópia do pai já em execução e não reimporta nada, por isso um pool criado no corpo do módulo funcionava. Na 3.14 o padrão passou a ser forkserver (confira com multiprocessing.get_start_method()). Nesse modo, como no spawn do Windows e do macOS, cada filho importa o módulo principal para achar a função que vai executar — e, sem guarda, reexecuta o código que cria o pool. O Python detecta essa recursão e levanta RuntimeError. Medido: o mesmo arquivo de seis linhas roda na 3.12 e quebra na 3.14. A correção é a que o código deveria ter desde o início: mover a criação do pool para dentro de if __name__ == "__main__":, deixando no nível do módulo só importações e definições. Forçar a volta com multiprocessing.set_start_method("fork") faz o erro sumir, mas é um remendo arriscado: se o processo já tiver outras threads, o fork copia locks que podem estar presos, e a própria 3.14 avisa com DeprecationWarning: This process ... is multi-threaded, use of fork() may lead to deadlocks in the child.

Exercício 4

Um pipeline assíncrono tem um produtor e três consumidores ligados por uma asyncio.Queue, e o produtor encerra colocando três None na fila. Para dar conta do volume, alguém aumenta os consumidores para quatro. A partir daí o job noturno nunca termina: todos os itens aparecem processados no log, não há exceção, e o processo fica vivo até ser morto de manhã. Explique o travamento e duas formas de evitar que ele volte.

Ver resposta

✓ Resposta: Cada None encerra exatamente um consumidor. Com quatro consumidores e três sentinelas, três deles saem do laço e o quarto fica parado no await fila.get(), esperando um item que nunca vai chegar. Como o gather espera todos os consumidores, o programa espera para sempre — e não há erro porque nada falhou: esperar numa fila vazia é o comportamento correto de um consumidor. Medido com o exemplo do artigo, os doze itens são processados e o processo não termina. A primeira correção é estrutural: o número de sentinelas e o de consumidores devem sair da mesma constante, como no exemplo corrigido com N_CONSUMIDORES, para que um não possa mudar sem o outro. A segunda dispensa as sentinelas: a partir da 3.13 existe fila.shutdown(). Depois dela, put() passa a falhar e cada get() levanta asyncio.QueueShutDown assim que a fila esvazia, qualquer que seja o número de consumidores — medido com quatro consumidores e doze itens, todos encerraram. Para versões anteriores, outra saída é o produtor aguardar await fila.join() e depois cancelar os consumidores. Em qualquer caso, um job em lote deveria ter um tempo máximo, com asyncio.timeout() em volta do pipeline, para que um travamento vire erro em vez de uma noite inteira de espera.

Exercício 5

Uma API assíncrona precisa gerar uma miniatura de imagem e chamar um serviço legado que só tem cliente síncrono. O desenvolvedor escreve as duas coisas dentro de async def, com requests.get(...) e a função de redimensionar, e dispara três de uma vez com asyncio.gather. Os testes passam, mas o tempo total é a soma das três chamadas, e durante esse intervalo a API não responde nem ao health check. Por que o gather não ajudou, e como corrigir cada uma das duas chamadas?

Ver resposta

✓ Resposta: O asyncio é cooperativo: uma corrotina só cede o controle num await. Uma chamada síncrona dentro de async def não tem await nenhum, então ocupa o único thread do event loop do começo ao fim, e nada mais anda — nem as outras corrotinas do gather, nem o health check. Medido com três corrotinas que chamam time.sleep(1), o gather levou 3,00 s; com await asyncio.sleep(1), 1,00 s. O async def não torna o código assíncrono; só o await em algo que realmente espera faz isso. Para a chamada de rede, o ideal é um cliente assíncrono, como o httpx.AsyncClient; se o cliente legado for a única opção, await asyncio.to_thread(funcao, ...) a executa numa thread à parte, e o loop continua livre — as mesmas três esperas de 1 s por to_thread levaram 1,00 s. Para o redimensionamento, que é CPU, a thread só ajuda se a biblioteca liberar o GIL durante o cálculo; o caminho seguro é um ProcessPoolExecutor com loop.run_in_executor(pool, funcao, ...), que tira o cálculo do processo da API. Para achar esse tipo de problema cedo, asyncio.run(main(), debug=True) registra um aviso sempre que uma etapa do loop passa de 100 ms.

Comentários

Mais em Python

FastAPI: APIs modernas com tipagem e documentação automática
FastAPI: APIs modernas com tipagem e documentação automática

O FastAPI valida, converte e documenta a partir dos tipos, e os erros moram no…

Tuplas e Sets: imutabilidade e unicidade
Tuplas e Sets: imutabilidade e unicidade

Tuplas e sets parecem variações da lista, mas resolvem problemas distintos…

Consumindo APIs REST com requests e httpx
Consumindo APIs REST com requests e httpx

Consumindo APIs REST em Python com requests e httpx, do GET simples ao cliente…