Paralelismo y concurrencia

Objetivos del módulo

Al completar este módulo serás capaz de:

  • Entender la diferencia entre paralelismo y concurrencia
  • Usar threading para tareas I/O-bound
  • Usar multiprocessing para tareas CPU-bound
  • Implementar código asíncrono con asyncio

¿Por qué hacer varias cosas a la vez?

Tu programa funciona. Hace lo que tiene que hacer. Pero… ¿podría ir más rápido si hiciera varias cosas a la vez?

Imagina un restaurante. Un solo camarero puede atender 10 mesas: toma un pedido, lo lleva a cocina, sirve otra mesa, cobra a otra… Funciona, pero si el restaurante se llena con 100 clientes, ese camarero no da abasto. Necesitas más personal o un sistema más eficiente.

En programación ocurre lo mismo:

  • Descargar 100 archivos uno por uno puede tardar horas. Descargarlos en paralelo, minutos
  • Procesar miles de imágenes secuencialmente es lento. Con varios núcleos trabajando, mucho más rápido
  • Una app que espera a que el servidor responda se queda “congelada”. Si hace otras cosas mientras espera, sigue respondiendo al usuario

La buena noticia es que Python te da tres herramientas para esto: threading, multiprocessing y asyncio. La clave está en saber cuál usar en cada situación.


Concurrencia vs Paralelismo

Antes de ver las herramientas, necesitas entender la diferencia entre estos dos conceptos. Son parecidos pero no son lo mismo.

La metáfora del cocinero

Concurrencia es como un cocinero que prepara varios platos a la vez, pero solo tiene dos manos. Pone el arroz a hervir, y mientras espera, corta las verduras. Luego vuelve al arroz, remueve, y aprovecha para poner la sartén a calentar. Está alternando entre tareas, pero en cada momento solo hace una cosa.

Paralelismo es tener varios cocineros en la cocina, cada uno trabajando en su plato al mismo tiempo. Uno hace el arroz, otro la ensalada, otro el postre. Trabajo simultáneo de verdad.

graph TD
    subgraph Concurrencia["Concurrencia: 1 cocinero, varios platos"]
        C1["🍳 Empieza arroz"]
        C2["🥗 Corta verduras"]
        C3["🍳 Remueve arroz"]
        C4["🥗 Aliña ensalada"]
        C1 --> C2 --> C3 --> C4
    end

    subgraph Paralelismo["Paralelismo: varios cocineros"]
        P1["👨‍🍳 Cocinero 1: Arroz"]
        P2["👨‍🍳 Cocinero 2: Ensalada"]
        P3["👨‍🍳 Cocinero 3: Postre"]
    end

El GIL: la cocina de Python

Aquí viene lo importante. Python tiene algo llamado Global Interpreter Lock (GIL), que es como decir que la cocina de Python solo tiene una hornilla potente.

Con threading (hilos), puedes tener varios cocineros, pero solo uno puede usar la hornilla a la vez. Los demás tienen que esperar su turno. Esto significa que threading no acelera los cálculos (todos necesitan la hornilla), pero sí funciona bien cuando los cocineros pasan mucho tiempo esperando (a que hierva el agua, a que llegue un pedido…).

Con multiprocessing (procesos), cada cocinero tiene su propia cocina completa. Pueden trabajar realmente en paralelo, pero montar cocinas separadas tiene un coste.

Regla de oro
  • ¿Tu código espera mucho? (descargas, archivos, APIs) → Usa threading o asyncio
  • ¿Tu código calcula mucho? (matemáticas, procesamiento) → Usa multiprocessing

Threading: El malabarista

Threading es como un malabarista que mantiene varias pelotas en el aire. No puede agarrar todas a la vez, pero alterna tan rápido entre ellas que parece que lo hace todo simultáneamente.

¿Cuándo usar threading? Cuando tu programa pasa mucho tiempo esperando:

  • Esperando a que un servidor responda
  • Esperando a que un archivo se descargue
  • Esperando a que una base de datos devuelva datos

Mientras esperas una cosa, puedes hacer otra. Es como cuando cocinas: mientras el agua hierve (espera), cortas las verduras (trabajo útil).

Crear hilos básicos

Un “hilo” (thread) es como un ayudante que puede hacer una tarea mientras tú haces otra. Así se crean:

 1import threading
 2import time
 3
 4def tarea(nombre, duracion):
 5    print(f"[{nombre}] Iniciando...")
 6    time.sleep(duracion)  # Simula espera (descarga, API, etc.)
 7    print(f"[{nombre}] Completado!")
 8
 9# Crear dos hilos (dos ayudantes)
10hilo1 = threading.Thread(target=tarea, args=("Tarea1", 2))
11hilo2 = threading.Thread(target=tarea, args=("Tarea2", 1))
12
13# Iniciar ambos (empiezan a trabajar en paralelo)
14hilo1.start()
15hilo2.start()
16
17# Esperar a que ambos terminen
18hilo1.join()
19hilo2.join()
20
21print("Todas las tareas completadas")

Sin threading, esto tardaría 3 segundos (2 + 1). Con threading, tarda solo 2 segundos porque ambas tareas “esperan” al mismo tiempo.

ThreadPoolExecutor: El equipo de descarga

Cuando tienes muchas tareas similares (descargar 100 archivos, por ejemplo), no quieres crear 100 hilos manualmente. ThreadPoolExecutor es como tener un equipo de descarga listo para trabajar:

 1from concurrent.futures import ThreadPoolExecutor
 2import time
 3
 4def descargar(url):
 5    print(f"Descargando {url}...")
 6    time.sleep(1)  # Simular descarga
 7    return f"Contenido de {url}"
 8
 9urls = ["url1.com", "url2.com", "url3.com", "url4.com"]
10
11# Crear un equipo de 4 trabajadores
12with ThreadPoolExecutor(max_workers=4) as executor:
13    resultados = list(executor.map(descargar, urls))
14
15for resultado in resultados:
16    print(resultado)

Las 4 descargas ocurren “a la vez” (mientras una espera, otra avanza), así que tarda ~1 segundo en lugar de ~4.

Sincronización con Lock: El baño con pestillo

Aquí viene un problema. Si dos hilos intentan modificar la misma variable al mismo tiempo, pueden pisarse el uno al otro. Es como si dos personas intentaran escribir en el mismo papel a la vez: el resultado sería un desastre.

La solución es un Lock (cerrojo). Funciona como el pestillo de un baño: solo una persona puede entrar a la vez. Los demás esperan su turno.

 1import threading
 2
 3contador = 0
 4lock = threading.Lock()
 5
 6def incrementar():
 7    global contador
 8    for _ in range(100000):
 9        with lock:  # "Cierro el pestillo" - solo yo puedo tocar el contador
10            contador += 1
11        # Aquí "abro el pestillo" - el siguiente puede entrar
12
13hilos = [threading.Thread(target=incrementar) for _ in range(4)]
14for h in hilos:
15    h.start()
16for h in hilos:
17    h.join()
18
19print(f"Contador final: {contador}")  # 400000 (correcto!)
Sin Lock

Sin el Lock, el contador podría dar un número incorrecto (por ejemplo, 387432 en lugar de 400000). Esto se llama race condition y es uno de los bugs más difíciles de detectar.

Multiprocessing: Contratar más trabajadores

Si threading es un malabarista, multiprocessing es contratar más empleados. Cada proceso es un trabajador independiente con su propia “cocina” (memoria, recursos). Pueden trabajar realmente en paralelo, sin compartir la hornilla.

¿Cuándo usar multiprocessing? Cuando tu programa hace cálculos pesados:

  • Procesar miles de imágenes
  • Calcular números primos grandes
  • Entrenar modelos de machine learning
  • Comprimir/descomprimir archivos
graph LR
    subgraph Secuencial["Secuencial: 1 trabajador"]
        S1["Tarea 1"] --> S2["Tarea 2"] --> S3["Tarea 3"] --> S4["Tarea 4"]
    end

    subgraph Paralelo["Paralelo: 4 trabajadores"]
        P1["👷 Tarea 1"]
        P2["👷 Tarea 2"]
        P3["👷 Tarea 3"]
        P4["👷 Tarea 4"]
    end

Procesos básicos

Cada proceso tiene su propio ID (PID), como cada empleado tiene su número de identificación:

 1from multiprocessing import Process
 2import os
 3
 4def tarea(nombre):
 5    print(f"[{nombre}] PID: {os.getpid()}")
 6    # Aquí va trabajo CPU-intensivo
 7
 8if __name__ == "__main__":
 9    procesos = []
10    for i in range(4):
11        p = Process(target=tarea, args=(f"Proceso-{i}",))
12        procesos.append(p)
13        p.start()
14
15    for p in procesos:
16        p.join()
Importante: if name

El if __name__ == "__main__": es obligatorio en multiprocessing. Sin él, cada proceso intentaría crear más procesos, creando un bucle infinito. Es como decirle a tus empleados: “Solo el jefe puede contratar gente nueva”.

Pool de procesos: El equipo de cálculo

Igual que con threading, no quieres crear procesos manualmente para cientos de tareas. Pool te da un equipo de trabajadores listos:

 1from multiprocessing import Pool
 2import time
 3
 4def calcular_cuadrado(n):
 5    time.sleep(0.1)  # Simular cálculo pesado
 6    return n ** 2
 7
 8if __name__ == "__main__":
 9    numeros = list(range(20))
