fork vs spawn vs forkserver: el start method importa

Cuando llamas a multiprocessing.Process(target=f).start(), Python necesita crear un proceso nuevo del sistema operativo. La pregunta que raramente te haces hasta que algo explota es: ¿cómo exactamente se crea ese proceso? Esa decisión se llama start method, y elegir mal puede darte desde deadlocks silenciosos hasta corrupción de memoria en producción.

Hay tres opciones: fork, spawn y forkserver. No son variantes estéticas del mismo mecanismo — son estrategias fundamentalmente distintas con trade-offs reales.

Cómo funciona cada uno internamente

fork llama directamente al syscall fork(2) del kernel. El proceso hijo recibe una copia completa del espacio de memoria del padre — heap, descriptores de archivo, mutexes, estado de librerías nativas, y todos los threads que existían en ese momento. El “copy-on-write” hace que sea rápido, pero hay un problema serio: fork solo copia el thread principal. Si el padre tenía threads activos (y casi siempre los tiene — GIL, logging, OpenSSL, librerías C como NumPy o SQLite), el hijo hereda esos objetos pero sin los threads que los mantenían vivos. Un mutex que estaba bloqueado en el padre sigue bloqueado en el hijo, y nadie lo va a desbloquear jamás. Deadlock garantizado, de forma intermitente y difícil de reproducir.

spawn no hereda nada. Lanza un intérprete Python completamente nuevo que importa el módulo de arranque desde cero, re-ejecuta el setup necesario, y solo entonces llama a tu función target. Es el equivalente a abrir una terminal nueva y ejecutar python -c "from mymodule import f; f()". Más lento (50–200ms por proceso dependiendo del tamaño del módulo), pero el estado es predecible: no hay mutexes heredados, no hay threads fantasma, no hay recursos del padre que se filtren al hijo.

forkserver es un híbrido. Al inicio del programa se lanza un proceso servidor auxiliar limpio (antes de que el proceso principal haga cualquier import pesado o cree threads). Cuando necesitas un proceso hijo, le pides al forkserver que haga el fork — el hijo hereda el estado del servidor, no del padre. Esto evita el problema del estado contaminado de fork, con menos overhead que spawn una vez que el servidor está en marcha.

Cuándo usar cada uno (y cuándo no tienes elección)

spawn es la opción segura por defecto hoy en día. Desde Python 3.12, macOS usa spawn como default (cambio de fork que tenía desde siempre), y Windows nunca tuvo fork. Si tu código mezcla multiprocessing con librerías como torch, cv2, grpc, o cualquier cosa que use threads internamente en C, spawn es lo que quieres aunque sea más lento.

fork tiene sentido solo cuando el proceso padre es simple (sin threads externos, sin librerías C con estado mutable), y necesitas lanzar miles de procesos con mínima latencia, o cuando heredar el estado del padre es exactamente lo que quieres — por ejemplo, un servidor que pre-calcula estructuras de datos grandes y luego hace fork para servir requests.

forkserver es el término medio razonable en Linux cuando necesitas más velocidad que spawn pero el padre ya está “contaminado” con threads antes de que empieces a crear procesos hijos.

El riesgo real de equivocarse: con fork y una librería no fork-safe, puedes obtener un programa que funciona el 95% del tiempo y falla de forma no reproducible en producción bajo carga. Con spawn y el código de arranque fuera de if __name__ == '__main__', obtienes recursión infinita de procesos que agota la memoria o golpea el límite de procesos del OS.

# main.py
import multiprocessing as mp
import logging
import time
import os
from typing import Any

# Configuración de logging que funciona con spawn
# (debe estar en una función, no en el módulo top-level, para evitar
#  que se configure dos veces cuando spawn re-importa este módulo)
def setup_logging() -> None:
    logging.basicConfig(
        level=logging.INFO,
        format="%(processName)s [%(process)d] %(message)s",
    )


def cpu_bound_task(n: int, result_queue: "mp.Queue[dict[str, Any]]") -> None:
    """Simula trabajo pesado; no comparte estado con el padre."""
    setup_logging()
    log = logging.getLogger(__name__)

    start = time.perf_counter()
    total = sum(i * i for i in range(n))
    elapsed = time.perf_counter() - start

    log.info(f"Suma de cuadrados hasta {n}: {total}")
    result_queue.put({"pid": os.getpid(), "result": total, "elapsed": elapsed})


def demonstrate_start_methods() -> None:
    setup_logging()
    log = logging.getLogger(__name__)

    for method in ("fork", "spawn", "forkserver"):
        # forkserver no está disponible en Windows
        try:
            ctx = mp.get_context(method)
        except ValueError:
            log.info(f"Método '{method}' no disponible en esta plataforma, saltando.")
            continue

        # Queue creada desde el mismo contexto — crítico con spawn/forkserver
        # porque los objetos de sincronización deben ser compatibles con el método
        queue: mp.Queue = ctx.Queue()  # type: ignore[type-arg]

        t0 = time.perf_counter()
        proc = ctx.Process(
            target=cpu_bound_task,
            args=(5_000_000, queue),
            name=f"Worker-{method}",
        )
        proc.start()
        proc.join()
        wall_time = time.perf_counter() - t0

        if not queue.empty():
            data = queue.get_nowait()
            log.info(
                f"[{method}] startup+trabajo={wall_time:.3f}s | "
                f"solo trabajo={data['elapsed']:.3f}s | pid={data['pid']}"
            )


