← Blog

ThreadPoolExecutor e ProcessPoolExecutor: quando usar

Os dois têm a mesma interface e resultados opostos. O que decide não é o gosto, é onde o seu código gasta tempo: esperando ou calculando.

Duzentas tarefas de cálculo em Python puro levam 20,4 segundos com ThreadPoolExecutor e 5,1 segundos com ProcessPoolExecutor. As mesmas duzentas tarefas, agora requisições HTTP, levam 0,39 segundo com threads e 1,89 segundo com processos.

A troca foi de uma palavra nos dois casos. Quatro vezes mais rápido de um lado, quase cinco vezes mais lento do outro.

Os números vêm do benchmark publicado junto com este artigo, rodando em CPython 3.14 com GIL, Windows, quatro núcleos físicos. Reproduzir leva um comando.

O que faz os dois parecerem intercambiáveis

Ambos implementam a mesma interface, concurrent.futures.Executor. O código que os usa não muda:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def rodar(executor_cls, tarefas):
    with executor_cls(max_workers=8) as pool:
        return list(pool.map(processar, tarefas))

Trocar a classe é trocar um identificador. É essa simetria de API que engana: ela sugere que a escolha é de preferência, quando é de arquitetura.

O mecanismo: onde o tempo é gasto

O CPython tem um Global Interpreter Lock: dentro do mesmo processo, só uma thread executa bytecode por vez. A leitura comum para por aqui, com a conclusão de que threads são inúteis.

Falta a outra metade: o GIL é liberado durante operações de entrada e saída. Quando a thread chama requests.get(), ela solta o lock enquanto o pacote viaja pela rede, e as outras rodam nesse intervalo. O trabalho não é dividido entre núcleos, é sobreposto ao tempo de espera.

Então há dois regimes:

  • Trabalho de espera (rede, disco, banco). O processo fica ocioso a maior parte do tempo. Threads preenchem essa ociosidade e custam quase nada para criar.
  • Trabalho de cálculo (parsear, comprimir, somar matriz em Python puro). O processo está executando bytecode o tempo todo. O GIL nunca é liberado, e as threads se enfileiram para usar o mesmo núcleo. Você paga a troca de contexto e não ganha paralelismo.

O segundo caso é medível no formato mais limpo possível: as duzentas tarefas de cálculo levam 15,1 segundos em um laço sequencial e 20,4 segundos com oito threads. Threads deixaram o programa 35% mais lento. O trabalho é o mesmo, e a única diferença é o custo de coordenar quem segura o lock.

ProcessPoolExecutor resolve esse caso porque cada worker é um processo separado, com seu próprio interpretador e seu próprio GIL. As mesmas tarefas caem para 5,1 segundos.

Repare que 5,1 não é 15,1 dividido por oito. os.cpu_count() devolveu 8 na máquina do teste, mas são quatro núcleos físicos com hyperthreading, e a frequência cai quando todos trabalham. O ganho real foi de 3x. Contar workers não é contar núcleos.

O que fazer

O critério cabe em uma pergunta: enquanto essa função roda, a CPU está ocupada ou esperando?

# Espera: 200 requisições HTTP. Threads.
with ThreadPoolExecutor(max_workers=32) as pool:
    respostas = list(pool.map(buscar_url, urls))

# Cálculo em Python puro: 200 documentos normalizados. Processos.
if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
        resultados = list(pool.map(normalizar_documento, documentos))

Repare no max_workers diferente. Threads esperando não consomem núcleo, então 32 threads para 200 requisições é razoável. Processos calculando consomem núcleo, e passar de os.cpu_count() só adiciona disputa.

O if __name__ == "__main__": também não é cosmético. No Windows e no macOS, o método padrão de criar processos é spawn: o módulo é importado de novo em cada worker. Sem essa guarda, o código de nível de módulo roda outra vez em cada processo filho, e o programa cria workers recursivamente até morrer. É o erro que aparece como travamento inexplicável na máquina do cliente e nunca no Linux do dev.

Quando processos não servem

O ProcessPoolExecutor cobra três coisas que threads não cobram: custo de criação, serialização e a ausência de memória compartilhada.

Custo de criação. Cada processo é um interpretador novo. Para tarefas de poucos milissegundos, o custo de criar o worker e transportar os dados supera o ganho. Se a função termina rápido, meça antes de trocar.

Serialização. Argumentos e retornos viajam por pickle. Objeto não serializável (conexão de banco, socket aberto, lambda, closure) quebra na hora da submissão, não na hora do uso. E um DataFrame grande passado como argumento é copiado na íntegra para cada worker.

Ausência de memória compartilhada. Não existe estado global entre processos. Cache em dicionário de módulo, contador, conexão reaproveitada: nada disso atravessa. Compartilhar de propósito exige multiprocessing.shared_memory ou uma fila, e isso é outro projeto.

O exemplo que quase todo texto usa, e que não reproduz

Redimensionar imagens é o exemplo clássico de trabalho de CPU. No benchmark, 200 JPEGs de 1600x1200 reduzidos para 400x300 com Pillow deram 4,24 segundos com oito threads e 4,37 segundos com oito processos. Empate, com vantagem marginal para threads.

O motivo é que Pillow libera o GIL dentro de open, resize e save. O trabalho pesado acontece em código C, fora do interpretador, e as threads escalam. Trocar para processos ali adiciona spawn e serialização sem comprar paralelismo que já existia.

Vale para NumPy pela mesma razão. Trabalho que parece de cálculo puro às vezes escala com threads porque não é o interpretador que está calculando. A regra dos dois regimes descreve onde o tempo é gasto, e "chama uma função pesada" não diz se esse tempo é gasto dentro ou fora do GIL. Meça em vez de deduzir.

Detalhes que mudam o número

Três coisas alteraram o resultado do benchmark mais do que a escolha do executor.

Tamanho da tarefa. Com tarefas de 8 milissegundos, o ganho dos processos caiu de 3x para 1,5x: o custo de criar os workers passou a dominar. Um ProcessPoolExecutor(max_workers=8) vazio custa 0,38 segundo com spawn no Windows, antes de qualquer trabalho útil.

chunksize. pool.map sem chunksize envia uma tarefa por vez. Passando chunksize=25, a carga de cálculo caiu de 7,6 para 5,4 segundos.

Ruído da máquina. Abaixo de aproximadamente 50 tarefas, a variação de frequência da CPU chegou a 70% entre execuções e inverteu a comparação. Benchmark curto demais não mede executor, mede o governador de energia do sistema operacional.

O que muda no 3.14

Tudo acima descreve o CPython padrão, o build com GIL. No 3.14 o build free-threaded, sem GIL, saiu do estado experimental (PEP 779): nele, trabalho de CPU puro escala com threads de verdade, e o preço em código de uma thread só fica em torno de 5% a 10%. Se o seu código roda nesse build, a pergunta muda de "o GIL me trava?" para "as bibliotecas que uso já rodam sem GIL?".

O 3.14 também ganhou um terceiro executor, o InterpreterPoolExecutor, que roda subinterpretadores no mesmo processo: memória isolada entre eles, sem criar um processo novo. Ainda sofre com spawn lento e extensões de terceiros nem sempre compatíveis, então não é troca automática. Mas quebra a premissa de que a escolha é sempre entre thread e processo.

O takeaway

Trocar ThreadPoolExecutor por ProcessPoolExecutor resolve cálculo que roda dentro do interpretador e piora trabalho de espera. Fora isso, a resposta depende de onde o tempo é gasto, e nem sempre o palpite acerta: o caso das imagens, que parece o exemplo mais óbvio de CPU, deu empate.

O benchmark deste artigo roda em qualquer máquina com um comando e imprime a versão do Python, o método de criação de processos, a contagem de núcleos e se o GIL está ativo. Sem esse bloco de contexto, número de concorrência não significa nada.

A trilha Backend Python Engineer trabalha concorrência com testes rodando em container, incluindo o caso do spawn que só quebra fora do Linux.