Python Multiprocessing: Um Guia Técnico Completo para Execução Paralela
O módulo multiprocessing do Python permite a execução paralela real ao criar processos independentes ao nível do SO, cada um com o seu próprio espaço de memória e interpretador Python — contornando completamente o Global Interpreter Lock (GIL). Ao contrário das threads, que partilham um único estado do interpretador e são serializadas pelo GIL, os processos separados são executados simultaneamente em todos os núcleos de CPU disponíveis, tornando o multiprocessing a ferramenta correta para cargas de trabalho com uso intensivo de CPU, como computação numérica, processamento de imagens e inferência de machine learning.
Este guia abrange tudo, desde a arquitetura fundamental do modelo de processos do Python até padrões avançados, incluindo memória partilhada, pools de processos, comunicação entre processos e armadilhas de nível de produção que a maioria dos tutoriais omite completamente.
Por que o GIL Torna o Multithreading Insuficiente para Trabalho com Uso Intensivo de CPU
O Global Interpreter Lock é um mutex que protege os contadores de referência de objetos internos do CPython. Apenas uma thread pode deter o GIL e executar bytecode Python em qualquer momento. Para tarefas com uso intensivo de I/O — pedidos de rede, consultas a bases de dados, leituras de ficheiros — as threads continuam a ser úteis porque o GIL é libertado durante as syscalls de I/O bloqueantes. No entanto, para computação pura, as threads disputam o GIL continuamente, não produzindo paralelismo real mesmo numa máquina com 64 núcleos.
O multiprocessing contorna isto completamente. Cada processo criado é um processo de SO completo e independente com o seu próprio interpretador CPython, heap e GIL. O escalonador do sistema operativo distribui estes processos pelos núcleos físicos, proporcionando paralelismo genuíno.
Impacto do GIL: Um Exemplo Concreto
Considere uma função que realiza 10 milhões de adições de inteiros. Executá-la em duas threads numa máquina dual-core levará aproximadamente o mesmo tempo de relógio que executá-la numa única thread — por vezes mais, devido à sobrecarga de contenção do GIL. Executá-la em dois processos separados reduzirá o tempo de relógio para metade.
Multiprocessing vs. Multithreading vs. Asyncio
Compreender quando usar cada modelo de concorrência é tão importante quanto saber como usá-los.
| Funcionalidade | `multiprocessing` | `threading` | `asyncio` |
|---|---|---|---|
| — | — | — | — |
| Tipo de paralelismo | Real (processos do SO) | Pseudo (limitado pelo GIL) | Cooperativo (single-threaded) |
| Contorno do GIL | Sim | Não | Não |
| Modelo de memória | Separado por processo | Partilhado | Partilhado |
| Melhor caso de uso | Tarefas com uso intensivo de CPU | I/O-bound + bibliotecas legadas | I/O-bound, alta concorrência |
| Sobrecarga de comunicação | Alta (IPC necessário) | Baixa (memória partilhada) | Baixa (corrotinas) |
| Isolamento de falhas | Forte (isolamento de falhas) | Fraco (uma falha de thread pode matar todas) | Fraco |
| Sobrecarga de arranque | Alta | Baixa | Muito baixa |
| Uso típico de memória | Alto | Baixo | Muito baixo |
Regra geral: Use `multiprocessing` para trabalho com uso intensivo de CPU, `threading` ou `asyncio` para trabalho com uso intensivo de I/O. Se precisar de ambos, `concurrent.futures` fornece uma interface unificada sobre ambos os modelos.
Arquitetura Central: Como o Python Cria Processos
O Python suporta três métodos de início para criar processos filhos, e a escolha tem consequências significativas:
- `fork` (padrão no Linux/macOS): Copia a memória do processo pai usando copy-on-write. Rápido, mas pode causar problemas com processos pai com múltiplas threads ou extensões C que mantêm bloqueios.
- `spawn` (padrão no Windows, disponível em todas as plataformas): Inicia um novo interpretador Python e importa o módulo. Mais lento, mas mais seguro. Requer que todo o código seja importável, razão pela qual a proteção `if __name__ == "__main__":` é obrigatória.
- `forkserver`: Um processo servidor dedicado faz fork sob demanda. Evita problemas de segurança de fork sendo mais eficiente do que o spawn puro para muitos processos de curta duração.
Defina o método de início explicitamente no topo do seu ponto de entrada:
“`python
import multiprocessing
if __name__ == "__main__":
multiprocessing.set_start_method("spawn")
“`
Não compreender os métodos de início é uma das fontes mais comuns de bugs subtis e específicos de plataforma no código de multiprocessing em produção.
Importar o Módulo
“`python
import multiprocessing
from multiprocessing import Process, Pool, Queue, Lock, Pipe, Value, Array
“`
Primitivos Principais e os Seus Papéis
| Primitivo | Finalidade |
|---|---|
| — | — |
| `Process` | Cria um único processo independente |
| `Pool` | Gere um pool de workers reutilizável |
| `Queue` | FIFO seguro para threads e processos para IPC |
| `Pipe` | Ligação rápida de dois endpoints entre dois processos |
| `Lock` / `RLock` | Exclusão mútua para recursos partilhados |
| `Value` / `Array` | Memória partilhada para tipos simples |
| `Manager` | Objetos proxy para estado partilhado complexo |
| `Event` / `Semaphore` | Primitivos de sincronização |
Exemplo 1: Criar um Único Processo
A classe `Process` é o bloco de construção fundamental. Mapeia diretamente para um processo do SO.
“`python
from multiprocessing import Process
def compute_square(n):
result = n ** 2
print(f"Square of {n} is {result}")
if __name__ == "__main__":
process = Process(target=compute_square, args=(7,))
process.start()
process.join()
print(f"Process exit code: {process.exitcode}")
“`
Atributos e métodos principais:
- `target`: O callable a executar no processo filho.
- `args` / `kwargs`: Argumentos passados para a função alvo.
- `start()`: Faz fork ou spawn do processo filho.
- `join(timeout=None)`: Bloqueia o chamador até o processo terminar. Chame sempre `join()` para evitar processos zumbi.
- `exitcode`: `0` em saída limpa, valor negativo se morto por um sinal, valor positivo se o processo levantou uma exceção não tratada.
- `is_alive()`: Retorna `True` se o processo ainda está em execução.
- `terminate()` / `kill()`: Envia `SIGTERM` / `SIGKILL` respetivamente. Use com cuidado — os recursos podem não ser limpos.
Armadilha crítica: Se criar um processo sem chamar `join()`, o filho torna-se um processo zumbi em sistemas Unix, consumindo uma entrada na tabela de processos até o pai sair.
Exemplo 2: Pools de Processos com `multiprocessing.Pool`
Para cargas de trabalho que aplicam a mesma função a muitos itens de dados, `Pool` é muito mais eficiente do que gerir manualmente instâncias individuais de `Process`. Mantém um número fixo de processos worker e distribui o trabalho entre eles.
“`python
from multiprocessing import Pool
import os
def process_chunk(data_chunk):
worker_pid = os.getpid()
result = sum(x ** 2 for x in data_chunk)
return result, worker_pid
if __name__ == "__main__":
dataset = [range(i, i + 1000) for i in range(0, 10000, 1000)]
with Pool(processes=4) as pool:
results = pool.map(process_chunk, dataset)
for result, pid in results:
print(f"Worker PID {pid} computed sum: {result}")
“`
Comparação de Métodos do Pool
| Método | Bloqueante | Retorna | Melhor Para |
|---|---|---|---|
| — | — | — | — |
| `pool.map(f, iterable)` | Sim | Lista de resultados | Map paralelo simples |
| `pool.imap(f, iterable)` | Lazy | Iterador | Iteráveis grandes, eficiência de memória |
| `pool.imap_unordered(f, iterable)` | Lazy | Iterador (não ordenado) | Quando a ordem não importa |
| `pool.starmap(f, iterable)` | Sim | Lista de resultados | Funções com múltiplos argumentos |
| `pool.apply_async(f, args)` | Não | `AsyncResult` | Fire-and-forget ou callbacks |
| `pool.map_async(f, iterable)` | Não | `AsyncResult` | Submissão em lote não bloqueante |
Armadilha — seleção do tamanho do pool: Definir `processes` acima de `os.cpu_count()` raramente melhora o throughput para tarefas com uso intensivo de CPU e aumenta a sobrecarga de troca de contexto. Uma heurística comum é `processes = os.cpu_count() – 1` para deixar um núcleo para o SO e o processo principal.
Armadilha — serialização: Todos os argumentos e valores de retorno passados entre o processo principal e os workers são serializados usando `pickle`. Objetos que não podem ser serializados (funções lambda, funções aninhadas definidas dentro de outras funções, handles de ficheiros, ligações a bases de dados) levantarão um `PicklingError`. Use `pool.starmap` com funções ao nível do módulo, ou reestruture o seu código para evitar passar objetos não serializáveis.
Exemplo 3: Comunicação Entre Processos com Queue
`multiprocessing.Queue` é um FIFO seguro para processos construído sobre um pipe e um bloqueio. É o mecanismo padrão para o padrão produtor-consumidor.
“`python
from multiprocessing import Process, Queue
import time
def producer(queue, items):
for item in items:
queue.put(item)
print(f"[Producer] Enqueued: {item}")
time.sleep(0.01)
queue.put(None) # Sentinel value to signal completion
def consumer(queue):
while True:
item = queue.get()
if item is None:
print("[Consumer] Received sentinel, shutting down.")
break
print(f"[Consumer] Processing: {item}")
if __name__ == "__main__":
q = Queue(maxsize=10) # Bounded queue prevents unbounded memory growth
data = list(range(20))
p = Process(target=producer, args=(q, data))
c = Process(target=consumer, args=(q,))
p.start()
c.start()
p.join()
c.join()
“`
Nota de design crítica: Nunca use `queue.empty()` para determinar se deve parar de consumir. A verificação `empty()` não é fiável num contexto de multiprocessing — existe uma condição de corrida entre a verificação e o subsequente `get()`. Use sempre um valor sentinela (como `None` ou um objeto `STOP` dedicado) para sinalizar que a produção está completa.
Exemplo 4: Memória Partilhada com Value e Array
Quando os processos precisam de partilhar estado numérico simples sem a sobrecarga de um `Queue`, `multiprocessing.Value` e `multiprocessing.Array` fornecem memória partilhada direta suportada por `ctypes`.
“`python
from multiprocessing import Process, Value, Array, Lock
import ctypes
def increment_counter(counter, lock, iterations):
for _ in range(iterations):
with lock:
counter.value += 1
if __name__ == "__main__":
counter = Value(ctypes.c_int, 0)
lock = Lock()
processes = [
Process(target=increment_counter, args=(counter, lock, 1000))
for _ in range(4)
]
for p in processes:
p.start()
for p in processes:
p.join()
print(f"Final counter value: {counter.value}") # Expected: 4000
“`
Sem o bloqueio, o valor final seria imprevisível e inferior a 4000 devido a condições de corrida no ciclo de leitura-modificação-escrita. Proteja sempre o estado mutável partilhado com um `Lock`.
Para estruturas de dados partilhadas complexas (listas, dicionários, objetos personalizados), use `multiprocessing.Manager`, que cria um processo servidor que gere os objetos e fornece acesso por proxy. A contrapartida é uma latência mais alta por acesso em comparação com a memória partilhada direta.
Exemplo 5: Pipe para Comunicação Direta Entre Dois Processos
`multiprocessing.Pipe` cria um par de objetos de ligação. É mais rápido do que `Queue` para comunicação ponto a ponto entre exatamente dois processos porque tem menos sobrecarga.
“`python
from multiprocessing import Process, Pipe
def worker(conn):
data = conn.recv()
result = [x ** 3 for x in data]
conn.send(result)
conn.close()
if __name__ == "__main__":
parent_conn, child_conn = Pipe()
p = Process(target=worker, args=(child_conn,))
p.start()
parent_conn.send([1, 2, 3, 4, 5])
result = parent_conn.recv()
p.join()
print(f"Cubed values: {result}")
“`
Use `Queue` quando estiverem envolvidos múltiplos produtores ou consumidores. Use `Pipe` quando exatamente dois processos trocam dados diretamente.
Exemplo 6: Usando `concurrent.futures.ProcessPoolExecutor`
Para código Python moderno (3.2+), `concurrent.futures.ProcessPoolExecutor` fornece uma API de nível superior e mais limpa sobre `multiprocessing.Pool` e integra-se naturalmente com objetos `Future`.
“`python
from concurrent.futures import ProcessPoolExecutor, as_completed
def heavy_computation(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
inputs = [106, 2 * 106, 3 * 106, 4 * 106]
with ProcessPoolExecutor(max_workers=4) as executor:
futures = {executor.submit(heavy_computation, n): n for n in inputs}
for future in as_completed(futures):
n = futures[future]
try:
result = future.result()
print(f"Input {n}: result = {result}")
except Exception as e:
print(f"Input {n} raised an exception: {e}")
“`
`as_completed()` produz futuros à medida que terminam em vez de na ordem de submissão, o que é útil quando as durações das tarefas variam significativamente.
Armadilhas de Produção e Considerações Avançadas
Processos Daemon
Definir `process.daemon = True` antes de chamar `start()` torna o processo filho um daemon. Os processos daemon são automaticamente terminados quando o processo pai sai, evitando workers em segundo plano órfãos. No entanto, os processos daemon não podem criar processos filhos por si próprios.
Tratamento de Exceções em Processos Worker
As exceções levantadas dentro das funções worker não se propagam para o processo pai automaticamente ao usar `Pool.map()` — são re-levantadas quando chama `result()` no valor retornado ou quando `map()` retorna. Com `apply_async`, deve chamar explicitamente `.get()` no `AsyncResult` para expor as exceções.
“`python
from multiprocessing import Pool
def risky_function(x):
if x == 3:
raise ValueError(f"Cannot process value {x}")
return x * 10
if __name__ == "__main__":
with Pool(2) as pool:
try:
results = pool.map(risky_function, [1, 2, 3, 4])
except ValueError as e:
print(f"Caught worker exception: {e}")
“`
Consumo de Memória
Cada processo criado duplica a pegada de memória do pai (em `fork`) ou reimporta todos os módulos (em `spawn`). Para um processo pai que consome 2 GB de RAM, criar 8 workers num sistema baseado em `fork` pode aparentemente consumir 16 GB antes de o copy-on-write entrar em ação. Analise cuidadosamente o uso de memória antes de escalar o número de workers.
Evitar Estado Global
As variáveis globais no processo pai não são partilhadas com os processos filhos após `spawn`. As alterações feitas a variáveis globais num processo filho são invisíveis para o pai e outros filhos. Se depender de configuração global, passe-a explicitamente como argumentos ou use um `Manager`.
Chunking para Eficiência do Pool
`pool.map()` aceita um parâmetro `chunksize`. Para iteráveis grandes, definir um tamanho de chunk adequado reduz a sobrecarga de IPC ao agrupar múltiplos itens por ciclo de pickle/unpickle:
“`python
results = pool.map(process_item, large_list, chunksize=500)
“`
Escolher o Hardware Certo para Cargas de Trabalho de Multiprocessing
O teto de desempenho de qualquer aplicação de multiprocessing é determinado em última análise pelo número de núcleos de CPU físicos disponíveis. Um pool de processos com 32 workers numa máquina de 4 núcleos não superará um pool de 4 workers — será mais lento devido à sobrecarga de troca de contexto.
Para implementações em produção de aplicações Python com uso intensivo de CPU — pipelines de dados, computação científica, inferência de ML em lote — precisa de recursos de computação dedicados. Servidores Dedicados com processadores de alto número de núcleos eliminam a contenção de recursos inerente em ambientes partilhados, dando a cada processo worker acesso incontestado a um núcleo físico.
Para desenvolvimento, staging ou cargas de trabalho moderadas, uma instância de Alojamento VPS devidamente dimensionada fornece um ambiente económico onde pode ajustar o número de workers em relação aos vCPUs disponíveis. Se precisar de um painel de controlo para gerir o ambiente da sua aplicação Python, VPS com cPanel simplifica a implementação e a monitorização de processos.
Para cargas de trabalho aceleradas por GPU onde o multiprocessing Python é combinado com bibliotecas baseadas em CUDA como PyTorch ou CuPy, o Alojamento GPU fornece o hardware necessário para executar pré-processamento paralelo de CPU juntamente com pipelines de computação GPU.
Ao implementar aplicações que expõem APIs suportadas por multiprocessing via HTTPS, combinar o seu servidor com um Certificado SSL devidamente configurado é uma base inegociável para a segurança em produção.
Matriz de Decisão Prática
Use a seguinte lista de verificação para determinar a abordagem correta para a sua carga de trabalho:
Use `multiprocessing.Process` diretamente quando:
- Tem um número pequeno e fixo de tarefas heterogéneas
- Cada tarefa tem um ciclo de vida distinto e requer monitorização individual
- Precisa de controlo detalhado sobre os atributos do processo (daemon, nome, afinidade)
Use `multiprocessing.Pool` ou `ProcessPoolExecutor` quando:
- Está a aplicar a mesma função a muitos itens de dados
- Quer gestão automática do ciclo de vida dos workers
- Precisa de recolha de resultados com código mínimo
Use `multiprocessing.Queue` quando:
- Tem uma arquitetura produtor-consumidor
- Estão envolvidos múltiplos produtores ou consumidores
- Precisa de controlo de contrapressão via `maxsize`
Use `multiprocessing.Pipe` quando:
- Exatamente dois processos comunicam diretamente
- A latência por mensagem importa mais do que a flexibilidade
Use `multiprocessing.Value` / `Array` quando:
- Partilha estado numérico simples entre muitos workers
- A frequência de acesso é alta e a sobrecarga do proxy Manager é inaceitável
Use `multiprocessing.Manager` quando:
- Precisa de partilhar objetos Python complexos (listas, dicionários)
- A consistência é mais importante do que a velocidade de acesso bruta
Evite completamente o multiprocessing quando:
- O seu gargalo é I/O (rede, disco) — use `asyncio` ou `threading`
- As tarefas são de muito curta duração (< 1 ms) — a sobrecarga de criação de processos dominará
- O seu código depende fortemente de objetos não serializáveis
FAQ
P: Por que devo usar `if __name__ == "__main__":` em scripts Python de multiprocessing?
No Windows e ao usar o método de início `spawn`, o Python reimporta o módulo principal em cada processo filho. Sem a proteção `__main__`, o processo filho tentará criar os seus próprios filhos recursivamente, causando uma fork bomb infinita. Esta proteção é obrigatória no Windows e uma boa prática em todas as plataformas.
P: Qual é a diferença entre `pool.map()` e `pool.imap()`?
`pool.map()` consome o iterável inteiro imediatamente, serializa todos os itens, distribui-os pelos workers e bloqueia até todos os resultados serem recolhidos numa lista. `pool.imap()` é lazy — submete itens incrementalmente e retorna um iterador, tornando-o eficiente em memória para conjuntos de dados muito grandes. Use `imap` quando o iterável de entrada não cabe confortavelmente em memória.
P: Os processos de multiprocessing Python podem partilhar uma ligação a uma base de dados?
Não. As ligações a bases de dados não são serializáveis e não podem ser passadas entre processos. Cada processo worker deve estabelecer a sua própria ligação. Use uma biblioteca de pool de ligações (como `SQLAlchemy` com `pool_pre_ping=True`) inicializada dentro da função worker, não no processo pai.
P: Como trato interrupções de teclado (Ctrl+C) de forma elegante num pool de multiprocessing?
Envolva a sua chamada `pool.map()` num bloco `try/except KeyboardInterrupt` e chame `pool.terminate()` seguido de `pool.join()` na cláusula `except`. Adicionalmente, defina os processos worker como processos daemon se quiser que terminem automaticamente quando o pai for morto. Sem tratamento explícito, os processos worker podem continuar a executar como órfãos após o pai ser interrompido.
P: O multiprocessing Python é seguro de usar com `fork` no macOS?
Desde o Python 3.8, o método de início padrão no macOS mudou de `fork` para `spawn` especificamente porque `fork` combinado com o runtime Objective-C do macOS e certas extensões C (incluindo as usadas pelo NumPy e PyTorch) causava deadlocks. Use sempre `spawn` ou `forkserver` no macOS e defina explicitamente o método de início em vez de depender dos padrões, que diferem entre sistemas operativos.
