threading y multiprocessing — Hilos, procesos y el GIL

Hilos vs procesos, por qué el GIL impide paralelizar CPU-bound, cuándo usar ThreadPoolExecutor o multiprocessing.Pool y cómo subprocess invoca comandos externos, con un diagrama de decisión.

El kit de concurrencia y paralelismo de Python — threading (hilos), multiprocessing (procesos) y subprocess (comandos externos) — puesto uno al lado del otro. El sandbox de Python en el navegador no puede crear hilos ni procesos reales, así que este artículo usa diagramas y bloques de código de solo lectura para fijar los conceptos.

El código de aquí no se ejecuta de verdad

Los bloques de code de este artículo son ejemplos pensados para un entorno Python real. Llamadas como threading.Thread.start() o multiprocessing.Pool.map() no funcionan en el sandbox del navegador porque el runtime no puede crear hilos del SO ni procesos del SO. En su lugar, hay un quiz de comprobación de conceptos al final.

Dónde encajan threading / multiprocessing / subprocess
threadingI/O-boundArchivos / BD / APIsmultiprocessingCPU-boundImagen / numéricasubprocessComandos externosgit / ffmpeg / shellasyncio (anterior)Muchas E/S concurrentes100 llamadas Web API
threading para trabajo I/O-bound, multiprocessing para cómputo intensivo en CPU, subprocess para llamar a comandos fuera de Python. Junto a asyncio (cubierto en el artículo anterior para muchas E/S concurrentes), los cuatro roles encajan.

Procesos vs hilos

Un proceso es una unidad de ejecución que reparte el SO — tiene su propio espacio de memoria, y los procesos no interfieren entre sí. Un hilo (thread) es una unidad de ejecución ligera dentro de un proceso, y comparte memoria con sus hilos hermanos. Compartir hace que pasar datos sea rápido, pero hay que vigilar las race conditions (condiciones de carrera — varios hilos escriben la misma variable a la vez y corrompen el resultado).

El modelo mental simple: "proceso = pesado pero independiente, hilo = ligero pero compartido".

Procesos, hilos y corrutinas se anidan así
Proceso (multiprocessing los crea)
  • Una unidad de ejecución independiente desde la vista del SO
  • Memoria independiente · Sin restricción del GIL
  • Coste de arranque alto; el verdadero paralelismo funciona
Hilo (threading los crea)
  • La unidad que realmente ejecuta el código dentro de un proceso
  • Memoria compartida · Sujeto al GIL
  • Compensa para trabajo I/O-bound
Corrutina (asyncio las ejecuta)
  • Se ejecuta alternando dentro de un solo hilo
  • Aún más ligera; ideal para muchas E/S concurrentes
multiprocessing crea los procesos más externos, threading crea los hilos del medio y asyncio ejecuta las corrutinas más internas. Tres capas distintas — qué quieres paralelizar decide cuál usar.
Diferencias entre proceso e hilo
Proceso(multiprocessing)Memoria independienteCoste de arranque altoSin interferenciasParalelismo realHilo(threading)Memoria compartidaArranque ligeroCuidado con carrerasRestricción del GIL
Procesomemoria independiente, coste de arranque alto a cambio de cero interferencias. Hilomemoria compartida, ligero, pero se requiere sincronización (Lock y similares). El GIL de Python limita el paralelismo de hilos en trabajo CPU-bound.

threading y el GIL — la restricción de los hilos en Python

Python (CPython) tiene un mecanismo llamado GIL (Global Interpreter Lock) que impone una restricción: "solo un hilo puede ejecutar bytecode de Python a la vez". Para trabajo intensivo en CPU, ejecutarlo en varios hilos en concurrencia no va más rápido en la práctica que un solo hilo.

GIL — solo un hilo corre a la vez
Thread ACalculandoGILLo tiene AThread BEsperandoThread AEspera de E/S→ libera el GILGILLo adquiere BThread BEmpieza a calculartraspaso
El GIL es un único bloqueo que controla el derecho a ejecutar bytecode de Python. Los hilos hacen cola para adquirirlo y lo liberan en cuanto entran en una espera de E/S, lo que permite que otro hilo intervenga en esa ventana.

En cambio, durante el trabajo I/O-bound (donde el tiempo lo domina la espera de respuestas externas — red, archivos, BD) Python libera el GIL, así que threading sí acelera el trabajo I/O-bound. Dicho esto, si la carga es I/O-bound, asyncio suele tener menos sobrecarga y es más fácil de escribir, así que prefiere asyncio para código nuevo.

Cómo afecta el GIL a los hilos
CPU-bound(cómputo)threadingHace cola en el GIL→ Sin paralelismohace falta multiprocessingI/O-bound(API / BD / archivos)threadingGIL liberado en E/S→ Corre en concurrencia(asyncio aún más ligero)
El trabajo CPU-bound se mantiene en serie bajo el GIL por muchos hilos que crees. El trabajo I/O-bound libera el GIL durante la espera, así que threading sí ayuda. Para verdadero paralelismo CPU-bound, recurre a multiprocessing.
# threading: API de bajo nivel
import threading

def worker(name):
    print(f"{name} started")
    # hacer algo
    print(f"{name} done")

t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
t1.join()  # espera la finalización
t2.join()

# concurrent.futures: API de alto nivel (recomendada)
from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    # en código real: requests.get(url) u otro trabajo I/O-bound
    return f"fetched: {url}"

with ThreadPoolExecutor(max_workers=4) as executor:
    urls = ["a.com", "b.com", "c.com"]
    results = list(executor.map(fetch, urls))
    print(results)

