Serializuj pracę asynchroniczną w JavaScript za pomocą mutexa obietnic.
Serywalizuj nakładające się zadania asynchroniczne w JavaScript za pomocą mutexa opartego na obietnicach: łańcuch AsyncLock realizuje żądania w kolejności FIFO, dzięki czemu stan interfejsu pozostaje uporządkowany, a nie są potrzebne blokady wątków.
Źródła: Concurrency management in JavaScript: the mutex (mutual exclusion) lock pattern
Kontrola współbieżności występuje we wszystkich poważnych bazach kodu, a JavaScript sprawia, że temat ten wydaje się bardziej złożony, ponieważ język ten nie oferuje mechanizmów blokad wielowątkowych w stylu systemu operacyjnego. Jeden wątek wykonywa operacje na stosie kolejno. Prace asynchroniczne nadal są realizowane równolegle: obietnice (promises) są realizowane, przekształcane w mikrozadania i ponownie włączane do obiegu poprzez pętlę zdarzeń po ich zakończeniu. Kolejność wywołań funkcji callback po umieszczeniu obietnicy w kolejce jest dobrze określona, natomiast czas realizacji całego procesu nie jest – to zewnętrzna opóźniona reakcja decyduje, która wywołanie sieciowe zostanie zakończone pierwsze. Dopóki obietnica nie zostanie zrealizowana lub odrzucona, jej obsługi .then, .catch i .finally pozostają poza pętlą. Właśnie w tym okresie pojawiają się warunki wyścigu i tam przydaje się celowe użycie określonych wzorców.
Rozważmy interfejs w stylu bankowym. Klienci oczekują, że saldo widziane po wpłatach i wypłatach będzie zgodne z tym, o co prosi interfejs użytkownika. Serwer pozostaje źródłem prawdy, ale klient nadal wysyła aktualizacje i musi przedstawiać odpowiedzi w sensownej kolejności.
Bez kontroli równoczesności pojawia się dobrze znany sposób awarii. Ktoś otwiera aplikację, widzi 100 dolarów na koncie bieżącym, wpłaca kolejne 100 dolarów za pomocą czeki i oczekuje 200 dolarów. Zanim zakończy się proces wpłaty, inicjuje przelew, zakładając, że saldo na koncie bieżącym wynosi już 200 dolarów. Jeśli powolne centrum danych pozwala na zapisanie przelewu przed potwierdzeniem wpłaty, przelew kończy się niepowodzeniem z powodu niewystarczających środków, mimo że intencja użytkownika była spójna.
Ramy klienta rzadko udostępniają prymitywy w stylu pthread, więc kod aplikacji musi samodzielnie zapewnić uporządkowanie operacji na podstawie obietnic. Istnieje kilka podejść – wśród nich blokowanie optymistyczne i pesymistyczne. Inną opcją jest serializacja za pomocą mutexa: kolejka sekcji krytycznych sprawia, że tylko jedna może być wykonywana naraz. Poniższy szkic w TypeScriptie realizuje tę koncepcję w scenariuszu depozytu/przelewu:
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
Jak działa łańcuch blokad
Konstrukcja new AsyncLock() alokuje nowy obiekt blokady. Przypisanie this.disableNext = Promise.resolve() wprowadza do łańcucha już zrealizowaną obietnicę, dzięki czemu pierwszy wywołujący może natychmiast działać. Każde wezwanie lock.acquire() musi badać wspólny łańcuch w danej instancji: pobiera poprzednią obietnicę jako currentLock, a następnie zastępuje this.disableNext nową, oczekującą obietnicą. Ponieważ this odnosi się do długo istniejącej blokady, kolejne wezwanie acquire() widzi zaktualizowany początek łańcucha i czeka za nim. Gdy aktywna sekcja krytyczna się zakończy, release() realizuje obietnicę w trakcie wykonywania i odblokowuje następny element łańcucha.
Efektem jest sterowana kolejka FIFO. Późniejsza prośba nie może wyprzedzić wcześniejszej, dzięki czemu interfejs pozostaje zgodny z kolejnością realizacji zadań. dowolna liczba nakładających się żądań może być uporządkowana bez konieczności tworzenia dodatkowych wątków. Użycie pola klasowego disableNext umożliwia przechowywanie tego stanu współdzielonego w jednym obiekcie, dlatego wzorzec mutex nadaje się do aplikacji JavaScript, które wymagają przewidywalnego uporządkowania działań związanych ze współdzielonym stanem klienta, bez blokowania reszty strony.
Niech sekcja krytyczna będzie krótka: uzyskaj dostęp, wykonaj żądanie wpływające na równowagę, zaktualizuj lokalny stan na podstawie odpowiedzi, a następnie zwolnij zasoby. Długie sekcje oczekujące na niepowiązane operacje interfejsu psują cel, powodując zatrzymanie innych operacji korzystających z tej samej instancji blokady.
Literatura pokrewna
- Await widzi tylko jeden Promise: Projektowanie asynchronicznych API, które samodzielnie wykonują zadania — Dowiedz się dokładnie, co obserwuje JavaScript await, dlaczego odłączone zadania, callbacki, strumienie oraz Promise.all unikają jego wpływu, oraz jak projektować Promises, które odzwierciedlają rzeczywiste zakończenie zadania.
- Przenoszenie obciążających procesor zadań w Node.js za pomocą worker_threads i Pools — Dowiedz się, jak worker_threads w Node.js utrzymują pętlę zdarzeń responsywną: tworzenie pracowników, komunikacja typu request-response, buforzy przenoszalne, pooli oraz ich pułapki.