Sérialiser le travail asynchrone en JavaScript à l’aide d’un mutex de promesse
Serialiser le travail asynchrone superposé en JavaScript à l’aide d’un mutex de promesse : une chaîne AsyncLock exécute les requêtes en FIFO afin que l’état de l’interface reste ordonné, sans nécessiter de verrous de thread.
Sources: Concurrency management in JavaScript: the mutex (mutual exclusion) lock pattern
Le contrôle de concurrence est présent dans tous les projets de code sérieux, et JavaScript rend ce sujet encore plus crucial puisque le langage ne dispose pas de mécanismes de verrouillage multithreadé au style du système d’exploitation. Un seul thread exécute les opérations de pile dans l’ordre. Les tâches asynchrones s’enchaînent néanmoins : les promesses produisent des microtâches qui sont traitées par la boucle d’événements une fois qu’elles ont été résolues. L’ordre d’exécution des fonctions de callback après que une promesse a été mise en file est bien défini, mais le timing global ne l’est pas — la latence externe détermine quelle appel réseau se termine en premier. Tant qu’une promesse n’est ni résolue ni rejetée, ses gestionnaires .then, .catch et .finally restent en dehors de la boucle. C’est précisément dans cette période que surviennent les conditions de course, et c’est là qu’un schéma délibéré devient utile.
Considérez une interface de type bancaire. Les clients s’attendent à ce que le solde affiché après les virements et retraits corresponde à celui demandé par l’interface utilisateur. Le serveur reste la source de vérité, mais le client émet toujours des mises à jour et doit présenter les réponses dans un ordre logique.
Sans contrôle de concurrence, un mode d’échec familier apparaît. Quelqu’un ouvre l’application, voit 100 $ sur son compte courant, verse encore 100 $ par chèque et s’attend à voir 200 $. Alors que la demande de virement n’est pas encore finalisée, cette personne lance un transfert en supposant que le solde du compte courant est déjà de 200 $. Si un centre de données lent permet au transfert d’être enregistré avant que le virement ne soit confirmé, celui-ci échoue faute de fonds suffisants, même si l’intention de l’utilisateur était cohérente.
Les frameworks clients exposent rarement des primitives de type pthread, ce qui oblige le code d’application à inventer un mécanisme d’ordonnancement basé sur des promesses. Plusieurs approches existent, dont le verrouillage optimiste et pessimiste. Une autre option consiste à utiliser une synchronisation par mutex : on met en file les sections critiques afin qu’une seule puisse s’exécuter à la fois. Le schéma TypeScript ci-dessous met en œuvre cette idée pour le scénario de dépôt/transfert :
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
Fonctionnement de la chaîne de verrous
La création de new AsyncLock() alloue un nouvel objet de verrou. L’attribution de this.disableNext = Promise.resolve() initialise la chaîne avec une promesse déjà résolue, permettant ainsi au premier appelant d’exécuter immédiatement. Chaque appel à lock.acquire() doit observer la chaîne partagée de cette instance : il capture la promesse précédente sous le nom de currentLock, puis remplace this.disableNext par une nouvelle promesse en attente. Comme this fait référence au verrou à longue durée de vie, le prochain appel à acquire() voit la tête mise à jour et attend derrière elle. Lorsque la section critique active se termine, release() résout la promesse en cours d’exécution et débloque le successeur.
L’effet obtenu est une file FIFO contrôlable. Une demande arrivée plus tard ne peut pas dépasser une demande antérieure, ce qui permet à l’interface utilisateur de rester alignée avec l’ordre de traitement. Un nombre quelconque d’appels superposés peuvent s’enchaîner sans avoir recours à des threads supplémentaires. L’utilisation d’un champ de classe pour disableNext permet de conserver cet état partagé sur un seul objet, c’est pourquoi le modèle mutex convient aux applications JavaScript qui nécessitent un ordre prévisible pour gérer l’état client partagé sans paralyser le reste de la page.
Rendez la section critique courte : acquérez le verrou, effectuez la demande qui affecte l’équilibre, mettez à jour l’état local à partir de la réponse, puis libérez le verrou. Des sections longues qui attendent des tâches d’interface utilisateur non liées contrecarrent cet objectif en bloquant des opérations indépendantes qui partagent la même instance de verrou.