Lección 0.4.6 · 55 min

Concurrencia: threading, multiprocessing, asyncio y free-threading

Un sistema de trading pasa casi todo su tiempo esperando: a la red, al bróker, al siguiente tick. Otras veces calcula sin parar: simulaciones, optimizaciones. Cada caso tiene su herramienta en Python, y elegir mal puede no acelerar nada. Esta lección las compara con ejemplos deterministas y explica qué cambia con el Python sin GIL.

Lecciones del nivel 0
  1. 0.1 · Bienvenida y expectativas

    1. 0.1.1 Trading sistemático, algorítmico y cuantitativo frente al discrecional
    2. 0.1.2 Expectativas realistas: costes, tiempo y riesgo de pérdida
    3. 0.1.3 Estructura del curso, tests de entrada, repositorio personal y aprendizaje con IA
  2. 0.2 · Entorno de trabajo moderno

    1. 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
    2. 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
    3. 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
    5. 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
    6. 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
  3. 0.3 · Fundamentos del lenguaje

    1. 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
    2. 0.3.2 Cadenas, f-strings y formato de números y fechas
    3. 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
    4. 0.3.4 Control de flujo: if, elif, else, for, while y match-case
    5. 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
    6. 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
  4. 0.4 · Python intermedio

    1. 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
    2. 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
    3. 0.4.3 Iteradores, generadores, decoradores y context managers
    4. 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
    5. 0.4.5 Logging profesional y testing con pytest
    6. 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
  5. 0.5 · Computación numérica y datos

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
    2. 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
    3. 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
    4. 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
    5. 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
  6. 0.6 · Visualización

    1. 0.6.1 Matplotlib orientado a objetos, Seaborn y Plotly: gráficos de precios, drawdown y velas
    2. 0.6.2 Dashboards rápidos con Streamlit: un panel de seguimiento en un solo script
  7. 0.7 · Matemáticas y estadística mínimas

    1. 0.7.1 Rentabilidades simples y logarítmicas, interés compuesto y estadística descriptiva
    2. 0.7.2 Probabilidad y distribuciones, correlación frente a causalidad y regresión simple
    3. 0.7.3 Álgebra lineal intuitiva: vectores y matrices como preparación para carteras

Esperar o calcular

Un programa puede ir lento por dos motivos muy distintos:

  • Espera (limitado por E/S, entrada/salida): pedir precios a una API, recibir mensajes de un websocket, leer ficheros, escribir en una base de datos. El procesador está casi parado; el tiempo se va en esperar respuestas.
  • Calcula (limitado por CPU): simular miles de trayectorias, optimizar parámetros, recalcular indicadores sobre años de datos. El procesador está al 100 %.

La concurrencia consiste en tener varias tareas en marcha a la vez; el paralelismo, en ejecutarlas literalmente al mismo tiempo en varios núcleos. Para esperar basta con concurrencia: mientras una petición espera, se lanza otra. Para calcular hace falta paralelismo. Python ofrece tres herramientas, y una cuarta que cambia las reglas:

HerramientaQué creaParalelismo real de código PythonEncaja con
threading / ThreadPoolExecutorHilos en un procesoNo (con GIL); sí en 3.14tEsperas: APIs REST, ficheros
multiprocessing / ProcessPoolExecutorProcesos con su memoriaSíCálculo: simulaciones, optimizaciones
asyncioCorrutinas en un hiloNoMuchas esperas: websockets, varios exchanges
Free-threading (3.14t)Hilos sin GILSíCálculo con memoria compartida

Antes de todo esto hay una opción que no aparece en la tabla: vectorizar. Un cálculo que NumPy o Polars hacen sobre arrays enteros (lecciones 0.5.1 y 0.5.4) corre en C, suele ser decenas de veces más rápido que un bucle de Python y a menudo hace innecesario el paralelismo.

Todos los ejemplos de esta lección son deterministas: esperas simuladas con sleep, simulaciones con semilla y resultados que se recogen en el orden pedido. El orden en que terminan las tareas, en cambio, puede cambiar de una ejecución a otra, y ningún resultado debe depender de él.

Hilos para esperar a la red

La función descargar simula la petición del último cierre de un activo: espera un cuarto de segundo, como una llamada HTTP, y devuelve los cierres del 31 de diciembre de 2025 de cartera_sint.csv. Cuatro peticiones seguidas tardan cuatro cuartos de segundo:

IN [01]python
import time

CIERRES = {"RVAR": 164.35, "RFIJ": 102.29, "ORO": 171.44,
           "TECH": 77.82}

def descargar(simbolo: str) -> tuple[str, float]:
    """Simula una petición HTTP: 0,25 s de espera de red."""
    time.sleep(0.25)
    return simbolo, CIERRES[simbolo]

simbolos = list(CIERRES)
t0 = time.perf_counter()
secuencial = [descargar(s) for s in simbolos]
t_secuencial = time.perf_counter() - t0
print(secuencial)
OUT [01]Cuatro descargas, una tras otra
[('RVAR', 164.35), ('RFIJ', 102.29), ('ORO', 171.44), ('TECH', 77.82)]

Mientras descargar espera, el programa no hace nada. Con hilos, las cuatro esperas se solapan. concurrent.futures.ThreadPoolExecutor mantiene un grupo de hilos y les reparte las tareas; su método map funciona como el map normal y devuelve los resultados en el orden de la entrada, aunque las tareas terminen en otro:

IN [02]python
from concurrent.futures import ThreadPoolExecutor

t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
    en_hilos = list(pool.map(descargar, simbolos))
t_hilos = time.perf_counter() - t0

print(en_hilos == secuencial)
print("al menos el doble de rápido:", t_hilos < t_secuencial / 2)
OUT [02]Las mismas descargas con hilos
True
al menos el doble de rápido: True

Los resultados son idénticos y el tiempo total se acerca al de una sola descarga. La lección no imprime los tiempos porque cambian en cada ejecución; la comparación, con un margen amplio, no. El with cierra el grupo de hilos al salir y espera a que terminen todas las tareas (lección 0.4.3).

Con una API real, el número de hilos lo limita el proveedor, no el ordenador: casi todas las APIs de datos y de brókeres fijan un máximo de peticiones por segundo, y superarlo lleva a errores 429 o a un bloqueo temporal.

Errores y orden

submit lanza una tarea y devuelve un futuro, un objeto que tendrá el resultado cuando la tarea termine. futuro.result() lo entrega o, si la tarea lanzó una excepción, la vuelve a lanzar en el hilo que pregunta. Recorrer los futuros en el orden en que se lanzaron mantiene la salida estable y permite tratar cada error por separado:

IN [03]python
with ThreadPoolExecutor(max_workers=4) as pool:
    futuros = {s: pool.submit(descargar, s)
               for s in ["ORO", "XYZ", "TECH"]}

for simbolo, futuro in futuros.items():
    try:
        print(simbolo, futuro.result()[1])
    except KeyError as e:
        print(simbolo, "error:", repr(e))
OUT [03]Un símbolo que no existe
ORO 171.44
XYZ error: KeyError('XYZ')
TECH 77.82

Un fallo no tumba las demás descargas. concurrent.futures.as_completed(futuros) entregaría los futuros según terminan, útil para mostrar progreso, pero en un orden que puede cambiar en cada ejecución.

Estado compartido: Lock

Los hilos comparten la memoria del proceso. Si varios modifican la misma variable a la vez, una actualización puede pisar a otra: exposicion += importe lee, suma y escribe en pasos separados, y otro hilo puede colarse entre ellos. El error es intermitente, depende de la carga de la máquina y casi nunca sale en las pruebas. La protección es un threading.Lock: solo un hilo a la vez ejecuta el bloque with cerrojo:

IN [04]python
import threading

exposicion = 0.0
cerrojo = threading.Lock()

def registrar_ejecucion(importe: float) -> None:
    global exposicion
    with cerrojo:
        exposicion += importe

importes = [7_782.0, 17_144.0, -3_500.0, 10_229.0] * 2_500
with ThreadPoolExecutor(max_workers=8) as pool:
    list(pool.map(registrar_ejecucion, importes))
print(f"{exposicion:,.2f}", f"{sum(importes):,.2f}")
OUT [04]Exposición compartida con Lock
79,137,500.00 79,137,500.00

Con el cerrojo, el total coincide siempre con la suma directa. El diseño más seguro, de todos modos, es no compartir: que cada tarea devuelva su resultado y que el hilo principal los sume al final, como en las descargas.

Procesos para calcular

Los hilos no aceleran el cálculo en Python con GIL (el Global Interpreter Lock): un cerrojo dentro del intérprete que deja ejecutar código Python a un solo hilo a la vez. Los hilos lo sueltan mientras esperan (por eso funcionan con la red), pero no mientras calculan. Para usar varios núcleos con código Python puro, la solución clásica son varios procesos, cada uno con su intérprete, su GIL y su memoria.

El cálculo de ejemplo es una simulación de Monte Carlo: para cada semilla, 2.000 años simulados de 252 sesiones con rentabilidades normales, y el peor drawdown encontrado. Es Python puro, sin NumPy, a propósito:

IN [05]python
import random

def peor_drawdown(semilla: int, anios: int = 2_000) -> float:
    """Peor drawdown en `anios` años simulados de 252 sesiones."""
    rng = random.Random(semilla)
    peor = 0.0
    for _ in range(anios):
        valor = pico = 1.0
        for _ in range(252):
            valor *= 1 + rng.gauss(0.0004, 0.012)
            pico = max(pico, valor)
            peor = min(peor, valor / pico - 1)
    return peor

semillas = [1, 2, 3, 4]
peores = [peor_drawdown(s) for s in semillas]
print([f"{p:.2%}" for p in peores])
OUT [05]Una simulación que consume CPU
['-49.04%', '-44.49%', '-49.17%', '-48.19%']

Con una volatilidad diaria del 1,2 % (un 19 % anual), el peor de 2.000 años simulados pierde entre el 44 % y el 49 % desde su máximo según la semilla: los años malos de verdad son raros, pero existen. Cada semilla tarda una fracción de segundo; con cientos de semillas, el reparto entre núcleos empieza a importar.

Con multiprocessing, un Pool de procesos reparte las semillas y map devuelve los resultados en orden. Aquí entra una novedad de Python 3.14: en Linux, el método de arranque por defecto pasó de fork a forkserver.1Nota 1 · Documentación de multiprocessing en Python 3.14: «On POSIX platforms the default start method was changed from fork to forkserver to retain the performance but avoid common multithreaded process incompatibilities». En macOS y Windows sigue siendo spawn.

  • fork copia el proceso padre tal cual, con todas sus funciones y datos. Es rápido, pero copiar un proceso que tiene hilos en marcha puede dejar el hijo bloqueado, y por eso dejó de ser el valor por defecto.
  • forkserver arranca al principio un proceso servidor limpio, de un solo hilo, y crea los hijos a partir de él. Los hijos no heredan las funciones definidas en el programa: las reciben por nombre y las importan, así que tienen que estar en un módulo importable.
  • spawn arranca un intérprete nuevo para cada hijo. Es el valor por defecto en Windows y macOS.

En un notebook, las funciones definidas en una celda no están en ningún fichero importable, así que con forkserver o spawn el pool falla. La salida en un cuaderno es pedir fork explícitamente con un contexto:

IN [06]python
import multiprocessing as mp

print("por defecto:", mp.get_start_method())
ctx = mp.get_context("fork")
with ctx.Pool(processes=4) as pool:
    en_procesos = pool.map(peor_drawdown, semillas)
print(en_procesos == peores)
OUT [06]La simulación en cuatro procesos
por defecto: forkserver
True

Mismos resultados, porque cada simulación depende solo de su semilla, no del proceso que la ejecuta ni del momento en que termina. Sin semillas por tarea, cada proceso generaría números distintos en cada ejecución.

En un script, la forma recomendada en cualquier plataforma es la otra: las funciones en el nivel superior del fichero y el arranque bajo if __name__ == "__main__":, para que los hijos puedan importar el fichero sin volver a lanzar el pool. Con ProcessPoolExecutor, la interfaz es la misma que con hilos:

IN [07]simular.pypython
from concurrent.futures import ProcessPoolExecutor

from montecarlo import peor_drawdown  # la función, en su módulo


def main() -> None:
    semillas = range(1, 33)
    with ProcessPoolExecutor() as pool:  # un proceso por núcleo
        peores = list(pool.map(peor_drawdown, semillas))
    print(f"peor de {len(peores)} simulaciones: {min(peores):.2%}")


if __name__ == "__main__":
    main()

Los procesos tienen costes que los hilos no tienen: arrancar cada uno lleva su tiempo, no comparten memoria y todo lo que entra o sale de ellos se serializa con pickle. Compensan cuando cada tarea calcula bastante y recibe y devuelve poco, como una simulación que devuelve un número. Repartir entre procesos un DataFrame de millones de filas para hacer una suma suele ir más lento que hacerla en uno.

asyncio: muchas esperas en un solo hilo

asyncio resuelve la espera de otra forma. En lugar de varios hilos, un solo hilo ejecuta un bucle de eventos que alterna entre corrutinas: funciones definidas con async def que, al llegar a un await, ceden el control mientras esperan. Es la base de los clientes de websockets y de las APIs asíncronas de casi todos los exchanges, y de los sistemas que escuchan muchos flujos de datos a la vez (nivel 3).

Tres exchanges ficticios, con latencias distintas, devuelven la mejor compra (bid) y la mejor venta (ask) de BTC. asyncio.gather lanza las tres consultas a la vez y devuelve los resultados en el orden de los argumentos:

IN [08]python
import asyncio

LIBROS = {  # exchange: (latencia en s, bid, ask), ficticios
    "alfa": (0.30, 67_412.5, 67_415.0),
    "beta": (0.10, 67_418.0, 67_421.5),
    "gamma": (0.20, 67_409.0, 67_410.5),
}

async def mejor_precio(exchange: str) -> tuple[str, float, float]:
    latencia, bid, ask = LIBROS[exchange]
    await asyncio.sleep(latencia)  # la petición de red
    return exchange, bid, ask

async def consultar() -> list[tuple[str, float, float]]:
    return await asyncio.gather(*(mejor_precio(e) for e in LIBROS))

cotizaciones = asyncio.run(consultar())
for c in cotizaciones:
    print(c)
compra = min(cotizaciones, key=lambda c: c[2])
venta = max(cotizaciones, key=lambda c: c[1])
print(f"comprar en {compra[0]} a {compra[2]:,.1f}, "
      f"vender en {venta[0]} a {venta[1]:,.1f}")
OUT [08]Tres exchanges a la vez
('alfa', 67412.5, 67415.0)
('beta', 67418.0, 67421.5)
('gamma', 67409.0, 67410.5)
comprar en gamma a 67,410.5, vender en beta a 67,418.0

beta responde primero y alfa el último, pero gather devuelve los resultados en el orden pedido. El total tarda lo que la consulta más lenta, no la suma de las tres. Comprar en gamma a 67.410,5 y vender en beta a 67.418 deja 7,5 dólares por BTC antes de comisiones, que en la práctica se las llevarían enteras: es el tipo de cálculo que hace un arbitrajista, y la razón de que la latencia importe.

asyncio.run crea el bucle, ejecuta la corrutina y lo cierra: es la entrada de un programa asíncrono. Jupyter y Marimo ya tienen un bucle en marcha, y en ellos se escribe directamente await consultar() en la celda.

Un exchange lento no debe retrasar a los demás. asyncio.timeout pone un tiempo límite a una espera y lanza TimeoutError si se supera:

IN [09]python
LIBROS["delta"] = (2.0, 67_420.0, 67_423.0)  # muy lento

async def con_limite(exchange: str, limite: float = 0.5):
    try:
        async with asyncio.timeout(limite):
            return await mejor_precio(exchange)
    except TimeoutError:
        return exchange, None, None

async def consultar_todos():
    return await asyncio.gather(*(con_limite(e) for e in LIBROS))

for c in asyncio.run(consultar_todos()):
    print(c)
OUT [09]Tiempo límite por exchange
('alfa', 67412.5, 67415.0)
('beta', 67418.0, 67421.5)
('gamma', 67409.0, 67410.5)
('delta', None, None)

delta se descarta a los 0,5 s y el resto llega a tiempo. Para lanzar tareas que deben cancelarse juntas si una falla, Python 3.11+ ofrece asyncio.TaskGroup, la forma estructurada de hacer lo mismo que gather.

El GIL y el Python sin GIL

El GIL simplificó durante décadas el intérprete y las extensiones en C, a cambio de que los hilos no pudieran calcular en paralelo. El PEP 703 propuso hacerlo opcional y se aceptó con la condición de un despliegue gradual y reversible.2Nota 2 · PEP 703, estado Final. El Steering Council lo aceptó «with clear proviso: that the rollout be gradual and break as little as possible». Python 3.13 incluyó una compilación experimental sin GIL, y en Python 3.14 esa compilación pasó a estar soportada oficialmente (PEP 779), aunque sigue siendo opcional: el Python que se instala por defecto lleva GIL.3Nota 3 · PEP 779, estado Final, aceptado el 16 de junio de 2025; «What's New In Python 3.14»: «Free-threaded Python is officially supported» y «The performance penalty on single-threaded code in free-threaded mode is now roughly 5-10%» (https://docs.python.org/3.14/whatsnew/3.14.html). Hacerla la opción por defecto queda para un PEP futuro.

La compilación sin GIL se llama free-threading y su ejecutable lleva una t (python3.14t). Con ella, los hilos ejecutan código Python en paralelo, y una simulación como la de la lección escala con hilos, sin los costes de los procesos. Tiene contrapartidas: según las notas de la versión, el código de un solo hilo va entre un 5 % y un 10 % más lento, y una extensión en C que no declare compatibilidad vuelve a activar el GIL al importarse, con un aviso.

Desde Python se comprueba qué compilación se está usando y si el GIL está activo:

IN [10]python
import sys
import sysconfig

