AulaML Python · C++
Python avanzado · 48 min · Python avanzado

Concurrencia, multiprocessing y asyncio

Elegir hilos, procesos o corrutinas según el trabajo sea de entrada/salida o de CPU

Tres modelos de concurrencia, tres problemas distintos. Entender el GIL, el bucle de eventos y el coste de crear procesos te permite elegir hilos para esperar, procesos para calcular y asyncio para miles de conexiones sin bloquear.

Lección 6 de 6 del módulo
Al terminar esta lección podrás
  • Distinguir concurrencia de paralelismo y trabajo de E/S de trabajo de CPU
  • Explicar qué limita el GIL y por qué los hilos sí sirven para entrada/salida
  • Escribir código con threading, multiprocessing y async/await
  • Trazar el orden de ejecución de varias corrutinas en un event loop
  • Reconocer y evitar condiciones de carrera y bloqueos mutuos

Antes necesitas: Iteradores y generadores · Errores y excepciones

En esta lección
  1. ¿Qué es la concurrencia?
  2. ¿Para qué sirve cada modelo?
  3. ¿Cómo funciona por dentro? GIL, procesos y event loop
  4. Sintaxis: tres formas de lanzar trabajo
  5. Ejemplo básico: dos descargas con hilos
  6. Ejemplo intermedio: las mismas descargas con asyncio
  7. Caso práctico: repartir cálculo entre procesos
  8. Visualización: el orden real de un event loop
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

Tu programa pasa la mayor parte del tiempo esperando: la respuesta de una API, un disco, una consulta a la base de datos. Mientras espera, la CPU está libre. La concurrencia es la capacidad de organizar el trabajo para aprovechar esas pausas, y Python ofrece tres herramientas con costes y límites distintos: hilos, procesos y corrutinas.

La palabra clave es elegir. Lanzar hilos a todo no acelera un cálculo numérico, y usar asyncio con una biblioteca bloqueante lo vuelve más lento que el código secuencial.

¿Qué es la concurrencia?

Concurrencia es tener varias tareas en curso a la vez; paralelismo es ejecutarlas físicamente al mismo tiempo en varios núcleos. Un solo cocinero que alterna dos ollas es concurrente; dos cocineros, uno por olla, son paralelos. Los hilos y asyncio te dan concurrencia en un proceso; los procesos te dan paralelismo real.

Hay una segunda distinción que decide casi todo: el tipo de trabajo. El trabajo de E/S (I/O bound) pasa el tiempo esperando datos: red, disco, base de datos. El trabajo de CPU (CPU bound) pasa el tiempo calculando: bucles numéricos, procesar imágenes, entrenar un modelo. La primera categoría se beneficia de hilos o asyncio; la segunda, de procesos.

¿Para qué sirve cada modelo?

  • Hilos (threading): descargar varios archivos, consultar varias APIs o leer muchos ficheros. Código secuencial que sigue leyéndose igual, con memoria compartida.
  • Procesos (multiprocessing): repartir un cálculo pesado entre núcleos. Cada proceso tiene su propia memoria y evita el GIL.
  • asyncio: manejar cientos o miles de conexiones con un solo hilo, sin crear un hilo por conexión. Es el modelo de FastAPI, aiohttp y los clientes de bases de datos asíncronos.
  • Ninguno: si el programa dura milisegundos o el cuello de botella es una sola operación, la versión secuencial suele ser más simple y rápida.

¿Cómo funciona por dentro? GIL, procesos y event loop

CPython tiene el GIL (Global Interpreter Lock): un candado que permite que un solo hilo ejecute bytecode Python a la vez, aunque la máquina tenga ocho núcleos. No es un candado de tu código, es del intérprete. Por eso dos hilos que suman en un bucle no van el doble de rápido: se turnan.

El GIL se libera cuando un hilo hace una operación de E/S o llama a una extensión de C que lo suelta (NumPy, por ejemplo). Ahí está la grieta que hace útiles los hilos: mientras uno espera la red, otro ejecuta Python. Para cálculo puro, en cambio, los hilos compiten por el mismo candado.

