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 9:10–10:00
La comanda de esta noche
#12 · Duodécima noche
Problema
La clienta de la ventana deja una tarjeta sobre la mesa: crítica gastronómica. Esta noche pide formalmente un menú de doce platos. Los platos 3, 7 y 11 tardan muchísimo más la primera vez.
Objetivo
Servir los doce platos usando como mucho tres fogones, y terminar en menos de 3 segundos.
Restaurante nocturno Dongsi
Viernes, diez de la noche. Tal como prometió, entra la clienta de la ventana. Siempre sola, siempre un bol de fideos, siempre el reloj de pulsera y la libretita. Pero hoy, nada más sentarse, deja una tarjeta de visita sobre la mesa. Crítica gastronómica.
«Llevo unas semanas observando esta cocina. Hoy voy a pedir como es debido: el menú de doce platos». La crítica abre su libreta y se quita el reloj de la muñeca.
La cocina se queda en silencio. A Minho se le cae el cuchillo, Sua vuelve a ordenar el riel de comandas y el señor Tak, sin más, limpia los fogones. Lou entra con la comanda. La chef Yoon Seoyeon pone a cero el temporizador que lleva al cuello. «Una cocina es un programa. Hoy ejecutamos todo lo que hemos construido».
En once noches hemos acumulado muchas herramientas. Cuando entra un cliente, la primera pregunta es solo una: ¿esto es trabajo de espera o trabajo de manos?
| Situación | Herramienta | En la cocina | Lección |
|---|---|---|---|
| Muchísimo trabajo de espera (de cientos a decenas de miles de conexiones) | asyncio (gather, TaskGroup) | Lou, la encargada de sala que lo recorre todo sola | 9–10 |
| Trabajo de espera, pero solo con bibliotecas síncronas, unas decenas | threading, ThreadPoolExecutor | Varios cocineros, cocineros de agencia | 2, 7 |
| Mucho cálculo (CPU) | ProcessPoolExecutor, multiprocessing (o hilos en 3.14t) | La cocina sucursal | 8 |
| Código bloqueante antiguo dentro de asyncio | asyncio.to_thread | Alguien aparte plantado delante del horno | 11 |
| Cálculo pesado dentro de asyncio | loop.run_in_executor(grupo de procesos, ...) | Mandar la masa a la sucursal | 11 |
| Varios modifican un mismo valor | Lock, o una cola para que haya un solo dueño | El cerrojo de la pizarra, el riel de comandas | 3, 6 |
| Como mucho n a la vez | Semaphore(n) | Tres fogones | 4, 11 |
| Debe terminar junto y no pasarse de tiempo | TaskGroup, asyncio.timeout | La reserva de grupo | 10 |
El orden para elegir es este.
to_thread dentro de asyncio.La comanda de la crítica es esta: doce platos, 0,5 segundos por plato. Pero los platos 3, 7 y 11, la primera vez, tardan el triple (1,5 segundos) porque los ingredientes aún no se han descongelado del todo. Al repetirlos, salen en 0,5 segundos. Y solo hay tres fogones.
Hacen falta tres piezas: un semáforo que respete el número de fogones, un límite de tiempo para que ningún plato se eternice y reintentos para repetir los platos que se cortan. Todo dentro de un solo TaskGroup.
import asyncio, time
SLOW = {3, 7, 11} # platos que la primera vez tardan el triple
async def plate(n, attempt):
await asyncio.sleep(0.5 * (3 if n in SLOW and attempt == 0 else 1))
async def order(n, stoves, limit_s, retries):
for attempt in range(retries + 1):
async with stoves: # solo se cocina mientras se tiene un fogón
try:
async with asyncio.timeout(limit_s):
await plate(n, attempt)
return True
except TimeoutError:
pass # suelta el fogón, vuelve al final de la cola y reintenta
return False
async def service(stoves_n, limit_s, retries):
stoves = asyncio.Semaphore(stoves_n)
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(order(n, stoves, limit_s, retries)) for n in range(1, 13)]
served = sum(t.result() for t in tasks)
print(f"stoves={stoves_n} limit={limit_s} retries={retries}: "
f"served {served}/12, {time.perf_counter() - start:.1f}s")
asyncio.run(service(3, None, 0)) # sin límite de tiempo
asyncio.run(service(3, 0.6, 0)) # solo límite
asyncio.run(service(3, 0.6, 1)) # límite + un reintento
# stoves=3 limit=None retries=0: served 12/12, 3.5s
# stoves=3 limit=0.6 retries=0: served 9/12, 2.2s
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s
# (el tiempo varía unos 0.1 s entre ejecuciones; la primera línea da 3.5s o 3.6s)asyncio.timeout(None) significa sin límite. Por eso la misma función sirve para ejecutar «sin límite» y «límite de 0,6 segundos».Hay dos detalles de diseño que conviene señalar.
async with stoves está dentro del for, el plato cortado libera el fogón y vuelve a hacer cola. Si reintentara con el fogón en la mano, otros platos se quedarían parados mientras tanto.False. Si salta una excepción en un TaskGroup, se cancela todo lo demás (lección 10). En una reserva de grupo eso es lo correcto, pero en un menú, aunque falle un plato, el resto tiene que salir. Por la misma razón, también podrías reunirlos con asyncio.gather.En la práctica, es habitual descansar entre reintentos un poco más cada vez, por ejemplo con await asyncio.sleep(0.05 * 2 ** attempt). Así se evita que todos vuelvan a lanzarse a la vez (la misma razón que el bloqueo activo de la lección 5).
La línea de tiempo de abajo ejecuta este mismo procesador en el servidor. Cada fila es un plato; los intentos cortados por el límite de tiempo salen en gris y los reintentos llevan r1, r2. La configuración inicial está hecha a propósito para no cumplir el objetivo.
Línea de tiempo de la cocina
Pedidos
6
Fogones
2
Límite de tiempo (s)
apagado
Reintentos
0
Cocinando
Esperando
Cancelado
Falló
El consejo de la chef
El límite de tiempo es «más largo que un plato normal, más corto que uno que se ha torcido». Si cortas hasta los platos normales, da igual cuántos reintentos pongas. Antes de decidir el número, mide cuánto tarda normalmente.
El menú de la crítica lleva masa (cálculo), pan del horno viejo (bloqueante) y platos de fogón (espera), todo junto. Cada cosa se le encarga a quien corresponde, y Lou, en la sala, espera a todos. Como usa procesos, guárdalo en un archivo y ejecútalo.
import asyncio, threading, time
from concurrent.futures import ProcessPoolExecutor
def knead(n): # masa: trabajo de manos → cocina sucursal (proceso)
return sum(i * i for i in range(n)) % 97
tally_lock = threading.Lock() # el libro de cuentas que modifican el hilo del horno y el bucle
tally = {"plates": 0}
def count_plate():
with tally_lock: # dentro del cerrojo no hay await ni trabajo largo
tally["plates"] += 1
def old_oven(item): # horno viejo: bloqueante → hilo
time.sleep(0.4)
count_plate()
return f"{item} baked"
async def stove_dish(n, stoves): # plato de fogón: semáforo
async with stoves:
await asyncio.sleep(0.2)
count_plate()
return f"#{n}"
async def main():
loop = asyncio.get_running_loop()
stoves = asyncio.Semaphore(3)
with ProcessPoolExecutor(max_workers=2) as dough_room:
async with asyncio.timeout(5): # hora límite de todo el menú
results = await asyncio.gather(
loop.run_in_executor(dough_room, knead, 3_000_000),
asyncio.to_thread(old_oven, "bread"),
*(stove_dish(n, stoves) for n in range(1, 7)),
)
print(results)
print(tally)
if __name__ == "__main__":
asyncio.run(main())
# [56, 'bread baked', '#1', '#2', '#3', '#4', '#5', '#6']
# {'plates': 7}| Trabajo | Quién se encarga | Herramienta |
|---|---|---|
| Toda la sala, esperar | Lou | asyncio.run, gather |
| Masa (cálculo) | La cocina sucursal | run_in_executor + ProcessPoolExecutor |
| Horno viejo (bloqueante) | El cocinero plantado delante del horno | asyncio.to_thread |
| Platos de fogón | Los tres fogones | asyncio.Semaphore(3) |
| Libro de cuentas (estado compartido) | El cerrojo de la pizarra | threading.Lock (porque lo usan el hilo y el bucle) |
| Hora límite | El temporizador de la chef | asyncio.timeout(5) |
Al libro de cuentas lo modifican tanto el hilo del horno como el bucle, por eso usamos threading.Lock. Que el bucle tome este cerrojo no es un problema, porque dentro no hay ningún await: cambia un número y lo suelta enseguida. El accidente de la lección 11 ocurre cuando se cede el paso con await sin soltar el cerrojo.
Para que la cocina no se pare delante de la crítica, se revisan estas seis cosas antes del servicio.
| Revisión | Síntoma | Remedio | Lección |
|---|---|---|---|
| Estado compartido | A veces los números no cuadran | Encerrar lectura–escritura en un Lock, o una cola para que haya un solo dueño | 3, 11 |
| Orden de los cerrojos | A veces se queda parado para siempre | Que todos cierren en el mismo orden, acquire(timeout=) | 5 |
| Llamadas bloqueantes | Todo asyncio va lento | to_thread; encontrarlas con debug=True | 11 |
| Cancelaciones tragadas | No funcionan los límites de tiempo ni TaskGroup | Con CancelledError, recoger y volver a hacer raise | 10 |
| Tareas sin referencia | A veces desaparece trabajo en segundo plano | Guardar referencias a las tareas o usar TaskGroup | 9 |
| Colas que no terminan | join() no vuelve nunca | Un task_done() por cada cosa sacada, señal de fin (None, shutdown()) | 6, 11 |
El remedio para las «tareas sin referencia» es este: meter las tareas en un conjunto para sujetarlas y que, al terminar, se saquen solas.
import asyncio
running = set() # aquí se sujetan las tareas
async def wipe(table):
await asyncio.sleep(0.05)
print("wiped", table)
async def main():
for t in ("T1", "T2"):
task = asyncio.create_task(wipe(t))
running.add(task)
task.add_done_callback(running.discard) # al terminar, se saca sola
await asyncio.sleep(0.1)
print(len(running))
asyncio.run(main())
# wiped T1
# wiped T2
# 0Una «cola que no termina» aparece en cuanto se olvida un solo task_done(). Si sospechas, ponle un límite de tiempo a join() para comprobarlo.
import asyncio
async def cook(q):
while True:
await q.get()
await asyncio.sleep(0.01)
# q.task_done() ← se nos olvidó
async def main():
q = asyncio.Queue()
for n in range(3):
q.put_nowait(n)
worker = asyncio.create_task(cook(q))
try:
async with asyncio.timeout(0.3):
await q.join()
except TimeoutError:
print("join never finished:", q.qsize(), "in queue")
worker.cancel()
asyncio.run(main())
# join never finished: 0 in queueLa cola está vacía (0 in queue), pero join() no termina. Las tres comandas que se sacaron nunca recibieron la marca de «hecho».
Hemos reunido en un solo banco de pruebas las cocinas de las noches anteriores. Ejecuta los mismos ocho pedidos cambiando solo el modo. Los controles de cocineros y fogones solo tienen efecto en los modos que usan esos valores.
Línea de tiempo de la cocina
Pedidos
6
Cocineros
3
Fogones
2
Cocinando
Esperando
Cancelado
Falló
La configuración que cumple con el menú de la crítica es 3 fogones, límite de 0,6 segundos, 1 reintento. Son valores comprobados ejecutándolos directamente con el motor del banco de pruebas en el servidor.
| Fogones | Límite de tiempo | Reintentos | Servidos · Fallidos | Tiempo | Objetivo |
|---|---|---|---|---|---|
| 2 | 0,4 s | 0 | 0 · 12 | unos 2,4 s | Fallo |
| 3 | apagado | 0 | 12 · 0 | unos 3,5–3,6 s | Fuera de tiempo |
| 4 | apagado | 0 | 12 · 0 | unos 3,0 s | Demasiados fogones |
| 3 | 0,4 s | 2 | 0 · 12 | unos 4,9 s | Fallo |
| 3 | 0,6 s | 0 | 9 · 3 | unos 2,2 s | Fallan 3 platos |
| 3 | 0,6 s | 1 | 12 · 0 | unos 2,7 s | Conseguido |
# con la función service de 12-2
asyncio.run(service(3, 0.6, 1))
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7sSale el duodécimo plato: 2,7 segundos. El temporizador de la chef se detiene. La crítica escribe unas líneas en su libreta, termina su bol de fideos y se levanta. Delante de la caja, arranca una hoja de la libreta y se la da a la chef.
«Los doce platos salieron sin enfriarse ni una sola vez. Hubo esperas, pero nadie se quedó esperando de brazos cruzados. Volveré. La próxima vez, como clienta».
Cuando la crítica se marcha, las luces de la cocina se van apagando una a una. La chef saca del bolsillo del delantal la última tarjeta de recetas y se la da a su segundo de cocina. «Han sido doce noches. Mañana a las diez la puerta vuelve a abrirse. Esta vez te toca a ti escribir la comanda. ¿Qué vas a poner en la primera línea?».
| Lección | Aquella noche | Lo que queda en una línea |
|---|---|---|
| 1 | La cola de la primera noche: concurrencia y paralelismo | Separa el trabajo de espera del trabajo de manos. Concurrencia es alternar; paralelismo, en el mismo instante. |
| 2 | Contratar más cocineros: lo básico de threading | Thread(target=), start(), join(). Los resultados los recoges tú. |
| 3 | La pizarra no cuadra: condiciones de carrera y Lock | Si alguien se cuela entre leer y escribir, los números no cuadran. with lock:, cuanto más corto mejor. |
| 4 | Solo tres fogones: herramientas de sincronización | Semaphore, Event, Condition, Barrier. |
| 5 | Cuchillos y tablas: interbloqueo | Cerrar en el mismo orden o, si no lo consigues, soltar. |
| 6 | El riel de comandas: queue | Separar productores y consumidores con una cola, y saber cuándo se ha terminado con task_done() y join(). |
| 7 | Cocineros de agencia: concurrent.futures | ThreadPoolExecutor, Future, as_completed y map. |
| 8 | Por qué la masa no va más rápido: el GIL y el paralelismo | El cálculo, a procesos, o a 3.14t. |
| 9 | Una sola encargada de sala: primeros pasos con asyncio | async def, await, gather, create_task. |
| 10 | Se cae la reserva de grupo: concurrencia estructurada | TaskGroup, except*, asyncio.timeout; la cancelación se vuelve a lanzar. |
| 11 | La sala se congela: llamadas bloqueantes y combinar herramientas | Lo bloqueante, con to_thread; las herramientas de asyncio, solo dentro de asyncio. |
Diario de servicio — Duodécima noche. Los doce platos de la crítica, servidos en unos 2,7 segundos con 3 fogones, límite de 0,6 segundos y 1 reintento. La sala con asyncio, la masa con procesos, el horno viejo con un hilo y los fogones con un semáforo. A partir de mañana, las comandas las escribe el segundo de cocina.
await asyncio.sleep(0.05 * 2 ** attempt)). ¿Hay que descansar antes de tomar el fogón o después de tomarlo? Ejecuta las dos versiones y compara el tiempo.La tarjeta de recetas de la chef
Primera pregunta: ¿es trabajo de espera (asyncio, hilos) o trabajo de manos (procesos, 3.14t)?
Dentro de asyncio, lo bloqueante con to_thread; el cálculo pesado con run_in_executor (grupo de procesos).
Límite de recursos: Semaphore. Límite de tiempo: asyncio.timeout. Lo que termina junto: TaskGroup.
Reintentar soltando el fogón y desde el final de la cola. El límite de tiempo: «más largo que lo normal, más corto que lo que se ha torcido».
Lista de comprobación: estado compartido, orden de los cerrojos, llamadas bloqueantes, cancelaciones tragadas, tareas sin referencia, colas que no terminan.
Una cocina es un programa. Dibuja las barras y lo verás.
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.