print(sys.version_info[:3])
print("compilación free-threading:",
      bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL activo:", sys._is_gil_enabled())
OUT [10]¿Hay GIL en este intérprete?
(3, 14, 8)
compilación free-threading: False
GIL activo: True

El curso usa la compilación normal, con GIL. Para probar la otra, uv instala las dos variantes:4Nota 4 · Documentación de uv, «Python versions»: uv instala variantes free-threaded desde CPython 3.13; en 3.14, uv python install 3.14 sigue prefiriendo la compilación con GIL (https://docs.astral.sh/uv/concepts/python-versions/).

uv python install 3.14t
uv run --python 3.14t python simular_hilos.py
PYTHON_GIL=1 uv run --python 3.14t python simular_hilos.py  # GIL forzado

Con free-threading, el código que comparte estado entre hilos necesita más que nunca los Lock: sin GIL, las condiciones de carrera que antes eran raras se vuelven frecuentes.

Qué usar en cada caso

El árbol de decisión, de arriba abajo:

esperapocascientoscalculasíno3.14t¿Espera o calcula?¿Muchas a la vez?Hilosasyncio¿Se vectoriza?NumPy o PolarsProcesosHilos sin GIL
Fuente Mermaid
flowchart TB
  Q{¿Espera o calcula?} -->|espera| N{¿Muchas a la vez?}
  N -->|pocas| T[Hilos]
  N -->|cientos| A[asyncio]
  Q -->|calcula| V{¿Se vectoriza?}
  V -->|sí| NP[NumPy o Polars]
  V -->|no| P[Procesos]
  P -.->|3.14t| H[Hilos sin GIL]

Para pedir precios de unas decenas de símbolos a una API REST, hilos; para mantener abiertos cientos de websockets, asyncio; para un cálculo que se puede expresar con arrays, NumPy o Polars antes que cualquier otra cosa; para un cálculo en Python puro que no se vectoriza (simulaciones con lógica de trading barra a barra, optimizaciones), procesos, o hilos en una compilación sin GIL. Y en todos los casos, resultados que no dependan del orden en que terminan las tareas.

Ejercicios

  1. Repite las descargas con max_workers=2. ¿Cambian los resultados? ¿Qué esperas que pase con el tiempo total?
Solución
IN [11]python
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as pool:
    dos_hilos = list(pool.map(descargar, simbolos))
t_dos = time.perf_counter() - t0
print(dos_hilos == secuencial)
print("más lento que con 4 hilos:", t_dos > t_hilos)
OUT [11]Menos hilos
True
más lento que con 4 hilos: True

Los resultados no cambian. Con dos hilos, las cuatro descargas se hacen en dos tandas: unos 0,5 s, el doble que con cuatro y la mitad que en secuencia. max_workers es el número de peticiones simultáneas, el parámetro que hay que ajustar al límite de la API.

  1. Un exchange admite como máximo dos peticiones simultáneas. Con asyncio.Semaphore(2), consulta alfa, beta y gamma sin pasar de dos a la vez y comprueba que el resultado es el mismo que antes.
Solución
IN [12]python
async def consultar_con_limite(maximo: int = 2):
    semaforo = asyncio.Semaphore(maximo)

    async def una(exchange: str):
        async with semaforo:  # espera turno si ya hay `maximo`
            return await mejor_precio(exchange)

    nombres = ["alfa", "beta", "gamma"]
    return await asyncio.gather(*(una(e) for e in nombres))

print(asyncio.run(consultar_con_limite()) == cotizaciones)
OUT [12]Límite de peticiones con un semáforo
True

El semáforo deja pasar a dos corrutinas y hace esperar a la tercera hasta que una termina. Es la herramienta habitual para respetar los límites de peticiones de una API asíncrona.

  1. Con el pool de procesos, lanza peor_drawdown para las semillas 1 a 4 con 500 años en lugar de 2.000 (usa pool.starmap con pares (semilla, anios)). ¿Por qué el peor drawdown es menos extremo?
Solución
IN [13]python
with ctx.Pool(processes=4) as pool:
    cortos = pool.starmap(peor_drawdown, [(s, 500) for s in semillas])
print([f"{p:.2%}" for p in cortos])
OUT [13]starmap con dos argumentos
['-49.04%', '-41.01%', '-43.26%', '-38.45%']

starmap desempaqueta cada tupla como argumentos. Con menos años simulados hay menos ocasiones de encontrar una racha mala: el peor caso de 500 años es como mucho igual de extremo que el de 2.000, y casi siempre menos. Es la misma razón por la que un backtest corto subestima el drawdown que una estrategia puede llegar a tener.

Footnotes

  1. Documentación de multiprocessing en Python 3.14: «On POSIX platforms the default start method was changed from fork to forkserver to retain the performance but avoid common multithreaded process incompatibilities». En macOS y Windows sigue siendo spawn. ↩

  2. PEP 703, estado Final. El Steering Council lo aceptó «with clear proviso: that the rollout be gradual and break as little as possible». ↩

  3. PEP 779, estado Final, aceptado el 16 de junio de 2025; «What's New In Python 3.14»: «Free-threaded Python is officially supported» y «The performance penalty on single-threaded code in free-threaded mode is now roughly 5-10%» (https://docs.python.org/3.14/whatsnew/3.14.html). ↩

  4. Documentación de uv, «Python versions»: uv instala variantes free-threaded desde CPython 3.13; en 3.14, uv python install 3.14 sigue prefiriendo la compilación con GIL (https://docs.astral.sh/uv/concepts/python-versions/). ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Hay que pedir el último precio de 200 símbolos a una API REST. ¿Qué herramienta encaja mejor?

    • Amultiprocessing, con un proceso por símbolo
    • BHilos o asyncio: la tarea es esperar a la red, y mientras un hilo espera los demás pueden lanzar sus peticiones
    • CUn bucle secuencial: Python no puede hacer dos cosas a la vez
    • DNumPy
    Ver respuesta

    Respuesta B. Hilos o asyncio: la tarea es esperar a la red, y mientras un hilo espera los demás pueden lanzar sus peticiones

    Es una tarea de E/S: casi todo el tiempo se va en esperar respuestas. Durante la espera el GIL se libera, así que los hilos (o las corrutinas) se solapan. Doscientos procesos costarían mucha más memoria sin ninguna ventaja.

  2. ¿En qué orden devuelve los resultados ThreadPoolExecutor.map(f, simbolos)?

    • AEn el orden en que terminan las tareas
    • BEn el orden de simbolos, aunque las tareas terminen en otro orden
    • CAl azar
    • DOrdenados alfabéticamente
    Ver respuesta

    Respuesta B. En el orden de simbolos, aunque las tareas terminen en otro orden

    map conserva el orden de la entrada. as_completed, en cambio, entrega los futuros según terminan, un orden que puede cambiar en cada ejecución.

  3. En Python 3.14 sobre Linux, ¿cuál es el método de arranque por defecto de multiprocessing?

    • Afork
    • Bspawn
    • Cforkserver
    • DDepende de la CPU
    Ver respuesta

    Respuesta C. forkserver

    Python 3.14 cambió el valor por defecto de fork a forkserver en Linux y el resto de Unix salvo macOS, donde sigue spawn. La función que se envía a otro proceso tiene que poder importarse desde un módulo; para fork hay que pedirlo con get_context("fork").

  4. ¿Qué hace el modo free-threading (compilación 3.14t)?

    • AEjecuta las corrutinas de asyncio en paralelo
    • BElimina el GIL, de modo que varios hilos pueden ejecutar código Python en paralelo en varios núcleos
    • CSustituye a multiprocessing en todas las plataformas
    • DAcelera el código de un solo hilo
    Ver respuesta

    Respuesta B. Elimina el GIL, de modo que varios hilos pueden ejecutar código Python en paralelo en varios núcleos

    Sin GIL, los hilos de código Python puro pueden usar varios núcleos. Tiene un coste: según las notas de la 3.14, el código de un solo hilo va un 5–10 % más lento, y algunas extensiones en C vuelven a activar el GIL.

Ideas clave

  • Primero, la pregunta: ¿el programa espera (red, disco) o calcula (CPU)? Para esperar, hilos o asyncio; para calcular, NumPy, procesos o, en 3.14t, hilos sin GIL.
  • ThreadPoolExecutor.map y asyncio.gather devuelven los resultados en el orden pedido. El orden de finalización no es reproducible: no dependas de él.
  • El estado compartido entre hilos se protege con un Lock. Mejor aún, que cada tarea devuelva su resultado y se combinen al final.
  • En Python 3.14 multiprocessing arranca con forkserver en Linux: las funciones tienen que estar en un módulo importable y el script, bajo if __name__ == "__main__":.
  • asyncio maneja miles de conexiones en un solo hilo, siempre que nada bloquee el bucle: un time.sleep dentro de una corrutina lo para todo.

Recursos