multiprocessing esquiva el problema creando procesos separados, cada uno con su intérprete y su GIL. El precio es alto: no comparten variables, los argumentos y resultados se serializan con pickle, y arrancar un proceso cuesta más que arrancar un hilo. En macOS y Windows el proceso hijo se inicia de nuevo (spawn), así que el código de arranque debe ir bajo if __name__ == "__main__":.

asyncio usa un único hilo y un bucle de eventos (event loop). El bucle mantiene una cola de tareas listas y otra de tareas esperando. Una función async def no se ejecuta al llamarla: devuelve una corrutina, un objeto que hay que programar. Cuando la corrutina llega a un await sobre una operación pendiente, se suspende y devuelve el control al bucle, que aprovecha para avanzar otra tarea. Cuando la operación termina, la corrutina se reanuda justo después del await.

Ese detalle explica el error más caro de asyncio: si dentro de una corrutina llamas a time.sleep(5) o a una biblioteca bloqueante, no hay await y el bucle entero se congela. Cinco segundos de espera se convierten en cinco segundos para todas las tareas. La regla es usar versiones asíncronas (asyncio.sleep, aiohttp, asyncpg) y dejar el código bloqueante en un hilo aparte con asyncio.to_thread.

Sintaxis: tres formas de lanzar trabajo

PYTHON El mismo patrón en los tres modelos
# Hilo: comparte memoria
import threading
hilo = threading.Thread(target=funcion, args=(dato,))
hilo.start()
hilo.join()

# Proceso: memoria separada
import multiprocessing
proceso = multiprocessing.Process(target=funcion, args=(dato,))
proceso.start()
proceso.join()

# Corrutina: hay que esperarla
import asyncio
asyncio.run(main())

Los dos primeros comparten la forma crear, start, join: join espera a que el hilo o el proceso termine. En asyncio no hay join, sino await, que espera el resultado de una corrutina y, mientras tanto, deja avanzar otras tareas.

Ejemplo básico: dos descargas con hilos

Cada hilo simula una descarga de un segundo con time.sleep, que libera el GIL. Si el trabajo fuera secuencial, el total sería de dos segundos; con hilos, de uno.

PYTHON hilos.py
import threading
import time

def descargar(nombre, segundos):
    print(f"inicia {nombre}")
    time.sleep(segundos)
    print(f"fin {nombre}")

inicio = time.perf_counter()
hilos = [
    threading.Thread(target=descargar, args=(f"archivo{i}", 1))
    for i in (1, 2)
]
for hilo in hilos:
    hilo.start()
for hilo in hilos:
    hilo.join()

print(f"total: {time.perf_counter() - inicio:.1f} s")

La salida deja claro que ambos empiezan antes de que ninguno acabe: inicia archivo1, inicia archivo2, fin archivo2, fin archivo1, total: 1.0 s. El orden de los fin no está garantizado. start() lanza el hilo y devuelve el control; join() bloquea el hilo principal hasta que ese hilo termina. Sin join, el programa podría acabar antes que las descargas.

Ejemplo intermedio: las mismas descargas con asyncio

Con asyncio no hay hilos: hay una sola función principal que crea dos corrutinas y las deja avanzar por turnos. gather las ejecuta concurrentemente y devuelve una lista con los resultados en el orden en que se pasaron.

PYTHON async.py
import asyncio
import time

async def descargar(nombre, segundos):
    print(f"inicia {nombre}")
    await asyncio.sleep(segundos)
    print(f"fin {nombre}")

async def main():
    inicio = time.perf_counter()
    await asyncio.gather(descargar("uno", 1), descargar("dos", 1))
    print(f"total: {time.perf_counter() - inicio:.1f} s")

asyncio.run(main())