10
11    # Secuencial: un trabajador hace todo
12    inicio = time.time()
13    resultados_seq = [calcular_cuadrado(n) for n in numeros]
14    print(f"Secuencial: {time.time() - inicio:.2f}s")  # ~2 segundos
15
16    # Paralelo: 4 trabajadores se reparten la tarea
17    inicio = time.time()
18    with Pool(4) as pool:
19        resultados_par = pool.map(calcular_cuadrado, numeros)
20    print(f"Paralelo: {time.time() - inicio:.2f}s")  # ~0.5 segundos

ProcessPoolExecutor: La interfaz moderna

ProcessPoolExecutor funciona igual que ThreadPoolExecutor, lo que hace fácil cambiar entre hilos y procesos:

 1from concurrent.futures import ProcessPoolExecutor
 2import math
 3
 4def es_primo(n):
 5    if n < 2:
 6        return False
 7    for i in range(2, int(math.sqrt(n)) + 1):
 8        if n % i == 0:
 9            return False
10    return True
11
12if __name__ == "__main__":
13    numeros = range(100000, 100020)
14
15    with ProcessPoolExecutor(max_workers=4) as executor:
16        resultados = list(executor.map(es_primo, numeros))
17
18    primos = [n for n, es in zip(numeros, resultados) if es]
19    print(f"Primos encontrados: {primos}")
¿Cuántos workers?

Una buena regla es usar tantos workers como núcleos tenga tu CPU. Puedes obtener este número con os.cpu_count(). Usar más workers de los que tienes núcleos no acelera nada, solo añade sobrecarga.

Asyncio: El camarero eficiente

Asyncio es como un camarero super eficiente en un restaurante. No se queda parado esperando a que cocina prepare el plato. En cuanto hace un pedido, va a otra mesa a tomar nota. Luego sirve bebidas, cobra una cuenta, y cuando el plato está listo, lo recoge y lo sirve. Un solo camarero puede atender muchas mesas porque nunca espera sin hacer nada.

graph LR
    A["Toma pedido Mesa 1"] --> B["Toma pedido Mesa 2"]
    B --> C["Sirve bebida Mesa 3"]
    C --> D["Recoge plato Mesa 1"]
    D --> E["Sirve plato Mesa 1"]

¿Cuándo usar asyncio? Cuando tienes muchas operaciones de red:

  • Descargar cientos de páginas web
  • Hacer peticiones a múltiples APIs
  • Manejar muchas conexiones simultáneas (chat, websockets)

Las palabras mágicas: async y await

  • async: “Esta función puede pausarse y continuar después”
  • await: “Esto va a tardar, así que mientras espero, haz otra cosa”
 1import asyncio
 2
 3async def tarea(nombre, duracion):
 4    print(f"[{nombre}] Iniciando...")
 5    await asyncio.sleep(duracion)  # "Espero, pero no bloqueo"
 6    print(f"[{nombre}] Completado!")
 7    return nombre
 8
 9async def main():
10    # gather() ejecuta todas las tareas "a la vez"
11    resultados = await asyncio.gather(
12        tarea("A", 2),
13        tarea("B", 1),
14        tarea("C", 3)
15    )
16    print(f"Resultados: {resultados}")
17
18# Punto de entrada para código async
19asyncio.run(main())

Las tres tareas tardan 2, 1 y 3 segundos. ¿Cuánto tarda el programa? Solo 3 segundos (no 6), porque mientras una espera, las otras avanzan.

Descargas asíncronas con aiohttp

Para descargas reales necesitas aiohttp (la versión async de requests):

 1import asyncio
 2import aiohttp  # pip install aiohttp
 3
 4async def descargar_url(session, url):
 5    async with session.get(url) as response:
 6        return await response.text()
 7
 8async def descargar_todas(urls):
 9    async with aiohttp.ClientSession() as session:
10        tareas = [descargar_url(session, url) for url in urls]
11        resultados = await asyncio.gather(*tareas)
12        return resultados
13
14urls = [
15    "https://httpbin.org/delay/1",
16    "https://httpbin.org/delay/2",
17    "https://httpbin.org/delay/1"
18]
19
20# Tarda ~2 segundos en lugar de ~4
21resultados = asyncio.run(descargar_todas(urls))

Semáforos: No colapses el servidor

Si descargas 1000 URLs a la vez, podrías colapsar el servidor (o que te bloqueen). Un semáforo es como un cartel de aforo máximo: solo permite N tareas simultáneas.

 1import asyncio
 2
 3async def tarea_limitada(semaforo, nombre):
 4    async with semaforo:  # "Espero mi turno si hay mucha gente"
 5        print(f"[{nombre}] Ejecutando...")
 6        await asyncio.sleep(1)
 7        print(f"[{nombre}] Completado")
 8
 9async def main():
