Architektura poza wątkiem głównym może znacznie poprawić niezawodność aplikacji i wrażenia użytkowników.
W ciągu ostatnich 20 lat internet przeszedł ogromną zmianę – 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: mamy tylko 1 wątek na kartę przeglądarki (z pewnymi wyjątkami), który renderuje nasze witryny i uruchamia kod JavaScript.
W rezultacie wątek główny jest bardzo obciążony. W miarę wzrostu złożoności aplikacji internetowych wątek główny staje się wąskim gardłem, które znacząco wpływa na wydajność. Co gorsza, czas potrzebny na uruchomienie kodu w głównym wątku dla danego użytkownika jest prawie całkowicie nieprzewidywalny, ponieważ możliwości urządzenia mają ogromny wpływ na wydajność. Ta nieprzewidywalność będzie tylko rosła, ponieważ użytkownicy korzystają z internetu na coraz bardziej zróżnicowanych urządzeniach – od telefonów z podstawową przeglądarką po zaawansowane urządzenia flagowe z wysoką częstotliwością odświeżania.
Jeśli chcemy, aby zaawansowane aplikacje internetowe niezawodnie spełniały wytyczne dotyczące wydajności, takie jak Core Web Vitals, które opierają się na danych empirycznych dotyczących ludzkiej percepcji i psychologii, potrzebujemy sposobów na wykonywanie kodu poza wątkiem głównym.
Dlaczego warto używać skryptów web worker?
JavaScript jest domyślnie językiem jednowątkowym, który wykonuje zadania w głównym wątku. Skrypty Web Worker zapewniają jednak pewnego rodzaju ucieczkę z głównego wątku, umożliwiając deweloperom tworzenie oddzielnych wątków do obsługi pracy poza głównym wątkiem. Chociaż zakres skryptów web worker jest ograniczony i nie oferuje bezpośredniego dostępu do DOM, mogą one być bardzo przydatne, jeśli trzeba wykonać dużo pracy, która w przeciwnym razie przeciążyłaby wątek główny.
Jeśli chodzi o Core Web Vitals, wykonywanie pracy poza wątkiem głównym może być korzystne. W szczególności przeniesienie pracy z głównego wątku do skryptów web worker może zmniejszyć obciążenie głównego wątku, co może poprawić wskaźnik responsywności strony Interakcja do kolejnego wyrenderowania (INP). Gdy główny wątek ma mniej pracy, może szybciej reagować na interakcje użytkownika.
Mniej pracy w głównym wątku – zwłaszcza podczas uruchamiania – może też przynieść korzyści w przypadku największego wyrenderowania treści (LCP) dzięki skróceniu długich zadań. Renderowanie elementu LCP wymaga czasu wątku głównego – do renderowania tekstu lub obrazów, które są częstymi i typowymi elementami LCP. Zmniejszając ogólne obciążenie wątku głównego, możesz sprawić, że element LCP strony będzie mniej narażony na zablokowanie przez kosztowne zadania, które mógłby wykonać skrypt web worker.
Wątki z internetowymi instancjami roboczymi
Inne platformy zwykle obsługują pracę równoległą, umożliwiając przypisanie wątkowi funkcji, która działa równolegle z resztą programu. Możesz uzyskać dostęp do tych samych zmiennych z obu wątków, a dostęp do tych udostępnionych zasobów można zsynchronizować z muteksami i semaforami, aby zapobiec sytuacjom wyścigu.
W przypadku JavaScriptu podobną funkcjonalność możemy uzyskać dzięki mechanizmowi Web Workers, który jest dostępny od 2007 roku i obsługiwany przez wszystkie popularne przeglądarki od 2012 roku. Skrypty web worker 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ć instancję roboczą, przekaż plik do konstruktora instancji roboczej, który zacznie uruchamiać ten plik w osobnym wątku:
const worker = new Worker("./worker.js");
Komunikuj się z procesem roboczym, wysyłając wiadomości za pomocą interfejsu API postMessage. Przekaż wartość wiadomości jako parametr w wywołaniu postMessage, a następnie dodaj do skryptu roboczego detektor 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 API postMessage w skrypcie web worker i skonfiguruj detektor 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 skrypty web worker były używane głównie do przenoszenia pojedynczych, wymagających zadań z głównego wątku. Próba obsługi wielu operacji za pomocą jednego skryptu web worker szybko staje się uciążliwa: musisz zakodować w wiadomości nie tylko parametry, ale też operację, a także prowadzić księgowość, aby dopasować odpowiedzi do żądań. Ta złożoność jest prawdopodobnie powodem, dla którego mechanizmy Web Worker nie są powszechnie stosowane.
Jeśli jednak udałoby się usunąć niektóre trudności związane z komunikacją między wątkiem głównym a procesami roboczymi, ten model mógłby być świetnym rozwiązaniem w wielu przypadkach. Na szczęście istnieje biblioteka, która właśnie to robi.
Comlink: ułatwianie pracy z web workerami
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, podobnie jak w innych językach programowania, które obsługują wątki.
Aby skonfigurować Comlink, zaimportuj go w procesie roboczym i zdefiniuj zestaw funkcji, które mają być widoczne dla wątku głównego. Następnie importujesz Comlink w głównym wątku, opakowujesz skrypt i uzyskujesz 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 zachowuje się tak samo jak w procesie roboczym, z tą różnicą, że każda funkcja zwraca obietnicę wartości, a nie samą wartość.
Który kod należy przenieść do skryptu web worker?
Skrypty Web Worker nie mają dostępu do modelu DOM ani wielu interfejsów API, takich jak WebUSB, WebRTC czy Web Audio, więc nie można umieścić w nich części aplikacji, które wymagają takiego dostępu. Jednak każdy mały fragment kodu przeniesiony do instancji roboczej zapewnia więcej miejsca w głównym wątku na elementy, które muszą się tam znajdować, takie jak aktualizowanie interfejsu.
Jednym z problemów, z którymi borykają się twórcy stron internetowych, jest to, że większość aplikacji internetowych opiera się na platformie UI, takiej jak Vue lub React, która koordynuje wszystko w aplikacji. Wszystko jest komponentem platformy, a więc jest nierozerwalnie związane z DOM. Wydaje się, że utrudnia to migrację do architektury OMT.
Jeśli jednak przejdziemy na model, w którym kwestie związane z interfejsem są oddzielone od innych, takich jak zarządzanie stanem, skrypty service worker mogą być bardzo przydatne nawet w przypadku aplikacji opartych na platformach. Właśnie takie podejście zastosowano w przypadku gry PROXX.
PROXX: studium przypadku OMT
Zespół Google Chrome opracował grę PROXX jako klon Sapera, który spełnia wymagania progresywnej aplikacji internetowej, w tym działa 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 z podstawową przeglądarką. Zespół zdał sobie sprawę, że wąskim gardłem jest główny wątek.
Zespół zdecydował się użyć skryptów Web Worker, aby oddzielić stan wizualny gry od jej logiki:
- Wątek główny obsługuje renderowanie animacji i przejść.
- Skrypt web worker 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 jest zablokowany przez 6 sekund po interakcji użytkownika. Nie ma żadnych informacji zwrotnych, a użytkownik musi poczekać 6 sekund, zanim będzie mógł zrobić coś innego.
W wersji OMT aktualizacja interfejsu zajmuje jednak 12 sekund. Może się wydawać, że to pogarsza wydajność, ale w rzeczywistości zapewnia użytkownikowi więcej informacji. Spowolnienie występuje, ponieważ aplikacja wysyła więcej klatek niż wersja bez OMT, która nie wysyła żadnych klatek. Użytkownik wie, że coś się dzieje, i może kontynuować grę podczas aktualizowania interfejsu, co znacznie poprawia wrażenia z rozgrywki.
To świadomy kompromis: użytkownikom urządzeń o ograniczonych możliwościach zapewniamy lepsze wrażenia, nie pogarszając przy tym wrażeń użytkowników urządzeń wysokiej klasy.
Konsekwencje 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ść.
- Dodatkowe obciążenie komunikacyjne między skryptem web worker a głównym wątkiem może czasami nieznacznie spowolnić działanie.
Rozważ zalety i wady każdego rozwiązania
Ponieważ wątek główny może swobodnie przetwarzać interakcje użytkownika, takie jak przewijanie, podczas działania JavaScriptu, występuje mniej pominiętych klatek, mimo że łączny czas oczekiwania może być nieco dłuższy. Lepiej jest, gdy użytkownik musi chwilę poczekać, niż gdy klatka zostanie pominięta, ponieważ margines błędu w przypadku pominiętych klatek jest mniejszy: pominięcie klatki następuje w ciągu milisekund, a użytkownik zauważa czas oczekiwania 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 związane z równoległym wykonywaniem zadań. Wzrost odporności i poprawa wygody użytkowników są warte niewielkiego kompromisu w kwestii szybkości.
Uwaga dotycząca narzędzi
Skrypty Web Worker nie są jeszcze powszechnie stosowane, więc większość narzędzi do modułów, takich jak webpack i Rollup, nie obsługuje ich od razu. (Parcel to robi). Na szczęście istnieją wtyczki, które umożliwiają współpracę skryptów web worker z webpack i Rollup:
- worker-plugin dla 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, ponieważ to właśnie za ich pomocą większość użytkowników na całym świecie uzyskuje dostęp do 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ń wysokiej klasy.
OMT ma też dodatkowe zalety:
- Przenosi koszty wykonywania JavaScriptu do osobnego wątku.
- Przenosi koszty analizy, co oznacza, że interfejs może uruchamiać się szybciej. Może to skrócić czas pierwszego wyrenderowania treści, a nawet czas do pełnej interaktywności, co z kolei może poprawić wynik w Lighthouse.
Skrypty web worker nie muszą być straszne. Narzędzia takie jak Comlink ułatwiają pracę z instancjami roboczymi i sprawiają, że są one dobrym wyborem w przypadku wielu aplikacji internetowych.