Multiprocessing
Nesta aula, você aprenderá sobre o módulo multiprocessing em Python, que permite executar tarefas em paralelo usando múltiplos processos para aproveitar múltiplos núcleos de CPU. Serão abordados os casos de uso (CPU-bound), as principais classes (Process e Pool), mecanismos de comunicação entre processos e a comparação com threading.
O módulo multiprocessing do Python é uma ferramenta poderosa para executar código em paralelo, contornando as limitações do GIL (Global Interpreter Lock). Ele permite criar processos separados, cada um com seu próprio interpretador Python e espaço de memória, o que é ideal para tarefas que exigem muita CPU. Nesta aula, vamos explorar quando usar multiprocessing, como criar e gerenciar processos, como comunicar entre eles e como ele se compara a threading.
Entender a diferença entre processos e threads é fundamental para escrever programas eficientes. Enquanto threads compartilham o mesmo espaço de memória e são limitadas pelo GIL, processos são independentes e podem rodar verdadeiramente em paralelo em múltiplos núcleos. Vamos mergulhar nos detalhes práticos.
Quando usar (CPU-bound)
O multiprocessing é especialmente útil para tarefas que são CPU-bound, ou seja, que passam a maior parte do tempo executando operações aritméticas ou lógicas, em vez de esperar por I/O. Exemplos incluem processamento de imagens, cálculos numéricos intensivos, análise de dados, e treinamento de modelos de machine learning.
Nesses casos, o GIL (Global Interpreter Lock) do Python impede que múltiplas threads executem código Python simultaneamente, o que significa que o threading não proporciona ganho de performance para CPU-bound. O multiprocessing contorna isso criando processos separados, cada um com seu próprio GIL, permitindo que o sistema operacional os distribua entre os núcleos da CPU.
Para ilustrar, considere o seguinte cálculo pesado:
import time
def soma_ate(n):
total = 0
for i in range(n):
total += i
return total
if __name__ == "__main__":
inicio = time.time()
resultados = [soma_ate(10**7) for _ in range(4)]
print("Tempo sequencial:", time.time() - inicio)Esse código executa a função quatro vezes sequencialmente. Usando multiprocessing, podemos dividir essas tarefas entre processos e reduzir o tempo total em máquinas com múltiplos núcleos.
Process e Pool
O módulo multiprocessing oferece duas abordagens principais: a classe Process para criar processos individuais e a classe Pool para gerenciar um conjunto de processos de forma mais simples.
Process
A classe Process permite criar um novo processo que executa uma função alvo. Você pode iniciar o processo com start() e aguardar sua conclusão com join(). Exemplo:
import multiprocessing
def trabalhador(nome):
print(f"Processo {nome} executando")
return
if __name__ == "__main__":
p1 = multiprocessing.Process(target=trabalhador, args=("A",))
p2 = multiprocessing.Process(target=trabalhador, args=("B",))
p1.start()
p2.start()
p1.join()
p2.join()
print("Processos concluídos")É importante proteger o código com if __name__ == "__main__": para evitar a criação recursiva de processos em alguns sistemas operacionais.
Pool
A classe Pool é mais conveniente quando você tem uma lista de tarefas independentes. Ela cria um conjunto de processos e distribui as tarefas automaticamente. Métodos como map() e apply_async() facilitam o processamento paralelo.
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 Pool também permite usar starmap() para funções com múltiplos argumentos e imap() para iteração preguiçosa.
Comunicação
Como processos não compartilham memória, precisamos de mecanismos para trocar dados. O módulo multiprocessing oferece Queue (filas), Pipe (tubos) e Value/Array (memória compartilhada).
Queue
Uma fila é uma estrutura thread-safe para comunicação entre processos. Um produtor pode colocar itens na fila e um consumidor pode retirá-los.
import multiprocessing
def produtor(fila):
for i in range(5):
fila.put(i)
fila.put(None) # sinal de fim
def consumidor(fila):
while True:
item = fila.get()
if item is None:
break
print(f"Recebido: {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()Pipe
Um pipe conecta dois processos, fornecendo um canal de comunicação bidirecional. Cada extremidade tem um objeto Connection com métodos send() e recv().
import multiprocessing
def filho(conn):
msg = conn.recv()
print(f"Filho recebeu: {msg}")
conn.send("Olá do filho")
conn.close()
if __name__ == "__main__":
parent_conn, child_conn = multiprocessing.Pipe()
p = multiprocessing.Process(target=filho, args=(child_conn,))
p.start()
parent_conn.send("Olá do pai")
print(parent_conn.recv())
p.join()É importante fechar as conexões quando não forem mais necessárias para evitar deadlocks.
vs threading
Para tarefas I/O-bound (como downloads, leitura de arquivos, acesso a APIs), threading pode ser mais eficiente porque as threads são mais leves e não sofrem com o custo de criação de processos. O GIL é liberado durante operações de I/O, permitindo que as threads alternem e aproveitem o tempo de espera.
Em contraste, para tarefas CPU-bound, o multiprocessing é a escolha certa, pois cada processo tem seu próprio GIL e pode executar em um núcleo separado. No entanto, a criação de processos tem overhead maior (memória, tempo de inicialização) e a comunicação entre processos é mais cara.
Na prática, a escolha depende do tipo de tarefa. Uma regra simples: se a tarefa gasta mais tempo esperando (I/O), use threading; se ela gasta mais tempo calculando, use multiprocessing. Existem também bibliotecas como concurrent.futures que oferecem interfaces de alto nível para ambos.
Boas práticas
Ao trabalhar com multiprocessing, lembre-se de:
- Proteger o código com
if __name__ == "__main__":para evitar problemas em sistemas Windows. - Usar
Poolpara tarefas independentes eProcesspara controle fino. - Evitar compartilhar estado entre processos; prefira passar dados via
QueueouPipe. - Fechar conexões e juntar processos para evitar processos zumbis.
- Testar em máquinas com múltiplos núcleos para ver ganhos reais.
Referências
- Documentação oficial do módulo multiprocessing
- Documentação oficial do módulo threading
- Real Python: Concurrency in Python
- Documentação oficial do concurrent.futures
- PyMOTW: multiprocessing
- GeeksforGeeks: Multiprocessing em Python
Exercícios
- Exercício 1: Escreva um programa que use
multiprocessing.Processpara calcular o quadrado de números de 1 a 5, cada um em um processo separado. Imprima os resultados. - Exercício 2: Use
multiprocessing.Poolpara calcular a soma dos quadrados de 1 a 10. Usemap(). - Exercício 3: Crie um programa que use
Queuepara passar uma lista de números de um processo produtor para um consumidor, que calcula o dobro de cada número e imprime. - Exercício 4: Explique a diferença entre multiprocessing e threading para uma tarefa que envolve muitas operações de I/O (como baixar arquivos).
- Exercício 5: Escreva um código que use
Pipepara enviar uma mensagem de um processo pai para um filho e o filho responda com outra mensagem.
import multiprocessing
def quadrado(n):
return n * n
if __name__ == "__main__":
processos = []
for i in range(1, 6):
p = multiprocessing.Process(target=quadrado, args=(i,))
processos.append(p)
p.start()
for p in processos:
p.join()import multiprocessing
def quadrado(x):
return x * x
if __name__ == "__main__":
with multiprocessing.Pool() as pool:
resultados = pool.map(quadrado, range(1, 11))
soma = sum(resultados)
print("Soma dos quadrados:", soma)import multiprocessing
def produtor(fila, numeros):
for n in numeros:
fila.put(n)
fila.put(None) # fim
def consumidor(fila):
while True:
n = fila.get()
if n is None:
break
print(f"Dobro de {n} = {n * 2}")
if __name__ == "__main__":
fila = multiprocessing.Queue()
numeros = [1, 2, 3, 4, 5]
p1 = multiprocessing.Process(target=produtor, args=(fila, numeros))
p2 = multiprocessing.Process(target=consumidor, args=(fila,))
p1.start()
p2.start()
p1.join()
p2.join()import multiprocessing
def filho(conn):
msg = conn.recv()
print(f"Filho recebeu: {msg}")
conn.send("Oi pai!")
conn.close()
if __name__ == "__main__":
parent_conn, child_conn = multiprocessing.Pipe()
p = multiprocessing.Process(target=filho, args=(child_conn,))
p.start()
parent_conn.send("Olá filho!")
resposta = parent_conn.recv()
print(f"Pai recebeu: {resposta}")
p.join()