# Todo lo que crea procesos DEBE estar bajo este guard con spawn.
# Con spawn, Python re-importa este módulo en cada proceso hijo;
# sin el guard, el hijo también llamaría a demonstrate_start_methods()
# y lanzaría más hijos, hasta colapsar el sistema.
if __name__ == "__main__":
    # Opcionalmente: fijar el método globalmente para todo el programa.
    # Debe llamarse antes de crear cualquier proceso o Pool.
    # mp.set_start_method("spawn")  # ← línea real si quieres forzarlo

    demonstrate_start_methods()

Qué está pasando aquí

Lo más importante del ejemplo es mp.get_context(method) en lugar de mp.set_start_method(). El contexto te da un namespace aislado: ctx.Process, ctx.Queue, ctx.Pool, etc. Todos los objetos de sincronización y los procesos creados desde el mismo contexto son compatibles entre sí. Mezclar un mp.Queue() del contexto global con un ctx.Process() de spawn es una fuente clásica de deadlocks porque los mecanismos de serialización y los pipes subyacentes pueden diferir.

Fíjate en que setup_logging() es una función y no se llama en el top-level del módulo. Con spawn, el proceso hijo re-importa main.py completamente. Si el logging (u otro código de inicialización) estuviera en el top-level, se ejecutaría tanto en el padre como en cada hijo, duplicando handlers o reinicializando recursos que no deberían reinicializarse.

La Queue se crea desde ctx.Queue() — el contexto — no desde mp.Queue(). Esto parece un detalle menor pero en la práctica, con spawn o forkserver, los objetos de sincronización tienen que ser “pickleable” y compatibles con el método de inicio. Los objetos del contexto garantizan esa compatibilidad.

El wall time que imprime el ejemplo te muestra algo interesante: con fork, el tiempo total es casi igual al tiempo de trabajo puro porque no hay overhead de arranque. Con spawn, verás 50–200ms adicionales de startup, tiempo en el que Python está importando módulos. Con forkserver, el segundo proceso es más rápido que el primero (el servidor ya está caliente).

Errores que debes conocer

Error: Llamar a mp.set_start_method() más de una vez — o llamarlo después de que ya existen procesos — lanza RuntimeError porque el estado global del módulo ya está fijado.

# ❌ Wrong
def create_workers():
    mp.set_start_method("spawn")  # Falla si ya fue llamado antes
    return mp.Pool(4)

# ✅ Right
if __name__ == "__main__":
    mp.set_start_method("spawn")  # Una sola vez, al inicio del programa
    create_workers()

Usa set_start_method("spawn", force=True) solo en tests donde necesitas cambiar el método entre pruebas; en código de producción, el método debe fijarse una vez y no cambiar.


Error: Heredar un lock de threading a través de fork cuando el lock está adquirido en otro thread del padre.

# ❌ Wrong — con fork, el hijo hereda el lock en estado "bloqueado"
import threading
import multiprocessing as mp  # default fork en Linux

lock = threading.Lock()

def worker():
    lock.acquire()  # deadlock: el lock ya estaba acquired en el padre
    print("Esto nunca se imprime")
    lock.release()

# En el padre, otro thread adquirió el lock justo antes del fork
t = threading.Thread(target=lambda: lock.acquire())
t.start(); t.join()

p = mp.Process(target=worker)
p.start(); p.join()  # cuelga para siempre

# ✅ Right — usa spawn para evitar heredar estado de threads
ctx = mp.get_context("spawn")
p = ctx.Process(target=worker)
p.start(); p.join()

Con spawn, el hijo no hereda nada del padre, así que el lock en el hijo empieza limpio y acquire() funciona correctamente.


Error: No pasar la Queue como argumento explícito con spawn, sino referenciarla como global del módulo.

# ❌ Wrong — con spawn, las variables globales no se comparten; el hijo
# tiene su propia copia desconectada del objeto
result_queue = mp.Queue()

def worker():
    result_queue.put(42)  # va a una Queue diferente en el espacio del hijo

mp.Process(target=worker).start()
print(result_queue.get())  # bloquea para siempre

# ✅ Right — pasa el objeto como argumento para que se picklee y transfiera
def worker(q):
    q.put(42)

result_queue = mp.Queue()
mp.Process(target=worker, args=(result_queue,)).start()
print(result_queue.get())  # 42

Con spawn, el hijo importa el módulo fresco; la variable result_queue en el hijo es un objeto completamente distinto. Solo los objetos pasados como argumentos (y por tanto picklizados y enviados al hijo) son los mismos.


El hecho de que macOS haya cambiado el default en 3.12 no es cosmético: el equipo de CPython decidió que los bugs de fork con estado de threads eran demasiado comunes y sutiles para seguir siendo el default en un OS que lleva años recomendando spawn. Si tu código lleva tiempo en producción en macOS y empieza a fallar al actualizar Python, este cambio es el primer sospechoso.

145

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio