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 1:40–2:30
La comanda de esta noche
#03 · Tercera noche
Problema
Cuatro cocineros hicieron 1000 platos cada uno y, por cada plato, sumaron 1 al número de la pizarra de la cocina. La pizarra no dice 4000, sino 1086.
Objetivo
Que el número de la pizarra sea exacto, haya los cocineros que haya. Y que la cocina no se vuelva más lenta por eso.
Restaurante nocturno Dongsi
Tercera noche, dos de la madrugada; el servicio ha terminado. Hoy vino a echar una mano un amigo del señor Tak, así que eran cuatro cocineros. La chef puso una regla: «Cada plato que sale, un punto más en la pizarra.»
Al contar las comandas, salen 1000 platos por cabeza: 4000 en total. Pero en la pizarra pone 1086.
Minho se rasca la cabeza. «¡Pero si sumé cada vez, de verdad!» Sua y el señor Tak dicen lo mismo. La chef toma la tiza. «Nadie ha mentido. Lo que pasa es que los cuatro estuvieron delante de la pizarra a la vez.»
Sumar 1 en la pizarra son en realidad tres pasos: leer la pizarra, sumar 1 de cabeza y escribir en la pizarra. Pasemos esos tres pasos tal cual a código.
import threading
import time
board = 0 # el número de la pizarra
def cook(times):
global board
for _ in range(times):
seen = board # 1. leer la pizarra
time.sleep(0) # 2. distraerse un instante (hueco para que le toque a otro cocinero)
board = seen + 1 # 3. escribir lo leído más 1
threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board)
# por ejemplo: 1086 (cambia en cada ejecución; 4000 casi nunca sale)time.sleep(0) significa «descansar 0 segundos», pero en la práctica tiene el efecto de ceder el turno a otro hilo. Hemos abierto a propósito un hueco entre la lectura y la escritura para que otro cocinero se cuele.sleep, sino código normal y corriente: escribir un log, consultar una base de datos, llamar a una función. El hueco siempre está en alguna parte.Esto de que el resultado dependa del orden en que varios hilos leen y reescriben el mismo valor se llama condición de carrera (race condition). Compruébalo en el experimento de la pizarra de abajo. Los cocineros hacen exactamente lo mismo que el código de arriba: «leer → time.sleep(0) → escribir».
Experimento de la pizarra
Cocineros
4
Veces por cocinero
1000
Usar un Lock
Supongamos que la pizarra marca 41 y que Minho y Sua sacan un plato cada uno casi al mismo tiempo. Si cortamos el tiempo en casillas, queda así:
| Instante | Minho | Sua | Pizarra |
|---|---|---|---|
| 1 | Lee la pizarra: 41 | 41 | |
| 2 | (se distrae) | Lee la pizarra: 41 | 41 |
| 3 | Escribe 41 + 1 = 42 | 42 | |
| 4 | Escribe 41 + 1 = 42 | 42 |
Entre los dos sacaron dos platos, así que la pizarra debería marcar 43. Pero marca 42. Sua leyó el valor antes de que Minho lo cambiara, y escribió su 42 encima del 42 de Minho. El plato de Minho se ha esfumado.
La solución es sencilla: delante de la pizarra, una sola persona a la vez. Se deja una única tiza y solo quien la tiene en la mano puede escribir. La tiza de Python es threading.Lock.
board = 0
chalk = threading.Lock() # solo hay una tiza
def cook(times):
global board
for _ in range(times):
with chalk: # solo quien tiene la tiza se pone delante de la pizarra
seen = board
time.sleep(0)
board = seen + 1
threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board)
# 4000with chalk: se toma el cerrojo (acquire), y al salir se suelta (release). Si otro hilo ya lo tiene, se espera ahí mismo hasta que lo suelte.time.sleep(0) dentro del bloque. Aunque otro cocinero reciba el turno, sin la tiza no puede tocar la pizarra.También puedes tomarlo y soltarlo a mano, sin with. En ese caso envuélvelo siempre en try/finally, para que la tiza se suelte aunque salte una excepción a mitad.
chalk = threading.Lock()
print(chalk.locked())
chalk.acquire() # tomar la tiza
try:
print(chalk.locked())
print(chalk.acquire(blocking=False)) # si ya la tiene alguien, False al instante
print(chalk.acquire(timeout=0.1)) # espera 0,1 s; si no la consigue, False
finally:
chalk.release() # pase lo que pase, se suelta
print(chalk.locked())
chalk.release()
# False
# True
# False
# False
# False
# RuntimeError: release unlocked lock| Método | Qué hace |
|---|---|
acquire() | Toma el cerrojo. Si lo tiene otro, espera a que lo suelte. Si lo consigue, True |
acquire(blocking=False) | No espera. Si no lo consigue, False al instante |
acquire(timeout=0.1) | Espera como mucho 0,1 segundos. Si no lo consigue, False |
release() | Suelta el cerrojo. Si no estaba cerrado, RuntimeError |
locked() | Si está cerrado en este momento |
Un cerrojo hace esperar a los demás. Así que hay que envolver solo las líneas imprescindibles. ¿Qué pasa si alguien cocina con la tiza en la mano?
board = 0
chalk = threading.Lock()
def cook_wide():
global board
with chalk:
time.sleep(0.1) # cocina sin soltar la tiza
board += 1
def cook_narrow():
global board
time.sleep(0.1) # cada uno cocina por su cuenta
with chalk:
board += 1 # se cierra solo delante de la pizarra
for fn in (cook_wide, cook_narrow):
board = 0
threads = [threading.Thread(target=fn) for _ in range(4)]
start = time.perf_counter()
for th in threads:
th.start()
for th in threads:
th.join()
print(fn.__name__, board, f"{time.perf_counter() - start:.2f}s")
# cook_wide 4 0.40s
# cook_narrow 4 0.10sEn los dos casos el número es exactamente 4. Pero con cook_wide, los demás hacen cola hasta que quien tiene la tiza termina de cocinar, y aunque haya cuatro cocineros se tarda 0,4 segundos, lo mismo que una persona sola. Cierra solo las líneas que tocan el valor compartido.
El consejo de la chef
Un cerrojo es una tiza para una pizarra. Si cada uno usa una tiza distinta, o alguien escribe sin tiza, no sirve de nada. Todo el código que toca el mismo valor tiene que pasar por el mismo Lock. Con que falte uno, por ese agujero se escapa el número.
Python (en concreto, CPython) tiene un gran cerrojo llamado GIL (Global Interpreter Lock). Es la regla de que, en cada instante, solo un hilo ejecuta código Python. Lo vemos a fondo en la lección 8, pero aquí nace un malentendido muy común: «Si solo se ejecuta uno a la vez, no puede haber condiciones de carrera, ¿no?»
Pues no. El GIL solo cambia de hilo entre instrucciones de bytecode; no protege tu «leer → calcular → escribir» como un bloque. Hasta una sola línea board += 1, por dentro, son varios pasos.
import dis
count = 0
def add():
global count
count += 1
dis.dis(add)
# parte de la salida (3.14):
# LOAD_GLOBAL 0 (count) ← leer
# LOAD_SMALL_INT 1
# BINARY_OP 13 (+=) ← calcular
# STORE_GLOBAL 0 (count) ← escribirSi el hilo cambia entre la lectura y la escritura, pasa exactamente lo de la tabla de 3-2. Cuándo cambiar de hilo lo decide el intérprete, y el intervalo se puede consultar con sys.getswitchinterval().
import sys
print(sys.getswitchinterval())
# 0.005El valor por defecto es 0,005 segundos. Aunque un hilo siga ejecutándose sin parar, más o menos cada ese tiempo se le pide que ceda el turno a otro. Si se topa con algo que espera, como time.sleep o una operación de I/O, lo cede antes.
¿Y si hacemos solo count += 1 cuatro millones de veces, sin time.sleep(0)?
count = 0
def cook(times):
global count
for _ in range(times):
count += 1
threads = [threading.Thread(target=cook, args=(1_000_000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(count, sys._is_gil_enabled())
# Python 3.14: 4000000 True
# Python 3.14t: por ejemplo: 1127695 False (cambia en cada ejecución)+= de forma atómica (como un bloque indivisible). Puede romperse al cambiar de versión, o con que añadas una sola línea más al bucle.A un Lock no le importa quién lo tiene. Por eso, si alguien que ya tiene la tiza intenta tomarla otra vez, se queda esperando a que él mismo la suelte, y se detiene para siempre. Como quedarse parado de verdad sería un problema, vamos a esperar solo 0,5 segundos.
def probe(name, lock):
with lock: # se cierra una vez
start = time.perf_counter()
ok = lock.acquire(timeout=0.5) # y el mismo hilo lo intenta otra vez
waited = time.perf_counter() - start
print(name, ok, f"{waited:.1f}s")
if ok:
lock.release()
probe("Lock", threading.Lock())
probe("RLock", threading.RLock())
# Lock False 0.5s
# RLock True 0.0sLock espera los 0,5 segundos enteros y devuelve False. Sin el timeout, este hilo se habría quedado parado aquí para siempre.RLock (cerrojo reentrante, reentrant lock) recuerda qué hilo lo tiene. El mismo hilo puede volver a tomarlo las veces que quiera, y tiene que soltarlo tantas veces como lo tomó para liberarlo del todo. Los demás hilos esperan como siempre.Esto pasa a menudo cuando un método que cierra el cerrojo llama a otro método que también lo cierra.
class Board:
def __init__(self):
self.lock = threading.RLock()
self.count = 0
def add(self):
with self.lock:
self.count += 1
def add_table(self, dishes):
with self.lock: # una mesa entera, de una vez
for _ in range(dishes):
self.add() # dentro vuelve a tomar el mismo cerrojo
board = Board()
threads = [threading.Thread(target=board.add_table, args=(250,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board.count)
# 1000Si self.lock fuera un Lock, se habría detenido en cuanto add_table llamara a add. RLock es algo más pesado y además puede hacer que no sepas cuántas capas de cerrojo llevas. También es buena idea cambiar el diseño y tener aparte un «método interno que no cierra» (_add).
El objetivo de esta noche era que el número de la pizarra cuadrara con los cocineros que hubiera, sin que la cocina fuera más lenta. Convertimos la pizarra en una clase y cerramos solo la línea que la modifica. Cocinar (time.sleep) lo hace cada uno fuera del cerrojo.
class Chalkboard:
def __init__(self):
self._lock = threading.Lock()
self.count = 0
def add(self, n=1):
with self._lock: # solo delante de la pizarra
self.count += n
board = Chalkboard()
def cook(dishes):
for _ in range(dishes):
time.sleep(0.001) # se cocina fuera del cerrojo
board.add()
threads = [threading.Thread(target=cook, args=(100,)) for _ in range(4)]
start = time.perf_counter()
for th in threads:
th.start()
for th in threads:
th.join()
print(board.count, f"{time.perf_counter() - start:.2f}s")
# por ejemplo: 400 0.15s (el número siempre es 400; el tiempo, aproximado)count) es add(). Si escondes el cerrojo dentro de la clase, es más difícil que alguien escriba sin tiza por descuido.La chef borra la pizarra y vuelve a escribir 400. «Para la pizarra basta con una persona cada vez. Pero mañana hay tres fogones y seis cocineros. Si dejo pasar de uno en uno, dos fogones se quedan sin usar. ¿No habrá una tiza que deje pasar hasta a tres?»
Diario de servicio — Tercera noche. Incidente: los 4000 de la pizarra se quedaron en 1086. Causa: otros cocineros que se colaban entre la lectura y la escritura. Solución: una sola tiza (Lock) delante de la pizarra y cerrojo solo en la línea que la modifica.
time.sleep(0) del código de 3-1 y vuelve a ejecutarlo. Aunque el número salga bien, explica, apoyándote en 3-4, por qué no puedes decir que es seguro.Board de 3-5 a un Lock y añade un método interno _add() que no cierre, de modo que ni add() ni add_table() se queden parados.La tarjeta de recetas de la chef
Condición de carrera: otro hilo se cuela entre leer → calcular → escribir y se pierden valores. El resultado cambia en cada ejecución.
lock = threading.Lock() / with lock: envuelve solo las líneas que modifican el valor compartido. A mano: acquire() … finally: release()
acquire(blocking=False) y acquire(timeout=segundos) devuelven False si no lo consiguen. locked() para ver el estado.
RLock: el mismo hilo puede volver a cerrarlo. Si tomas un Lock dos veces, te bloqueas tú solo.
Aunque haya GIL, count += 1 no es atómico. En 3.14t falla todavía más. Para valores compartidos, siempre un Lock.
sys.getswitchinterval() = 0.005: más o menos cada ese intervalo puede cambiar el hilo.
← Anterior
Lección 2 · Contratar más cocineros: lo básico de threading
Siguiente →
Lección 4 · Solo tres fogones: herramientas de sincronización
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.