Cuando el GIL te deja sin opciones para paralelismo real en CPU-bound work, multiprocessing es la respuesta obvia. Pero “usar multiprocessing” puede significar cosas muy distintas según la herramienta que elijas: un Process individual te da control total sobre el ciclo de vida de un proceso concreto, mientras que un Pool abstrae la gestión de workers y te deja enfocarte en distribuir trabajo.
La distinción importante es esta: Process modela un proceso como unidad de ejecución —lo creas, lo arrancas, esperas que termine—. Pool modela un grupo de workers reutilizables que procesan una cola de tareas; los procesos se crean una sola vez y reciben trabajo de forma continua, evitando el overhead de spawn por cada tarea.
Ambos trabajan serializando argumentos y resultados con pickle, lo que tiene implicaciones directas: solo pueden cruzar la frontera de proceso los objetos serializables. Si tu función target o sus argumentos no son picklables, el proceso hijo falla en silencio o lanza un error críptico.
Process tiene sentido cuando tienes uno o pocos procesos con lógica heterogénea, necesitas controlar explícitamente cuándo arrancan y cuándo bloqueas esperando, o quieres acceder a atributos como pid o exitcode. Pool es la herramienta cuando tienes un conjunto homogéneo de ítems que procesar en paralelo y quieres que Python gestione la distribución.
Lo que rompe cuando te equivocas: olvidar join() en un Process deja procesos zombie. No usar Pool como context manager puede dejar workers activos si ocurre una excepción, consumiendo memoria indefinidamente. Y mezclar pool.map cuando necesitas resultados incrementales —cuando cada resultado pesa mucho o la lista es enorme— bloquea innecesariamente y revienta la memoria.
from __future__ import annotations
import math
import time
from multiprocessing import Pool, Process
# ── Tarea CPU-bound representativa ────────────────────────────────────
def is_prime(n: int) -> bool:
"""Prueba de primalidad ingenua; pura CPU, sin I/O."""
if n < 2:
return False
for i in range(2, int(math.isqrt(n)) + 1):
if n % i == 0:
return False
return True
def count_primes_in_range(start: int, end: int) -> int:
"""Función con múltiples argumentos para pool.starmap."""
return sum(1 for n in range(start, end) if is_prime(n))
def worker_with_callback(n: int) -> int:
"""Retorna el cuadrado; úsala con apply_async para ver callbacks."""
time.sleep(0.05) # simula trabajo variable
return n * n
# ── 1. Process individual ──────────────────────────────────────────────
def demo_single_process() -> None:
def report(label: str, limit: int) -> None:
count = sum(1 for n in range(2, limit) if is_prime(n))
print(f"[{label}] primes < {limit}: {count}")
p = Process(target=report, args=("worker-A", 500_000))
p.start()
# join() bloquea este proceso hasta que p termine;
# sin él, el proceso padre puede terminar antes que el hijo.
p.join()
print(f"Proceso terminó con exitcode={p.exitcode}")
# ── 2. Pool.map — eager, retorna lista completa ────────────────────────
def demo_pool_map() -> None:
numbers = range(2, 100_001)
# context manager garantiza pool.terminate() + pool.join() al salir,
# incluso si ocurre una excepción dentro del bloque.
with Pool(processes=4) as pool:
results: list[bool] = pool.map(is_prime, numbers)
prime_count = sum(results)
print(f"pool.map → primes en [2, 100_000]: {prime_count}")
# ── 3. Pool.starmap — múltiples argumentos ────────────────────────────
def demo_pool_starmap() -> None:
# Dividimos [0, 1_000_000) en 8 rangos para 8 workers
chunk = 125_000
ranges = [(i * chunk, (i + 1) * chunk) for i in range(8)]
with Pool(processes=4) as pool:
# starmap desempaqueta cada tupla como argumentos posicionales
partial_counts: list[int] = pool.starmap(count_primes_in_range, ranges)
print(f"pool.starmap → total primes: {sum(partial_counts)}")
# ── 4. Pool.imap — lazy, iterator de resultados ───────────────────────
def demo_pool_imap() -> None:
numbers = range(2, 100_001)
with Pool(processes=4) as pool:
# imap retorna un iterator; los resultados llegan en orden de entrada
# pero no espera a que todos estén listos antes de devolver el primero.
# chunksize agrupa ítems por lote para reducir overhead de IPC.
prime_iter = pool.imap(is_prime, numbers, chunksize=500)
prime_count = sum(prime_iter) # consumimos el iterator aquí
print(f"pool.imap → primes en [2, 100_000]: {prime_count}")
# ── 5. Pool.apply_async — no bloqueante con callback ──────────────────
def demo_apply_async() -> None:
results = []
def on_result(value: int) -> None:
# Este callback se ejecuta en el proceso padre, en el hilo del pool
results.append(value)
with Pool(processes=4) as pool:
async_results = [
pool.apply_async(worker_with_callback, args=(n,), callback=on_result)
for n in range(10)
]
# get() en cada AsyncResult bloquea hasta obtener el valor individual;
# úsalo cuando necesites el resultado concreto o capturar excepciones.
for ar in async_results:
ar.get(timeout=5)
print(f"apply_async → squares: {sorted(results)}")
if __name__ == "__main__":
# La guarda __main__ es obligatoria en sistemas con spawn (Windows, macOS);
# sin ella, cada worker re-ejecuta el módulo y entra en recursión infinita.
demo_single_process()
demo_pool_map()
demo_pool_starmap()
demo_pool_imap()
demo_apply_async()
Desglose del código
Process en demo_single_process ilustra el ciclo de vida mínimo viable: instancia, start(), join(). La función report existe dentro del cuerpo de demo_single_process solo por claridad del ejemplo —en producción evita lambdas y funciones locales como targets porque pickle no puede serializarlas en todos los contextos de spawn.
El uso de Pool como context manager en todas las demos no es ceremonial. Cuando el bloque with termina —limpiamente o por excepción—, Python llama pool.__exit__, que invoca pool.terminate() seguido de pool.join(). Sin eso, los worker processes quedan vivos hasta que el garbage collector se decida, y en scripts de larga duración eso es una fuga real.
La diferencia entre pool.map e pool.imap es de estrategia de memoria, no de corrección. map serializa toda la lista de resultados en memoria antes de retornar. Si procesas un millón de imágenes y cada resultado es un array NumPy grande, eso es un problema serio. imap con un chunksize razonable —entre 100 y 1000 según el overhead de tu función— reduce el tráfico IPC agrupando ítems sin cargar todo en RAM de golpe.
pool.starmap es simplemente azúcar sobre map para funciones con aridad > 1. La alternativa manual sería empaquetar los argumentos en tuplas y usar map con una función wrapper que los desempaquete, lo cual es exactamente lo que starmap hace por ti.
apply_async con callback es la vía cuando necesitas procesar resultados mientras llegan en lugar de esperar el lote completo. El callback se ejecuta en el proceso padre, en el hilo interno que maneja el pool —así que si mutás estado compartido dentro del callback, asegúrate de que sea thread-safe (una list.append básica lo es por el GIL, pero operaciones compuestas no).
Errores que debes conocer
Error: Olvidar la guarda if __name__ == "__main__" en sistemas con método de inicio spawn (Windows y macOS por defecto desde Python 3.8). Cuando un worker importa el módulo principal, vuelve a ejecutar el código de nivel superior y crea un nuevo Pool, que a su vez crea más workers, en recursión hasta agotar recursos.
# ❌ Wrong
pool = Pool(4)
results = pool.map(is_prime, range(1000))
# ✅ Right
if __name__ == "__main__":
with Pool(4) as pool:
results = pool.map(is_prime, range(1000))
La guarda asegura que solo el proceso original ejecuta el código de orquestación; los workers importan el módulo pero no entran en ese bloque.
Error: Llamar pool.map dentro de un bloque with Pool(...) as pool y después intentar usar pool fuera del bloque. El pool ya está cerrado y cualquier llamada lanza ValueError: Pool not running.
# ❌ Wrong
with Pool(4) as pool:
pass # pool se cierra aquí
results = pool.map(is_prime, range(1000)) # ValueError
# ✅ Right
with Pool(4) as pool:
results = pool.map(is_prime, range(1000))
# aquí 'results' ya está disponible, 'pool' no se necesita más
Todo el trabajo con el pool debe ocurrir dentro del bloque with; los resultados son datos ordinarios que viven fuera sin problema.
Error: Ignorar el valor de chunksize=1 (el defecto de imap) con listas grandes. Cada ítem genera un mensaje IPC separado, y el overhead de serialización/deserialización domina sobre el trabajo real para funciones rápidas.
# ❌ Wrong — chunksize=1 por defecto, 100_000 mensajes IPC
with Pool(4) as pool:
results = list(pool.imap(is_prime, range(100_000)))
# ✅ Right — agrupa en lotes de 500, reduce IPC ~500x
with Pool(4) as pool:
results = list(pool.imap(is_prime, range(100_000), chunksize=500))
El chunksize óptimo depende del ratio entre tiempo de ejecución por ítem y latencia IPC; para funciones de microsegundos, valores entre 500 y 5000 suelen ser razonables.
N° 143