52 · Multiprocessing: paralelismo real para trabajo CPU-bound
Cada Process es un intérprete Python completo — verdadero paralelismo de CPU que evita el GIL por completo. Aprende Process, Pool.map, fork vs spawn y el guard __main__ obligatorio.
`multiprocessing` genera procesos reales del OS — cada uno con su propio intérprete Python y espacio de memoria. Esto evita el GIL por completo, habilitando verdadero paralelismo de CPU en todos los núcleos. La compensación: todo lo que se pase entre procesos debe serializarse (el protocolo `marshal` de Python maneja esto), añadiendo overhead que hace que las tareas pequeñas sean más lentas en paralelo que en secuencial. `Pool(n).map(fn, items)` es el one-liner que convierte trabajo embarazosamente paralelo en speedup genuino.
Sin esto:
Sin `multiprocessing` (o vectorización C/numpy) no puedes usar más de un núcleo de CPU para cómputo Python — el GIL lo garantiza. Una máquina de 4 núcleos corriendo un cómputo numérico puro de Python usa el 25% de su hardware. `Pool(4)` puede llevar eso a casi el 100%.
¿Por qué un nuevo proceso en lugar de un nuevo hilo?
Cada multiprocessing.Process es un proceso OS completo: obtiene su propio intérprete Python, su propio GIL, su propio heap y su propia copia de cada import de módulo. Este aislamiento es lo que hace posible el verdadero paralelismo — dos procesos ejecutan bytecode Python simultáneamente en dos núcleos de CPU separados sin contención del GIL.
Los costos:
- Tiempo de inicio — hacer fork o spawn de un proceso tarda ~30–100 ms. No parallelices tareas más cortas que esto.
- Memoria — cada proceso copia la memoria del padre (en Unix vía copy-on-write, pero las escrituras divergen). 4 workers × 1 GB de modelo = 4 GB de RAM.
- Serialización — todos los argumentos y valores de retorno cruzan los límites del proceso como bytes serializados. Las funciones y arrays numpy grandes deben ser serializables.
Fork vs spawn
Unix te da dos formas de crear un proceso hijo:
- fork (predeterminado en Linux/macOS < 3.12) — el OS copia todo el mapa de memoria del proceso padre instantáneamente usando copy-on-write. Rápido, pero los descriptores de archivo abiertos heredados, las conexiones de base de datos y los locks de hilos pueden causar corrupción silenciosa en el hijo.
- spawn (predeterminado en Windows, predeterminado en todos lados desde 3.12+) — el hijo comienza con un intérprete Python limpio y vuelve a importar todo desde cero. Más lento pero seguro. Preferido para producción.
multiprocessing.set_start_method("spawn") fuerza spawn en cualquier plataforma.
El guard if __name__ == "__main__":
Con el método de inicio spawn, Python genera un nuevo intérprete e importa tu script para crear el proceso hijo. Si tu script llama inmediatamente a Pool(4).map(...) a nivel de módulo, el proceso hijo hace lo mismo — generando 4 hijos más, que cada uno genera 4 más, para siempre. El guard detiene esto:
if __name__ == "__main__": # solo corre en el padre, no en los hijos generados
with Pool(4) as pool:
results = pool.map(my_fn, range(100))
En Linux con fork, el guard es técnicamente opcional porque los hijos forked no re-ejecutan el código a nivel de módulo — pero escribirlo de todas formas es una buena práctica (tu código permanece portable a Windows y Python 3.12+).
Observa `p1.start(); p2.start()` antes de cualquier `.join()` — esto permite que ambos procesos corran concurrentemente. Si llamaras a `p1.start(); p1.join()` antes de crear `p2`, los procesos correrían secuencialmente.
`Pool(4)` usado como context manager llama automáticamente a `.terminate()` y `.join()` al salir. `pool.map(fn, iterable)` es un map paralelo síncrono — se bloquea hasta que todos los resultados estén listos. Para iterables grandes, `pool.imap(fn, iterable)` transmite resultados de forma perezosa. `pool.starmap(fn, [(a1, b1), ...])` pasa múltiples argumentos por tarea.
El tl;dr sobre los métodos de inicio: usa `spawn` en cualquier código que quieras correr en Windows o Python 3.12+. Si estás en Linux y spawn es demasiado lento, `forkserver` es una alternativa más segura a `fork` para padres multi-hilo.
El speedup esperado es `n_cores - epsilon` — no exactamente `n_cores` porque el inicio del proceso, el overhead de serialización IPC y el scheduling del OS consumen el máximo teórico. Para tareas pequeñas (< ~10 ms cada una), el overhead domina y podrías ver una ralentización en lugar de una aceleración.
¿Qué ocurre si olvidas el guard `if __name__ == '__main__':` al usar `multiprocessing.Pool` con el método de inicio `spawn` en Windows?
- `multiprocessing.Process` crea un proceso OS real con su propio intérprete y GIL — habilitando verdadero paralelismo de CPU. Cada proceso inicia con `fork` (Linux, rápido pero inseguro para padres multi-hilo) o `spawn` (Windows/3.12+, lento pero seguro).
- Siempre envuelve el punto de entrada en `if __name__ == '__main__':` — sin él, los procesos hijo basados en `spawn` re-importan el módulo y re-ejecutan la llamada `Pool(...)`, creando un bucle de spawn infinito.
- `Pool(n).map(fn, iterable)` es la API de alto nivel para trabajo embarazosamente paralelo — distribuye tareas entre n workers y recopila resultados en orden.
- Todo lo que cruce los límites del proceso debe ser serializable. El overhead de IPC hace que el paralelismo sea contraproducente para tareas más cortas de ~10 ms.
- `multiprocessing` NO funciona en el sandbox del navegador de Pyodide (no hay fork/spawn en WebAssembly). Ejecuta código de multiprocessing localmente.
El parámetro `n_jobs=-1` de scikit-learn (en `GridSearchCV`, `RandomForestClassifier`, `cross_val_score`, etc.) llama a `joblib.Parallel(n_jobs=-1)`, que usa multiprocessing internamente en la mayoría de las plataformas. El parámetro `n_jobs` de XGBoost y LightGBM, las pipelines de extracción de características en paralelo y la inferencia offline en batch sobre grandes datasets todos dependen de `multiprocessing.Pool`. Cuando ejecutas `RandomForestClassifier(n_estimators=200, n_jobs=4)`, cada uno de los 4 workers construye 50 árboles independientemente — una carga de trabajo embarazosamente paralela de libro.
Si lo quitas: Sin multiprocessing, cada carga de trabajo ML CPU-bound es de un solo hilo. La búsqueda de grilla sobre 1000 combinaciones de hiperparámetros tarda 10 horas en lugar de 2.5 en un portátil de 4 núcleos. La validación cruzada sobre 10 folds corre secuencialmente en lugar de en paralelo. Todos los parámetros `n_jobs` en scikit-learn se vuelven sin sentido.