Dzielenie się pamięcią między wątkami roboczymi Node.js za pomocą SharedArrayBuffer i Atomics
Dlaczego funkcja postMessage dostarcza każdemu pracownikowi Node.js prywatną kopię, skoro SharedArrayBuffer jest lepszym rozwiązaniem, oraz w jaki sposób Atomics zapobiega utracie aktualizacji i umożliwia czekanie między wątkami.
Źródła: The Day I Learned JavaScript Threads Don’t Actually Share Memory
Wątki robocze Node.js to rzeczywiste wątki systemu operacyjnego, z których każdy posiada własną instancję silnika JavaScript. To skłania do przypuszczenia, że dzielą pamięć w taki sam sposób jak wątki w C lub Java, ale to założenie okazuje się błędne już przy pierwszej próbie skoordynowania działań dwóch wątków roboczych nad tymi samymi danymi. Ten artykuł pokazuje, co tak naprawdę robi funkcja postMessage z przekazywanymi wartościami, kiedy uzasadniona jest prawdziwa współdzielona pamięć oraz w jaki sposób mechanizm Atomics zapewnia poprawność równoczesnych aktualizacji.
Jeśli jesteś początkującym w temacie wątków roboczych, zacznij od artykułu offloading CPU-heavy work in Node.js with worker threads and pools; tutaj temat jest bardziej wąski – skupiamy się na tym, co dzieje się z pamięcią po utworzeniu wątku roboczego.
postMessage wysyła kopię, a nie referencję
Eksperyment rozpoczyna się od tego, że wątek główny tworzy pracownika z oddzielnego pliku.
// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");
Następnie wysyła zwykły tablicę do pracownika, a chwilę później zapisuje swoją własną kopię tej tablicy.
const sharedLookingArray = [1, 2, 3];
worker.postMessage(sharedLookingArray);setTimeout(() => {
console.log("Main thread's array:", sharedLookingArray); // still [1, 2, 3]
}, 100);
Z drugiej strony pracownik otrzymuje tablicę, dodaje do niej element i zapisuje wynik.
// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (arr) => {
arr.push(4);
console.log("Worker's array:", arr); // [1, 2, 3, 4]
});
Pracownik wyświetla [1, 2, 3, 4], podczas gdy wątek główny nadal wyświetla [1, 2, 3], bez względu na to, jak długo czeka. Powodem jest to, że postMessage przetwarza wartość za pomocą algorytmu strukturalnego klonowania, tego samego mechanizmu dostępnego globalnie jako structuredClone. Dane są serializowane po stronie nadawczej i odbudowywane jako niezależny obiekt po stronie odbiorczej, więc po wywołaniu istnieją dwie niepowiązane tablice – po jednej na wątek.
To jest celowe i bezpieczne ustawienie domyślne. Dwa wątki nie mogą współdzielić pamięci, której nigdy im obojgu nie przydzielono. Kosztem jest sama kopia, która rośnie wraz z rozmiarem danych i jest pobierana przy każdej wiadomości. (W przypadku dużych danych binarnych Node może również przenieść ArrayBuffer do drugiego wątku jako obiekt przenoszalny, co unika konieczności kopiowania, ale sprawia, że nadawca nie może go używać; to jest przeniesienie, a nie współdzielenie.)
Kiedy pamięć wspólna jest odpowiednim narzędziem
Kopowanie staje się niewłaściwym kompromisem, gdy wątki muszą koordynować działania nad tymi samymi danymi z dużą częstotliwością, tak często, że ich przesyłanie tam i z powrotem kosztowałoby więcej niż praca, która ma być paralelizowana. W takim przypadku JavaScript oferuje SharedArrayBuffer: surowy blok pamięci, do którego kilka wątków może bezpośrednio odwoływać się, bez konieczności kopiowania.
Główny wątek ponownie ładuje moduł pracownika:
// main.js
const { Worker } = require("worker_threads");
Następnie alokuje się czterobajtowy bufor współdzielony, wystarczający na jeden liczbę całkowitą 32-bitową, otacza go widokiem typu Int32Array i przekazuje bufor do procesora zadaniowego przez workerData.
const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes, one 32-bit integer
const sharedCounter = new Int32Array(sharedBuffer);const worker = new Worker("./worker.js", { workerData: { sharedBuffer } });
Procesor zadaniowy tworzy swój własny widok typu Int32Array na tym samym buforze:
// worker.js
const { workerData } = require("worker_threads");
const sharedCounter = new Int32Array(workerData.sharedBuffer);
Od tego momentu zapis dokonany za pośrednictwem jednego widoku jest widoczny w drugim, ponieważ oba wskazują na te same bajty.
sharedCounter[0] += 1; // this thread and the main thread are now touching the same memory
To jest prawdziwe odejście od typowego zachowania konwergencji w JavaScript. Kod w jednym obszarze działania jest wykonywany na jednej nitce, więc znane problemy związane z operacjami asynchronicznymi, w których dwie funkcje async są wykonywane w zaskakującej kolejności, nadal polegają na tym, że jedna nitka przejmuje kontrolę w dobrze określonych momentach. Z użyciem SharedArrayBuffer dwie nitki mogą czytać i pisać w tej samej pamięci dosłownie w tym samym momencie.
Utracona aktualizacja, która teraz jest możliwa
Rozważmy oczywisty sposób zwiększania współdzielonego licznika:
// naive, and genuinely unsafe with real shared memory
sharedCounter[0] = sharedCounter[0] + 1;
Wygląda to jak jedna operacja, ale w rzeczywistości jest ich trzy: odczyt aktualnej wartości, dodanie jedności oraz zapisania wyniku. Jeśli inna wątek odczyta licznik pomiędzy odczytem tego samego wątku a jego zapisem, oba obliczą tę samą nową wartość, w wyniku czego jeden incydent zwiększenia znika. Nic się nie wywróci i nic nie zostanie zapisane; ostateczna liczba jest po prostu za niska. Ponieważ wynik zależy od dokładnego momentu wykonywania operacji przez dwa wątki systemu, błąd może pojawić się raz na tysiąc uruchomień i zniknąć po dodaniu logowania.
Operacje atomowe: nierozdzielne operacje na współdzielonej pamięci
Obiekt Atomics umożliwia wykonywanie operacji na współdzielonych tablicach liczb całkowitych, które są realizowane jako jedna całość. Żaden inny wątek nie może obserwować ani ingerować w operację atomową w jej trakcie.
Atomics.add(sharedCounter, 0, 1); // read, increment, and write, as one indivisible step
Jeśli każda wątek zwiększa wartość za pomocą Atomics.add zamiast stosować schemat odczyt-zmiana-zapis, żadna aktualizacja nie może zostać utracona, ponieważ silnik gwarantuje, że inne operacje atomowe na tym elemencie nie mogą się przeplatać z tą jedną. Gwarancja ta obowiązuje tylko wtedy, gdy wszystkie wątki używają operacji atomowych; pojedynczy wątek nadal stosujący prosty mechanizm zwiększania wartości ponownie wprowadza ryzyko konfliktów. Wszystko, co bardziej złożone i co można zbudować na wspólnej pamięci – czy to liczniki, blokady czy kolejki – w ostatecznym rozrachunku składa się z operacji takich jak ta, które są atomowe na poziomie sprzętowym, a nie „zazwyczaj wystarczające”.
Atomics.wait i Atomics.notify: blokowanie między wątkami
Atomics oferuje również coś, czego brakuje w JavaScript: możliwość całkowitego zatrzymania wykonywania się jednego wątku, dopóki inny wątek go nie obudzi. W workerze Atomics.wait blokuje się tak długo, jak długo licznik nadal ma wartość 0:
// worker.js — waits for main thread to signal it's time to proceed
Atomics.wait(sharedCounter, 0, 0); // blocks this thread while sharedCounter[0] === 0
console.log("Signaled to continue, value is now:", sharedCounter[0]);
Główny wątek zapisuje nową wartość, a następnie obudza jeden z czekających wątków:
// main.js — does the signaling
Atomics.store(sharedCounter, 0, 1);
Atomics.notify(sharedCounter, 0, 1); // wakes up one waiting thread
Ważne jest zapisanie wartości przed jej powiadomieniem. Atomics.wait najpierw sprawdza wartość i natychmiast zwraca się, jeśli nie odpowiada ona oczekiwanej, więc sygnał wysłany przed rozpoczęciem czekania przez workera nie jest tracony.
To jest prawdziwy wywołanie blokujące, co jest nietypowe w JavaScript, gdzie praca asynchroniczna zazwyczaj przebiega poprzez funkcje zwrotne w pętli zdarzeń. Zablokowanie wątku, który uruchamia pętlę zdarzeń, sparaliżowałoby wszystko inne, czym ten wątek się zajmuje, dlatego przeglądarki nie pozwalają na użycie Atomics.wait w głównym wątku i dlatego, ogólnie rzecz biorąc, należy on do wątków roboczych. (Przeglądarki wymagają również, aby strona była izolowana między różnymi źródłami, zanim w ogóle będzie dostępny SharedArrayBuffer; Node nie ma takiej ograniczenia.)
Rzeczywiste zastosowanie: aktualizacja postępu w grupie wątków roboczych
Oto przykład, w którym dodana złożoność przynosi korzyści. Kilka procesorowo intensywnych pracowników przetwarza po części dużego zbioru danych, a wątek główny chce mieć aktualny licznik postępów bez konieczności wysyłania wiadomości przez każdego pracownika dla każdego elementu. Wątek główny tworzy jeden wspólny licznik i przekazuje swój bufor każdemu pracownikowi wraz z jego częścią danych:
// main.js
const progress = new Int32Array(new SharedArrayBuffer(4));
const workers = chunks.map((chunk) =>
new Worker("./process-chunk.js", { workerData: { chunk, progressBuffer: progress.buffer } })
);
Następnie co pół sekundy pobiera wartość z licznika za pomocą Atomics.load, co gwarantuje odczytanie kompletnej, aktualnej wartości:
setInterval(() => {
console.log(`Processed ${Atomics.load(progress, 0)} items so far`);
}, 500);
Każdy pracownik otacza wspólny bufor swoją własną strukturą do pracy:
// process-chunk.js
const { workerData } = require("worker_threads");
const progress = new Int32Array(workerData.progressBuffer);
Podczas przetwarzania elementów atomowo zwiększa wartość wspólnego licznika:
for (const item of workerData.chunk) {
processItem(item);
Atomics.add(progress, 0, 1); // every worker safely increments the same shared counter
}
Wszystkie wątki zapisują bezpośrednio do tych samych czterech bajtów, nie ma dodatkowych kosztów związanych z przekazywaniem informacji o poszczególnych elementach, a wątek główny może w każdej chwili odczytać dokładną sumę bez konieczności proszenia innych o raportowanie. Właśnie taka forma problemu, polegająca na częstej i ścisłej koordynacji między naprawdę równoległymi zadaniami CPU, uzasadnia użycie pamięci wspólnej zamiast kopiowania.
B większości przypadków kod nie powinien się do tego uciekać
Typowy kod backendu w Node rzadko wymaga takich rozwiązań. Większość zadań asynchronicznych w serwerze API jest związana z operacjami wejścia/wyjścia, które są już dobrze obsługiwane przez pojedynczy pętlę zdarzeń. W przypadku zadań obciążających CPU, takich jak przetwarzanie obrazów, transformacja danych czy kryptografia, zazwyczaj wystarczają wątki robocze wykorzystujące zwykłe funkcje postMessage do kopiowania danych.
SharedArrayBuffer i Atomics są przeznaczone do bardziej specyficznych sytuacji: wysokoczęstotliwowej koordynacji między wątkami równoległymi, gdzie kopiowanie przy każdej wymianie danych stałoby się samym wąskim gardłem. Zastosowanie pamięci współdzielonej, zanim zmierzymy ten problem, nie sprawi, że kod będzie szybszy; doprowadzi jedynie do konfliktów na poziomie sprzętu, których model kopiowania miał na celu uniknięcie.
Główne wnioski
postMessageklonuje dane w formie strukturyzowanej, dzięki czemu każdy wątek pracuje ze swoją własną kopią, a modyfikacje nigdy się nie przenoszą między nimi.SharedArrayBufferumożliwia kilku wątkom bezpośredni dostęp do tych samych bajtów poprzez widoki typowych tablic.- Zwykłe wyrażenie
x = x + 1w pamięci współdzielonej może potajemnie utracić aktualizacje; do każdego zapisu należy używaćAtomics.addi podobnych funkcji.
Atomics.wait rzeczywiście blokuje wątek i powinien być używany w workerach; należy go łączyć z Atomics.store oraz Atomics.notify.Literatura pokrewna
- Przenoszenie obciążeń CPU w Node.js za pomocą worker_threads i pooli — Dowiedz się, jak worker_threads w Node.js utrzymują pętlę zdarzeń responsywną: tworzenie workerów, komunikacja typu request-response, buforzy przenoszalne, pooli oraz ich pułapki.
- Node.js i Express – przygotowanie do rozmowy: 70 zadań z dokładnymi odpowiedziami — Ustrukturyzowana analiza 70 pytań z rozmów rekrutacyjnych dotyczących Node.js i Express, obejmująca tematy takie jak pętla zdarzeń i strumienie, middleware, bezpieczeństwo oraz skalowanie, wraz z korektami powszechnie błędnych odpowiedzi.
- Sandboxing node_modules za pomocą flag modelu uprawnień w Node.js — Dowiedz się, jak flaga --permission w Node.js domyślnie blokuje dostęp do systemu plików, sieci i procesów, jak precyzyjnie jej używać oraz jakie są jej ograniczenia.