async def define una corrutina; llamarla no ejecuta el cuerpo. await asyncio.sleep(1) suspende esa corrutina y entrega el control al bucle, que arranca la otra. asyncio.run crea el bucle, ejecuta main hasta el final y lo cierra. El total vuelve a ser de un segundo, con un solo hilo y sin join.

Caso práctico: repartir cálculo entre procesos

Para trabajo de CPU, Pool reparte las llamadas entre procesos. Cada uno recibe su argumento, calcula y devuelve el resultado serializado. map conserva el orden de la entrada aunque los procesos terminen desordenados.

PYTHON procesos.py
from multiprocessing import Pool

def cuadrado(n):
    return n * n

if __name__ == "__main__":
    with Pool() as pool:
        print(pool.map(cuadrado, range(6)))

La salida es [0, 1, 4, 9, 16, 25]. El guardián if __name__ == "__main__": no es opcional en macOS y Windows: al crear el proceso, el hijo vuelve a importar el archivo y volvería a crear otro Pool en un bucle infinito. with cierra el pool y espera a que los procesos terminen.

En un caso real, cambiarías cuadrado por una función que procese un lote de imágenes o entrene un modelo sobre una partición de datos. Medir es obligatorio: si cada tarea dura microsegundos, el coste de crear procesos y serializar resultados se come la ganancia.

ModeloIdeal paraCoste principal
HilosE/S moderada, código bloqueante existenteComparten memoria: condiciones de carrera
ProcesosCálculo intensivo en CPUArranque y serialización; memoria separada
asyncioMiles de conexiones simultáneasTodo debe ser asíncrono; un bloqueo congela el bucle

Visualización: el orden real de un event loop

Recorre los pasos y observa que las dos tareas empiezan antes de que ninguna termine: la que espera un segundo acaba primero, aunque se haya lanzado después.

Ejecución visual ·Dos corrutinas, un solo hilo
1 / 8
1 import asyncio
2
3 async def tarea(nombre, espera):
4 print(f"inicia {nombre}")
5 await asyncio.sleep(espera)
6 print(f"termina {nombre}")
7
8 async def main():
9 await asyncio.gather(tarea("A", 2), tarea("B", 1))
10
11 asyncio.run(main())
Se importa asyncio. Todavía no existe ningún bucle ni ninguna tarea.
Variables
asyncio = <module 'asyncio'>
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

SíntomaPor qué ocurreCómo se arregla
El programa se queda colgado sin error ni salidaDos hilos esperan un candado que el otro retiene: interbloqueoAdquiere los candados siempre en el mismo orden o usa un timeout
Un contador compartido termina con valores distintos en cada ejecuciónCondición de carrera: += no es atómicoProtege la sección crítica con threading.Lock
RuntimeWarning: coroutine 'main' was never awaitedLlamaste a una corrutina y no la esperasteUsa asyncio.run(main()) o await main()
RuntimeError: asyncio.run() cannot be called from a running event loopYa hay un bucle activo, típico en JupyterUsa await main() directamente en la celda
Los hilos no aceleran un cálculo numéricoEl GIL impide ejecutar bytecode en paraleloCambia a multiprocessing o a una función vectorizada de NumPy
Dos candados en orden distinto
Incorrecto
PYTHON Interbloqueo garantizado
def tarea_uno():
    with lock_a:
        with lock_b:
            print("uno")

def tarea_dos():
    with lock_b:
        with lock_a:
            print("dos")
Correcto
PYTHON Mismo orden en ambas tareas
def tarea_uno():
    with lock_a:
        with lock_b:
            print("uno")

def tarea_dos():
    with lock_a:
        with lock_b:
            print("dos")

En la versión incorrecta, uno toma A y espera B mientras el otro toma B y espera A: ninguno suelta y el programa se congela. Si todas las tareas adquieren los candados en el mismo orden, la espera circular desaparece.

