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)}")Vistas: 400000400 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:
- leer las existencias;
- si es 0, rechazar;
- CAS: «si sigue siendo tanto, déjalo en uno menos»;
- si CAS falla, alguien ha comprado entretanto: volver a 1.
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 vezPaso a paso, en palabras
- Los últimos 3 auriculares: las existencias son 3. Los hilos A y B llaman a try_buy() al mismo tiempo.
- A lee las existencias: left = 3.
- B las lee antes de que A haya cambiado nada: también 3.
- 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ó.
- 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.
- A no espera ni se rinde: el bucle da otra vuelta y A lee de nuevo: 2. Ningún hilo ha dormido.
- A: «si todavía es 2, ponlo en 1». Todavía es 2: se escribe 1. A también compró.
- 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())Vendidos: 100Rechazados: 60Existencias: 0Cuá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 TrueVendidos: 100Rechazados: 60Existencias: 0Prué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())Los dos leen 3. El CAS de B encuentra 3 y escribe 2. El CAS de A espera 3 y encuentra 2, así que no escribe nada. A lee otra vez (2) y su segundo CAS escribe 1. Las existencias son 1, y los dos han comprado.
Pruébalo
Completa
¿Con qué compara el CAS al comprar?
if self.stock.compare_and_set(, left - 1):Con el valor que el hilo ha leído: left. Así el cambio ocurre solo si nadie ha tocado las existencias desde que se leyeron.
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.
self.stock > 0 está fuera del cerrojo. Tres hilos ven el último artículo, los tres pasan la comprobación y luego disminuyen uno por uno: las existencias quedan en −2. El cerrojo tiene que cubrir tanto la comprobación como la disminución.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 administradorprices["headphones"] = 48prices["case"] = 12prices["cable"] = 4
# el cobro, al mismo tiempototal = 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)} €")Totales comprobados: 20000Totales mezclados: 0Última versión: 1001, total 80 €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 = 10reserved = 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 juntasEntre 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)Existencias: 0, reservados: 10000Comprobaciones rotas: 0Má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:
Usados: 50Rechazados: 30Quedan: 0Má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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Hilos · Por qué hilos · Condición de carrera · Bloqueo
- Continúa en: Deadlock · Productor y consumidor · Cómo probar código concurrente
- Ejercicios sobre el tema: Concurrencia y condiciones de carrera