Architektura poza głównym wątkiem może znacznie zwiększyć niezawodność aplikacji i poprawić wrażenia użytkownika.
W ciągu ostatnich 20 lat internet przeszedł ogromną ewolucję od statycznych dokumentów z kilkoma stylami i obrazami do złożonych, dynamicznych aplikacji. Jednak jedna rzecz pozostała w dużej mierze niezmieniona: w każdej karcie przeglądarki mamy tylko jeden wątek (z pewnymi wyjątkami), który odpowiada za renderowanie witryn i wykonywanie kodu JavaScript.
W efekcie wątek główny jest bardzo obciążony. W miarę jak aplikacje internetowe stają się coraz bardziej złożone, główny wątek staje się poważnym wąskim gardłem dla wydajności. Co gorsza, czas potrzebny na uruchomienie kodu w głównym wątku w przypadku danego użytkownika jest niemal całkowicie nieprzewidywalny, ponieważ możliwości urządzenia mają ogromny wpływ na wydajność. Ta nieprzewidywalność będzie się tylko zwiększać, ponieważ użytkownicy uzyskują dostęp do internetu na coraz większej liczbie urządzeń – od bardzo ograniczonych telefonów z funkcjami podstawowymi po zaawansowane urządzenia flagowe o wysokiej częstotliwości odświeżania.
Jeśli chcemy, aby zaawansowane aplikacje internetowe spełniały wytyczne dotyczące wydajności, takie jak podstawowe wskaźniki internetowe – które opierają się na danych empirycznych dotyczących ludzkiej percepcji i psychologii – musimy mieć możliwość wykonywania kodu poza głównym wątkiem.
Dlaczego warto korzystać z web workerów?
JavaScript jest domyślnie językiem jednowątkowym, który wykonuje zadania w wątku głównym. Instancje robocze zapewniają jednak pewnego rodzaju wyjście z wątku głównego, ponieważ umożliwiają deweloperom tworzenie osobnych wątków do obsługi zadań poza wątkiem głównym. Zakres działania web workerów jest ograniczony i nie mają one bezpośredniego dostępu do DOM, ale mogą być bardzo przydatne, jeśli trzeba wykonać dużo pracy, która w przeciwnym razie obciążyłaby główny wątek.
W przypadku podstawowych wskaźników internetowych wykonywanie pracy poza wątkiem głównym może być korzystne. W szczególności przeniesienie pracy z głównego wątku do instancji roboczych może zmniejszyć rywalizację o główny wątek, co może poprawić wartość interakcji do kolejnego wyrenderowania (INP). Gdy wątek główny ma mniej pracy do wykonania, może szybciej reagować na interakcje użytkownika.
Mniejsza ilość pracy w wątku głównym – zwłaszcza podczas uruchamiania – może też przynieść korzyści w zakresie największego wyrenderowania treści (LCP), ponieważ zmniejsza liczbę długich zadań. Renderowanie elementu LCP wymaga czasu wątku głównego – na renderowanie tekstu lub obrazów, które są częstymi i powszechnymi elementami LCP. Zmniejszając ogólną ilość pracy wątku głównego, możesz mieć pewność, że element LCP na stronie będzie rzadziej blokowany przez kosztowne zadania, które mógłby wykonać zamiast niego proces roboczy.
Wielowątkowość z użyciem instancji roboczych
Inne platformy zwykle obsługują pracę równoległą, umożliwiając przypisanie funkcji do wątku, który działa równolegle z pozostałą częścią programu. Dostęp do tych samych zmiennych można uzyskać z obu wątków, a dostęp do tych udostępnionych zasobów można synchronizować za pomocą muteksów i semaforów, aby zapobiegać wyścigom.
W JavaScript podobną funkcjonalność zapewniają elementy web worker, które są dostępne od 2007 roku i obsługiwane przez wszystkie główne przeglądarki od 2012 roku. Web workerzy działają równolegle z głównym wątkiem, ale w przeciwieństwie do wątków systemu operacyjnego nie mogą udostępniać zmiennych.
Aby utworzyć proces roboczy, przekaż plik do konstruktora procesu roboczego, który zacznie uruchamiać ten plik w osobnym wątku:
const worker = new Worker("./worker.js");
Komunikuj się z procesem roboczym w internecie, wysyłając wiadomości za pomocą interfejsu postMessage API. Przekaż wartość wiadomości jako parametr w wywołaniu postMessage, a następnie dodaj do procesu roboczego odbiornik zdarzeń wiadomości:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
// ...
});
Aby wysłać wiadomość z powrotem do wątku głównego, użyj tego samego interfejsu postMessage API w procesie roboczym i skonfiguruj odbiornik zdarzeń w wątku głównym:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
console.log(event.data);
});
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
postMessage(a + b);
});
Trzeba przyznać, że to podejście jest nieco ograniczone. Wcześniej instancje robocze były używane głównie do przenoszenia pojedynczego zadania wymagającego dużej mocy obliczeniowej z głównego wątku. Próba obsługi wielu operacji za pomocą jednego procesu roboczego szybko staje się niewygodna: w wiadomości musisz zakodować nie tylko parametry, ale też operację, a także prowadzić ewidencję, aby dopasowywać odpowiedzi do żądań. To prawdopodobnie dlatego web workerzy nie są szerzej stosowani.
Jeśli jednak udałoby się nam uprościć komunikację między wątkiem głównym a procesami roboczymi, ten model mógłby się sprawdzić w wielu zastosowaniach. Na szczęście istnieje biblioteka, która to umożliwia.
Comlink: ułatwianie pracy z usługami Web Worker
Comlink to biblioteka, która umożliwia korzystanie z procesów roboczych bez konieczności zastanawiania się nad szczegółami postMessage. Comlink umożliwia udostępnianie zmiennych między wątkami roboczymi a głównym wątkiem niemal tak samo jak w innych językach programowania obsługujących wątki.
Aby skonfigurować Comlink, zaimportuj go w procesie roboczym i zdefiniuj zestaw funkcji, które mają być dostępne dla wątku głównego. Następnie zaimportuj Comlink w głównym wątku, opakuj proces roboczy i uzyskaj dostęp do udostępnionych funkcji:
worker.js
import {expose} from 'comlink';
const api = {
someMethod() {
// ...
}
}
expose(api);
main.js
import {wrap} from 'comlink';
const worker = new Worker('./worker.js');
const api = wrap(worker);
Zmienna api w głównym wątku działa tak samo jak w procesie roboczym, z tym wyjątkiem, że każda funkcja zwraca obietnicę wartości, a nie samą wartość.
Który kod należy przenieść do procesu roboczego?
Web workerzy nie mają dostępu do DOM ani do wielu interfejsów API, takich jak WebUSB, WebRTC czy Web Audio, więc nie możesz umieszczać w nich części aplikacji, które wymagają takiego dostępu. Każdy mały fragment kodu przeniesiony do procesu roboczego zwiększa jednak margines bezpieczeństwa w głównym wątku na potrzeby elementów, które muszą się w nim znajdować, np. aktualizowania interfejsu.
Jednym z problemów dla deweloperów stron internetowych jest to, że większość aplikacji internetowych korzysta z platformy interfejsu, takiej jak Vue lub React, aby koordynować wszystko w aplikacji. Wszystko jest komponentem platformy, a więc jest z natury powiązane z DOM. Wydaje się, że utrudnia to migrację do architektury OMT.
Jeśli jednak przejdziemy na model, w którym kwestie interfejsu są oddzielone od innych kwestii, takich jak zarządzanie stanem, web workerzy mogą być bardzo przydatni nawet w przypadku aplikacji opartych na frameworkach. Właśnie takie podejście zastosowano w przypadku PROXX.
PROXX: studium przypadku OMT
Zespół Google Chrome opracował PROXX jako klon gry Saper, który spełnia wymagania progresywnej aplikacji internetowej, w tym działa w trybie offline i zapewnia atrakcyjne wrażenia użytkownika. Niestety wczesne wersje gry działały słabo na urządzeniach o ograniczonych możliwościach, takich jak telefony komórkowe, co uświadomiło zespołowi, że główny wątek jest wąskim gardłem.
Zespół zdecydował się użyć web workerów, aby oddzielić stan wizualny gry od jej logiki:
- Wątek główny obsługuje renderowanie animacji i przejść.
- Instancja robocza sieci obsługuje logikę gry, która jest czysto obliczeniowa.
OMT miało interesujący wpływ na wydajność PROXX na telefonach z podstawową przeglądarką. W wersji bez OMT interfejs użytkownika jest zamrożony przez 6 sekund po interakcji użytkownika. Nie ma żadnych informacji zwrotnych, a użytkownik musi poczekać pełne 6 sekund, zanim będzie mógł zrobić coś innego.
W wersji OMT aktualizacja interfejsu zajmuje jednak 12 sekund. Chociaż może się to wydawać pogorszeniem wydajności, w rzeczywistości zwiększa liczbę informacji zwrotnych dla użytkownika. Spowolnienie występuje, ponieważ aplikacja wysyła więcej klatek niż wersja bez OMT, która nie wysyła żadnych klatek. Dzięki temu użytkownik wie, że coś się dzieje, i może kontynuować grę, gdy interfejs się aktualizuje, co znacznie poprawia wrażenia z gry.
To świadomy kompromis: użytkownikom urządzeń o ograniczonych możliwościach zapewniamy lepsze wrażenia, nie karząc przy tym użytkowników urządzeń z wyższej półki.
Implikacje architektury OMT
Jak pokazuje przykład PROXX, OMT sprawia, że aplikacja działa niezawodnie na większej liczbie urządzeń, ale nie przyspiesza jej działania:
- Przenosisz tylko pracę z głównego wątku, a nie zmniejszasz jej ilości.
- Dodatkowe obciążenie komunikacyjne między procesem roboczym a głównym wątkiem może czasami nieznacznie spowolnić działanie.
Zalety i wady każdego rozwiązania
Wątek główny może przetwarzać interakcje użytkownika, takie jak przewijanie, podczas działania JavaScriptu, więc liczba pominiętych klatek jest mniejsza, mimo że całkowity czas oczekiwania może być nieznacznie dłuższy. Lepszym rozwiązaniem jest krótkie opóźnienie niż pominięcie klatki, ponieważ margines błędu jest mniejszy w przypadku pominiętych klatek: pominięcie klatki następuje w milisekundach, a użytkownik zauważy opóźnienie dopiero po setkach milisekund.
Ze względu na nieprzewidywalność wydajności na różnych urządzeniach celem architektury OMT jest zmniejszenie ryzyka, czyli zwiększenie odporności aplikacji na bardzo zmienne warunki działania, a nie korzyści wynikające z równoległego przetwarzania. Większa odporność i ulepszenia interfejsu są warte niewielkiego kompromisu w zakresie szybkości.
Uwaga dotycząca narzędzi
Web workerzy nie są jeszcze powszechnie stosowani, więc większość narzędzi do obsługi modułów, takich jak webpack i Rollup, nie obsługuje ich od razu po zainstalowaniu. (Parcel już tak!) Na szczęście istnieją wtyczki, które umożliwiają działanie web workerów w webpacku i Rollupie:
- worker-plugin w przypadku webpacka
- rollup-plugin-off-main-thread dla Rollup
Podsumowanie
Aby nasze aplikacje były jak najbardziej niezawodne i dostępne, zwłaszcza na coraz bardziej zglobalizowanym rynku, musimy obsługiwać urządzenia o ograniczonych możliwościach – to na nich większość użytkowników na całym świecie korzysta z internetu. OMT to obiecujący sposób na zwiększenie wydajności na takich urządzeniach bez negatywnego wpływu na użytkowników urządzeń z wyższej półki.
OMT ma też dodatkowe zalety:
- Przenosi koszty wykonywania JavaScriptu do osobnego wątku.
- Przenosi koszty parsowania, co oznacza, że interfejs może uruchamiać się szybciej. Może to skrócić czas pierwszego wyrenderowania treści, a nawet czas do interaktywności, co z kolei może zwiększyć wynik Lighthouse.
Web workerzy nie muszą być straszni. Narzędzia takie jak Comlink odciążają instancje robocze i sprawiają, że są one dobrym wyborem w przypadku wielu aplikacji internetowych.