Buenas prácticas

  • Mide primero: compara la versión secuencial con la concurrente y no des por hecho la mejora.
  • Clasifica la tarea antes de elegir: si espera, hilos o asyncio; si calcula, procesos.
  • No compartas estado mutable entre hilos sin un Lock; prefiere colas (queue.Queue) para pasar datos.
  • En asyncio, nunca llames a time.sleep, requests o una consulta bloqueante dentro de una corrutina.
  • Usa ThreadPoolExecutor o ProcessPoolExecutor antes que crear hilos y procesos a mano.
  • Pon timeout a las esperas y a los candados: un bloqueo silencioso es peor que un error.
  • Documenta el modelo elegido y por qué; el próximo lector no verá tu razonamiento.

Ejercicios

Ejercicio 1

Tres descargas con un pool de hilos

Usa ThreadPoolExecutor para simular tres descargas de un segundo cada una con time.sleep. Recoge los resultados en una lista con pool.map e imprime cuántos segundos tardó el bloque completo. Ejecútalo y comprueba que tarda alrededor de un segundo, no de tres.
Pista

ThreadPoolExecutor(max_workers=3) como gestor de contexto; list(pool.map(funcion, nombres)) espera a que todas terminen y conserva el orden.

Ejercicio 2

Cancelar una consulta lenta

Escribe una corrutina consultar(nombre, segundos) que espere y devuelva un texto. Desde main, envuélvela con asyncio.wait_for(..., timeout=0.5) para una consulta de dos segundos y captura asyncio.TimeoutError imprimiendo un aviso. Comprueba que el programa no se queda esperando los dos segundos.
Pista

wait_for cancela la corrutina cuando vence el plazo y lanza asyncio.TimeoutError. El try/except va alrededor del await.

Completar código Completa la corrutina
import asyncio

 def consultar(nombre):
    print("consultando", nombre)
     asyncio.sleep(1)

async def main():
    await asyncio.(consultar("a"), consultar("b"))

asyncio.run(main())

Una corrutina se declara con `async def`. Para esperar sin bloquear se usa `await`. La función que ejecuta varias a la vez es `gather`.

Soluciones explicadas

En el ejercicio de hilos, la decisión de usar ThreadPoolExecutor en lugar de crear hilos a mano te da dos cosas: un límite de trabajadores y una forma limpia de recoger resultados o excepciones. map reparte las tareas y espera a todas. El tiempo de un segundo demuestra que las esperas se solapan; no demuestra paralelismo, porque no hay cálculo simultáneo.

En el de asyncio, wait_for resuelve un problema real: una dependencia externa que no responde. Cancelar no es abandonar el proceso, es liberar el recurso y seguir. Fíjate en que la cancelación ocurre dentro del bucle, en un punto de await; una corrutina que no cede el control no puede cancelarse a tiempo.

Si juntas ambos mundos, la receta es: asyncio para orquestar la red y asyncio.to_thread para las llamadas bloqueantes que no tienen versión asíncrona. Para cálculo pesado, ProcessPoolExecutor y medir. La pregunta que ordena todo es siempre la misma: ¿el tiempo se va esperando o calculando?

Resumen

  • Concurrencia es alternar tareas; paralelismo es ejecutarlas a la vez en varios núcleos.
  • El GIL permite que un solo hilo ejecute bytecode Python, pero se libera en operaciones de E/S.
  • Los hilos sirven para esperar; los procesos, para calcular; asyncio, para muchas conexiones.
  • Una async def devuelve una corrutina que no corre hasta que se espera o se programa.
  • await suspende la corrutina y devuelve el control al bucle de eventos.
  • Un time.sleep dentro de una corrutina congela todas las tareas del bucle.
  • Los candados tomados en órdenes distintos producen interbloqueos silenciosos.

Evaluación

Evaluación 1

¿Qué implica el GIL de CPython en la práctica?

Evaluación 2

Tienes que redimensionar 500 imágenes y la operación tarda 200 ms de CPU en cada una. ¿Qué modelo conviene?

Evaluación 3

Dentro de una corrutina llamas a time.sleep(3) en lugar de await asyncio.sleep(3). ¿Qué ocurre?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.