R

ReflexKR

Documentación de Reflex

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

Concurrencia en la cocina de medianoche
Lección 12

Tiempo 9:10–10:00

La noche de la crítica: todo junto

La comanda de esta noche

#12 · Duodécima noche

La noche de la crítica

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».

12-1. Qué herramienta sacar (8 min)

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ónHerramientaEn la cocinaLecció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 sola9–10
Trabajo de espera, pero solo con bibliotecas síncronas, unas decenasthreading, ThreadPoolExecutorVarios cocineros, cocineros de agencia2, 7
Mucho cálculo (CPU)ProcessPoolExecutor, multiprocessing (o hilos en 3.14t)La cocina sucursal8
Código bloqueante antiguo dentro de asyncioasyncio.to_threadAlguien aparte plantado delante del horno11
Cálculo pesado dentro de asyncioloop.run_in_executor(grupo de procesos, ...)Mandar la masa a la sucursal11
Varios modifican un mismo valorLock, o una cola para que haya un solo dueñoEl cerrojo de la pizarra, el riel de comandas3, 6
Como mucho n a la vezSemaphore(n)Tres fogones4, 11
Debe terminar junto y no pasarse de tiempoTaskGroup, asyncio.timeoutLa reserva de grupo10

El orden para elegir es este.

  1. ¿De verdad hace falta concurrencia? Si hacerlo en orden ya es bastante rápido, déjalo así. Como vimos en la lección 1, la concurrencia no sale gratis.
  2. ¿Es espera o es cálculo? Si es espera, asyncio o hilos; si es cálculo, procesos (o 3.14t). En una compilación con GIL, repartir el cálculo entre hilos no lo acelera (lección 8).
  3. ¿La biblioteca que ya usas soporta async? Si la soporta, asyncio; si no, empieza con hilos o envuélvela con to_thread dentro de asyncio.
  4. ¿Qué se comparte? Si hay estado compartido, añade un cerrojo o una cola; si los recursos tienen un límite, un semáforo; si las cosas deben terminar juntas, un TaskGroup.

12-2. El procesador del menú de la crítica (14 min)

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».
  • Sin límite, los tres platos lentos ocupan un fogón 1,5 segundos cada uno, y servir los doce lleva algo más de 3,5 segundos.
  • Con solo el límite, los platos lentos se cortan a los 0,6 segundos y los fogones quedan libres antes. A cambio, esos tres platos no salen nunca.
  • Con límite y reintentos, el plato cortado suelta el fogón y vuelve a ponerse al final de la cola. La segunda vez termina en 0,5 segundos, y salen los doce platos.

Hay dos detalles de diseño que conviene señalar.

  • Al reintentar, se suelta el fogón. Como 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.
  • El fallo no se lanza como excepción, sino que se devuelve 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ó

Los doce platos de la crítica. Ajusta fogones, límite de tiempo y reintentos hasta conseguir «Servidos 12 · Fallidos 0, como mucho 3 fogones, en menos de 3 segundos». Con la configuración inicial no sale ni un plato.
  • Con la configuración inicial (2 fogones, límite de 0,4 segundos, 0 reintentos), a los 2,4 segundos más o menos sale Servidos 0 · Fallidos 12. Incluso un plato normal tarda 0,5 segundos y el límite es de 0,4, así que se cortan todos.
  • Si subes a 3 fogones y solo pones 2 reintentos, va peor. Con el mismo límite de 0,4 segundos, cada plato se corta tres veces: siguen sin servirse platos y el tiempo sube a unos 4,9 segundos. Los reintentos no arreglan un límite de tiempo mal elegido. Incluso con el límite igual a un plato normal, 0,5 segundos, se cortan por los pelos y fallan todos.
  • Si quitas el límite de tiempo, se sirve todo, pero incluso con 3 fogones tarda algo más de 3,5 segundos. Con 4 fogones baja a unos 3,0 segundos, demasiado justo, y sobre todo: solo hay tres fogones.
  • La pantalla solo espera al experimento algo más de 5 segundos. Las configuraciones que tardan mucho, como 2 fogones con 2 reintentos, pueden parecer cortadas a mitad; cambia la configuración y vuelve a ejecutarlo.

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.

12-3. Todo en una cocina (8 min)

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}
TrabajoQuién se encargaHerramienta
Toda la sala, esperarLouasyncio.run, gather
Masa (cálculo)La cocina sucursalrun_in_executor + ProcessPoolExecutor
Horno viejo (bloqueante)El cocinero plantado delante del hornoasyncio.to_thread
Platos de fogónLos tres fogonesasyncio.Semaphore(3)
Libro de cuentas (estado compartido)El cerrojo de la pizarrathreading.Lock (porque lo usan el hilo y el bucle)
Hora límiteEl temporizador de la chefasyncio.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.

12-4. Lista de comprobación de bugs de concurrencia (6 min)

Para que la cocina no se pare delante de la crítica, se revisan estas seis cosas antes del servicio.

RevisiónSíntomaRemedioLección
Estado compartidoA veces los números no cuadranEncerrar lectura–escritura en un Lock, o una cola para que haya un solo dueño3, 11
Orden de los cerrojosA veces se queda parado para siempreQue todos cierren en el mismo orden, acquire(timeout=)5
Llamadas bloqueantesTodo asyncio va lentoto_thread; encontrarlas con debug=True11
Cancelaciones tragadasNo funcionan los límites de tiempo ni TaskGroupCon CancelledError, recoger y volver a hacer raise10
Tareas sin referenciaA veces desaparece trabajo en segundo planoGuardar referencias a las tareas o usar TaskGroup9
Colas que no terminanjoin() no vuelve nuncaUn 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
# 0

Una «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 queue

La cola está vacía (0 in queue), pero join() no termina. Las tres comandas que se sacaron nunca recibieron la marca de «hecho».

12-5. Volver a poner en marcha las cocinas de once noches (4 min)

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ó

8 pedidos de 0,3 s cada uno. Ejecuta uno tras otro «Solo, uno por uno», «Varios cocineros», «Fogones limitados», «Cocineros de agencia» y «Todo a la vez con gather», y compara la forma de las barras y el tiempo.
  • Solo, uno por uno: una sola fila, unos 2,4 segundos (lección 1).
  • 4 cocineros: cuatro filas solapadas, unos 0,6 segundos (lección 2). Si limitas los fogones a 2, aparece la espera amarilla y tarda unos 1,2 segundos (lección 4).
  • Cocineros de agencia: cuanto más alto el número, más corto el pedido, así que el orden de llegada sale revuelto; unos 0,4 segundos (lección 7).
  • gather: las ocho mesas se solapan todas, unos 0,3 segundos (lección 9). Da igual cuántos cocineros haya: Lou sola.

Cierre (10 min)

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.

FogonesLímite de tiempoReintentosServidos · FallidosTiempoObjetivo
20,4 s00 · 12unos 2,4 sFallo
3apagado012 · 0unos 3,5–3,6 sFuera de tiempo
4apagado012 · 0unos 3,0 sDemasiados fogones
30,4 s20 · 12unos 4,9 sFallo
30,6 s09 · 3unos 2,2 sFallan 3 platos
30,6 s112 · 0unos 2,7 sConseguido
# 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.7s
  • El límite de 0,6 segundos deja pasar los platos normales (0,5 segundos) y solo corta, a los 0,6, los que se alargan la primera vez (1,5 segundos).
  • Los tres platos cortados sueltan el fogón, lo intentan una vez más desde el final de la cola y terminan en 0,5 segundos.
  • Si bajas un poco el límite, a 0,55 segundos, también se cumple el objetivo (unos 2,65 segundos). Si lo subes a 0,7, tarda unos 2,9 segundos, pegado a los 3; con 0,8, se pasa de 3 segundos.

Sale 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?».

Mirando atrás las doce noches

LecciónAquella nocheLo que queda en una línea
1La cola de la primera noche: concurrencia y paralelismoSepara el trabajo de espera del trabajo de manos. Concurrencia es alternar; paralelismo, en el mismo instante.
2Contratar más cocineros: lo básico de threadingThread(target=), start(), join(). Los resultados los recoges tú.
3La pizarra no cuadra: condiciones de carrera y LockSi alguien se cuela entre leer y escribir, los números no cuadran. with lock:, cuanto más corto mejor.
4Solo tres fogones: herramientas de sincronizaciónSemaphore, Event, Condition, Barrier.
5Cuchillos y tablas: interbloqueoCerrar en el mismo orden o, si no lo consigues, soltar.
6El riel de comandas: queueSeparar productores y consumidores con una cola, y saber cuándo se ha terminado con task_done() y join().
7Cocineros de agencia: concurrent.futuresThreadPoolExecutor, Future, as_completed y map.
8Por qué la masa no va más rápido: el GIL y el paralelismoEl cálculo, a procesos, o a 3.14t.
9Una sola encargada de sala: primeros pasos con asyncioasync def, await, gather, create_task.
10Se cae la reserva de grupo: concurrencia estructuradaTaskGroup, except*, asyncio.timeout; la cancelación se vuelve a lanzar.
11La sala se congela: llamadas bloqueantes y combinar herramientasLo 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.

Ejercicios

  1. Cuando hayas cumplido el objetivo en el banco de pruebas, baja los pedidos a 8 y busca, cambiando el límite de tiempo y los reintentos, si con solo 2 fogones se puede terminar en menos de 3 segundos.
  2. Añade al procesador de 12-2 un descanso entre reintentos (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.
  3. Elige un programa que quieras construir y diséñalo con la tabla de 12-1 y la lista de comprobación de 12-4. Basta con escribir una línea por herramienta: cuál eliges y por qué.

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.

← Anterior

Lección 11 · La sala se congela: llamadas bloqueantes y combinar herramientas

Recibe avisos de nuevas lecciones

Te avisaremos por correo cuando este curso tenga lecciones nuevas o se abra un curso de continuación.

Datos: tu correo electrónico · Finalidad: avisos de nuevas lecciones o de la publicación de este curso · Conservación: hasta que pidas que lo borremos. No lo usamos para nada más ni lo compartimos. Responde a cualquier aviso y lo borraremos enseguida.

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.

Se muestra como texto plano, sin enlaces ni formato.

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
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex