Concurrencia en la cocina de medianoche
·
Resumen del curso
1
La cola de la primera noche: concurrencia y paralelismo
2
Contratar más cocineros: lo básico de threading
3
La pizarra no cuadra: condiciones de carrera y Lock
4
Solo tres fogones: herramientas de sincronización
5
Cuchillos y tablas: interbloqueo
6
El riel de comandas: queue
7
Cocineros de agencia: concurrent.futures
8
Por qué la masa no va más rápido: el GIL y el paralelismo
9
Una sola encargada de sala: primeros pasos con asyncio
10
Se cae la reserva de grupo: concurrencia estructurada
11
La sala se congela: llamadas bloqueantes y combinar herramientas
12
La noche de la crítica: todo junto
Tiempo 5:50–6:40
La comanda de esta noche
#08 · Octava noche
Problema
Cuatro bolas de masa de fideos para mañana. Les pusimos cuatro cocineros, y se tarda exactamente lo mismo que amasando una sola persona.
Objetivo
Terminar las cuatro bolas de masa al menos el doble de rápido que una persona sola.
Restaurante nocturno Dongsi
La una de la madrugada; el servicio ha terminado. Sobre la mesa de trabajo esperan cuatro bolas de masa de fideos para mañana. Minho, Sua, el señor Tak y tú. Cuatro personas, una bola cada una: debería ir cuatro veces más rápido.
Pero el temporizador de la chef se detiene casi en el mismo número que cuando amasaba una sola persona. Minho ladea la cabeza. «Pero si todos teníamos las manos ocupadas...»
La chef Yoon Seoyeon señala el taco de cuchillos de la mesa. Solo hay un cuchillo clavado. «En esta cocina hay una regla muy antigua: cortar y amasar, de uno en uno. Esta noche hablamos de esa regla.»
Los pedidos de la lección 1 eran trabajo de espera: aguardar a que hirviera la sopa. La masa de hoy es distinta. Es un bucle que suma números dos millones de veces; es decir, trabajo en el que las manos no paran (trabajo de CPU).
import threading
import time
def knead(n=2_000_000):
s = 0
for i in range(n): # calcular sin parar: amasar
s += i
return s
start = time.perf_counter()
for _ in range(4):
knead()
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
cooks = [threading.Thread(target=knead) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# sequential 0.24s
# 4 threads 0.23s (medido en un PC de 12 núcleos, Python 3.14)CPython (el Python normal que usamos) tiene el GIL (Global Interpreter Lock, el cerrojo global del intérprete). La regla es una sola:
Dentro de un proceso, solo un hilo a la vez ejecuta bytecode de Python.
En la cocina, es la regla de «un solo cuchillo». Aunque haya cuatro cocineros, solo corta quien tiene el cuchillo, y los demás esperan a que vuelva. El cuchillo pasa al siguiente más o menos cada 0,005 segundos (sys.getswitchinterval(), lección 3).
| Instante | Minho | Sua | El señor Tak | Tú |
|---|---|---|---|---|
| 0–5 ms | Amasa con el cuchillo | Espera | Espera | Espera |
| 5–10 ms | Espera | Amasa con el cuchillo | Espera | Espera |
| 10–15 ms | Espera | Espera | Amasa con el cuchillo | Espera |
| 15–20 ms | Espera | Espera | Espera | Amasa con el cuchillo |
Como los cuatro se turnan, las cuatro bolas avanzan un poquito cada una. Hay concurrencia, pero no paralelismo (lección 1). La cantidad total de trabajo es la misma, así que el tiempo también.
¿Por qué existe esta regla? Python lleva la cuenta, para cada objeto, de «desde cuántos sitios me están usando» (el contador de referencias). Si varios hilos modifican ese número a la vez, se descuadra como la pizarra de la lección 3. Hace mucho, CPython eligió el camino fácil de proteger todo el intérprete con un solo cerrojo, y eso es el GIL.
El GIL tiene una excepción importante. Mientras un hilo espera (time.sleep, leer un archivo, una respuesta de red), suelta el GIL. Quien espera a que hierva la sopa no necesita cuchillo.
def simmer():
time.sleep(0.3) # esperar a que hierva la sopa: mientras tanto se suelta el GIL
start = time.perf_counter()
cooks = [threading.Thread(target=simmer) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# 4 threads 0.30s (en orden habrían sido 1,2 s)Las funciones escritas en C también pueden soltar el GIL mientras hacen un cálculo pesado. Pasa, por ejemplo, cuando hashlib calcula el hash de muchos datos. No es trabajo de manos, sino de la amasadora (código C), así que queda fuera de la regla del cuchillo.
import hashlib
dough = b"x" * 50_000_000 # 50 MB
def press():
hashlib.sha256(dough).hexdigest() # mientras calcula el código C, se suelta el GIL
# midiéndolo igual que arriba (aprox., en este PC)
# sequential 0.11s
# 4 threads 0.04s| Tipo de trabajo | Con hilos y GIL | Por qué |
|---|---|---|
| Trabajo de espera (sleep, archivos, red) | Se acelera | Mientras espera, suelta el GIL |
| Cálculo pesado en una extensión de C (hashlib, zlib, etc.) | Normalmente se acelera | El código C suelta el GIL para calcular |
| Cálculo en Python puro (bucles, armar cadenas) | No se acelera | El bytecode lo ejecuta un hilo a la vez |
Y el GIL no protege tu código. Solo protege el interior del intérprete; algo de varios pasos como count += 1 puede cortarse a medias. Por eso la pizarra de la lección 3 falló aunque hubiera GIL.
El banco de pruebas de abajo es una cocina en la que hilos reales hacen time.sleep en el servidor de producción. Aunque el servidor es un Python con GIL, como es trabajo de espera, las barras se solapan.
Línea de tiempo de la cocina
Pedidos
6
Cocineros
3
Cocinando
Esperando
Cancelado
Falló
El consejo de la chef
Si añades hilos y no va más rápido, pregúntate primero si ese trabajo es sopa o masa. Si es sopa, con hilos basta. Si es masa, no hace falta más gente, hacen falta más cocinas.
Si solo hay un cuchillo, montas otra cocina con su propio cuchillo. Eso es un proceso. Cada proceso ejecuta su propio intérprete de Python, y tiene su propio GIL.
import multiprocessing as mp
import os
def branch(name):
print(name, "pid", os.getpid(), "parent", os.getppid())
if __name__ == "__main__":
print("main pid", os.getpid())
p = mp.Process(target=branch, args=("branch-1",))
p.start()
p.join()
print("exitcode", p.exitcode)
# por ejemplo:
# main pid 39516
# branch-1 pid 40268 parent 39516
# exitcode 0Process se usa igual que threading.Thread (target, args, start, join). La diferencia está en os.getpid(), el número de proceso. La sucursal es un programa aparte, con un número distinto del de la casa central.
La sucursal no comparte memoria. Metas lo que metas en una lista desde la sucursal, la lista de la casa central sigue igual.
orders = []
def add(n):
orders.append(n)
print("in branch:", orders)
if __name__ == "__main__":
p = mp.Process(target=add, args=("#1",))
p.start()
p.join()
print("in main:", orders)
# in branch: ['#1']
# in main: []El problema de la pizarra de la lección 3 ni siquiera aparece, pero a cambio, para recibir los resultados hay que pedir que te los devuelvan. Para repartir varios trabajos, lo cómodo es Pool.
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
with mp.Pool(processes=4) as pool:
print(pool.map(knead, [10, 100, 1000, 10_000]))
# [45, 4950, 499500, 49995000]if __name__ == "__main__":Windows y macOS abren las sucursales con el método spawn. Se lanza un Python nuevo, y ese Python vuelve a importar tu archivo desde el principio. Tiene que leer otra vez las definiciones de funciones para saber qué es knead.
Sin esta comprobación, la sucursal, al leer el archivo, se encuentra otra vez con la línea mp.Process(...).start() e intenta abrir otra sucursal. Python lo impide y lanza un error.
# si se arranca directamente al nivel superior, sin la comprobación (Windows)
p = mp.Process(target=knead, args=(1,))
p.start()
# en la sucursal:
# RuntimeError:
# An attempt has been made to start a new process before the
# current process has finished its bootstrapping phase.
# ...Por eso los ejemplos de multiprocessing se guardan en un archivo y se ejecutan con python archivo.py. En la consola interactiva o en un notebook, es fácil que falle porque la sucursal no encuentra la función.
¿Te acuerdas del ThreadPoolExecutor de la lección 7? ProcessPoolExecutor reparte el trabajo entre procesos con la misma interfaz. submit, Future, result(), map y as_completed funcionan igual.
import os
from concurrent.futures import ProcessPoolExecutor
def knead(n):
s = 0
for i in range(n):
s += i
return os.getpid(), s
if __name__ == "__main__":
print("main", os.getpid())
with ProcessPoolExecutor(max_workers=2) as ex:
future = ex.submit(knead, 1000)
print(future.result())
for pid, s in ex.map(knead, [10, 100, 1000]):
print(pid, s)
# por ejemplo:
# main 43452
# (42784, 499500)
# 42784 45
# 42784 4950
# 42784 499500El número de proceso es distinto del de main. Qué sucursal se encarga de cada masa cambia en cada ejecución.
Como la sucursal y la casa central no comparten memoria, la función, los argumentos y los resultados se empaquetan en bytes (pickle) para enviarlos y recibirlos. Lo que no se puede empaquetar no se puede enviar. El ejemplo típico es una lambda.
def double(x):
return x * 2
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=2) as ex:
print(ex.submit(double, 21).result())
try:
ex.submit(lambda x: x * 2, 21).result()
except Exception as e:
print(type(e).__name__, e)
# 42
# PicklingError Can't pickle <function <lambda> at 0x...>: it's not found as __main__.<lambda>double del archivo» y lo usa. Una lambda, que no tiene nombre, la sucursal no la puede encontrar.def al nivel superior del archivo. Ni funciones dentro de funciones, ni lambdas.Una sucursal no sale gratis. Hay que lanzar un Python nuevo, volver a importar el archivo y empaquetar los valores de ida y vuelta. Si la masa es pequeña, esa preparación cuesta más que el trabajo.
# las 4 masas pequeñas de 8-1 (dos millones de vueltas) con 4 procesos
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# 4 processes 0.31s (medido en un PC de 12 núcleos; en orden, 0,24 s)| Método (4 masas pequeñas) | Tiempo (medido en un PC de 12 núcleos) |
|---|---|
| En orden | 0,24 s |
| 4 hilos | 0,23 s |
| 4 procesos | 0,31 s |
Hasta va más lento. Si haces la masa diez veces más grande (veinte millones de vueltas), la cosa cambia. En este PC salió aproximadamente de 2,4 a 2,7 s en orden y de 0,9 a 1,1 s con 4 procesos. El coste de preparación casi no cambia, pero el trabajo es mucho mayor.
chunksize= de map).with ProcessPoolExecutor() nuevo para cada trabajo es como montar una sucursal nueva cada vez.La manera de montar la sucursal se llama método de inicio (start method).
| Método | Qué hace | En la cocina |
|---|---|---|
spawn | Lanza un Python nuevo y vuelve a importar el archivo | Monta una cocina desde cero en un local vacío |
fork | Clona el proceso actual entero | Copia la casa central tal cual (hasta los cerrojos de quien estaba trabajando) |
forkserver | Mantiene un proceso servidor limpio y clona a partir de él | Tiene una cocina modelo limpia y copia esa |
forkserver. En Windows y macOS es spawn. El anterior por defecto en Linux, fork, se cambió porque al clonar un programa con hilos en marcha los cerrojos podían quedar enredados.spawn.import multiprocessing as mp
if __name__ == "__main__":
print(mp.get_start_method()) # spawn (Windows)
print(mp.get_all_start_methods()) # ['spawn']
ctx = mp.get_context("spawn") # spawn solo para este pool
with ctx.Pool(processes=2) as pool:
print(pool.map(knead, [10, 100])) # [45, 4950]mp.get_context("spawn") solo se aplica a los Pool y Process creados con ese contexto. Dentro de una biblioteca, esta es la opción segura.mp.set_start_method("spawn") una sola vez, al principio del if __name__ == "__main__":. Si lo llamas dos veces, salta RuntimeError: context has already been set.spawn explícitamente y pruébalo.Desde 3.14, la versión free-threaded tiene soporte oficial. Es un Python compilado sin GIL, y el ejecutable se llama python3.14t (con una t al final). Es como reformar la cocina para que haya tantos cuchillos como cocineros.
uv run --python 3.14t archivo.py.sys._is_gil_enabled().# knead_threads.py: el código de 8-1 con solo dos líneas más arriba del todo
import sys
print(sys._is_gil_enabled())
...
# python knead_threads.py
# True
# sequential 0.24s
# 4 threads 0.23s
# python3.14t knead_threads.py
# False
# ...
# 4 threads 0.09s (medido en un PC de 12 núcleos, unas 2,7 veces más rápido)No hemos cambiado ni una letra del código. El mismo código con hilos fue unas 2,7 veces más rápido solo por cambiar de intérprete. No llega a cuatro veces por el coste de lanzar y gestionar los hilos, y por el de los mecanismos internos que permiten funcionar de forma segura sin GIL. A cambio, el código que usa un solo hilo puede ir algo más lento que en la versión normal.
Hay dos cosas que conviene saber.
PYTHON_GIL=0 (o -X gil=0) puedes forzar que siga desactivado, y con PYTHON_GIL=1 el GIL se activa incluso en 3.14t.En el banco de pruebas de abajo, las cuatro barras de arriba son valores medidos en un PC de 12 núcleos al preparar el curso. Abajo, «Amasar en el servidor» ejecuta la masa (600 000 vueltas × 4) en orden y con 4 hilos, directamente en el servidor de producción de este sitio. El servidor usa una versión con GIL y no se sabe de antemano cuántos núcleos tiene, así que los números pueden ser distintos de las barras medidas. Fíjate en si las dos barras tienen una longitud parecida.
Comparación de masa
Medido en local (PC de 12 núcleos, Python 3.14)
Uno por uno
0.24s
4 hilos
0.23s
4 procesos
0.31s
4 hilos (3.14t, sin GIL)
0.09s
El consejo de la chef
Sin GIL desaparece la regla del cuchillo, no la de la pizarra. Si varios escriben en la misma pizarra, ahora sí que lo hacen a la vez de verdad. Antes de pasarte a 3.14t, revisa primero los cerrojos.
Las sucursales salen caras, y a la reforma todavía no se han sumado todas las bibliotecas. Entre medias hay una opción más: cocinas separadas por mamparas dentro de un mismo local, es decir, subintérpretes. Dentro de un solo proceso hay varios intérpretes, y cada compartimento tiene su propio GIL (su propio cuchillo). En 3.14 llegaron a la biblioteca estándar con el módulo concurrent.interpreters.
from concurrent import interpreters
booth = interpreters.create()
booth.exec("menu = ['#1', '#2']; print('booth menu:', menu)")
booth.exec("menu.append('#3'); print('booth menu:', menu)")
print("main has menu?", "menu" in globals())
def knead(n):
s = 0
for i in range(n):
s += i
return s
print(booth.call(knead, 1000))
booth.close()
# booth menu: ['#1', '#2']
# booth menu: ['#1', '#2', '#3']
# main has menu? False
# 499500create() se crea el compartimento, con exec(cadena de código) se ejecuta código en él y con call(función, argumentos) se llama a una función y se recibe el resultado.menu) solo existen dentro de él. Igual que en una sucursal, no se comparte memoria.En la práctica, lo cómodo es InterpreterPoolExecutor (3.14), con la misma forma que en la lección 7.
from concurrent.futures import InterpreterPoolExecutor
if __name__ == "__main__":
with InterpreterPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# en este PC, unos 0.18 s (3.14 con GIL; en orden, 0.24 s)Como cada compartimento tiene su propio cuchillo, incluso con el Python normal las masas avanzan en paralelo. Ten en cuenta que muchas extensiones de C todavía no son compatibles con los subintérpretes.
El objetivo de esta noche era terminar las cuatro bolas de masa al menos el doble de rápido que una persona sola. Es cálculo en Python puro, así que con hilos no sirve; y como cada bola es grande, compensa el coste de abrir sucursales.
import time
from concurrent.futures import ProcessPoolExecutor
BIG = 20_000_000 # una bola de masa grande
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
start = time.perf_counter()
alone = [knead(BIG) for _ in range(4)]
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as ex:
branches = list(ex.map(knead, [BIG] * 4))
print(f"4 processes {time.perf_counter() - start:.2f}s", branches == alone)
# por ejemplo (en este PC):
# sequential 2.43s
# 4 processes 0.93s TrueUnas 2,5 veces más rápido. Y el resultado es idéntico al de una persona sola. Con 3.14t, ejecutar knead con cuatro hilos daría un efecto parecido.
| Herramienta | Cuchillo (GIL) | Memoria | Coste de arranque | Adecuada para |
|---|---|---|---|---|
| Hilos (3.14) | Uno por proceso | Compartida | Bajo | Trabajo de espera |
| Hilos (3.14t) | Ninguno | Compartida | Bajo | Trabajo de espera + cálculo |
| Procesos | Uno por sucursal | Separada (pickle) | Alto | Cálculos grandes |
| Subintérpretes | Uno por compartimento | Separada | Medio | Cálculo, código en Python puro |
La chef se desata el delantal. «La masa la hemos resuelto con más cocinas. ¿Y la sala? Con diez mesas, ¿pondrías diez camareros, o una sola persona que atienda las diez?»
Diario de servicio — Octava noche. Con hilos, la masa no se aceleró: solo hay un cuchillo. Repartida entre cuatro sucursales, salió 2,5 veces más rápido. Mañana reorganizamos la sala.
knead de 8-1 por time.sleep(0.06) y vuelve a medir en orden y con 4 hilos. Explica con el GIL por qué esta vez los hilos sí lo aceleran.ProcessPoolExecutor. ¿A partir de qué tamaño empiezan a ganar los procesos en tu PC?submit en un ProcessPoolExecutor de una función definida dentro de otra función (una función anidada), mira qué error sale y corrígelo.La tarjeta de recetas de la chef
GIL: el bytecode de Python lo ejecuta un hilo a la vez. Al esperar (I/O) y en parte del código C, se suelta el GIL.
El trabajo de cálculo (CPU) no se acelera con hilos → procesos, 3.14t, subintérpretes.
ProcessPoolExecutor se usa igual que ThreadPoolExecutor. if __name__ == "__main__": obligatorio.
Lo que va y viene de la sucursal se serializa con pickle: nada de lambdas ni funciones anidadas. def al nivel superior.
Lanzar procesos cuesta caro: solo para cálculos grandes. Método de inicio: spawn en Windows y macOS, forkserver en Linux desde 3.14.
sys._is_gil_enabled(): False en 3.14t (python3.14t). El mismo código con hilos corre en paralelo tal cual.
concurrent.interpreters: create() → exec() / call(), InterpreterPoolExecutor (3.14).
← Anterior
Lección 7 · Cocineros de agencia: concurrent.futures
Siguiente →
Lección 9 · Una sola encargada de sala: primeros pasos con asyncio
Recibe avisos de nuevas lecciones
Te avisaremos por correo cuando este curso tenga lecciones nuevas o se abra un curso de continuación.
R
ReflexKR
Aprende Python y Reflex haciendo. Todos los ejemplos se ejecutaron y verificaron en Reflex 0.10.0.
ReflexKR es gratis y sin banners publicitarios. Si te ayudó, puedes apoyarlo con un café. ☕ Invítanos un café
Aviso de afiliados© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Preguntas y respuestas
Pregunta lo que quieras sobre esta lección. Aquí se reúnen las preguntas de todas las versiones de idioma de esta página.