Papyrus quiere modernizarse: antes de reponer stock, Ana consulta el precio de cada título en tres distribuidoras. Cada consulta tarda unos 2 segundos (red, servidores lentos), y el programa se queda parado esperando: 3 consultas × 2 s = 6 segundos mirando el cursor. Lo mismo pasa al descargar las portadas de los libros nuevos. La observación clave es que durante esos 2 segundos Python no hace nada: espera. La concurrencia consiste en aprovechar esas esperas para avanzar otras tareas. En esta lección: hilos, el famoso GIL contado con honestidad, las condiciones de carrera (con un contador de ventas que se corrompe en directo), y procesos para cuando el problema no es esperar sino calcular. Una nota antes de empezar: simularemos las consultas con time.sleep() — es la forma estándar de aprender concurrencia sin depender de servidores reales; las peticiones HTTP de verdad llegan en el módulo 10.
Contenido
- Secuencial, concurrente y paralelo
threading.Thread: el primer hilo- El GIL, explicado honestamente
- Condiciones de carrera: el contador que pierde ventas
Lock: zona de un solo hiloconcurrent.futures.ThreadPoolExecutor: la API recomendada- Procesos para CPU:
multiprocessingyProcessPoolExecutor - La tabla de decisión (incluida la opción "no necesitas concurrencia")
Secuencial, concurrente y paralelo
Tres palabras que se confunden y no significan lo mismo:
- Secuencial: una tarea tras otra. Consulta A, espera; consulta B, espera; consulta C.
- Concurrente: varias tareas en curso a la vez, aunque una sola avance en cada instante — como Ana atendiendo a Júlia mientras a Omar se le imprime el ticket. Aprovecha las esperas.
- Paralelo: varias tareas ejecutándose literalmente al mismo tiempo en núcleos distintos de la CPU. Aprovecha el hardware.
gantt
dateFormat s
axisFormat %S s
title Consultar 3 distribuidoras (2 s cada una)
section Secuencial (6 s)
Distribuidora A :a1, 0, 2s
Distribuidora B :a2, after a1, 2s
Distribuidora C :a3, after a2, 2s
section Concurrente con hilos (~2 s)
Distribuidora A :b1, 0, 2s
Distribuidora B :b2, 0, 2s
Distribuidora C :b3, 0, 2s
Las tres esperas se solapan: el total pasa de la suma (6 s) al máximo (2 s).
threading.Thread: el primer hilo
Un hilo es una línea de ejecución dentro del mismo proceso: comparte memoria (el mismo catalogo, las mismas variables) con los demás hilos.
import threading
import time
def consultar_precio(distribuidora: str, titulo: str) -> None:
print(f"→ consultando {titulo} en {distribuidora}...")
time.sleep(2) # simula la espera de red (E/S)
print(f"← {distribuidora} responde")
inicio = time.perf_counter()
hilos = [
threading.Thread(target=consultar_precio, args=(d, "Fausto"))
for d in ("Distribuidora A", "Distribuidora B", "Distribuidora C")
]
for h in hilos:
h.start() # arranca el hilo: la función empieza a ejecutarse "en paralelo"
for h in hilos:
h.join() # espera a que ese hilo termine
print(f"Total: {time.perf_counter() - inicio:.1f} s") # Total: 2.0 s (no 6.0)Dos detalles de la API: a target se le pasa la función sin llamar (el objeto función, como en los decoradores de 08-02) y los argumentos aparte en args; y join() bloquea hasta que el hilo acabe — sin los join(), el programa principal podría terminar antes que las consultas.
El GIL, explicado honestamente
Aquí toca la conversación que muchos tutoriales esquivan. CPython (el Python que usas) tiene el GIL (Global Interpreter Lock): un cerrojo global que permite que solo un hilo ejecute bytecode Python en cada instante. Aunque tu portátil tenga 8 núcleos, dos hilos Python no ejecutan código Python a la vez.
¿Entonces por qué el ejemplo anterior tardó 2 s y no 6? Porque el GIL se suelta durante las operaciones de E/S: mientras un hilo espera en time.sleep(), en una lectura de disco o en una respuesta de red, libera el GIL y otro hilo avanza. Las esperas sí se solapan; el cálculo no.
| Tipo de tarea | ¿Los hilos ayudan? | Por qué |
|---|---|---|
| E/S (red, disco, esperas) | Sí | El GIL se libera al esperar; las esperas se solapan |
| CPU pura (cálculos, bucles grandes) | No (incluso algo peor) | El GIL deja avanzar a un solo hilo; los demás hacen cola |
Demostración con CPU pura — sumar 10 millones de números en 2 hilos tarda lo mismo o más que en uno:
def calcular(): # CPU pura: sin esperas que solapar
return sum(range(10_000_000))
# 1 hilo: ~0.4 s · 2 hilos con threading: ~0.4 s también (¡o más, por la gestión!)Conclusión honesta: en Python, los hilos son para esperar a varias cosas a la vez, no para calcular más rápido. Para calcular en paralelo hay otra herramienta (procesos, más abajo). Nota al margen: en 2026 existen builds experimentales de CPython sin GIL, pero el Python que tienes instalado funciona como se acaba de describir.
Condiciones de carrera: el contador que pierde ventas
Compartir memoria entre hilos tiene un precio. Supón que cada consulta registra ventas en un contador global:
contador_ventas = 0
def registrar_ventas(n: int) -> None:
global contador_ventas
for _ in range(n):
contador_ventas += 1 # parece atómico... y NO lo es
hilos = [threading.Thread(target=registrar_ventas, args=(100_000,)) for _ in range(2)]
for h in hilos: h.start()
for h in hilos: h.join()
print(contador_ventas) # esperado: 200000 · obtenido: 200000... o 173942, según el díacontador_ventas += 1 son en realidad tres pasos: leer el valor, sumarle 1, escribirlo. Si el hilo A lee 50, el sistema cambia al hilo B, que también lee 50, suma y escribe 51... y luego A escribe su 51: una venta se ha perdido. Es una condición de carrera (race condition): el resultado depende del orden azaroso en que se intercalan los hilos. Es el peor tipo de bug — no falla siempre, no deja traza en papyrus.log, y desaparece cuando intentas reproducirlo.
Lock: zona de un solo hilo
La solución es un cerrojo que convierta leer-sumar-escribir en una operación indivisible. Y la forma de usarlo te va a sonar de la lección anterior — un Lock es un administrador de contexto:
contador_ventas = 0
cerrojo = threading.Lock()
def registrar_ventas(n: int) -> None:
global contador_ventas
for _ in range(n):
with cerrojo: # __enter__ adquiere; __exit__ libera SIEMPRE
contador_ventas += 1Mientras un hilo está dentro del with cerrojo:, cualquier otro que llegue se queda esperando en la puerta. Resultado: 200000, siempre. El coste: esa zona vuelve a ser secuencial — cuanto más grande sea el bloque protegido, menos concurrencia queda. Protege lo mínimo imprescindible.
ThreadPoolExecutor: la API recomendada
Crear hilos a mano está bien para aprender, pero la API moderna es concurrent.futures: un pool (equipo) de hilos reutilizables al que le entregas tareas. Además — y esto los Thread a pelo no lo hacen — recoge los valores de retorno y te re-lanza las excepciones donde puedas atraparlas con las anillas de M7:
from concurrent.futures import ThreadPoolExecutor, as_completed
def consultar_precio(distribuidora: str, titulo: str) -> tuple[str, float]:
time.sleep(2) # E/S simulada, recuerda
precios = {"Distribuidora A": 19.90, "Distribuidora B": 21.50,
"Distribuidora C": 20.10}
return distribuidora, precios[distribuidora]
distribuidoras = ["Distribuidora A", "Distribuidora B", "Distribuidora C"]
# Opción 1 — map: resultados en el ORDEN de envío (API casi idéntica a map() de M3)
with ThreadPoolExecutor(max_workers=3) as pool: # otro context manager (08-04)
for dist, precio in pool.map(lambda d: consultar_precio(d, "Fausto"),
distribuidoras):
print(f"{dist}: {precio} €")
# Opción 2 — submit + as_completed: resultados SEGÚN van llegando
with ThreadPoolExecutor(max_workers=3) as pool:
futuros = [pool.submit(consultar_precio, d, "Fausto") for d in distribuidoras]
for futuro in as_completed(futuros):
try:
dist, precio = futuro.result() # aquí re-aparece la excepción, si la hubo
print(f"{dist}: {precio} €")
except OSError:
logger.exception("una distribuidora falló") # el patrón de 07-05submit(f, *args)devuelve un futuro: un recibo por un resultado que llegará.as_completedlos entrega según terminan — la distribuidora rápida no espera a la lenta.futuro.result()devuelve el retorno o re-lanza la excepción que el hilo sufrió: nada se pierde en silencio.- El
withdel pool espera a que todas las tareas acaben antes de salir:__exit__haciendo dejoin()colectivo.
Procesos: paralelismo de verdad para CPU
¿Y cuando el cuello de botella sí es el cálculo? Imagina que el informes/ anual de Papyrus exige procesar estadísticas pesadas por año. Un proceso es un Python completo e independiente, con su propia memoria y su propio GIL — por tanto, varios procesos sí calculan en paralelo en núcleos distintos:
from concurrent.futures import ProcessPoolExecutor
def generar_informe_pesado(anyo: int) -> tuple[int, float]:
total = sum(i * i for i in range(20_000_000)) # CPU pura (simulada)
return anyo, total
if __name__ == "__main__": # OBLIGATORIO con procesos en Windows
with ProcessPoolExecutor() as pool: # por defecto, un proceso por núcleo
for anyo, total in pool.map(generar_informe_pesado, [2023, 2024, 2025, 2026]):
print(f"informe {anyo} listo")La misma API que ThreadPoolExecutor — solo cambia la clase — y ahí está su elegancia: pruebas con hilos, y si el problema resulta ser de CPU, cambias una palabra. Los costes de los procesos: arrancarlos es lento, no comparten memoria (los argumentos y retornos se serializan para viajar entre procesos, en un proceso parecido al json de M6 — nada de compartir el catalogo alegremente), y el if __name__ == "__main__": de M3 deja de ser opcional: sin él, en Windows cada proceso hijo re-importa el módulo y re-lanza procesos sin fin.
La tabla de decisión
| Tu situación | Herramienta | Por qué |
|---|---|---|
| Muchas esperas de red/disco (consultas, descargas de portadas) | ThreadPoolExecutor |
El GIL se libera al esperar; las esperas se solapan |
| Cálculo intensivo (informes pesados, estadísticas) | ProcessPoolExecutor |
Cada proceso tiene su GIL: paralelismo real |
| Miles de conexiones simultáneas | asyncio (próxima lección) |
Los hilos no escalan a esos números |
| Tarda poco, se ejecuta una vez, o el código es simple | Nada: secuencial | La concurrencia añade bugs sutiles; no la pagues sin necesidad |
La última fila va en serio: el 90 % del código de Papyrus — vender un libro, guardar el catálogo, el cierre diario — es y debe seguir siendo secuencial. La concurrencia se añade donde hay una espera medida que duele, no por defecto.
Errores Comunes y Consejos
- Usar hilos para acelerar cálculo puro: el GIL lo impide; obtendrás el mismo tiempo con más complejidad. Cálculo → procesos.
- Olvidar
join()(o elwithdel pool): el programa principal sigue su curso y puede terminar con consultas a medias. - Compartir estado mutable sin
Lock: contadores, listas o elcatalogotocados desde varios hilos son condiciones de carrera latentes. Mejor aún que elLock: que cada tarea devuelva su resultado y el hilo principal los combine — los futuros existen para eso. if __name__ == "__main__":ausente conProcessPoolExecutor: en Windows produce un error de arranque (o una cascada de procesos). Es el error número uno con procesos.- Ignorar las excepciones de los futuros: si nunca llamas a
futuro.result(), la excepción del hilo muere en silencio — el pecado de 07-04 en versión concurrente. Llama siempre aresult()dentro de untry. - Consejo: mide antes y después con el
@cronometrarde 08-02 o elcronometrode 08-04. Si la versión concurrente no gana claramente, vuelve a la secuencial.
Ejercicios
-
Escribe
consultar_todas(titulo)que consulte las tres distribuidoras conThreadPoolExecutor.mapy devuelva la más barata como tupla(distribuidora, precio). Usa laconsultar_preciode la lección (con sutime.sleep(2)) y comprueba contime.perf_counter()que el total ronda los 2 s. -
Sin ejecutarlo: ¿qué imprime (aproximadamente) este código y por qué? ¿Qué única palabra cambiarías para arreglarlo?
def digerir_estadisticas(datos): return sum(x * x for x in datos) # CPU pura with ThreadPoolExecutor(max_workers=4) as pool: resultados = list(pool.map(digerir_estadisticas, cuatro_bloques_grandes)) -
Dos hilos reponen stock del mismo
Libroconlibro.stock += n. Explica la condición de carrera paso a paso (con valores concretos: stock inicial 4, dos reposiciones de 3) y arréglala con unLock, protegiendo el mínimo código posible.
Soluciones
-
from concurrent.futures import ThreadPoolExecutor import time def consultar_todas(titulo: str) -> tuple[str, float]: distribuidoras = ["Distribuidora A", "Distribuidora B", "Distribuidora C"] inicio = time.perf_counter() with ThreadPoolExecutor(max_workers=3) as pool: resultados = list(pool.map(lambda d: consultar_precio(d, titulo), distribuidoras)) print(f"consultado en {time.perf_counter() - inicio:.1f} s") # ~2.0 s return min(resultados, key=lambda r: r[1]) # el sorted key de M3Tres esperas de 2 s solapadas ≈ 2 s total;
min(key=...)elige la barata. -
Imprime los resultados correctos, pero tarda igual (o más) que en secuencial:
digerir_estadisticases CPU pura y el GIL solo deja avanzar un hilo a la vez — los 4 workers hacen turnos, no paralelismo. La palabra a cambiar:ThreadPoolExecutor→ProcessPoolExecutor(recordando elif __name__ == "__main__":). -
Carrera: el hilo A lee
stock = 4; el sistema cambia a B, que lee también4, suma 3 y escribe7; A despierta y escribe su resultado,7. Stock final: 7 en lugar de 10 — una reposición de Fausto se ha esfumado sin dejar rastro. Arreglo:cerrojo = threading.Lock() def reponer_concurrente(libro, n: int) -> None: with cerrojo: libro.stock += n # SOLO la operación leer-sumar-escribirEl
with cerrojo:envuelve únicamente la línea crítica: cualquier trabajo previo (validarn, logging) queda fuera para no secuencializar de más.
Conclusión
Los hilos solapan esperas (el GIL se suelta en E/S), los procesos paralelizan cálculo (cada uno con su GIL), concurrent.futures da a ambos la misma API con futuros que transportan resultados y excepciones, y compartir memoria exige Lock — que resultó ser un administrador de contexto de la lección 08-04 trabajando a destajo. Y la opción más subestimada sigue disponible: no usar concurrencia cuando no hay una espera que duela. Pero queda una pregunta incómoda: para esperar 3 consultas montamos 3 hilos, con su Lock, sus carreras potenciales y su coste por hilo. ¿Y si hay 500 consultas? ¿500 hilos? Existe otro modelo donde un solo hilo gestiona todas las esperas turnándose entre tareas — sin cerrojos ni carreras — y se llama asyncio. Es la última lección del módulo, y cierra la visita de Papyrus a los temas avanzados.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