Recurre a ThreadPoolExecutor en código nuevo

Usar threading.Thread directamente complica la gestión del ciclo de vida. concurrent.futures.ThreadPoolExecutor es seguro de manejar con with y te permite procesar listas enteras con executor.map(func, iterable) como una sola API. El límite de hilos (max_workers) también va bien para no saturar un servidor con conexiones.

Cuándo encaja threading

threading / ThreadPoolExecutor brilla cuando quieres rellenar el tiempo de espera con otras tareas:

- Procesado concurrente de varias Web APIs / consultas BD / E/S de archivos

- Paralelizar bibliotecas síncronas existentes (sin soporte async)

- Leer en concurrencia la salida de varios procesos lanzados con subprocess

- Ejecutar trabajo en segundo plano sin bloquear el bucle principal de una GUI

multiprocessing — paralelismo real

multiprocessing es el módulo para lanzar varios procesos de Python y ejecutarlos en paralelo. Como los procesos están libres de la restricción del GIL, puedes ejecutar trabajo CPU-bound con paralelismo real — en una CPU de 4 núcleos, el procesado de imágenes o los cálculos numéricos van aproximadamente 4 veces más rápido.

multiprocessing.Pool — paralelismo real en 4 núcleos
Entrada[d1, d2, d3, d4]Process 1Core 1heavy(d1)Process 2Core 2heavy(d2)Process 3Core 3heavy(d3)Process 4Core 4heavy(d4)Resultado[r1, r2, r3, r4]
Cuatro procesos de Python corren en cuatro núcleos de CPU separados, así que no hay restricción del GIL y obtienes ejecución paralela de verdad. El trabajo CPU-bound se acelera unas 4 veces.
from multiprocessing import Pool

def heavy(n):
    return sum(i * i for i in range(n))   # trabajo CPU-bound

if __name__ == "__main__":   # forma obligada para multiprocessing
    with Pool(processes=4) as pool:
        results = pool.map(heavy, [10**6, 10**6, 10**6, 10**6])
        print("sum:", sum(results))

multiprocessing requiere `if __name__ == '__main__':`

multiprocessing funciona reejecutando el script padre en cada proceso hijo, así que llamar a Pool(...).map(...) en el nivel superior dispara una recursión infinita y revienta. El método spawn de Windows / macOS es especialmente estricto con esto — pon siempre tu código principal dentro de un bloque if __name__ == "__main__":.

subprocess — comandos externos

subprocess es el módulo para llamar a comandos externos (comandos de shell del SO) desde Python — ejecutar git status, convertir vídeo con ffmpeg, invocar un script de shell y otros casos de uso de "lanzar un programa que no es Python". El nombre se parece a multiprocessing, pero es una herramienta totalmente distinta.

subprocess.run — llamar a un comando externo desde Python
Pythonsubprocess.run([...])SOlanza un procesoComando externogit / ffmpeg etc.Devuelve stdout /returncode a Python
Python le pide al SO que ejecute un comandoun proceso del SO aparte ejecuta el comando externo → stdout y el código de retorno vuelven en un objeto CompletedProcess. Útil para delegar trabajo que Python por sí solo no puede hacer.
import subprocess

result = subprocess.run(
    ["git", "status", "--short"],
    capture_output=True,
    text=True,
    check=True,    # lanza CalledProcessError si falla
)
print(result.stdout)
print("return code:", result.returncode)

Flujo de decisión — ¿cuál eliges?

Elegir entre asyncio / threading / multiprocessing / subprocess se reduce a dos ejes: ¿CPU-bound o I/O-bound? y ¿Dentro de Python o un comando externo?. El diagrama de flujo de abajo elimina casi toda la duda.

Flujo de decisión de concurrencia / paralelismo
¿Llamas a uncomando externo?¿I/O-bound?(domina la espera)¿CPU-bound?(domina el cómputo)→ subprocess→ asyncio(o threading)→ multiprocessing
Comando externo → subprocess, I/O-bound → asyncio (o threading), CPU-bound → multiprocessing. Tres ejes para elegir la herramienta correcta.
Carga de trabajoElecciónPor qué
Atacar 100 Web APIs en concurrenciaasyncioI/O-bound; ligero y fácil de escribir
Paralelizar un cliente HTTP síncrono existentethreading (ThreadPoolExecutor)Si la biblioteca no tiene soporte async, threading
Paralelizar procesado de imágenes en 4 núcleosmultiprocessingEl trabajo CPU-bound necesita procesos para esquivar el GIL
Ejecutar comandos como git o ffmpegsubprocessDedicado a llamar programas fuera de Python
Millones de operaciones matemáticas simplesNumPy / CythonLa vectorización gana al paralelismo a nivel de Python

Lo verdaderamente CPU-bound es más raro de lo que parece

Mucho código Python que parece atascado en cómputo en realidad se acelera 100 veces con la vectorización de NumPy / Pandas / Cython. Antes de perseguir 4x con multiprocessing, comprueba qué deberías hacer primero: NumPy para numérica, Pandas para datos, regex optimizadas para cadenas.

QUIZ

Verificación de conocimientos

Responde cada pregunta una a una.

Pregunta 1Por culpa del GIL (Global Interpreter Lock) de Python, ¿qué tipo de trabajo no se paraleliza añadiendo más hilos?

Pregunta 2¿Cuál usas para llamar a comandos externos como git status desde Python?

Pregunta 3¿Cuál encaja mejor para paralelizar de verdad cómputo numérico pesado en 4 núcleos de CPU?