Inicio / Artículos

Serializa el trabajo asíncrono en JavaScript con un mutex de promesas

Serializa el trabajo asíncrono superpuesto en JavaScript con un mutex de promesas: una cadena AsyncLock ejecuta las solicitudes en orden FIFO para que el estado de la interfaz de usuario se mantenga ordenado, sin necesidad de bloqueos de hilos.

Editor: REACTAPP.TOP3 min de lectura775 palabras

Fuentes: Concurrency management in JavaScript: the mutex (mutual exclusion) lock pattern

El control de concurrencia está presente en cualquier base de código seria, y JavaScript hace que este tema sea aún más relevante porque el lenguaje no incluye mecanismos de bloqueo multihilo al estilo del sistema operativo. Un único hilo ejecuta las tareas de la pila en orden. Las tareas asíncronas, sin embargo, se intercalan: las promesas generan microtareas y vuelven a entrar al bucle de eventos una vez que se resuelven. El orden en el que se ejecutan las funciones de callback tras la cola de una promesa está bien definido, pero el tiempo total desde el inicio hasta el final no lo está; la latencia externa determina qué llamada de red termina primero. Hasta que una promesa se resuelve o rechaza, sus manejadores .then, .catch y .finally permanecen fuera del bucle. Ese intervalo es precisamente donde aparecen las condiciones de carrera y donde resulta útil utilizar un patrón deliberado.

Considere una interfaz de tipo bancario. Los clientes esperan que el saldo que ven después de realizar depósitos y retiros coincida con lo que solicita la interfaz. El servidor sigue siendo la fuente de verdad, pero el cliente aún emite actualizaciones y debe presentar las respuestas en un orden lógico.

Sin control de concurrencia, aparece un modo de fallo familiar. Alguien abre la aplicación, ve $100 en su cuenta corriente, deposita otros $100 con un cheque y espera tener $200. Mientras la solicitud de depósito aún no se ha completado, inicia una transferencia asumiendo que el saldo de la cuenta corriente ya es de $200. Si un centro de datos lento permite que la transferencia llegue al sistema de almacenamiento antes de que el depósito se confirme, la transferencia fallará por fondos insuficientes, aunque la intención del usuario era coherente.

Los frameworks de cliente rara vez exponen primitivas al estilo pthread, por lo que el código de la aplicación debe inventar mecanismos de ordenación basados en promesas. Existen varios enfoques, entre ellos el bloqueo optimista y el pesimista. Otra opción es la serialización a través de un mutex: se colocan en cola las secciones críticas para que solo una progrese a la vez. El boceto en TypeScript a continuación implementa esa idea para el escenario de depósito/transferencia:

class AsyncLock {
  private disableNext: Promise<void>;

  constructor() {
    this.disableNext = Promise.resolve();
  }

  // force async task to wait in line
  async acquire(): Promise<() => void> {
    let release!: () => void;
    const currentLock = this.disableNext; // assign private reference to the most recent promise created

    this.disableNext = new Promise<void>(resolve => { // create a new promise for the next request in queue
      release = resolve;
    });

    await currentLock;
    return release;
  }
}

// how to use AsyncLock
const lock = new AsyncLock();
let balance = 100;

type TransactionType = "withdraw" | "deposit";

async function updateBalance(amount: number, transactionType: TransactionType): Promise<void> {
  const release = await lock.acquire(); // wait in line
  try {
    console.log(`Reading balance... Current: ${balance}`);
    await new Promise(r => setTimeout(r, 50)); // simulate database delay
    if (transactionType === "withdraw" && balance >= amount) { // withdraw
      balance -= amount;
      console.log(`Withdraw: ${amount}. Current: ${balance}`);
    } else if (transactionType === "deposit") { // deposit
      balance += amount;
      console.log(`Deposit: ${amount}. Current: ${balance}`);
    } else {
      console.log("NOP");
    }
  } finally {
    release(); // let the next task run by calling the resolve method on the current promise
  }
}

updateBalance(100, "deposit"); // deposit $100 into checking
updateBalance(200, "withdraw"); // transfer (withdraw) $200 to savings

Cómo funciona la cadena de bloqueos

Al crear new AsyncLock() se asigna un objeto de bloqueo nuevo. Al establecer this.disableNext = Promise.resolve() se inicia la cadena con una promesa ya resuelta para que el primer llamante pueda ejecutarse de inmediato. Cada llamada a lock.acquire() debe observar la cadena compartida de esa instancia: captura la promesa anterior como currentLock y luego reemplaza this.disableNext por una nueva promesa pendiente. Dado que this se refiere al bloqueo de larga duración, la siguiente llamada a acquire() ve el inicio actualizado de la cadena y espera detrás de él. Cuando finaliza la sección crítica activa, release() resuelve la promesa en ejecución y desbloquea al siguiente solicitante.

El efecto es una cola FIFO controlable. Una solicitud posterior no puede adelantarse a una anterior, por lo que la interfaz de usuario se mantiene alineada con el orden de resolución. Cualquier número de llamadas superpuestas puede organizarse sin necesidad de crear hilos adicionales. Utilizar un campo de clase para disableNext mantiene ese estado compartido en un único objeto, y por eso el patrón de mutex es adecuado para aplicaciones en JavaScript que necesitan un orden predecible en torno al estado del cliente compartido sin congelar el resto de la página.

Mantenga la sección crítica corta: adquiera el bloqueo, realice la solicitud que afecte al equilibrio, actualice el estado local con la respuesta y luego libere el bloqueo. Secciones largas que esperan tareas de interfaz de usuario no relacionadas frustran el objetivo al retrasar operaciones ajenas que compartían la misma instancia de bloqueo.

Lecturas relacionadas

  • Subidas multi-pestaña con coordenadas mediante la API Web Locks — Evita las subidas duplicadas reanudables entre pestañas del navegador al reemplazar los trucos con localStorage y BroadcastChannel por navigator.locks y un coordinador de subidas en React.