PythonAprende PythonDocumentación

Concurrencia: threads y procesos

threading, multiprocessing y el GIL: cuándo usar cada uno.

Cuando tu programa espera red, disco o calcula demasiado, la concurrencia le deja hacer otras cosas mientras tanto. Python ofrece dos caminos: threading (hilos) y multiprocessing (procesos) — y elegir mal es el error clásico.

El GIL: la regla del juego

El GIL (Global Interpreter Lock) es un candado del intérprete estándar de Python: solo un hilo ejecuta bytecode de Python a la vez. Dos hilos no calculan en paralelo real.

Entonces, ¿para qué sirven los hilos? El GIL se libera durante esperas de I/O (red, disco, sleep). Mientras un hilo espera, otro puede correr. Ahí está el truco:

  • I/O-bound (esperar red/disco): hilos y asyncio ayudan muchísimo
  • CPU-bound (calcular): necesitas procesos, cada uno con su GIL propio

threading: hilos para esperar mejor

import threading
import time

def descargar(nombre):
    print(f"descargando {nombre}...")
    time.sleep(1)                    # simula espera de red
    print(f"{nombre} lista")

inicio = time.perf_counter()

hilos = [
    threading.Thread(target=descargar, args=("archivo1",)),
    threading.Thread(target=descargar, args=("archivo2",)),
]
for hilo in hilos:
    hilo.start()                     # arrancar
for hilo in hilos:
    hilo.join()                      # esperar a que terminen

print(f"total: {time.perf_counter() - inicio:.1f}s")   # ~1s, no 2s

Dos descargas de 1 segundo terminan en ~1 segundo: mientras una espera, la otra corre. Sin hilos serían 2 segundos.

Compartir datos: la zona peligrosa

Los hilos comparten memoria. Si dos hilos modifican lo mismo a la vez, aparecen condiciones de carrera:

import threading

contador = 0
lock = threading.Lock()

def incrementar():
    global contador
    for _ in range(100_000):
        with lock:           # solo un hilo a la vez en esta sección
            contador += 1    # leer + sumar + escribir NO es atómico

hilos = [threading.Thread(target=incrementar) for _ in range(2)]
for h in hilos: h.start()
for h in hilos: h.join()

print(contador)   # 200000 con lock; SIN lock, un número impredecible

Regla: datos compartidos entre hilos → protégelos con Lock (o evita compartir).

multiprocessing: paralelismo real

from multiprocessing import Pool

def es_primo(n):
    if n < 2:
        return False
    for divisor in range(2, int(n ** 0.5) + 1):
        if n % divisor == 0:
            return False
    return True

if __name__ == "__main__":        # ¡obligatorio en Windows/Mac!
    with Pool(processes=4) as pool:
        resultados = pool.map(es_primo, range(2, 100))
    print(resultados.count(True))

Pool.map reparte la lista entre procesos que corren en núcleos distintos, en paralelo real. Cada proceso tiene su intérprete y su GIL — ideal para CPU-bound.

Ojo: los procesos NO comparten memoria (se comunican serializando datos). Y el bloque if __name__ == "__main__": es obligatorio para el arranque de Pool en la mayoría de plataformas.

concurrent.futures: la API moderna

Una interfaz unificada para ambos mundos:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

with ThreadPoolExecutor(max_workers=5) as executor:
    resultados = list(executor.map(descargar, ["a", "b", "c"]))

# mismo código con ProcessPoolExecutor para CPU-bound

En código nuevo, usa concurrent.futures: cambia de hilos a procesos cambiando una clase.

La tabla de decisión

Tu tareaHerramienta
Espera red/disco (muchas tareas)ThreadPoolExecutor o asyncio
Cálculo pesado en CPUProcessPoolExecutor
Pocas tareas rápidasSecuencial — no compliques

Resumen

  • El GIL permite un solo hilo ejecutando Python a la vez; se libera en esperas de I/O
  • Hilos para I/O-bound; procesos para CPU-bound
  • Datos compartidos entre hilos → threading.Lock, o no los compartas
  • concurrent.futures es la API moderna unificada
  • La concurrencia agrega complejidad: úsala solo cuando la espera sea medible

Quiz

  1. 1. ¿Qué es el GIL de Python?

  2. 2. Tu tarea es descargar 100 URLs (esperar red). ¿Qué enfoque conviene?

  3. 3. Tu tarea es procesar 100 imágenes con cálculo pesado (CPU). ¿Qué conviene?

Ejercicios

Ejercicio 1: Hilos en acción

Usa threading.Thread para ejecutar dos funciones que imprimen su nombre, arranca ambas con start() y espera con join(). Salida esperada en dos líneas.

Cargando editor…

Ejercicio 2: Pool de procesos

Usa multiprocessing.Pool (o concurrent.futures.ProcessPoolExecutor) para calcular cubo(x) para [1, 2, 3, 4] en paralelo e imprimir la lista de resultados. Resultado: [1, 8, 27, 64]

Cargando editor…