WebWorkery pozwalają na tworzenie kodu wielowątkowego. Przed ES8 kontakt między webworkerami odbywał się przez eventy, jednak nie było to idealne rozwiązanie. Dopiero w ES8 wprowadzono coś, co znamy z języków obiektowych, a co rozwiązuje problemy wyścigów. Dzisiaj opisze wam shared memory oraz atomics.

Worker

Zacznijmy od początku. Aby stworzyć kod działający wielowątkowo, potrzebujemy workerów. Tworzy się je tak:

let w = new Worker("mojworker.js")

Jako parametr przekazujemy nazwę pliku z kodem, jaki ma się wykonywać w naszym workerze. Komunikacja między plikiem głównym a workerem następuje przez message. W pliku głównym wywołujemy postMessage, wysyłając wiadomość do workera oraz obsługując wiadomości, które worker nam wysyła:

w.postMessage("Witaj");     // wysylamy “Witaj” do workera

w.onmessage = function (ev) {
  console.log(ev.data);  // Ustalamy co ma się stać, gdy dostaniemy wiadomośc z workera
}

W workerze natomiast tworzymy globalną funkcję, która przechwytuje wiadomości. Następnie dzięki globalnej funkcji postMessage wysyłamy wiadomość z workera do głównego pliku:

onmessage = function (ev) {
  console.log(ev.data);
  postMessage("No hej");     // wysyla “No hej” do głównego pliku
}

Niby proste, ale nie do końca. Kiedy workerów jest więcej i wysyłamy nie tylko stringi, ale też złożone obiekty, kod zaczyna być skomplikowany. Co ważniejsze, Jeżeli kilka workerów pracuje na tych samych danych, komunikacja i wspólna praca zaczyna być naprawdę straszna. Właśnie dlatego powstało shared memory.

Shared memory

Aby użyć shared memory, musimy najpierw stworzyć jej instancje. Robimy to tak:

let sab = new SharedArrayBuffer(1024);

Funkcja przyjmuje tylko jeden parametr – długość pamięci w bajtach. Jeżeli składnia przypomina ci użycie ArrayBuffer, to masz rację. Większość funkcji działa tak samo.

Nasza zmienna sab niestety nie zadziała sama z siebie. Jest to tylko zarezerwowana pamięć, nie możemy jej wprost odczytać i zapisać. Żeby to zrobić, potrzebujemy stworzyć coś, co nazywa się widok, czyli obiekt DataView, który powie nam, z jakimi danymi mamy do czynienia. W widoku możemy zapisywać i odczytywać jak w zwykłej tablicy.

let ia = new Int32Array(sab);

ia[0] = 0;
ia[1] = 1;
ia[2] = 2;
...

Teraz wystarczy w pliku głównym wysłać widok do workera:

w.postMessage(ia);

A w workerze odebrać widok i przypisać do lokalnej zmiennej:

let ia;

onmessage = function (ev) {
  ia = ev.data;        // ia.length == 100000
  console.log(ia[2]); // wypisze 2
}

Wydaje się proste. Pamięć jest współdzielona wszędzie, gdyż ten sam obiekt widoku, u nas nazwany “ia”, można wysłać do wielu wątków. Zmiany wprowadzone w jednym wątku po chwili będą widoczne we wszystkich.

No właśnie – po chwili. Nie wiadomo, czy kiedy używamy jakiejś wartości, nie jest ona nadpisywana dokładnie w tym momencie przez inny kawałek kodu. Tutaj dochodzimy do problemu wyścigów – co, jeżeli mamy np. pięć wątków i wszystkie nadpiszą pierwszy element tablicy w tym samym czasie? Właśnie – nie wiadomo.

Atomics

Dlatego wymyślono atomic. Mają one na celu synchronizację użycia shared memory.  Żeby zapisać coś synchronicznie, używamy obiektu globalnego Atomics:

Atomics.store(ia, 1, 111);  // inaczej - synchroniczne ia[1] = 111

Wszystkie zapisy do zmiennej rozpoczęte przed twoim zapisem zostaną ukończone i dopiero wtedy zapiszesz nową wartość. Podobnie z odczytem:

Atomics.load(ia, 1) // inaczej - synchroniczne ia[1]

Metoda ta poczeka na zamknięcie wszystkich zapisów to shared memory i dopiero udostępni Ci wartość. Teraz masz pewność, że w momencie zapisu lub odczytu żaden inny wątek nie robi tego samego.

Podsumowanie

Shared memory jest użyteczne, atomics jeszcze bardziej. Owszem, jest to programowanie w JS bardzo niskiego poziomu (jakkolwiek to nie brzmi), ale są wypadki, gdzie jest to potrzebne, a nawet niezbędne. Właśnie dlatego polecam zapoznać się z nimi i spróbować samemu.

Jeżeli masz jakieś pytania o shared memory lub atomics pisz śmiało!