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

  1. Secuencial, concurrente y paralelo
  2. threading.Thread: el primer hilo
  3. El GIL, explicado honestamente
  4. Condiciones de carrera: el contador que pierde ventas
  5. Lock: zona de un solo hilo
  6. concurrent.futures.ThreadPoolExecutor: la API recomendada
  7. Procesos para CPU: multiprocessing y ProcessPoolExecutor
  8. 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) 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ía

contador_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 += 1

Mientras 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-05
  • submit(f, *args) devuelve un futuro: un recibo por un resultado que llegará.
  • as_completed los 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 with del pool espera a que todas las tareas acaben antes de salir: __exit__ haciendo de join() 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 el with del pool): el programa principal sigue su curso y puede terminar con consultas a medias.
  • Compartir estado mutable sin Lock: contadores, listas o el catalogo tocados desde varios hilos son condiciones de carrera latentes. Mejor aún que el Lock: que cada tarea devuelva su resultado y el hilo principal los combine — los futuros existen para eso.
  • if __name__ == "__main__": ausente con ProcessPoolExecutor: 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 a result() dentro de un try.
  • Consejo: mide antes y después con el @cronometrar de 08-02 o el cronometro de 08-04. Si la versión concurrente no gana claramente, vuelve a la secuencial.

Ejercicios

  1. Escribe consultar_todas(titulo) que consulte las tres distribuidoras con ThreadPoolExecutor.map y devuelva la más barata como tupla (distribuidora, precio). Usa la consultar_precio de la lección (con su time.sleep(2)) y comprueba con time.perf_counter() que el total ronda los 2 s.

  2. 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))
    
  3. Dos hilos reponen stock del mismo Libro con libro.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 un Lock, protegiendo el mínimo código posible.

Soluciones

  1. 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 M3
    

    Tres esperas de 2 s solapadas ≈ 2 s total; min(key=...) elige la barata.

  2. Imprime los resultados correctos, pero tarda igual (o más) que en secuencial: digerir_estadisticas es CPU pura y el GIL solo deja avanzar un hilo a la vez — los 4 workers hacen turnos, no paralelismo. La palabra a cambiar: ThreadPoolExecutorProcessPoolExecutor (recordando el if __name__ == "__main__":).

  3. Carrera: el hilo A lee stock = 4; el sistema cambia a B, que lee también 4, suma 3 y escribe 7; 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-escribir
    

    El with cerrojo: envuelve únicamente la línea crítica: cualquier trabajo previo (validar n, 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados