Hilos: concurrencia dentro de un proceso
Un hilo es una línea de ejecución independiente dentro de un mismo programa que comparte su memoria con los demás hilos. Un programa de trading que debe obtener cotizaciones de diez mercados, escribir un registro y mantener una interfaz receptiva usa hilos para que esperar a uno no detenga al resto. El módulo threading de Python y, más cómodamente, concurrent.futures.ThreadPoolExecutor los inician y gestionan. Como los hilos comparten variables, se comunican rápido y es peligroso equivocarse.
# Ten slow "network calls" in a thread pool wait at the same time, so the total is about one call, not ten
import time
from concurrent.futures import ThreadPoolExecutor
def fetch_quote(symbol):
time.sleep(0.1) # stands in for waiting on a venue
return symbol, len(symbol) * 10.0
symbols = ["AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "META", "GOOG", "NFLX", "ORCL", "INTC"]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
quotes = list(pool.map(fetch_quote, symbols))
elapsed = time.perf_counter() - start
print(quotes[0], len(quotes)) # ('AAPL', 40.0) 10
print(elapsed < 0.5) # True about 0.1 s in total, not 10 x 0.1 sEl GIL: hilos que esperan e hilos que calculan
CPython tiene un bloqueo global del intérprete (GIL): solo un hilo ejecuta bytecode de Python en cada instante. Un hilo lo cede mientras espera la red, un archivo o time.sleep, así que los hilos solapan muy bien las esperas: diez hilos que obtienen cotizaciones terminan en aproximadamente el tiempo de uno. Pero para código Python limitado por CPU (un bucle de Python puro con aritmética) los hilos se turnan, así que cuatro hilos no son más rápidos que uno y a menudo algo más lentos. El código que corre fuera de Python, como NumPy o una extensión de C que libera el GIL, puede correr en paralelo en hilos. Para cálculo pesado en Python puro usa procesos separados (siguiente tema) o la compilación sin GIL (free-threaded) de Python 3.13+, donde esté disponible.
# Splitting a list into chunks for workers: sizes differ by at most one, the first chunks get the extra items
def split_chunks(items, workers):
size, extra = divmod(len(items), workers)
chunks, start = [], 0
for index in range(workers):
stop = start + size + (1 if index < extra else 0)
chunks.append(list(items[start:stop]))
start = stop
return chunks
chunks = split_chunks(list(range(7)), 3)
print(chunks) # [[0, 1, 2], [3, 4], [5, 6]]
print([sum(chunk) for chunk in chunks]) # [3, 7, 11] one partial sum per worker
print(sum(sum(chunk) for chunk in chunks)) # 21Datos compartidos, condiciones de carrera y cerrojos
Los hilos que actualizan la misma variable pueden corromperla, porque total += amount son tres pasos (leer, sumar, escribir) y puede haber un cambio de hilo entre ellos: dos hilos leen 100, suman 5, escriben 105, y se pierde una actualización. Es una condición de carrera, que aparece raramente y de forma impredecible, lo que la hace el peor tipo de error. El remedio es un cerrojo: with lock: deja que un solo hilo a la vez ejecute el bloque, y tanto la lectura como la escritura deben estar dentro. Aún mejor, evita compartir: da a cada trabajador su propio trozo y su propio resultado y combina los resultados al final, o pasa trabajo y resultados por una queue.Queue, que ya es segura para hilos.
# A lock makes the read-modify-write a single step; the total comes out exact
import threading
totals = {"AAPL": 0}
lock = threading.Lock()
def safe_add(shared, key, amount, lock):
with lock:
shared[key] = shared.get(key, 0) + amount
return shared[key]
def worker():
for _ in range(10_000):
safe_add(totals, "AAPL", 1, lock)
threads = [threading.Thread(target=worker) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(totals["AAPL"]) # 40000 nothing lost: four threads x 10,000 additions# A back-of-envelope estimate before writing any threaded code
import math
def estimate_time(n_tasks, seconds, workers, kind):
if kind in ("io", "processes"):
return math.ceil(n_tasks / workers) * seconds
return n_tasks * seconds # "cpu_threads": the GIL makes the threads take turns
print(estimate_time(40, 0.5, 8, "io")) # 2.5 forty slow requests, eight at a time
print(estimate_time(40, 0.5, 8, "cpu_threads")) # 20.0 forty CPU-bound tasks in threads: no gain
print(estimate_time(40, 0.5, 8, "processes")) # 2.5 separate processes use separate coresasyncio (visto antes en esta ruta) suele escalar mejor y no tiene carreras entre await. Para aritmética sobre arrays, vectoriza con NumPy. Para Python limitado por CPU, usa procesos. Si compartes datos entre hilos, mantén corta la sección bloqueada, nunca llames a código lento mientras sostienes un cerrojo y toma varios cerrojos siempre en el mismo orden, o dos hilos pueden esperarse para siempre (un interbloqueo).- Los hilos comparten memoria y solapan bien las esperas (red, archivos); un
ThreadPoolExecutorpuede consultar muchos mercados a la vez. - El GIL deja que un solo hilo ejecute bytecode de Python a la vez: sin aceleración para Python puro limitado por CPU, pero NumPy y las extensiones de C pueden liberarlo.
- Protege las actualizaciones compartidas con
with lock:alrededor de la lectura y la escritura, prefiere no compartir (trozos, colas) y vigila los interbloqueos.
A continuación: multiprocessing, usar varios procesos para poner a trabajar cada núcleo de la CPU.
Lab: qué se califica
- Repartir el trabajo con
split_chunks25 pts - Aplicar una función a los trozos con
run_in_chunks25 pts - Proteger datos compartidos con
safe_add25 pts - Saber lo que ganan los hilos con
estimate_time25 pts