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 Pool para tarefas independentes e Process para controle fino.
  • Evitar compartilhar estado entre processos; prefira passar dados via Queue ou Pipe.
  • 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

Exercícios

  1. Exercício 1: Escreva um programa que use multiprocessing.Process para calcular o quadrado de números de 1 a 5, cada um em um processo separado. Imprima os resultados.
  2. ✓ Resposta:
    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()
  3. Exercício 2: Use multiprocessing.Pool para calcular a soma dos quadrados de 1 a 10. Use map().
  4. ✓ Resposta:
    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)
  5. Exercício 3: Crie um programa que use Queue para passar uma lista de números de um processo produtor para um consumidor, que calcula o dobro de cada número e imprime.
  6. ✓ Resposta:
    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()
  7. 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).
  8. ✓ Resposta: Para tarefas I/O-bound, threading é geralmente mais eficiente porque as threads são mais leves e o GIL é liberado durante operações de I/O, permitindo que várias threads esperem por I/O simultaneamente sem bloquear a CPU. Multiprocessing também funciona, mas tem overhead maior de criação e comunicação, tornando-o menos eficiente para muitas tarefas I/O-bound.
  9. Exercício 5: Escreva um código que use Pipe para enviar uma mensagem de um processo pai para um filho e o filho responda com outra mensagem.
  10. ✓ Resposta:
    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()