Multiprocessing
Nesta aula, exploramos o módulo multiprocessing do Python, aprendendo quando utilizá-lo (tarefas CPU-bound), como criar e gerenciar processos com Process e Pool, como realizar comunicação entre processos e comparando com threading para escolher a abordagem correta.
O módulo multiprocessing do Python permite criar processos paralelos, aproveitando múltiplos núcleos da CPU de forma eficiente. Em tarefas intensivas de CPU (CPU-bound), o uso de múltiplos processos pode trazer ganhos significativos de performance, pois cada processo executa em um núcleo separado, contornando as limitações do GIL (Global Interpreter Lock) que afeta threads.
Nesta aula, vamos explorar quando usar multiprocessing, as principais APIs (Process e Pool), como comunicar entre processos e as diferenças cruciais em relação a threading. Ao final, você terá uma base sólida para paralelizar seus programas Python de forma correta e eficiente.
Quando usar (CPU-bound)
Tarefas CPU-bound são aquelas que demandam muito processamento da CPU, como cálculos matemáticos intensivos, processamento de imagens, simulações numéricas, entre outros. Nesses casos, o gargalo é a capacidade de processamento, e não a espera por I/O (como leitura de arquivos ou requisições de rede).
O Python possui o GIL, que impede que múltiplas threads executem código Python simultaneamente em um mesmo processo. Isso significa que, para tarefas CPU-bound, usar threads não traz ganho de performance, pois apenas uma thread é executada por vez. Já com processos, cada um tem seu próprio interpretador e seu próprio GIL, permitindo verdadeiro paralelismo em múltiplos núcleos.
Portanto, a regra prática é: se sua tarefa é CPU-bound, use multiprocessing; se é I/O-bound (espera por rede, disco, etc.), use threading ou asyncio. Vamos ver um exemplo de tarefa CPU-bound:
import time
import multiprocessing
def soma_ate(n):
total = 0
for i in range(n):
total += i
return total
if __name__ == '__main__':
n = 10**7
inicio = time.time()
resultado = soma_ate(n)
fim = time.time()
print(f"Sequencial: resultado={resultado}, tempo={fim - inicio:.2f}s")
# Com multiprocessing (4 processos)
inicio = time.time()
with multiprocessing.Pool(4) as pool:
# Divide o intervalo em 4 partes
partes = [n//4] * 4
resultados = pool.map(soma_ate, partes)
fim = time.time()
print(f"Multiprocessing: resultado={sum(resultados)}, tempo={fim - inicio:.2f}s")Neste exemplo, a função soma_ate é CPU-bound, e ao dividir o trabalho entre 4 processos, o tempo de execução tende a ser menor (em máquinas com múltiplos núcleos). Note a proteção if __name__ == '__main__', essencial para evitar problemas ao criar processos em sistemas Windows.
Process e Pool
O módulo multiprocessing oferece duas principais formas de criar e gerenciar processos: a classe Process e a classe Pool. Ambas têm seus usos específicos.
Classe Process
A classe Process permite criar um processo individual que executa uma função alvo. Você pode iniciar, juntar (join) e terminar processos manualmente. É útil quando você precisa de controle fino sobre um número pequeno de processos.
import multiprocessing
import os
def trabalhar(nome):
print(f"Processo {nome} (PID {os.getpid()}) executando...")
if __name__ == '__main__':
processos = []
for i in range(4):
p = multiprocessing.Process(target=trabalhar, args=(f"P{i}",))
processos.append(p)
p.start()
for p in processos:
p.join()
print("Todos os processos concluídos.")O método start() inicia o processo, e join() aguarda sua conclusão. Isso é essencial para sincronizar o programa principal com os processos filhos.
Classe Pool
A classe Pool é mais conveniente para distribuir tarefas entre um conjunto de processos, especialmente quando você tem muitas tarefas pequenas. Ela gerencia automaticamente a fila de tarefas e distribui entre os processos disponíveis.
import multiprocessing
def quadrado(x):
return x * x
if __name__ == '__main__':
with multiprocessing.Pool(processes=4) as pool:
resultados = pool.map(quadrado, range(10))
print(resultados)O método map aplica a função a cada item da lista e retorna os resultados na mesma ordem. Também temos apply (executa uma única tarefa), map_async e apply_async para execução assíncrona, permitindo maior flexibilidade.
Comunicação
Processos são independentes e não compartilham memória por padrão. Para trocar dados, precisamos de mecanismos de comunicação, como Queue e Pipe.
Queue
Uma Queue é uma estrutura FIFO que pode ser usada para enviar dados de um processo para outro. É segura para uso com múltiplos processos.
import multiprocessing
def produtor(fila):
for i in range(5):
fila.put(i)
fila.put(None) # sinal de término
def consumidor(fila):
while True:
item = fila.get()
if item is None:
break
print(f"Consumidor recebeu: {item}")
if __name__ == '__main__':
fila = multiprocessing.Queue()
p1 = multiprocessing.Process(target=produtor, args=(fila,))
p2 = multiprocessing.Process(target=consumidor, args=(fila,))
p1.start()
p2.start()
p1.join()
p2.join()No exemplo, o produtor coloca itens na fila e envia None como sinal de término. O consumidor lê até encontrar o sinal. É importante usar put e get que são bloqueantes por padrão, mas você pode usar timeouts.
Pipe
Um Pipe conecta dois processos, permitindo comunicação bidirecional. É mais leve que uma Queue, mas só serve para dois pontos.
import multiprocessing
def enviar(conn):
conn.send("Olá do processo filho")
conn.close()
def receber(conn):
msg = conn.recv()
print(f"Processo pai recebeu: {msg}")
conn.close()
if __name__ == '__main__':
parent_conn, child_conn = multiprocessing.Pipe()
p = multiprocessing.Process(target=enviar, args=(child_conn,))
p.start()
receber(parent_conn)
p.join()Aqui, o processo filho envia uma mensagem e o pai a recebe. Note que a conexão deve ser fechada após o uso para liberar recursos.
Outros mecanismos
Além de filas e pipes, o módulo multiprocessing fornece Value e Array para compartilhar dados entre processos de forma sincronizada, e Manager para criar objetos compartilhados (listas, dicionários) que podem ser acessados por múltiplos processos. Por exemplo:
import multiprocessing
def incrementar(contador, lock):
for _ in range(1000):
with lock:
contador.value += 1
if __name__ == '__main__':
contador = multiprocessing.Value('i', 0)
lock = multiprocessing.Lock()
processos = [multiprocessing.Process(target=incrementar, args=(contador, lock)) for _ in range(4)]
for p in processos:
p.start()
for p in processos:
p.join()
print(f"Valor final: {contador.value}")O lock evita condições de corrida quando múltiplos processos acessam o mesmo Value.
vs threading
Threads e processos são formas de paralelismo, mas com diferenças fundamentais. Threads compartilham o mesmo espaço de memória e são leves, mas sofrem com o GIL. Processos têm memória separada e podem usar múltiplos núcleos, mas são mais pesados e a comunicação é mais complexa.
Para tarefas I/O-bound (como baixar arquivos da internet), threads são eficientes porque o GIL é liberado durante operações de I/O. Já para CPU-bound, processos são a escolha certa. A tabela abaixo resume:
| Característica | Threading | Multiprocessing |
|---|---|---|
| Memória | Compartilhada | Separada |
| GIL | Afetado | Não afetado |
| Custo de criação | Baixo | Alto |
| Comunicação | Fácil (variáveis globais) | Necessita de filas/pipes |
| Ideal para | I/O-bound | CPU-bound |
Na prática, para tarefas CPU-bound, usar multiprocessing pode reduzir o tempo de execução proporcionalmente ao número de núcleos, enquanto threads não trazem ganho. Para I/O-bound, threads são mais simples e suficientes.
Boas práticas e observações finais
Ao trabalhar com multiprocessing, lembre-se de:
- Sempre proteger a entrada do programa com
if __name__ == '__main__'para evitar problemas em sistemas Windows. - Evitar compartilhar objetos complexos entre processos; prefira filas ou pipes.
- Usar
Poolpara muitas tarefas pequenas eProcesspara controle fino. - Fechar recursos (como conexões) após o uso para evitar vazamentos.
- Testar em sua máquina para verificar se o número de processos escolhido é adequado (geralmente igual ao número de núcleos).
Com esses conceitos, você está pronto para paralelizar aplicações Python de forma eficiente.
Referências
- Documentação oficial do módulo multiprocessing
- Documentação oficial do módulo threading
- Real Python: O GIL em Python
- Documentação da Queue
- Troca de objetos entre processos
- Sincronização entre processos
Exercícios
- Crie um programa que calcule o fatorial de um número grande (ex.: 10000) usando multiprocessing com Pool para dividir o trabalho em partes. Compare o tempo com execução sequencial.
- Implemente um produtor-consumidor usando Queue, onde o produtor gera números aleatórios e o consumidor calcula o quadrado. Execute com 2 processos.
- Escreva um programa que use Pipe para enviar uma mensagem de um processo filho para o pai, e o pai envie uma resposta de volta.
- Usando Value e Lock, crie um contador que seja incrementado por 4 processos, cada um incrementando 1000 vezes. Verifique se o resultado final é 4000.
- Compare o tempo de execução de uma tarefa CPU-bound usando threading e multiprocessing. Crie uma função que calcula a soma de 1 a 10^7 e execute com 4 threads e 4 processos. Explique a diferença.
import math
import multiprocessing
import time
def fatorial_parcial(intervalo):
inicio, fim = intervalo
resultado = 1
for i in range(inicio, fim + 1):
resultado *= i
return resultado
if __name__ == '__main__':
n = 10000
num_processos = 4
tamanho = n // num_processos
intervalos = [(i * tamanho + 1, (i + 1) * tamanho) for i in range(num_processos)]
# Ajusta o último intervalo para incluir o resto
intervalos[-1] = (intervalos[-1][0], n)
inicio = time.time()
with multiprocessing.Pool(num_processos) as pool:
resultados = pool.map(fatorial_parcial, intervalos)
fatorial = 1
for r in resultados:
fatorial *= r
fim = time.time()
print(f"Fatorial calculado em {fim - inicio:.2f}s")
# Sequencial para comparação
inicio = time.time()
fatorial_seq = math.factorial(n)
fim = time.time()
print(f"Sequencial em {fim - inicio:.2f}s")import multiprocessing
import random
def produtor(fila, n):
for _ in range(n):
fila.put(random.randint(1, 10))
fila.put(None)
def consumidor(fila):
while True:
item = fila.get()
if item is None:
break
print(f"Quadrado de {item} = {item * item}")
if __name__ == '__main__':
fila = multiprocessing.Queue()
p1 = multiprocessing.Process(target=produtor, args=(fila, 5))
p2 = multiprocessing.Process(target=consumidor, args=(fila,))
p1.start()
p2.start()
p1.join()
p2.join()import multiprocessing
def filho(conn):
conn.send("Olá pai!")
msg = conn.recv()
print(f"Filho recebeu: {msg}")
conn.close()
if __name__ == '__main__':
parent_conn, child_conn = multiprocessing.Pipe()
p = multiprocessing.Process(target=filho, args=(child_conn,))
p.start()
msg = parent_conn.recv()
print(f"Pai recebeu: {msg}")
parent_conn.send("Olá filho!")
p.join()import multiprocessing
def incrementar(contador, lock):
for _ in range(1000):
with lock:
contador.value += 1
if __name__ == '__main__':
contador = multiprocessing.Value('i', 0)
lock = multiprocessing.Lock()
processos = [multiprocessing.Process(target=incrementar, args=(contador, lock)) for _ in range(4)]
for p in processos:
p.start()
for p in processos:
p.join()
print(f"Valor final: {contador.value}")
assert contador.value == 4000import time
import threading
import multiprocessing
def soma_ate(n):
total = 0
for i in range(n):
total += i
return total
if __name__ == '__main__':
n = 10**7
# Threading
inicio = time.time()
threads = []
for _ in range(4):
t = threading.Thread(target=soma_ate, args=(n//4,))
threads.append(t)
t.start()
for t in threads:
t.join()
fim = time.time()
print(f"Threading: {fim - inicio:.2f}s")
# Multiprocessing
inicio = time.time()
with multiprocessing.Pool(4) as pool:
pool.map(soma_ate, [n//4]*4)
fim = time.time()
print(f"Multiprocessing: {fim - inicio:.2f}s")O tempo do multiprocessing tende a ser menor em máquinas com múltiplos núcleos, pois os processos executam em paralelo, enquanto as threads ficam limitadas pelo GIL.