Concurrencia en Python: threads, multiprocessing y asyncio

D
DanisCh
(Actualizado: ) 6 min de lectura
Concurrencia en Python: threads, multiprocessing y asyncio
Python

Python ofrece tres modelos principales para escribir código concurrente o paralelo: el módulo threading, el módulo multiprocessing y el modelo asíncrono basado en asyncio. Cada uno resuelve un problema distinto y elegir el adecuado depende de si la tarea está limitada por E/S (I/O-bound) o por cómputo (CPU-bound).

El GIL: la pieza clave para entender Python

CPython, la implementación de referencia de Python, incluye un mecanismo llamado Global Interpreter Lock (GIL), un candado global que permite que solo un hilo ejecute bytecode de Python a la vez, incluso en máquinas con varios núcleos. Esto significa que el módulo threading no ofrece paralelismo real para tareas que consumen CPU, aunque sí es muy útil cuando los hilos pasan la mayor parte del tiempo esperando (por ejemplo, en operaciones de red o disco).

Desde Python 3.13 existe una compilación experimental "free-threaded" sin GIL, pero en la mayoría de entornos de producción el GIL sigue presente, por lo que la distinción entre tareas I/O-bound y CPU-bound continúa siendo fundamental.

1. threading: concurrencia para tareas de E/S

El módulo threading permite ejecutar varias tareas "al mismo tiempo" cediendo el control mientras se espera una operación externa (red, disco, bases de datos). Es ideal para tareas I/O-bound.

Ejemplo básico

import threading
import time

def descargar(nombre, segundos):
    print(f"Iniciando descarga de {nombre}")
    time.sleep(segundos)  # simula espera de red
    print(f"{nombre} descargado")

hilos = []
for nombre, segundos in [("archivo1", 2), ("archivo2", 1), ("archivo3", 3)]:
    hilo = threading.Thread(target=descargar, args=(nombre, segundos))
    hilos.append(hilo)
    hilo.start()

for hilo in hilos:
    hilo.join()

print("Todas las descargas completadas")

Sincronización con Lock

Cuando varios hilos modifican un mismo recurso compartido, es necesario sincronizar el acceso con un Lock para evitar condiciones de carrera.

import threading

contador = 0
lock = threading.Lock()

def incrementar():
    global contador
    for _ in range(100_000):
        with lock:
            contador += 1

hilos = [threading.Thread(target=incrementar) for _ in range(4)]
for h in hilos:
    h.start()
for h in hilos:
    h.join()

print(contador)  # 400000

ThreadPoolExecutor

El módulo concurrent.futures simplifica la gestión de hilos mediante un pool de trabajadores:

from concurrent.futures import ThreadPoolExecutor
import requests

urls = ["https://example.com"] * 5

def obtener(url):
    return requests.get(url).status_code

with ThreadPoolExecutor(max_workers=5) as executor:
    resultados = list(executor.map(obtener, urls))

print(resultados)

2. multiprocessing: paralelismo real para CPU

Para tareas CPU-bound (cálculos intensivos, procesamiento de imágenes, machine learning), el módulo multiprocessing es la opción correcta: crea procesos independientes, cada uno con su propio intérprete y memoria, evitando así las limitaciones del GIL y aprovechando varios núcleos del procesador.

Ejemplo básico

import multiprocessing
import time

def calcular_cuadrado(n):
    return n * n

if __name__ == "__main__":
    numeros = list(range(10))

    inicio = time.time()
    with multiprocessing.Pool(processes=4) as pool:
        resultados = pool.map(calcular_cuadrado, numeros)

    print(resultados)
    print(f"Tiempo: {time.time() - inicio:.2f}s")

El bloque if __name__ == "__main__": es obligatorio en Windows y recomendable en cualquier plataforma, ya que multiprocessing reimporta el módulo principal al crear cada nuevo proceso.

Comunicación entre procesos

Como los procesos no comparten memoria, se necesitan mecanismos específicos para intercambiar datos, como Queue o Pipe:

from multiprocessing import Process, Queue

def productor(q):
    for i in range(5):
        q.put(i)
    q.put(None)  # señal de fin

def consumidor(q):
    while True:
        item = q.get()
        if item is None:
            break
        print(f"Procesado: {item}")

if __name__ == "__main__":
    q = Queue()
    p1 = Process(target=productor, args=(q,))
    p2 = Process(target=consumidor, args=(q,))
    p1.start(); p2.start()
    p1.join(); p2.join()