10    semaforo = asyncio.Semaphore(3)  # Máximo 3 a la vez
11
12    tareas = [
13        tarea_limitada(semaforo, f"Tarea-{i}")
14        for i in range(10)
15    ]
16
17    await asyncio.gather(*tareas)
18
19asyncio.run(main())

Las 10 tareas se ejecutan, pero nunca más de 3 simultáneas. Es como un probador de ropa: aunque haya cola, solo 3 personas pueden estar probándose a la vez.


Cuándo usar cada enfoque

Situación Solución Ejemplo real
Descargar archivos threading o asyncio Bajar 50 imágenes de internet
Llamar a APIs asyncio + aiohttp Consultar precios en 10 tiendas
Leer/escribir archivos threading Procesar 100 logs simultáneamente
Cálculos matemáticos multiprocessing Calcular primos hasta 10 millones
Procesar imágenes multiprocessing.Pool Redimensionar 1000 fotos
Servidor web asyncio Manejar miles de conexiones

Comparativa rápida

Aspecto Threading Multiprocessing Asyncio
Memoria Compartida Separada Compartida
Complejidad Media Alta Alta
Mejor para I/O, pocas tareas CPU, cálculos I/O, muchas tareas
Limitación GIL Overhead de procesos Solo código async

Errores comunes

Trampas a evitar
  1. Olvidar if __name__ == "__main__": en multiprocessing

    • Tu programa entrará en un bucle infinito creando procesos
  2. No usar Lock cuando compartes datos entre hilos

    • Race conditions: resultados incorrectos e impredecibles
  3. Usar threading para cálculos pesados

    • El GIL impide que sea más rápido. Usa multiprocessing
  4. Mezclar código síncrono en asyncio

    • time.sleep() bloquea todo. Usa await asyncio.sleep()
    • requests.get() bloquea. Usa aiohttp
  5. Crear demasiados procesos/hilos

    • Más procesos que núcleos = overhead sin beneficio
    • Miles de hilos = consumo excesivo de memoria

Guía rápida de decisión

¿No sabes qué usar? Sigue este diagrama:

graph TD
    A["¿Qué hace tu código?"] --> B{"¿Espera mucho?<br/>(red, archivos, APIs)"}
    B -->|Sí| C{"¿Cuántas tareas?"}
    B -->|No, calcula| D["multiprocessing"]
    C -->|Pocas| E["threading"]
    C -->|Muchas| F["asyncio"]
Regla del 80/20

El 80% de los casos se resuelven así:

  • Descargas/APIs: asyncio con aiohttp
  • Procesamiento de datos: multiprocessing.Pool
  • Tareas simples en paralelo: ThreadPoolExecutor

Ejercicios prácticos

Ejercicio 1: Descargador paralelo

Crea una función que descargue múltiples URLs en paralelo usando ThreadPoolExecutor.

 1from concurrent.futures import ThreadPoolExecutor
 2import urllib.request
 3import time
 4
 5def descargar_url(url):
 6    try:
 7        with urllib.request.urlopen(url, timeout=5) as response:
 8            return len(response.read())
 9    except Exception as e:
10        return f"Error: {e}"
11
12def descargar_paralelo(urls, max_workers=4):
13    with ThreadPoolExecutor(max_workers=max_workers) as executor:
14        resultados = dict(zip(urls, executor.map(descargar_url, urls)))
15    return resultados
16
17urls = [
18    "https://www.python.org",
19    "https://www.google.com",
20    "https://www.github.com"
21]
22
23inicio = time.time()
24resultados = descargar_paralelo(urls)
25print(f"Tiempo: {time.time() - inicio:.2f}s")
26for url, tamaño in resultados.items():
27    print(f"{url}: {tamaño} bytes")
Ejercicio 2: Cálculo de primos en paralelo

Usa multiprocessing para encontrar todos los primos hasta N de forma paralela.

 1from multiprocessing import Pool
 2import math
 3import time
 4
 5def es_primo(n):
 6    if n < 2:
 7        return False
 8    if n == 2:
 9        return True
10    if n % 2 == 0:
11        return False
12    for i in range(3, int(math.sqrt(n)) + 1, 2):
13        if n % i == 0:
14            return False
15    return True
16
17def encontrar_primos_paralelo(limite, num_procesos=4):
18    with Pool(num_procesos) as pool:
19        numeros = range(2, limite + 1)
20        resultados = pool.map(es_primo, numeros)
21    return [n for n, es in zip(numeros, resultados) if es]
22
23if __name__ == "__main__":
24    inicio = time.time()
25    primos = encontrar_primos_paralelo(100000)
26    print(f"Encontrados {len(primos)} primos en {time.time() - inicio:.2f}s")

Quiz

🎮 Quiz: Paralelismo

0 / 0
Cargando preguntas...

Anterior: Optimización Siguiente: Testing