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ísticaThreadingMultiprocessing
MemóriaCompartilhadaSeparada
GILAfetadoNão afetado
Custo de criaçãoBaixoAlto
ComunicaçãoFácil (variáveis globais)Necessita de filas/pipes
Ideal paraI/O-boundCPU-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 Pool para muitas tarefas pequenas e Process para 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

Exercícios

  1. 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.
  2. ✓ Resposta: Uma possível solução:
    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")
  3. Implemente um produtor-consumidor usando Queue, onde o produtor gera números aleatórios e o consumidor calcula o quadrado. Execute com 2 processos.
  4. ✓ Resposta:
    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()
  5. 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.
  6. ✓ Resposta:
    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()
  7. Usando Value e Lock, crie um contador que seja incrementado por 4 processos, cada um incrementando 1000 vezes. Verifique se o resultado final é 4000.
  8. ✓ Resposta:
    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 == 4000
  9. 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.
  10. ✓ Resposta:
    import 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.