Curso de Python / Comparar threads, processos e asyncio com erros e cancelamento

Threads, processos e asyncio permitem avançar em várias tarefas com modelos diferentes. Escolha conforme o tempo gasto em cálculo ou espera e o suporte das bibliotecas; confirme o ganho com uma carga real antes de aumentar a concorrência.

Comparar concorrência e execução paralela

Concorrência significa organizar o progresso de mais de uma tarefa; paralelismo significa executar trabalho simultaneamente. Um programa pode alternar tarefas enquanto espera uma resposta sem executar cálculos Python em paralelo.

Modelo Funcionamento O que avaliar
Threads Compartilham memória dentro do processo. Espera de entrada/saída, sincronização e comportamento das bibliotecas.
Processos Usam espaços de memória separados. Inicialização, serialização, cópia de dados e memória utilizada.
asyncio Alterna tarefas cooperativamente em pontos de espera. Bibliotecas assíncronas, bloqueio do loop e limites de tarefas.

O GIL, Global Interpreter Lock, limita a execução simultânea de bytecode Python por threads em um CPython convencional com esse bloqueio habilitado. Nesse ambiente, adicionar threads não é uma solução automática para acelerar cálculo intensivo em Python puro. Extensões que liberam o GIL e builds sem GIL têm outras condições; confira o interpretador e as bibliotecas usados.

Como ponto de partida, avalie threads para operações bloqueantes de entrada/saída, processos para cálculo intensivo em Python puro e asyncio quando a biblioteca oferecer operações assíncronas. Nenhuma dessas escolhas elimina erros de rede, validação ou persistência. A aula de APIs HTTP estabelece esses limites antes de introduzir múltiplas requisições.

Executar o programa completo com três interfaces

Salve o código inteiro em concurrency.py, em UTF-8, numa pasta de prática. Ele usa somente a biblioteca padrão. No terminal do ambiente virtual do projeto, execute python concurrency.py; fora de um ambiente virtual no macOS ou Ubuntu, use python3 se esse for o nome do interpretador. Execute como arquivo .py: o pool de processos não tem as mesmas condições de importação em todos os notebooks e terminais interativos.

import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def square(value):
    return value * value

async def async_square(value):
    await asyncio.sleep(0.01)
    return square(value)

async def gather_values():
    return await asyncio.gather(*(async_square(x) for x in [1, 2, 3]))

def main():
    with ThreadPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    with ProcessPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    print(asyncio.run(gather_values()))

if __name__ == "__main__":
    main()

Resultado esperado:

[1, 4, 9]
[1, 4, 9]
[1, 4, 9]

Cada linha contém os quadrados de 1, 2 e 3, na ordem dos valores de entrada. pool.map entrega os resultados nessa ordem; isso não prova que os trabalhos terminaram nessa sequência. asyncio.gather também organiza sua lista de resultados na ordem dos argumentos.

A função square fica no nível superior do módulo para poder ser localizada pelos processos de trabalho. A proteção if __name__ == "__main__" impede que a importação do módulo durante a criação de processos execute novamente main(). Isso é especialmente visível no Windows; não retire a proteção ao adaptar o arquivo.

O asyncio.sleep(0.01) simula espera e permite que o loop avance em outras tarefas. Ele não consulta uma API. Escrever async def também não torna uma operação bloqueante assíncrona: um cálculo longo ou uma chamada de rede síncrona pode impedir o progresso das demais tarefas no mesmo loop.

Observar cancelamento, exceções e limites de espera

Salve o próximo exemplo completo em cancel.py e execute python cancel.py na mesma pasta. A espera longa torna o cancelamento observável; o programa não precisa aguardar 60 segundos para terminar.

import asyncio
async def worker():
    try:
        await asyncio.sleep(60)
    finally:
        print("worker cleanup")
async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(0)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("cancel confirmed")
if __name__ == "__main__":
    asyncio.run(main())

Saída esperada:

worker cleanup
cancel confirmed

O await asyncio.sleep(0) dá oportunidade para o trabalhador iniciar e entrar no try. task.cancel() solicita cancelamento; aguardar a tarefa permite observar sua conclusão. O finally imprime a limpeza antes de o chamador confirmar CancelledError. Não trate o pedido de cancelamento como encerramento já confirmado nem o use para esconder uma falha.

Para comparar uma falha em uma tarefa do pool, salve todo este código como future_failure.py e execute python future_failure.py:

from concurrent.futures import ThreadPoolExecutor
def calculate(value):
    if value < 0:
        raise ValueError("negative input")
    return value * value
with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(calculate, value) for value in [2, -1]]
    for future in futures:
        try:
            print(future.result(timeout=5))
        except ValueError as error:
            print("failed:", error)

O resultado esperado é:

4
failed: negative input

submit retorna um Future, objeto que representa um resultado pendente ou concluído. A exceção da função é observada ao chamar result. Aqui, ValueError é tratado individualmente, de modo que a falha do valor negativo aparece explicitamente.

max_workers=2 limita os trabalhadores, não estabelece por si só um limite de toda a fila de submissão. result(timeout=5) limita quanto essa chamada espera; não interrompe à força uma tarefa já em execução. A saída do with do executor aguarda os trabalhos pendentes. Para muitos dados, processe lotes limitados e planeje a espera e a interrupção de cada operação. No asyncio, use um limite explícito, como um semáforo, quando houver múltiplas operações de entrada/saída.

Consulte os contratos de executores, Future e encerramento e de cancelamento de tarefas asyncio ao adaptar as rotinas.

Testar mudanças e medir a carga relevante

  1. No primeiro arquivo, troque as três listas [1, 2, 3] por [3, 1, 2]. As três linhas devem passar a [9, 1, 4]. Restaure as listas.
  2. Troque somente max_workers=2 por max_workers=1. Os resultados devem continuar iguais; isso não determina qual configuração será mais rápida numa aplicação real. Restaure o valor.
  3. Em future_failure.py, substitua apenas -1 por 3: as linhas devem ser 4 e 9, sem a mensagem de falha. Restaure o valor negativo e confirme a mensagem original.
  4. Execute cancel.py separadamente e confira a ordem entre limpeza e confirmação. Mantenha o await task para observar o resultado do cancelamento.

O cálculo dos quadrados é pequeno e serve para comparar interfaces. O custo de iniciar processos pode superar o próprio cálculo. Meça com dados representativos, registrando versão e build do Python, sistema, tamanho dos dados, trabalhadores, tempo e memória; não publique uma promessa de aceleração com base apenas nessas três linhas.

Threads exigem coordenação quando alteram estado compartilhado; processos não compartilham automaticamente uma lista comum; tarefas asyncio ainda podem disputar estado entre pontos de espera. Separe coleta, validação e escrita, como no projeto de API e SQLite, antes de paralelizar requisições ou gravações.

Se aparecer erro de loop já em execução num notebook, não acrescente outro asyncio.run dentro desse loop. O arquivo desta aula é um programa independente. No ambiente que já mantém um loop, use a integração de espera suportada por ele; a restrição está na documentação de asyncio.run.