Pregunta 1Por culpa del GIL (Global Interpreter Lock) de Python, ¿qué tipo de trabajo no se paraleliza añadiendo más hilos?
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.
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".
- 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
- La unidad que realmente ejecuta el código dentro de un proceso
- Memoria compartida · Sujeto al GIL
- Compensa para trabajo I/O-bound
- Se ejecuta alternando dentro de un solo hilo
- Aún más ligera; ideal para muchas E/S concurrentes
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.
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.
# 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.
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.
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.
| Carga de trabajo | Elección | Por qué |
|---|---|---|
| Atacar 100 Web APIs en concurrencia | asyncio | I/O-bound; ligero y fácil de escribir |
| Paralelizar un cliente HTTP síncrono existente | threading (ThreadPoolExecutor) | Si la biblioteca no tiene soporte async, threading |
| Paralelizar procesado de imágenes en 4 núcleos | multiprocessing | El trabajo CPU-bound necesita procesos para esquivar el GIL |
Ejecutar comandos como git o ffmpeg | subprocess | Dedicado a llamar programas fuera de Python |
| Millones de operaciones matemáticas simples | NumPy / Cython | La 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.
Verificación de conocimientos
Responde cada pregunta una a una.
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?