ProcessPoolExecutor

from concurrent.futures import ProcessPoolExecutor

def es_primo(n):
    if n < 2:
        return False
    return all(n % i != 0 for i in range(2, int(n**0.5) + 1))

if __name__ == "__main__":
    numeros = range(100_000, 100_020)
    with ProcessPoolExecutor() as executor:
        resultados = list(executor.map(es_primo, numeros))
    print(resultados)

3. asyncio: concurrencia cooperativa de un solo hilo

asyncio implementa concurrencia mediante un único hilo y un bucle de eventos (event loop), donde las tareas ceden el control voluntariamente con await en lugar de ser interrumpidas por el sistema operativo. Es la opción más eficiente para manejar miles de conexiones de red simultáneas, como en servidores web o clientes HTTP masivos.

Ejemplo básico

import asyncio

async def descargar(nombre, segundos):
    print(f"Iniciando descarga de {nombre}")
    await asyncio.sleep(segundos)  # espera no bloqueante
    print(f"{nombre} descargado")
    return nombre

async def main():
    resultados = await asyncio.gather(
        descargar("archivo1", 2),
        descargar("archivo2", 1),
        descargar("archivo3", 3),
    )
    print("Completados:", resultados)

asyncio.run(main())

Tareas concurrentes con create_task

import asyncio

async def tarea(nombre, segundos):
    await asyncio.sleep(segundos)
    print(f"{nombre} terminada")

async def main():
    t1 = asyncio.create_task(tarea("A", 2))
    t2 = asyncio.create_task(tarea("B", 1))
    await t1
    await t2

asyncio.run(main())

Peticiones HTTP asíncronas con aiohttp

import asyncio
import aiohttp

async def obtener(session, url):
    async with session.get(url) as response:
        return response.status

async def main():
    urls = ["https://example.com"] * 5
    async with aiohttp.ClientSession() as session:
        tareas = [obtener(session, url) for url in urls]
        resultados = await asyncio.gather(*tareas)
    print(resultados)

asyncio.run(main())

Es importante recordar que asyncio requiere librerías compatibles (aiohttp, asyncpg, etc.); usar librerías bloqueantes como requests dentro de código asíncrono anula sus beneficios, ya que bloquea el único hilo del event loop.

Comparativa: cuándo usar cada uno

ModeloTipo de tareaParalelismo realCaso de uso típico
threadingI/O-boundNo (limitado por el GIL)Peticiones HTTP simples, lectura de archivos, scraping moderado
multiprocessingCPU-boundSí (procesos separados)Cálculos numéricos, procesamiento de imágenes, machine learning
asyncioI/O-bound de alta concurrenciaNo (un solo hilo)Servidores web, miles de conexiones simultáneas, websockets

Combinando modelos

En aplicaciones reales es común combinar varios enfoques. Por ejemplo, un servidor asíncrono puede delegar tareas CPU-intensivas a un pool de procesos sin bloquear el event loop:

import asyncio
from concurrent.futures import ProcessPoolExecutor

def tarea_pesada(n):
    return sum(i * i for i in range(n))

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        resultado = await loop.run_in_executor(pool, tarea_pesada, 10_000_000)
    print(resultado)

asyncio.run(main())

Buenas prácticas

  • Usa threading o asyncio para tareas limitadas por E/S, nunca multiprocessing si no hay cómputo intensivo (el overhead de crear procesos no compensa).
  • Usa multiprocessing cuando el cuello de botella sea CPU y se busque aprovechar varios núcleos.
  • Prefiere asyncio frente a threading cuando se necesiten miles de conexiones concurrentes, por su menor consumo de memoria por tarea.
  • Evita mezclar código bloqueante dentro de funciones async; usa run_in_executor si es inevitable.
  • Protege siempre los recursos compartidos con mecanismos de sincronización (Lock, Semaphore, colas) para evitar condiciones de carrera.

Conclusión

No existe un único modelo de concurrencia "mejor" en Python: la elección depende de la naturaleza del problema. threading resuelve bien la espera de E/S con poca complejidad, multiprocessing es la vía para lograr paralelismo real en tareas intensivas en CPU, y asyncio ofrece la mayor escalabilidad para escenarios con miles de operaciones de E/S concurrentes. Entender el papel del GIL y las diferencias entre tareas I/O-bound y CPU-bound es el primer paso para elegir la herramienta adecuada en cada situación.

Etiquetas: Python

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a