Architektura z użyciem wątku pomocniczego może znacznie zwiększyć niezawodność aplikacji i wygodę użytkowników.
W ciągu ostatnich 20 lat internet zmienił się diametralnie – z dokumentów statycznych z kilkoma stylami i obrazami stał się złożonymi, dynamicznymi aplikacjami. Jedna rzecz pozostała jednak w dużej mierze niezmieniona: mamy tylko 1 wątek na kartę przeglądarki (z pewnymi wyjątkami), który odpowiada za renderowanie witryn i wykonywanie kodu JavaScript.
W rezultacie wątek główny stał się bardzo obciążony. Wraz ze wzrostem złożoności aplikacji internetowych główny wątek staje się wąskim gardłem pod względem wydajności. Co gorsza, czas wykonywania kodu w głównym wątku przez danego użytkownika jest prawie całkowicie nieprzewidywalny, ponieważ możliwości urządzenia mają ogromny wpływ na wydajność. Ta nieprzewidywalność będzie się tylko pogłębiać, ponieważ użytkownicy będą korzystać z Internetu na coraz bardziej zróżnicowanych urządzeniach, od bardzo ograniczonych telefonów z podstawową przeglądarką po flagowe urządzenia o wysokiej mocy i częstotliwości odświeżania.
Jeśli chcemy, aby zaawansowane aplikacje internetowe niezawodnie spełniały wytyczne dotyczące wydajności, takie jak podstawowe wskaźniki internetowe, które opierają się na danych empirycznych dotyczących ludzkiego postrzegania i psychologii, potrzebujemy sposobów na wykonywanie kodu poza wątkiem głównym (OMT).
Dlaczego web workery?
JavaScript jest domyślnie językiem jednowątkowym, który wykonuje zadania w wątku głównym. Jednak instancje robocze w JavaScript zapewniają pewien rodzaj ucieczki z wątku głównego, ponieważ pozwalają deweloperom tworzyć osobne wątki do obsługi zadań poza wątkiem głównym. Chociaż zakres działania web workerów jest ograniczony i nie zapewnia bezpośredniego dostępu do DOM, mogą one być bardzo przydatne, jeśli trzeba wykonać dużą ilość pracy, która w przeciwnym razie przytłoczyłaby główny wątek.
Jeśli chodzi o podstawowe wskaźniki internetowe, wykonywanie pracy poza wątkiem głównym może być korzystne. Przeniesienie pracy z głównego wątku do instancji roboczych może zmniejszyć współzawodnictwo o główny wątek, co może poprawić wartość interakcji do kolejnego wyrenderowania (INP) strony. Gdy wątek główny ma mniej pracy do wykonania, może szybciej reagować na interakcje użytkownika.
Mniej pracy wątku głównego, zwłaszcza podczas uruchamiania, może też przynieść korzyści w zakresie największego wyrenderowania treści (LCP), ponieważ skraca czas wykonywania długich zadań. Renderowanie elementu LCP wymaga czasu głównego wątku – na potrzeby renderowania tekstu lub obrazów, które są częstymi i typowymi elementami LCP. Ogólnie rzecz biorąc, zmniejszając obciążenie głównego wątku, możesz zmniejszyć prawdopodobieństwo zablokowania elementu LCP przez kosztowne zadania, które może wykonać pracownik sieciowy.
Przetwarzanie wątek za pomocą procesów wątekowych w przeglądarce
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 w obu wątkach, a dostęp do tych udostępnionych zasobów można zsynchronizować z muteksami i semhorami, aby zapobiec warunkom wyścigu.
W JavaScript możemy uzyskać mniej więcej podobną funkcjonalność dzięki procesom web worker, które istnieją od 2007 r. i są obsługiwane we wszystkich głównych przeglądarkach od 2012 r. Instancje robocze przeglądarki działają równolegle z głównym wątkiem, ale w odróżnieniu od wątków w systemie operacyjnym nie mogą udostępniać zmiennych.
Aby utworzyć proces roboczy w sieci, prześlij plik do konstruktora procesu roboczego, który rozpocznie jego uruchamianie w osobnym wątku:
const worker = new Worker("./worker.js");
Komunikuj się z pracownikiem obsługi klienta wysyłając wiadomości za pomocą interfejsu API postMessage
. Przekaż wartość wiadomości jako parametr w wyzwaniu postMessage
, a potem dodaj do workera listenera zdarzenia 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 procesie web worker 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 jednak przyznać, że to podejście ma pewne ograniczenia. Wcześniej web workery były używane głównie do przenoszenia pojedynczego zadania o wysokich wymaganiach z głównego wątku. Próba obsługi wielu operacji za pomocą pojedynczego web workera szybko staje się niepraktyczna: w wiadomości musisz zakodować nie tylko parametry, ale też operację, a dodatkowo musisz prowadzić księgowość, aby dopasować odpowiedzi do żądań. Prawdopodobnie właśnie z powodu tej złożoności nie są one powszechnie stosowane.
Jeśli jednak uda nam się wyeliminować część trudności związanych ze współpracą głównego wątku z elementami web worker, ten model może być idealny w wielu przypadkach użycia. Na szczęście istnieje biblioteka, która właśnie to robi.
Comlink: making web workers less work
Comlink to biblioteka, która umożliwia korzystanie z elementów web worker bez konieczności zastanawiania się nad szczegółami postMessage
. Comlink umożliwia udostępnianie zmiennych między web workerami a głównym wątkiem prawie tak samo jak w przypadku innych języków programowania obsługujących wątki.
Aby skonfigurować Comlink, zaimportuj go do instancji roboczej w interfejsie internetowym i zdefiniuj zestaw funkcji, które mają być udostępniane wątkowi głównemu. Następnie importujesz Comlink w wątku głównym, owijasz workera i uzyskujesz dostęp do 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 web workerze, z tym wyjątkiem, że każda funkcja zwraca obietnicę wartości zamiast samej wartości.
Jaki kod należy przenieść do web workera?
Wątek web worker nie ma dostępu do DOM i wielu interfejsów API, takich jak WebUSB, WebRTC czy Web Audio, więc nie możesz umieszczać w nim części aplikacji, które wymagają takiego dostępu. Mimo to każdy mały fragment kodu przeniesiony do wątku roboczego daje więcej miejsca na głównym wątku na elementy, które musi się tam znajdować, np. na potrzeby aktualizacji interfejsu użytkownika.
Jednym z problemów dla deweloperów stron internetowych jest to, że większość aplikacji internetowych korzysta z ramy UI, takiej jak Vue czy React, aby zorganizować wszystko w aplikacji. Każdy element jest komponentem tej ramy i jest nierozerwalnie związany z DOM. To utrudnia migrację do architektury OMT.
Jeśli jednak przejdziemy na model, w którym kwestie związane z interfejsem są oddzielone od innych kwestii, takich jak zarządzanie stanem, web workery mogą być bardzo przydatne nawet w przypadku aplikacji opartych na frameworku. Właśnie takie podejście zastosowaliśmy w przypadku PROXX.
PROXX: studium przypadku dotyczące OMT
Zespół Google Chrome opracował PROXX jako klona gry Minesweeper, która spełnia wymagania dotyczące progresywnych aplikacji internetowych, w tym działa offline i zapewnia atrakcyjne wrażenia użytkownika. Niestety wczesne wersje gry działały słabo na urządzeniach z ograniczonymi możliwościami, takich jak telefony komórkowe. Zespół zdał sobie sprawę, że główna nić jest wąskim gardłem.
Zespół zdecydował się użyć procesów w tle, aby oddzielić stan wizualny gry od jej logiki:
- Wątek główny obsługuje renderowanie animacji i przejść.
- Wątek internetowy obsługuje logikę gry, która jest czysto obliczeniowa.
OMT miał ciekawy wpływ na wydajność telefonu z podstawową przeglądarką w PROXX. W wersji bez OMT interfejs jest zablokowany przez 6 sekund po interakcji z użytkownikiem. Nie ma żadnej informacji zwrotnej, a użytkownik musi odczekać 6 sekund, zanim będzie mógł wykonać inną czynność.
W wersji OMT gra potrzebuje 12 sekund na aktualizację interfejsu. Może się to wydawać stratą na skuteczności, ale w rzeczywistości prowadzi do zwiększenia liczby opinii użytkowników. Spowolnienie występuje, ponieważ aplikacja wysyła więcej klatek niż wersja bez OMT, która nie wysyła żadnych klatek. Użytkownik wie więc, że coś się dzieje, i może kontynuować grę, gdy interfejs się aktualizuje, co znacznie poprawia wrażenia z gry.
Jest to świadomy kompromis: użytkownicy urządzeń o ograniczonych możliwościach mają lepsze wrażenia, a użytkownicy urządzeń wysokiej klasy nie są dyskryminowani.
Wpływ architektury OMT
Jak pokazuje przykład PROXX, OMT zapewnia niezawodne działanie aplikacji na większej liczbie urządzeń, ale nie przyspiesza jej działania:
- Przenosisz tylko pracę z głównego wątku, a nie zmniejszasz jej.
- Dodatkowe obciążenie komunikacyjne między wątkiem przeglądarki a wątkiem głównym może czasami nieznacznie spowolnić działanie.
Rozważ zalety i wady
Ponieważ wątek główny może przetwarzać interakcje użytkownika, np. przewijanie, podczas działania kodu JavaScript, rzadziej dochodzi do utraty klatek, mimo że łączny czas oczekiwania może być nieco dłuższy. Lepiej jest chwilę zaczekać, niż zrezygnować z ramki, ponieważ margines błędu jest mniejszy w przypadku opuszczenia ramki: opuszczenie ramki następuje w milisekundach, a masz setki milisekund, zanim użytkownik zauważy czas oczekiwania.
Ze względu na nieprzewidywalność wydajności na różnych urządzeniach celem architektury OMT jest zmniejszanie ryzyka, czyli zwiększanie odporności aplikacji na bardzo zmienne warunki działania, a nie zwiększanie wydajności dzięki równoległemu wykonywaniu. Zwiększenie odporności i poprawa użyteczności zdecydowanie rekompensują niewielkie spowolnienie działania.
Uwaga na temat narzędzi
Web workery nie są jeszcze powszechnie stosowane, więc większość narzędzi do tworzenia modułów, takich jak webpack czy Rollup, nie obsługuje ich domyślnie. (Parcel to obsługuje!). Na szczęście istnieją wtyczki, które umożliwiają działanie procesom web worker z webpackem i Rollupem:
- worker-plugin dla webpack
- rollup-plugin-off-main-thread dla Rollup
Podsumowanie
Aby zapewnić jak największą niezawodność i dostępność naszych aplikacji, zwłaszcza na coraz bardziej globalnym rynku, musimy obsługiwać urządzenia o ograniczonych możliwościach – to na nich korzysta większość użytkowników na całym świecie. OMT to obiecująca metoda zwiększania wydajności na takich urządzeniach bez negatywnego wpływu na użytkowników urządzeń wysokiej klasy.
OMT ma też inne zalety:
- Przenosi koszty wykonywania kodu JavaScript do osobnego wątku.
- Przenosi koszty analizowania, co oznacza, że interfejs może uruchamiać się szybciej. Może to skrócić czas pierwszego wyrenderowania treści, a nawet czas do interakcji, co z kolei może zwiększyć wynik Lighthouse.
Pracownicy internetowi nie muszą być przerażający. Narzędzia takie jak Comlink odciążają pracowników i czynią ich bardziej atrakcyjnymi w przypadku wielu aplikacji internetowych.