Saltar al contenido

Capítulo 7 de 12

Estado compartido y coordinación

Reproduce una actualización perdida, protege la decisión completa y aprende cuándo conviene transferir mensajes en lugar de compartir objetos.

#Un solo hilo también puede perder una actualización

Una condición de carrera aparece cuando el resultado depende de una intercalación no controlada entre operaciones. En un único bucle de asyncio, las tareas no ejecutan instrucciones simultáneamente, pero sí pueden intercalarse en una suspensión. Si leer, decidir y escribir quedan separados por un await, otra tarea puede actuar sobre un estado que ya cambió.

Imagina dos tareas que leen saldo 10, esperan una comprobación externa y escriben saldo 9. Ambas confirman un consumo, pero el saldo solo baja una unidad. El problema es la operación de negocio completa, no que Python haya «calculado mal» una resta.

#Haz visible la actualización perdida

import asyncio


async def ejecutar(proteger):
    contador = 0
    lock = asyncio.Lock()

    async def incrementar_sin_proteccion():
        nonlocal contador
        anterior = contador
        await asyncio.sleep(0)
        contador = anterior + 1

    async def incrementar():
        if proteger:
            async with lock:
                await incrementar_sin_proteccion()
        else:
            await incrementar_sin_proteccion()

    async with asyncio.TaskGroup() as grupo:
        for _ in range(10):
            grupo.create_task(incrementar())
    return contador


async def main():
    sin_lock = await ejecutar(False)
    con_lock = await ejecutar(True)
    assert sin_lock == 1
    assert con_lock == 10
    print("sin protección:", sin_lock, "con protección:", con_lock)


asyncio.run(main())

La suspensión deliberada hace que todas lean el cero inicial antes de escribir. El lock compartido protege lectura y escritura juntas. Proteger solo contador = anterior + 1 no impediría que anterior ya fuera obsoleto.

En este caso artificial bastaría eliminar la suspensión y hacer contador += 1 dentro del mismo bucle. Entre tareas de ese bucle no hay intercalación dentro de ese tramo sin suspensión. Eso no es una garantía general de atomicidad para hilos, procesos ni futuras funciones que introduzcan un await.

#Sección crítica y espera externa

Una sección crítica es el tramo que debe verse como una única decisión respecto a otros participantes. Debe ser tan pequeño como permita la regla. Si mantienes un lock durante una petición de diez segundos, todos los participantes esperan diez segundos aunque solo necesiten leer un contador.

A veces la espera pertenece a la regla y es necesaria. El control de tasa del capítulo anterior mantiene el lock mientras espera su turno para no concederlo dos veces. Otras veces puedes separar cálculo y confirmación:

  1. Bajo lock, lee estado y versión.
  2. Fuera del lock, realiza el trabajo lento sobre esa copia.
  3. Bajo lock, verifica que la versión sigue siendo la misma.
  4. Confirma el cambio o descarta/reintenta con un presupuesto.

Esa técnica es concurrencia optimista. El conflicto no desaparece: cambia de «esperar» a «detectar una decisión obsoleta». Si la operación externa ya produjo un efecto, descartarlo en memoria no deshace el efecto; necesitas además un protocolo idempotente o transaccional con ese destino.

#El alcance del lock debe coincidir con el recurso

asyncio.Lock coordina tareas de un mismo bucle. No protege otra instancia del programa, un worker de otro proceso o una escritura directa en la base de datos. Crear un lock por petición tampoco coordina peticiones entre sí.

Recurso compartido Dónde debe vivir la garantía
Contador en una instancia y un bucle Tramo sin suspensión o lock de esa instancia
Datos entre hilos Primitivas de threading y API que admita esos hilos
Reserva guardada en base de datos Restricciones y transacción en la base
Una acción remota que puede repetirse Contrato idempotente del receptor
Varios servicios que reparten trabajos Almacén y protocolo de reclamación compartidos

No uses un lock local para prometer que dos procesos no reservan el mismo asiento. Esa garantía debe mantenerse incluso si uno de los procesos cae. El capítulo de persistencia muestra por qué el ámbito de una transacción importa.

#Event es una señal, no una cola

asyncio.Event representa una condición activada o desactivada. Varios participantes pueden esperar a que esté activa. Si llamas a set() tres veces antes de que un consumidor reaccione, no has guardado tres mensajes: sigue siendo una señal activa.

Usa un evento para «el servicio está listo» o «se solicitó parar». Usa una cola si necesitas conservar cada trabajo con su contenido. Para esperar cambios en una condición protegida, Condition combina un lock y notificaciones; el participante debe volver a comprobar el predicado después de despertar.

No hace falta usar todas las primitivas en cada proyecto. Selecciona según lo que representa la información: exclusión, señal, permiso o elemento pendiente.

#Transferir resultados a un único dueño

Otra forma de reducir carreras es tener un único consumidor que modifique un estado. Los workers producen mensajes; el consumidor actualiza contadores, escribe registros o calcula el resumen. Así se ve quién decide el orden de esas modificaciones.

# Fragmento: el colector es el único que modifica resumen.
async def recoger(cola_resultados, resumen):
    while True:
        resultado = await cola_resultados.get()
        try:
            if resultado is None:
                return
            estado = resultado["estado"]
            resumen[estado] = resumen.get(estado, 0) + 1
        finally:
            cola_resultados.task_done()

La cola de resultados también debe estar acotada. Si el destino escribe despacio, la presión se transmite a los workers y después al productor. Para terminar, el dueño debe enviar el centinela cuando todos los productores de resultados hayan terminado; un centinela prematuro puede abandonar resultados posteriores.

En el proyecto final acumularemos como máximo mil resultados pequeños y los imprimiremos al terminar, por sencillez y para no bloquear el bucle escribiendo continuamente a una terminal lenta. Si el volumen exige salida continua, el colector se convierte en una fase explícita con su propio límite.

#Evita los interbloqueos por orden inconsistente

Si una tarea toma A y espera B, mientras otra toma B y espera A, ninguna puede continuar. La prevención habitual es un orden único de adquisición y secciones pequeñas. No llames a código desconocido manteniendo un lock si ese código puede intentar adquirir otro en orden incompatible.

Añadir un timeout detecta que la espera es excesiva, pero no corrige el orden. Además debes liberar lo que ya adquiriste mediante contextos o finally. Si el estado quedó modificado a medias, la liberación del lock por sí sola no restaura la invariancia de negocio.

#Comprueba la propiedad que necesitas

La prueba del contador verifica una actualización perdida deliberada. Para tu aplicación define una invariancia más útil: saldo no negativo, un trabajo no aparece en dos estados terminales o cada resultado corresponde a una entrada admitida.

Coordina la intercalación de la prueba con eventos cuando sea posible. Una prueba que repite un caso mil veces y «no ha fallado todavía» no demuestra que la carrera no exista. Saber exactamente dónde puede suspenderse el código hace las pruebas más pequeñas y más convincentes.

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar