1 - Conceptos Fundamentales de Concurrencia en Python

Este primer tema define el terreno de juego de la concurrencia en Python. Explica qué significa ejecutar varias tareas aparentemente al mismo tiempo, cómo se diferencia de la asincronía y del paralelismo real, y por qué el Global Interpreter Lock (GIL) marca el ritmo cuando usamos hilos. También verás cuándo elegir threading o multiprocessing para obtener el mejor rendimiento.

1.1 ¿Qué es la concurrencia y por qué Python la necesita?

La concurrencia es la capacidad de gestionar más de una tarea a la vez, alternando el avance de cada una. El objetivo no siempre es usar todos los núcleos, sino mantener el programa reactivo.

  • Concurrencia: varias tareas avanzan de forma intercalada. Con hilos, cada uno puede estar bloqueado o listo; el sistema operativo decide el intercalado.
  • Paralelismo: tareas ejecutándose al mismo tiempo en diferentes núcleos. Requiere que el lenguaje y el runtime lo permitan.
  • Asincronía: modelo basado en eventos (por ejemplo, asyncio) que permite escribir lógica concurrente con una sola hebra cooperativa.

Cuándo usar Python concurrente:

  • Tareas I/O-bound: acceso a red, disco, sockets o APIs externas. Los hilos brillan porque el tiempo de espera se solapa.
  • Tareas CPU-bound: cálculo intensivo (cifrado, procesado numérico). Se obtiene paralelismo real con procesos separados o extensiones en C que liberen el GIL.
  • Cuándo no: scripts muy cortos, transformaciones ligeras o cuando la complejidad de sincronización supera el beneficio.
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def tarea_io(url):
    time.sleep(1)  # Simula red
    return f"Descargada {url}"

def tarea_cpu(n):
    return sum(i * i for i in range(n))  # Trabajo intensivo

def ejecutar_io(urls):
    with ThreadPoolExecutor(max_workers=5) as pool:
        return list(pool.map(tarea_io, urls))

def ejecutar_cpu(numeros):
    with ProcessPoolExecutor() as pool:
        return list(pool.map(tarea_cpu, numeros))

if __name__ == "__main__":
    print("I/O bound:", ejecutar_io(["/u/1", "/u/2", "/u/3"]))
    print("CPU bound:", ejecutar_cpu([5_000_000] * 3))

El ejemplo usa ThreadPoolExecutor para solapar esperas de I/O y ProcessPoolExecutor para repartir cálculo pesado entre procesos que ejecutan en paralelo.

1.2 El Global Interpreter Lock (GIL)

El Global Interpreter Lock es un mutex que asegura que solo un hilo de Python ejecuta bytecode a la vez dentro de un mismo proceso. Protege las estructuras internas del intérprete CPython.

  • Impacto en hilos: aunque haya varios hilos en ejecución, uno solo ejecuta bytecode Python a la vez. Para código CPU-bound, esto elimina el paralelismo real.
  • Por qué bloquea el paralelismo: el GIL se adquiere antes de ejecutar instrucciones y se libera periódicamente. Dos hilos que calculan en CPU compiten por el candado, por lo que el tiempo total no mejora.
  • Cuándo no es problema: en I/O-bound el hilo suelta el GIL al llamar a funciones bloqueantes (red, disco). Otros hilos pueden avanzar mientras uno espera.
  • Mitos frecuentes:
    • "Python no puede usar varios núcleos": falso; multiprocessing lanza procesos con intérpretes separados.
    • "El GIL afecta a extensiones en C": solo si no liberan el GIL; muchas librerías numéricas lo liberan y paralelizan internamente.
    • "Quitar el GIL siempre aceleraría Python": su eliminación simplifica menos y complica la memoria compartida; por eso la decisión es de diseño y compatibilidad.

1.3 Tareas ideales para threading vs multiprocessing

Elegir el modelo correcto evita sorpresas. Ambas APIs forman parte de la librería estándar: threading y multiprocessing.

  • Cuándo usar hilos: esperas de red, scraping, operaciones contra discos o bases de datos, workers que pasan la mayor parte del tiempo bloqueados. Crearlos es barato y comparten memoria.
  • Cuándo usar procesos: cálculos intensivos (parsing pesado, cifrado, ML básico), pipelining con CPU dedicada, tareas que podrían bloquear el bucle principal. Ofrecen paralelismo real a costa de mayor overhead.
  • Costos: un hilo suele pesar pocos MB y se crea rápido; un proceso duplica el intérprete, tiene costo de serialización de datos (pickle) y arranque más lento, sobre todo en Windows.
  • Reglas prácticas:
    • I/O-bound → primero hilos; si necesitas miles de conexiones, evalúa también asyncio.
    • CPU-bound → procesos o extensiones nativas; evita hilos puros.
    • Estados mutables compartidos → hilos con cuidado y Lock; si el estado es grande y solo lectura, procesos pueden compartirlo mediante memoria mapeada.
    • Arranques muy frecuentes → usa pools (ThreadPoolExecutor o ProcessPoolExecutor) para reutilizar workers.