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 3

Tiempo 1:40–2:30

La pizarra no cuadra: condiciones de carrera y Lock

La comanda de esta noche

#03 · Tercera noche

La pizarra no cuadra

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

3-1. Cuando 4000 se convierte en 1086 (8 min)

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.
  • En un programa real, ese hueco no es un 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.
  • El número cambia en cada ejecución. En este PC, con Python 3.14, salió más o menos entre 1000 y 1200.

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

4 cocineros suman 1 en la pizarra 1000 veces cada uno. Escribe varias veces con «Usar un Lock» desactivado, luego actívalo y vuelve a escribir. Fíjate en si «Pedidos perdidos» llega a 0.
  • Sin el Lock, el número real queda muy por debajo del esperado y cambia en cada intento. La casilla «Pedidos perdidos» es esa diferencia.
  • Si dejas un solo cocinero, sale exacto aunque no haya Lock. Las condiciones de carrera solo aparecen cuando dos o más tocan el mismo valor.
  • Con el Lock activado, sale igual que lo esperado, con los cocineros y las veces que sean. Qué es ese Lock lo vemos en 3-3.

3-2. Una condición de carrera, paso a paso (8 min)

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í:

InstanteMinhoSuaPizarra
1Lee la pizarra: 4141
2(se distrae)Lee la pizarra: 4141
3Escribe 41 + 1 = 4242
4Escribe 41 + 1 = 4242

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.

  • El problema es que otro hilo se cuela entre «leer → calcular → escribir». Nada garantiza que los tres pasos se ejecuten como un bloque.
  • Si leen los cuatro a la vez, pueden desaparecer tres platos de golpe. Si los cuatro leen 41 y los cuatro escriben 42, cuatro platos se quedan en uno. Por eso 4000 puede bajar hasta 1086.
  • Si el orden sale bien por suerte, el número a veces cuadra. Por eso las condiciones de carrera son los errores más difíciles de encontrar: pasan las pruebas y solo explotan la noche en que se llena el local.

3-3. Solo hay una tiza: Lock (10 min)

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)
# 4000
  • Al entrar en el bloque with 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.
  • No pasa nada por ceder el turno con time.sleep(0) dentro del bloque. Aunque otro cocinero reciba el turno, sin la tiza no puede tocar la pizarra.
  • Lo ejecutes las veces que lo ejecutes, sale 4000. Dentro del cerrojo, «leer → calcular → escribir» se ha convertido en un bloque en el que ningún otro hilo puede colarse.

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étodoQué 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

Que el cerrojo dure poco: solo delante de la pizarra

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.10s

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

3-4. Con GIL también hay condiciones de carrera (8 min)

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)     ← escribir

Si 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.005

El 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)
  • En este PC, con 3.14 (con GIL), salió 4000000 todas las veces. Pero es pura casualidad: el cambio de hilo nunca cayó entre esos cuatro pasos. Python nunca ha prometido ejecutar += 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.
  • En la versión free-threaded 3.14t, sin GIL, los hilos corren de verdad a la vez, y de cuatro millones solo quedan algo más de un millón. Un código que en la versión con GIL cuadraba por casualidad falla al instante en 3.14t.
  • El código de la pizarra de 3-1, en 3.14t, dio por ejemplo 2410 de 4000. El número es otro, pero el fallo es el mismo.
  • La conclusión es una sola: si varios hilos modifican el mismo valor, envuélvelo en un Lock, haya GIL o no. Más todavía si ese código va a pasar a 3.14t.

3-5. Cuando la misma persona vuelve a cerrar: RLock (6 min)

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.0s
  • Lock 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)
# 1000

Si 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).

Cierre (10 min)

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)
  • Cuatro cocineros, 100 platos cada uno: la pizarra siempre marca 400. También en 3.14t.
  • El tiempo es solo un poco más de lo que tarda una persona en hacer 100 platos (en este PC, unos 0,11 segundos). Los cuatro cocinan a la vez y solo hacen una breve cola delante de la pizarra.
  • La única forma de modificar la pizarra (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.

Ejercicios

  1. En el experimento de la pizarra, desactiva el Lock y compara 2 cocineros × 2000 veces con 8 cocineros × 500 veces. El total es el mismo, 4000. ¿En cuál se pierden más pedidos? ¿Por qué?
  2. Quita el 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.
  3. Cambia el 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.

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