Crea Multiples Procesos En Python

Alex Jimenez
Alex Jimenez
Nov 22, 2024


Crea Multiples Procesos En Python

Cuando tu programa Python se arrastra como tortuga mientras tu procesador apenas suda, probablemente estés dejando sobre la mesa un montón de potencia sin usar. La mayoría de computadoras modernas tienen múltiples núcleos esperando trabajar, pero el código Python estándar solo usa uno. Ahí es donde Crea Múltiples Procesos en Python: Guía Práctica se convierte en tu mejor aliado para aprovechar todo ese poder dormido.

El módulo multiprocessing de Python esquiva elegantemente el famoso Global Interpreter Lock (GIL), ese guardián que impide que los hilos ejecuten código Python simultáneamente. Al crear procesos separados en lugar de hilos, cada uno obtiene su propio intérprete de Python y puede correr en paralelo real.

En esta guía práctica aprenderás a crear múltiples procesos de manera efectiva, desde los conceptos básicos hasta técnicas avanzadas que transformarán tus programas lentos en máquinas de velocidad.

Por Qué Necesitas Múltiples Procesos

¿Alguna vez has visto a un chef trabajando solo en una cocina enorme con cinco estufas? Así trabaja Python por defecto: un solo trabajador con muchos recursos disponibles.

El paralelismo basado en procesos resuelve este problema dividiendo el trabajo entre múltiples trabajadores independientes. Cada proceso tiene su propio espacio de memoria y su propio intérprete de Python.

A diferencia del threading, donde el GIL limita la ejecución simultánea, los procesos múltiples realmente corren en paralelo. Esto es especialmente valioso para tareas que consumen mucha CPU como procesamiento de imágenes, cálculos matemáticos complejos o análisis de datos masivos.

La diferencia de rendimiento puede ser dramática. Un programa que tarda 10 minutos con un solo proceso puede completarse en 2 minutos con 5 procesos bien implementados.

La Clase Process: Tu Primer Proceso

La forma más básica de crear procesos en Python es usando la clase Process. Su sintaxis es sorprendentemente similar a la de threading, lo que facilita la transición.

Aquí está el ejemplo más simple posible:

from multiprocessing import Process

def saludar(nombre):
    print(f'Hola {nombre}, soy un proceso separado')

if __name__ == '__main__':
    p = Process(target=saludar, args=('María',))
    p.start()
    p.join()

Fíjate en el bloque if __name__ == '__main__':. No es decoración, es absolutamente necesario. Sin él, tu programa entrará en un bucle infinito creando procesos que crean más procesos.

💡 Si estás dando tus primeros pasos en inteligencia artificial y te preguntas cómo funcionan realmente los algoritmos que aprenden de los datos, te recomiendo explorar qué es el machine learning y cómo se aplica en la práctica para entender los fundamentos de esta tecnología transformadora.

El método start() inicia el proceso de manera asíncrona. El proceso padre continúa ejecutándose inmediatamente después de llamar a start().

El método join() hace que el proceso padre espere a que el hijo termine. Sin join(), el padre podría terminar antes que el hijo, dejando procesos huérfanos.

Pool: El Poder del Paralelismo de Datos

Cuando necesitas ejecutar la misma función con diferentes datos, Pool es tu mejor amigo. Este objeto crea múltiples procesos automáticamente y distribuye el trabajo entre ellos.

from multiprocessing import Pool

def calcular_cuadrado(numero):
    return numero * numero

if __name__ == '__main__':
    numeros = [1, 2, 3, 4, 5, 6, 7, 8]
    
    with Pool(4) as pool:
        resultados = pool.map(calcular_cuadrado, numeros)
    
    print(resultados)  # [1, 4, 9, 16, 25, 36, 49, 64]

El argumento 4 en Pool(4) especifica cuántos procesos trabajadores crear. Una buena regla general es usar el número de núcleos de tu CPU.

El método map() distribuye automáticamente los datos entre los procesos disponibles. Es como tener varios empleados procesando facturas simultáneamente en lugar de uno solo.

¿Necesitas más control? Pool ofrece otros métodos útiles:

MétodoDescripciónCuándo Usarlo
map()Aplica función a lista completaDatos simples, orden importante
imap()Versión iteradora de mapListas grandes, procesar mientras llegan resultados
apply()Ejecuta función con argumentosUna sola tarea, bloqueante
apply_async()Versión asíncrona de applyUna tarea, sin bloquear
starmap()Como map pero con múltiples argumentosFunciones con varios parámetros

La sintaxis with Pool(4) as pool: garantiza que los procesos se cierren correctamente, incluso si ocurre un error. Siempre úsala.

Comunicación Entre Procesos: Queue y Pipe

Los procesos no comparten memoria como los hilos. Necesitas mecanismos específicos para que múltiples procesos se comuniquen entre sí.

Queue: La Cola Segura

Queue funciona como una cola en el supermercado: primero en entrar, primero en salir. Es thread-safe y process-safe, perfecto para comunicación entre procesos.

💡 Si te has preguntado alguna vez qué tecnologías impulsan las plataformas más grandes del mundo, descubrir qué lenguaje utiliza realmente Facebook en su infraestructura te dará una perspectiva fascinante sobre cómo Python y otros lenguajes coexisten en ecosistemas de alto rendimiento.

from multiprocessing import Process, Queue

def productor(cola, items):
    for item in items:
        cola.put(item)
        print(f'Producido: {item}')
    cola.put(None)  # Señal de terminación

def consumidor(cola):
    while True:
        item = cola.get()
        if item is None:
            break
        print(f'Consumido: {item}')

if __name__ == '__main__':
    cola = Queue()
    items = ['manzana', 'banana', 'naranja']
    
    p1 = Process(target=productor, args=(cola, items))
    p2 = Process(target=consumidor, args=(cola,))
    
    p1.start()
    p2.start()
    
    p1.join()
    p2.join()

El patrón productor-consumidor es extremadamente común cuando trabajas con procesos múltiples. Un proceso genera datos mientras otro los procesa.

Pipe: Comunicación Directa

Pipe crea una conexión bidireccional entre dos procesos. Es más rápido que Queue pero solo funciona entre dos procesos.

from multiprocessing import Process, Pipe

def enviar_mensajes(conn):
    conn.send('Mensaje desde el proceso hijo')
    conn.close()

if __name__ == '__main__':
    parent_conn, child_conn = Pipe()
    p = Process(target=enviar_mensajes, args=(child_conn,))
    p.start()
    
    print(parent_conn.recv())
    p.join()

Cada extremo del Pipe tiene métodos send() y recv(). Simple pero efectivo para comunicación punto a punto.

Compartir Estado: Manager y Value

A veces necesitas que los procesos compartan datos. Python ofrece varias opciones, cada una con sus ventajas.

Manager: Objetos Compartidos Complejos

Manager permite compartir listas, diccionarios y otros objetos entre procesos:

from multiprocessing import Process, Manager

def agregar_a_lista(lista_compartida, valor):
    lista_compartida.append(valor)

💡 Si estás dando tus primeros pasos en programación o buscas reforzar conceptos fundamentales, te resultará fascinante explorar [cómo implementar la secuencia de Fibonacci en Python paso a paso](/tutoriales-python/serie-de-fibonacci-en-python/), un ejercicio clásico que te ayudará a dominar bucles, recursividad y optimización de código de manera práctica.

if __name__ == '__main__':
    with Manager() as manager:
        lista = manager.list()
        
        procesos = []
        for i in range(5):
            p = Process(target=agregar_a_lista, args=(lista, i))
            procesos.append(p)
            p.start()
        
        for p in procesos:
            p.join()
        
        print(list(lista))  # [0, 1, 2, 3, 4] (orden puede variar)

Los objetos Manager tienen un costo de rendimiento porque cada operación requiere comunicación entre procesos. Úsalos cuando realmente necesites compartir estructuras complejas.

Value y Array: Variables Compartidas Simples

Para tipos de datos simples, Value y Array son más eficientes:

from multiprocessing import Process, Value, Array

def incrementar(contador, arr):
    contador.value += 1
    for i in range(len(arr)):
        arr[i] *= 2

if __name__ == '__main__':
    contador = Value('i', 0)  # 'i' = entero
    numeros = Array('d', [1.0, 2.0, 3.0])  # 'd' = double
    
    procesos = [Process(target=incrementar, args=(contador, numeros)) 
                for _ in range(3)]
    
    for p in procesos:
        p.start()
    for p in procesos:
        p.join()
    
    print(f'Contador: {contador.value}')
    print(f'Array: {numeros[:]}')

Nota el uso de .value para acceder al valor real. El tipo se especifica con códigos de un carácter como 'i' (integer), 'd' (double), 'f' (float).

Sincronización: Lock y Semaphore

Cuando múltiples procesos acceden a recursos compartidos, necesitas sincronización para evitar condiciones de carrera.

Lock: El Guardián de Recursos

Un Lock garantiza que solo un proceso acceda a un recurso a la vez:

from multiprocessing import Process, Lock, Value

def incrementar_con_lock(contador, lock, veces):
    for _ in range(veces):
        with lock:
            contador.value += 1

def incrementar_sin_lock(contador, veces):
    for _ in range(veces):
        contador.value += 1

if __name__ == '__main__':
    # Con lock
    contador1 = Value('i', 0)
    lock = Lock()
    procesos = [Process(target=incrementar_con_lock, 
                        args=(contador1, lock, 1000)) 
                for _ in range(10)]
    
    for p in procesos:
        p.start()
    for p in procesos:
        p.join()
    
    print(f'Con lock: {contador1.value}')  # 10000
    
    # Sin lock
    contador2 = Value('i', 0)
    procesos = [Process(target=incrementar_sin_lock, 
                        args=(contador2, 1000)) 
                for _ in range(10)]
    
    for p in procesos:
        p.start()
    for p in procesos:
        p.join()
    
    print(f'Sin lock: {contador2.value}')  # Probablemente < 10000

💡 Si estás trabajando con funciones en Python y necesitas modificar variables fuera de su ámbito local, te recomiendo explorar cómo usar la palabra clave global correctamente en Python para evitar errores comunes y escribir código más limpio y mantenible.

Sin Lock, obtendrás resultados impredecibles. Con Lock, el resultado es siempre correcto pero más lento.

Semaphore: Control de Acceso Limitado

Semaphore permite que un número limitado de procesos accedan a un recurso simultáneamente:

from multiprocessing import Process, Semaphore
import time

def usar_recurso(sem, id_proceso):
    with sem:
        print(f'Proceso {id_proceso} usando recurso')
        time.sleep(2)
        print(f'Proceso {id_proceso} liberó recurso')

if __name__ == '__main__':
    sem = Semaphore(2)  # Máximo 2 procesos simultáneos
    
    procesos = [Process(target=usar_recurso, args=(sem, i)) 
                for i in range(5)]
    
    for p in procesos:
        p.start()
    for p in procesos:
        p.join()

Imagina un semáforo como una sala con capacidad limitada. Solo dos procesos pueden estar “dentro” al mismo tiempo.

Métodos de Inicio: spawn, fork y forkserver

Python ofrece tres métodos para crear procesos, cada uno con características diferentes:

spawn: Inicia un proceso completamente nuevo de Python. Es el método más seguro pero también el más lento. Es el predeterminado en Windows y macOS.

fork: Copia el proceso padre completo. Rápido pero puede causar problemas con ciertos recursos. Solo disponible en Unix/Linux.

forkserver: Inicia un proceso servidor que luego crea nuevos procesos. Equilibrio entre seguridad y velocidad. Solo en Unix/Linux.

import multiprocessing as mp

def funcion_ejemplo():
    print('Hola desde el proceso')

if __name__ == '__main__':
    mp.set_start_method('spawn')  # o 'fork' o 'forkserver'
    
    p = mp.Process(target=funcion_ejemplo)
    p.start()
    p.join()

En general, usa spawn para máxima compatibilidad. Usa fork si necesitas velocidad y estás solo en Linux. Usa forkserver cuando fork cause problemas.

💡 Si estás dando tus primeros pasos en programación, comprender la base es fundamental: te recomiendo explorar esta guía completa sobre los tipos de datos fundamentales en Python para dominar strings, listas, diccionarios y más desde cero.

Casos de Uso Prácticos

Veamos ejemplos reales donde crear múltiples procesos marca la diferencia.

Procesamiento de Imágenes

from multiprocessing import Pool
from PIL import Image
import os

def procesar_imagen(ruta):
    img = Image.open(ruta)
    img_pequeña = img.resize((100, 100))
    nombre = os.path.basename(ruta)
    img_pequeña.save(f'thumbnails/{nombre}')
    return nombre

if __name__ == '__main__':
    imagenes = ['foto1.jpg', 'foto2.jpg', 'foto3.jpg', 'foto4.jpg']
    
    with Pool() as pool:
        resultados = pool.map(procesar_imagen, imagenes)
    
    print(f'Procesadas: {resultados}')

Este código procesa múltiples imágenes en paralelo. En un solo núcleo tardaría 4 veces más.

Web Scraping Masivo

from multiprocessing import Pool
import requests

def descargar_pagina(url):
    try:
        respuesta = requests.get(url, timeout=5)
        return len(respuesta.content)
    except:
        return 0

if __name__ == '__main__':
    urls = [
        'https://ejemplo1.com',
        'https://ejemplo2.com',
        'https://ejemplo3.com',
        # ... muchas más URLs
    ]
    
    with Pool(10) as pool:
        tamaños = pool.map(descargar_pagina, urls)
    
    print(f'Total descargado: {sum(tamaños)} bytes')

El scraping paralelo es ideal porque pasas más tiempo esperando respuestas de red que procesando.

Cálculos Científicos

from multiprocessing import Pool
import numpy as np

def calcular_estadisticas(datos):
    return {
        'media': np.mean(datos),
        'std': np.std(datos),
        'max': np.max(datos)
    }

if __name__ == '__main__':
    # Simular múltiples conjuntos de datos
    conjuntos = [np.random.rand(1000000) for _ in range(8)]
    
    with Pool() as pool:
        resultados = pool.map(calcular_estadisticas, conjuntos)
    
    for i, stats in enumerate(resultados):
        print(f'Conjunto {i}: {stats}')

Los cálculos numéricos intensivos se benefician enormemente del paralelismo basado en procesos.

💡 Si estás dando tus primeros pasos en programación, entender cómo funcionan los tipos de datos y variables en Python te ayudará a construir una base sólida para desarrollar scripts más complejos y eficientes desde el principio.

Errores Comunes y Cómo Evitarlos

Olvidar el if name == ‘main

Este es el error número uno. Sin este bloque, especialmente en Windows, crearás procesos infinitos:

# MAL - Causará problemas
from multiprocessing import Process

def trabajar():
    print('Trabajando')

p = Process(target=trabajar)
p.start()

# BIEN
if __name__ == '__main__':
    p = Process(target=trabajar)
    p.start()
    p.join()

Intentar Compartir Objetos No Serializables

Los procesos solo pueden compartir objetos que se puedan serializar con pickle:

# MAL - Las funciones lambda no se pueden serializar
with Pool() as pool:
    pool.map(lambda x: x*2, [1, 2, 3])  # Error

# BIEN - Función definida normalmente
def duplicar(x):
    return x * 2

with Pool() as pool:
    pool.map(duplicar, [1, 2, 3])

No Cerrar Pools Correctamente

Siempre cierra tus pools para liberar recursos:

# BIEN - Cierre automático
with Pool() as pool:
    resultados = pool.map(funcion, datos)

# TAMBIÉN BIEN - Cierre manual
pool = Pool()
try:
    resultados = pool.map(funcion, datos)
finally:
    pool.close()
    pool.join()

💡 Si estás comenzando en programación o quieres dominar ambos lenguajes simultáneamente, te recomiendo explorar esta guía comparativa entre Python y JavaScript donde descubrirás sus diferencias clave, casos de uso ideales y cómo aprovechar lo mejor de cada uno según tu proyecto.

Optimización y Mejores Prácticas

Elige el número correcto de procesos. Más no siempre es mejor. Usa multiprocessing.cpu_count() como referencia:

import multiprocessing as mp

num_procesos = mp.cpu_count()
print(f'CPUs disponibles: {num_procesos}')

# Para tareas CPU-bound
with Pool(num_procesos) as pool:
    pass

# Para tareas I/O-bound, puedes usar más
with Pool(num_procesos * 2) as pool:
    pass

Minimiza la comunicación entre procesos. Cada intercambio tiene un costo. Diseña tu código para que los procesos trabajen independientemente.

Usa chunksize con map(). Para listas grandes, especificar el tamaño de fragmento mejora el rendimiento:

datos_grandes = list(range(10000))

# Más eficiente para listas grandes
with Pool() as pool:
    resultados = pool.map(funcion, datos_grandes, chunksize=100)

Considera concurrent.futures. Para casos simples, ProcessPoolExecutor ofrece una interfaz más moderna:

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=4) as executor:
    resultados = executor.map(funcion, datos)

La biblioteca concurrent.futures proporciona una API de más alto nivel que puede ser más fácil de usar para principiantes.

Monitoreo y Debugging

Depurar múltiples procesos puede ser complicado. Aquí algunas técnicas útiles:

from multiprocessing import Process, current_process
import os
import time

def proceso_con_info(nombre):
    proceso = current_process()
    print(f'Proceso: {proceso.name}')
    print(f'PID: {os.getpid()}')
    print(f'PID Padre: {os.getppid()}')
    time.sleep(2)
    print(f'{nombre} completado')

if __name__ == '__main__':
    procesos = []
    for i in range(3):
        p = Process(target=proceso_con_info, 
                    args=(f'Tarea-{i}',),
                    name=f'Proceso-{i}')
        procesos.append(p)
        p.start()
    
    for p in procesos:
        p.join()

Para logging en múltiples procesos, usa un Queue centralizado:

from multiprocessing import Process, Queue
import logging

def configurar_logger(cola):
    logger = logging.getLogger()
    handler = logging.handlers.QueueHandler(cola)
    logger.addHandler(handler)
    logger.setLevel(logging.INFO)
    return logger

def trabajador(cola, num):
    logger = configurar_logger(cola)
    logger.info(f'Trabajador {num} iniciado')
    # Tu código aquí
    logger.info(f'Trabajador {num} finalizado')

Ahora tienes las herramientas para crear múltiples procesos en Python de manera efectiva. Desde la clase básica Process hasta pools avanzados, comunicación entre procesos y sincronización, estás preparado para aprovechar todo el poder de tu hardware. Recuerda que el multiprocesamiento brilla en tareas CPU-intensivas, mientras que para operaciones I/O el threading o asyncio pueden ser mejores opciones. Experimenta, mide el rendimiento y elige la herramienta correcta para cada trabajo.