Startseite / Artikel

Asynchrone Aufgaben in JavaScript mit einem Promise-Mutex serialisieren

Serialisieren Sie überlappende asynchrone Aufgaben in JavaScript mit einem Promise-Mutex: Eine AsyncLock-Kette führt Anfragen im FIFO-Modus aus, sodass der UI-Zustand geordnet bleibt und keine Thread-Sperren erforderlich sind.

Verlag: REACTAPP.TOP3 Min. Lesezeit775 Wörter

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

Der Konkurrenzkontrolle kommt in jeder ernsthaften Codebasis entgegen, und JavaScript verstärkt dieses Thema, da die Sprache keine mehrthreadigen Sperrmechanismen im Stil eines Betriebssystems bietet. Ein einzelner Thread führt die Stack-Arbeit nacheinander aus. Asynchrone Aufgaben werden dennoch verschachtelt abgewickelt: Promises liefern Ergebnisse, werden als Mikroaufgaben gespeichert und gelangen nach ihrer Abwicklung erneut über den Event-Loop in die Ausführung. Die Reihenfolge der Callbacks nach dem Anstellen einer Promise ist klar definiert, doch die Gesamtlaufzeit ist es nicht – externe Verzögerungen bestimmen, welche Netzwerkaufrufe zuerst abgeschlossen werden. Solange eine Promise nicht erfüllt oder abgelehnt wird, bleiben ihre Handler .then, .catch und .finally außerhalb des Event-Loops. Genau in dieser Lücke treten Wettbewerbsbedingungen auf, und hier wird ein bewusst gewähltes Muster nützlich.

Betrachten Sie eine Benutzeroberfläche im Stil von Banken. Kunden erwarten, dass der Betrag, den sie nach Ein- und Auszahlungen sehen, mit dem übereinstimmt, was die Benutzeroberfläche anfordert. Der Server bleibt weiterhin die Quelle der Wahrheit, doch der Client sendet weiterhin Aktualisierungen und muss die Antworten in einer sinnvollen Reihenfolge darstellen.

Ohne Konkurrenzkontrolle tritt ein bekanntes Versagen auf. Jemand öffnet die App, sieht 100 Dollar auf dem Girokonto, überweist weitere 100 Dollar per Scheck und erwartet insgesamt 200 Dollar. Während die Überweisungsanfrage noch nicht abgeschlossen ist, startet er eine weitere Überweisung unter der Annahme, dass das Girokonto bereits 200 Dollar enthält. Wenn ein langsames Rechenzentrum es zulässt, dass die zweite Überweisung vor der Bestätigung der ersten Einzahlung gespeichert wird, scheitert sie wegen ungenügender Mittel – obwohl die Absicht des Benutzers korrekt war.

Client-Frameworks stellen selten Primitiven im Stil von pthread zur Verfügung, weshalb der Anwendungscode eine Ordnungshierarchie auf Basis von Promises erstellen muss. Es gibt mehrere Ansätze – darunter optimistisches und pessimistisches Sperren. Eine weitere Option ist die Serialisierung über einen Mutex: Kritische Abschnitte werden in eine Warteschlange gelegt, sodass nur ein Prozess gleichzeitig fortgesetzt werden kann. Der untenstehende TypeScript-Entwurf implementiert diese Idee für das Szenario Einzahlung/Überweisung:

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

Wie die Sperrkette funktioniert

Durch das Erstellen von new AsyncLock() wird ein neues Sperrobjekt zugewiesen. Die Zuweisung von this.disableNext = Promise.resolve() legt eine bereits abgeschlossene Promise in die Kette, sodass der erste Aufrufer sofort ausgeführt werden kann. Jeder Aufruf von lock.acquire() muss die gemeinsame Kette dieser Instanz überwachen: Er speichert die vorherige Promise als currentLock und ersetzt anschließend this.disableNext durch eine neue, ausstehende Promise. Da this auf das lang lebende Sperrobjekt verweist, sieht der nächste acquire()-Aufruf den aktualisierten Anfang der Kette und wartet dahinter. Wenn der aktive kritische Abschnitt abgeschlossen ist, löst release() die laufende Promise auf und entblockiert den nachfolgenden Aufruf.

Das Ergebnis ist eine steuerbare FIFO-Warteschlange. Eine spätere Anfrage kann keine frühere überspringen, sodass die Benutzeroberfläche der Reihenfolge der Bearbeitung entspricht. Eine beliebige Anzahl überschneidender Aufrufe kann ohne Erstellung zusätzlicher Threads abgearbeitet werden. Die Verwendung eines Klassenfeldes für disableNext bewahrt diesen gemeinsamen Zustand in einem Objekt auf, weshalb das Mutex-Muster für JavaScript-Anwendungen geeignet ist, die eine vorhersehbare Reihenfolge bei gemeinsam genutztem Client-Zustand benötigen, ohne den Rest der Seite lahmzulegen.

Halten Sie den kritischen Abschnitt kurz: Erwerben Sie den Schutzmechanismus, führen Sie die anfechtbare Anfrage aus, aktualisieren Sie den lokalen Zustand mit der Antwort und geben Sie anschließend den Schutzmechanismus wieder frei. Lange Abschnitte, die auf unverwandte Benutzeroberflächenoperationen warten, vereiteln das Ziel, da dadurch unverwandte Operationen, die dieselbe Sperrinstanz teilten, blockiert werden.