Ir al contenido
wannacode

Operaciones atómicas y objetos inmutables

En resumen: Una operación atómica se ejecuta de una sola vez, sin que otro hilo la interrumpa; Python no tiene entero atómico ni compare-and-set, así que proteges los datos con un cerrojo, y un objeto inmutable no cambia nunca.

Problema

Un cerrojo para un solo +1

Una venta relámpago: 100 auriculares a mitad de precio, a las 10:00. La página del producto se abre miles de veces por segundo, y cada apertura incrementa el contador de visitas. De la lección sobre bloqueo sabes cómo no perder incrementos: un cerrojo alrededor de views++.

Eso es correcto. Pero cada hilo hace cola delante del cerrojo para un único +1. Un hilo que no consigue el cerrojo suele ser puesto a dormir y despertado más tarde por el sistema operativo. Eso cuesta mucho más que la suma misma.

El segundo problema es peor. El último artículo se compra con «si hay uno, disminuir en 1». Son dos pasos, y entre ellos otro hilo puede llevarse el mismo artículo.

La idea

Un paso indivisible

El procesador tiene instrucciones que leen, cambian y escriben una celda de memoria en un paso indivisible. Ningún otro hilo puede meterse en medio. Esto es una operación atómica. No hay cerrojo, nadie hace cola y nadie duerme.

Python no tiene entero atómico. views += 1 son varios pasos para el intérprete, con el GIL y sin él. Así que Python tiene tres maneras honestas:

  • un cerrojo alrededor del incremento, como en la lección sobre bloqueo;
  • una cola en la que los hilos mandan los eventos mientras un hilo cuenta (en las lecciones siguientes);
  • cada hilo cuenta en su propio lugar, y al final se suman los lugares. Entonces no se comparte nada y nada necesita protección.

Aquí está la tercera manera.

Míralo

El contador de visitas

Cuatro hilos, cada uno con 100 000 visitas:

counts = [0] * 4
def visitor(slot):
for _ in range(100_000):
counts[slot] += 1 # solo este hilo escribe aquí
threads = [threading.Thread(target=visitor, args=(slot,))
for slot in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(f"Vistas: {sum(counts)}")
Salida
Vistas: 400000

400 000 en cada ejecución. Cada hilo escribe solo en su propio counts[slot], así que nadie estorba a nadie. sum(counts) se llama solo después de join, cuando los hilos han terminado.

Más precisamente, para curiosos

En código ajeno encontrarás next(counter) sobre itertools.count() como contador «atómico». En CPython con el GIL de verdad no pierde valores, pero eso es un detalle de la implementación, no una promesa del lenguaje. En Python 3.13 sin GIL (free-threaded) no cuentes con ello. En Java esta idea (contar por separado, sumar al final) es una clase hecha: LongAdder.

¿Cuánto más rápido que un cerrojo es? Depende de la máquina y de cuántos hilos peleen por una celda. Sin pelea, un +1 atómico son unos pocos nanosegundos. Un cerrojo sin pelea se acerca. La diferencia llega con muchos hilos: entonces el cerrojo pone hilos a dormir, mientras que la operación atómica solo repite.

La idea

Comprobar y cambiar: compare-and-set

Comprar no es solo −1. Es «si hay al menos uno, disminuir en 1». La comprobación y el cambio deben ser un solo paso.

Para esto hay una operación atómica más general: compare-and-set (CAS). Le das dos números: lo que crees que hay y lo que quieres que haya. Cambia el valor solo si sigue siendo lo que crees, y te dice si lo consiguió. Si otro hilo lo ha cambiado entretanto, CAS no toca nada y responde «no».

Entonces comprar es un bucle:

  1. leer las existencias;
  2. si es 0, rechazar;
  3. CAS: «si sigue siendo tanto, déjalo en uno menos»;
  4. si CAS falla, alguien ha comprado entretanto: volver a 1.
Python no tiene CAS. Para que veas la idea, AtomicCell, más abajo, es un pequeño sustituto: compare_and_set hace la comprobación y el cambio bajo un Lock. Un CAS de verdad es una instrucción del procesador, sin cerrojo.

Míralo

Los últimos tres auriculares

class AtomicCell:
"""Python no tiene CAS: un pequeño sustituto con un cerrojo"""
def __init__(self, value):
self._value = value
self._lock = threading.Lock()
def get(self):
return self._value
def compare_and_set(self, expected, new):
with self._lock:
if self._value != expected:
return False
self._value = new
return True
class FlashSale:
def __init__(self, items):
self.stock = AtomicCell(items)
def try_buy(self):
while True:
left = self.stock.get()
if left == 0:
return False # agotado
if self.stock.compare_and_set(left, left - 1):
return True
# alguien cambió stock: leer otra vez
Paso a paso, en palabras
  1. Los últimos 3 auriculares: las existencias son 3. Los hilos A y B llaman a try_buy() al mismo tiempo.
  2. A lee las existencias: left = 3.
  3. B las lee antes de que A haya cambiado nada: también 3.
  4. B: compare_and_set(3, 2). «Si todavía es 3, ponlo en 2.» Todavía es 3, así que se escribe 2. B compró.
  5. A prueba lo mismo: «si todavía es 3…». Ahora es 2, así que no se escribe nada y la respuesta es false. No se pierde nada.
  6. A no espera ni se rinde: el bucle da otra vuelta y A lee de nuevo: 2. Ningún hilo ha dormido.
  7. A: «si todavía es 2, ponlo en 1». Todavía es 2: se escribe 1. A también compró.
  8. Dos compradores, dos vendidos, existencias 1. No hay cerrojo: el procesador comprueba y escribe en un solo paso, y el que pierde solo repite el bucle.

Nadie espera: el hilo que perdió solo repite dos líneas. Esto se llama lock-free. Cien artículos para 160 intentos:

sale = FlashSale(100)
results = [] # list.append es seguro desde muchos hilos
def buyers():
for _ in range(40):
results.append(sale.try_buy())
threads = [threading.Thread(target=buyers) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print("Vendidos:", results.count(True))
print("Rechazados:", results.count(False))
print("Existencias:", sale.stock.get())
Salida
Vendidos: 100
Rechazados: 60
Existencias: 0

Cuántas veces dio vueltas el bucle es distinto en cada ejecución, por eso el programa no lo imprime. Los totales son siempre los mismos: 100 vendidos, 60 rechazados, 0 en existencias.

Más precisamente, para curiosos

En código Python real no escribes AtomicCell. Pones un cerrojo alrededor de la comprobación y la disminución, y hace lo mismo:

class FlashSale:
def __init__(self, items):
self.stock = items
self.lock = threading.Lock()
def try_buy(self):
with self.lock:
if self.stock == 0:
return False # agotado
self.stock -= 1
return True
Salida
Vendidos: 100
Rechazados: 60
Existencias: 0

Pruébalo

Predice la salida

Aquí los dos «hilos» están escritos uno tras otro en un solo hilo, para que el orden sea el mismo que en la escena en cada ejecución. ¿Qué imprime?

AtomicCell es la clase de la escena.
stock = AtomicCell(3)
seen_by_a = stock.get()
seen_by_b = stock.get()
print("B:", stock.compare_and_set(seen_by_b, seen_by_b - 1))
print("A:", stock.compare_and_set(seen_by_a, seen_by_a - 1))
seen_by_a = stock.get()
print("A otra vez:", stock.compare_and_set(seen_by_a, seen_by_a - 1))
print("Existencias:", stock.get())
Respuesta

Pruébalo

Completa

¿Con qué compara el CAS al comprar?

if self.stock.compare_and_set(, left - 1):

Pruébalo

Encuentra el error

Un colega ha simplificado la compra. Cada línea por sí sola es segura, y aun así en la venta se venden 103 auriculares de 100. ¿En qué línea está el error?

Elige la línea con el error.

Problema

La lista de precios que cambia a la mitad

A las 10:00 el administrador empieza la promoción: auriculares de 60 a 48 €, funda de 15 a 12 €, cable de 5 a 4 €. Al mismo tiempo las cajas calculan carritos. Si los precios están en un diccionario compartido y cambian uno por uno, una caja puede leer el precio nuevo de los auriculares y el viejo de la funda:

# el administrador
prices["headphones"] = 48
prices["case"] = 12
prices["cable"] = 4
# el cobro, al mismo tiempo
total = sum(prices[item] for item in cart)

El carrito sale en 68 €: un total que no fue correcto en ningún momento. Ni con los precios viejos (80 €), ni con los nuevos (64 €).

La idea

No lo cambies, reemplázalo entero

La lista de precios se lee muchas veces y se cambia pocas. Entonces no la cambies nunca. Hazla un objeto inmutable: una vez creada, se queda igual. Para la promoción el administrador hace una lista de precios nueva y cambia una referencia: «la lista de precios actual es esta».

  • Un objeto que nadie cambia puede ser leído por tantos hilos como quieras, sin ninguna protección.
  • Lo único compartido es la referencia, y cambiarla es un paso atómico.
  • La caja toma la referencia una vez, al empezar el carrito, y calcula todo el carrito con ella. Ve la lista de precios vieja entera o la nueva entera, nunca una mezcla.

@dataclass(frozen=True) prohíbe cambiar los campos. Pero el diccionario de dentro todavía puede cambiar, por eso es un MappingProxyType: una vista de solo lectura del diccionario. El cambio es una asignación normal: current = PriceList(...). Asignar un nombre en Python es un solo paso, así que la caja lee el objeto viejo o el nuevo.

@dataclass(frozen=True)
class PriceList:
version: int
prices: MappingProxyType # solo lectura
def total(self, cart):
return sum(self.prices[item] for item in cart)
regular = MappingProxyType({"headphones": 60, "case": 15, "cable": 5})
promo = MappingProxyType({"headphones": 48, "case": 12, "cable": 4})
cart = ["headphones", "case", "cable"]
current = PriceList(1, regular)
mixed = 0
def admin():
global current
for v in range(2, 1002):
prices = promo if v % 2 == 0 else regular
current = PriceList(v, prices) # una instantánea nueva, una referencia
def checkout():
global mixed
for _ in range(20_000):
price_list = current # una instantánea para todo el carrito
total = price_list.total(cart)
if total not in (80, 64):
mixed += 1
threads = [threading.Thread(target=admin), threading.Thread(target=checkout)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print("Totales comprobados: 20000")
print("Totales mezclados:", mixed)
print(f"Última versión: {current.version}, total {current.total(cart)} €")
Salida
Totales comprobados: 20000
Totales mezclados: 0
Última versión: 1001, total 80 €
La caja ha comprobado decenas de miles de carritos mientras la lista de precios cambiaba mil veces, y ni un solo total es mezclado. Cuántos carritos fueron por la lista vieja y cuántos por la nueva es distinto en cada ejecución, por eso el programa no lo imprime.
Más precisamente, para curiosos

Así funcionan también las colecciones «copy on write»: CopyOnWriteArrayList en Java, ImmutableList en C#. Cada cambio hace una copia nueva, y leer no necesita cerrojo. Compensa cuando hay muchas lecturas y pocas escrituras. Si las escrituras son frecuentes, copiar sale caro.

La inmutabilidad es honesta solo si llega hasta el fondo. Un objeto con un campo final que apunta a una lista modificable no es inmutable: la lista se puede cambiar desde fuera. Por eso frozen=True no basta y el diccionario es un MappingProxyType.

Cómo es en los otros lenguajes: record y Map.copyOf con AtomicReference en Java; record con ImmutableDictionary y Volatile o Interlocked.Exchange en C#; una dataclass frozen, una tuple o MappingProxyType y asignación en Python; Object.freeze y mensajes entre Workers en JavaScript.

La idea

Cuando lo atómico no basta

Una reserva mueve un artículo de las existencias a la cuenta de reservados. La regla es que los dos juntos suman siempre 10 000. Dos variables atómicas no la mantienen:

stock = 10
reserved = 0
def reserve():
global stock, reserved
stock -= 1 # en Python ni una sola línea es atómica,
reserved += 1 # y mucho menos las dos juntas

Entre las dos líneas el auditor ve 9999 en total. Lo atómico protege un valor. Cuando la regla trata de varios valores juntos, necesitas un cerrojo alrededor de todos los cambios y de la comprobación:

class Warehouse:
def __init__(self, stock):
self.stock = stock
self.reserved = 0
self.lock = threading.Lock()
def reserve(self):
with self.lock: # las dos o ninguna
if self.stock == 0:
return False
self.stock -= 1
self.reserved += 1
return True
def audit(self):
with self.lock:
return self.stock + self.reserved
def report(self):
with self.lock:
return f"Existencias: {self.stock}, reservados: {self.reserved}"
warehouse = Warehouse(10_000)
broken = 0
def reserver():
while warehouse.reserve():
pass
def auditor():
global broken
for _ in range(20_000):
if warehouse.audit() != 10_000:
broken += 1
threads = [threading.Thread(target=f) for f in (reserver, reserver, auditor)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(warehouse.report())
print("Comprobaciones rotas:", broken)
Salida
Existencias: 0, reservados: 10000
Comprobaciones rotas: 0
Más precisamente, para curiosos

Otra manera sin cerrojo: los dos valores en un objeto inmutable (existencias, reservados), y el cambio con un CAS sobre la referencia. Un objeto nuevo con los dos valores nuevos reemplaza al viejo de golpe. Eso combina las dos ideas de esta lección, pero el cerrojo suele ser más claro.

Qué usar cuándo:

  • un valor, un contador o una bandera: una operación atómica;
  • comprobar y cambiar un valor: un bucle CAS;
  • datos que se leen mucho y se cambian poco: un objeto inmutable y un cambio de referencia;
  • varios valores relacionados: un cerrojo.

Ejercicio

Un vale con usos limitados

Resuelve la tarea en tu editor.

El vale «SPRING20» se puede usar 50 veces. Ocho hilos, cada uno con 10 clientes, intentan usarlo al mismo tiempo. Escribe Coupon con try_use() de modo que el vale nunca se use más de 50 veces. Con un cerrojo alrededor de la comprobación y la disminución. Imprime cuántas veces se usó, cuántos rechazos hubo y cuántos usos quedan:

Salida
Usados: 50
Rechazados: 30
Quedan: 0

Más tareas

  • El máximo. Guarda el pedido más grande del día mientras muchos hilos añaden pedidos. Pista: un bucle CAS que escribe solo si el nuevo es mayor.
  • Dos promociones. Dale una versión a la lista de precios y haz que dos administradores publiquen al mismo tiempo. ¿Cómo evitas que se pierda la promoción de uno? (Pista: cambia la referencia con CAS solo si la versión sigue siendo la que leíste.)
  • El auditor sin cerrojo. Pon las existencias y la cuenta de reservados en un objeto inmutable y cámbialo con CAS. Comprueba que el auditor sigue viendo siempre 10 000.

Test

Ponte a prueba

1. Las existencias son 6. Un hilo llama a CAS «si es 5, ponlo en 4». ¿Qué pasa?
2. tryBuy tiene «si las existencias son > 0» y luego «disminuir en 1». Las dos líneas son atómicas. ¿Por qué aun así vende más de lo que hay?
3. Las cajas leen la lista de precios miles de veces por segundo, y cambia una vez al día. ¿Cuál es la mejor manera de compartirla?
4. Reservas un artículo: las existencias bajan 1 y la cuenta de reservados sube 1. El auditor comprueba que la suma sea siempre 10 000. ¿Qué necesitas?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·