Utrzymanie responsywności strony: kiedy przenieść obliczenia CPU do Web Workera
Dowiedz się, dlaczego długie pętle zamykają interfejs użytkownika przeglądarki, jak wzorzec komunikacji Web Worker umożliwia przeniesienie tej pracy na inny wątek oraz jak ocenić, czy dana zadanie w ogóle wymaga użycia Workera.
Źródła: JavaScript Workers: How to Run Code Without Freezing Your UI
Użytkownik naciska przycisk, etykieta zmienia się na „Przetwarzanie…”, a potem nic już nie reaguje: animacje się zatrzymują, przewijanie przestaje działać, dalsze kliknięcia są ignorowane. Przeglądarka nie uległa awarii; po prostu jakiś fragment kodu JavaScript monopolizuje główny wątek. Ten przewodnik wyjaśnia, dlaczego tak się dzieje, pokazuje podstawowy wzorzec Web Worker do przenoszenia kosztownych obliczeń w inne miejsce oraz dostarcza praktycznych wskazówek, kiedy Worker jest właściwym rozwiązaniem, a kiedy tylko dodaje skomplikowania.
Dlaczego jedno długotrwałe zadanie zamyka wszystko
Przeglądarka jako całość robi o wiele więcej niż tylko wykonywa twoje skrypty, ale JavaScript należący do strony działa na jednym głównym wątku. Jeśli chcesz zrozumieć, jak ten wątek wpisuje się w wieloprocesową architekturę przeglądarki, sprawdź jednoliniowy wątek JavaScriptu kontra wieloprocesowy silnik przeglądarki. Praktyczne konsekwencje są proste: ten wątek może stać się wąskim gardłem.
Weźmy funkcję, która sumuje wszystkie liczby całkowite poniżej miliarda. Nie ma z nią nic nie tak, a silnik chętnie wykona ją do końca:
function calculate() {
let total = 0;
for (let i = 0; i < 1_000_000_000; i++) {
total += i;
}
return total;
}
calculate();
Problem polega na tym, co dzieje się w międzyczasie. Dopóki pętla jest uruchamiana, główny wątek nie może zajmować się niczym innym, co od niego zależy. Obsługa kliknięć, przetwarzanie danych wejściowych oraz większość zadań związanych z aktualizacją tego, co jest na ekranie, czekają w kolejce na ten sam wątek. To właśnie ta kolejka sprawia, że użytkownik postrzega interfejs jako zamarły.
Łańcuch wydarzeń wygląda następująco:
User clicks button
↓
JavaScript starts heavy calculation
↓
Main thread stays busy
↓
UI work has to wait
↓
Page feels frozen
Tutaj nic nie jest zepsute. Twój kod zatrzymuje wątek zbyt długo, a przeglądarka postępuje sprawiedliwie, pozwalając mu dokończyć pracę. To także powód, dla którego kod, który wydawał się w pełni poprawny podczas rozwoju, może stać się poważnym problemem wydajnościowym w środowisku produkcyjnym: funkcja się nie zmieniła, ale zbiór danych lub ilość obliczeń, które musi przetworzyć, wzrosła.
Dostarczenie ciężkich zadań własnego kontekstu wykonywania
Web Worker to oddzielny kontekst wykonywania JavaScriptu, który działa niezależnie od głównego skryptu strony. Zamiast gromadzić wszystko na jednym wątku:
MAIN THREAD
│
├── UI
├── Events
├── JavaScript
└── Heavy calculation ← problem
rozdziela się obowiązki tak, aby strona zajmowała się interfejsem użytkownika, danymi wejściowymi i logiką strony, podczas gdy Worker przejmuje kosztowne obliczenia:
MAIN THREAD WORKER
│ │
├── UI ├── Heavy calculation
├── User input ├── Data processing
└── Page logic └── Result
│ │
└── messages ─────┘
Linia łącząca te dwie części na tym diagramie jest kluczowym elementem. Worker nie dzieli się zmiennymi ani obiektami twojej strony. Obie strony wymieniają się wiadomościami, a to ograniczenie determinuje wszystko inne związane z pracą z Workerami.
Pierwszy Worker, krok po kroku
Oto pętla sumowania z wcześniejszego przykładu przeniesiona poza główny wątek. Skrypt strony tworzy Workera z oddzielnego pliku, wysyła mu górny zakres oraz rejestruje obsługę odpowiedzi:
const worker = new Worker("worker.js");
worker.postMessage(1_000_000_000);
worker.onmessage = (event) => {
console.log("Result:", event.data);
};
Plik Worker oczekuje przychodzących wiadomości, wykonywa pętlę używając podanego mu numeru i wysyła z powrotem wynik końcowy:
self.onmessage = (event) => {
let total = 0;
for (let i = 0; i < event.data; i++) {
total += i;
}
self.postMessage(total);
};
Pomaga to śledzić cały proces krok po kroku – strona przekazuje Workerowi dane wejściowe za pomocą postMessage:
worker.postMessage(...)
Wewnątrz Workera self odnosi się do globalnego zakresu Workera, a jego obsługa onmessage jest miejscem, gdzie docierają dane wejściowe:
self.onmessage = (...)
Gdy obliczenia zostaną zakończone, Worker odpowiada za pomocą własnego postMessage:
self.postMessage(...)
Na koniec obsługa zdarzeń na obiekcie Worker otrzymuje tę odpowiedź jako event.data:
worker.onmessage = (...)
Zauważ, że przypisanie właściwości onmessage po wywołaniu postMessage w skrypcie strony nie stanowi problemu: odpowiedź może przyjść tylko asynchronicznie, po zakończeniu wykonywania bieżącego skryptu, więc obsługa jest zawsze dostępna w odpowiednim czasie.
Co się zmienia, a co nie
Pożytecznym sposobem na zrozumienie tego jest koncepcja delegacji. Główny wątek faktycznie mówi „oto dane, zajmij się nimi”, a Worker wykonywać zadanie i informuje o zakończeniu pracy. Bez Workera przepływ jest ściśle sekwencyjny, a interfejs użytkownika musi czekać:
Main thread
↓
Heavy calculation
↓
UI waits
Z jego użyciem główny wątek przekazuje zadanie i kontynuuje obsługę strony, podczas gdy obliczenia przebiegają równolegle:
Main thread ──→ Worker
│ │
│ └── Heavy calculation
│
└── continues handling the page
Należy jasno określić, co się zyskuje. Przeniesienie pracy do Workera zazwyczaj nie sprawia, że ta praca zostanie ukończona szybciej. Pętla trwa mniej więcej tyle samo co wcześniej, a dodatkowy ruch wiadomości sprawia niewielki opóźnienie. Zaletą jest to, że obliczenia nie konkurują już o czas z głównym wątkiem, dzięki czemu strona pozostaje interaktywna podczas ich wykonywania.
Komunikacja z Workerem: wiadomości, a nie DOM
Największą zmianą w sposobie myślenia jest to, że Worker nie może uzyskać dostępu do strony. W globalnym zakresie Workera nie ma zmiennej document, więc taka próba wywołania tam się nie powiedzie:
document.querySelector("#result");
Wszystko, co przechodzi przez tę granicę, odbywa się poprzez wiadomości. Dane wysyła się za pomocą:
worker.postMessage(data);
a to, co wraca, odbiera się za pomocą:
worker.onmessage = (event) => {
console.log(event.data);
};
To ograniczenie okazuje się zaletą. Ponieważ Worker może jedynie odbierać dane wejściowe i emitować wyniki, przepływ danych pomiędzy obiema stronami jest zawsze jawny:
Main Thread
│
│ postMessage()
▼
Worker
│
│ postMessage()
▼
Main Thread
Dla większości aplikacji wąska granica w postaci wiadomości pomiędzy „częścią, która renderuje” a „częścią, która oblicza”, jest dokładnie takim rozwiązaniem, jakiego by chciano. Worker nigdy nie dotyka DOM-u, a strona decyduje, co zrobić z każdym wynikiem.
Refaktoryzacja blokującego obsługiwanego kliknięcia
Typowym punktem wyjścia jest obsługa kliknięcia, która synchronicznie przetwarza duży zbiór danych i natychmiast renderuje wyniki:
button.addEventListener("click", () => {
const result = processHugeDataset(data);
renderResult(result);
});
Jeśli funkcja processHugeDataset() działa przez znaczną ilość czasu, główny wątek jest zajęty przez cały ten proces, a strona przestaje reagować, dopóki funkcja nie zakończy się.
Wersja oparta na Workerze dzieli obsługę zdarzeń na dwie części. Kliknięcie jedynie wysyła dane; renderowanie następuje później, gdy przychodzi komunikat z wynikiem:
const worker = new Worker("worker.js");
button.addEventListener("click", () => {
worker.postMessage(data);
});
worker.onmessage = (event) => {
renderResult(event.data);
};
Skomplikowana obróbka odbywa się teraz wewnątrz Workera, a główny wątek może dalej obsługiwać stronę podczas oczekiwania. Należy pamiętać o dwóch rzeczach w tym podejściu. Po pierwsze, Worker jest tworzony tylko raz, poza obsługą zdarzeń, więc powtarzające się kliknięcia korzystają z tego samego Workera, zamiast tworzyć nowy za każdym razem. Po drugie, jeśli użytkownik może szybko kliknąć kilka razy, wyniki mogą przyjść po tym, jak użytkownik przejdzie dalej; w rzeczywistym kodzie zazwyczaj każdy komunikat jest oznaczany identyfikatorem lub ignorowane są stare odpowiedzi, aby stary wynik nie nadpisał nowszego.
Decydowanie o tym, co powinno znajdować się w Workerze
Latwo jest stać się entuzjastą i zacząć przenosić każdą funkcję do Workera. Rzadko jest to dobry pomysł. Worker znajduje swoje zastosowanie wtedy, gdy zadanie jest na tyle obciążające dla CPU, że musi konkurować z głównym wątkiem o czas wykonywania. Typowymi przykładami są:
- Przetwarzanie lub agregacja dużych zbiorów danych
- Złożone obliczenia liczbowe lub algorytmiczne
- Wielkie transformacje JSON
- Parsowanie dużych plików
- Modyfikacja obrazów
- Przetwarzanie dźwięku lub wideo
- Kompresja i inne transformacje danych
- Pewne procedury kryptograficzne lub matematyczne
- Podświetlanie składni i inne kosztowne operacje przetwarzania tekstu
Cechą wspólną jest ta sama: wielka ilość obliczeń i niewielka potrzeba bezpośredniego dostępu do DOM-u.
Kiedy Worker jest przesadą
W przypadku tak błahej czynności jak mnożenie dwóch liczb, Worker stanowi jedynie dodatkowy obciążenie:
const total = price * quantity;
Uruchomienie Workera wiąże się z kosztami startowymi, każda wiadomość generuje dodatkowe obciążenia komunikacyjne, a ponadto musisz zarządzać kolejnym plikiem z własnym cyklem życia. Przy małych operacjach lepszym wyborem jest zwykły JavaScript w głównej nitce. Zamiast zastanawiać się, czy można użyć Workera, zapytaj się, czy zadanie jest na tyle skomplikowane, że jego przeniesienie do głównej nitki faktycznie pogorszy doświadczenie użytkownika. To pytanie eliminuje większość przypadków, w których Worker tylko dodaje złożoności.
Efekt uboczny architektury: czysta granica
Workerzy to nie tylko narzędzie do poprawy wydajności. Imponują również podziałem obowiązków. Twój kod interfejsu może skupić się na przekształcaniu danych wejściowych w stan oraz stanu w wyświetlanie:
Input
↓
State
↓
Display
Podczas gdy Worker koncentruje się na przekształcaniu danych wejściowych w dane wyjściowe:
Input data
↓
Processing
↓
Output data
Izolowanie kosztownych operacji przetwarzania w ten sposób ułatwia ich testowanie i zrozumienie, ponieważ Worker zachowuje się jak funkcja czysta za pośrednictwem interfejsu komunikacyjnego.
Jest jednak pewna cena do zapłacenia – teraz trzeba zarządzać asynchronicznym przepływem komunikatów, startem i zatrzymaniem Workerów, sposobem pojawiania się błędów oraz przekazywaniem danych między nimi. Jest to rozsądna cena, gdy praca jest intensywna. Dobra inżynieria nie polega na usuwaniu złożoności wszędzie, lecz na umieszczaniu jej tam, gdzie przynosi korzyści.
Dedykowani, współdzieleni i Service Workers to różne narzędzia
Pojęcie „Web Worker” jest często używane tak, jakby oznaczało pojedynczą API, ale istnieje kilka powiązanych z Workerami interfejsów API, które służą różnym celom:
- Dedykowany Worker: uruchamia JavaScript w imieniu konkretnej strony lub kontekstu. To typ używany w całym tym przewodniku.
- Współdzielony Worker: jeden Worker, do którego mogą się łączyć i z którym komunikować kilka kontekstów przeglądania (na przykład kilka kart tej samej domeny)。
- Service Worker: znajduje się pomiędzy stroną a siecią, umożliwiając funkcje takie jak przechwytywanie żądań, cacheowanie i obsługę pracy offline.
Aby przenieść obciążenia związane z pracą CPU, najpierw należy zrozumieć dedykowanego Workera. Service Worker nie jest jego prostym zamiennikiem; oba rozwiązują różne problemy. Aby uzyskać bardziej szczegółowe porównanie tych trzech typów, zapoznaj się z Dedykowani, współdzieleni i Service Workers: wybór odpowiedniego wątku przeglądarki.
Detale, które mają znaczenie w produkcji
Minimalny przykład jest celowo prosty. Prawdziwe aplikacje wymagają większej uwagi pod względem awarii, czyszczenia i rozmiaru danych.
Słuchaj za awariami
Nieprzechwycona wyjątek wewnątrz Workera pojawia się na stronie jako zdarzenie error. Dołącz obsługę, aby awarie były rejestrowane, zamiast w tajemnicy pozostawiać interfejs użytkownika w oczekiwaniu na wynik:
worker.onerror = (error) => {
console.error("Worker error:", error);
};
Zakończ działanie Workerów, których już nie potrzebujesz
Worker nadal działa, dopóki nie zostanie zatrzymany. Gdy Worker służy tylko do jednorazowej zadania, wyłącz go z strony po zakończeniu pracy:
worker.terminate();
To ma największe znaczenie dla krótkotrwałych Workerów; Worker o długim czasie życia, który obsługuje całą aplikację, może po prostu pozostać aktywny.
Przenoszenie danych nie jest bezkosztowe
Każda wiadomość polega na przenoszeniu danych między kontekstami. Dla większości wartości w JavaScriptie przeglądarka używa algorytmu strukturalnego klonowania, który tworzy kopię po stronie odbiorczej. W przypadku niektórych dużych danych binarnych, takich jak instancje ArrayBuffer, można je przekazać jako obiekty przenoszalne, które przekazują własność drugiej stronie zamiast je kopiować; nadawca nie może już później używać przekazanego bufora. Nie musisz od razu znać wszystkich szczegółów, ale pamiętaj o jednej zasadzie: przeniesienie obliczeń z głównej wątku nie oznacza, że przesyłanie danych do niej jest bezkosztowe. Klonowanie bardzo dużego obiektu samo w sobie może zajmować znaczną ilość czasu na głównej wątku.
Pracownicy i React
React nie zmienia sposobu, w jaki przeglądarka planuje wykonywanie kodu JavaScript. Jeśli obsługa zdarzenia w React wykonuje skomplikowane, synchroniczne obliczenia przed aktualizacją stanu, obliczenia te nadal są wykonywane w głównym kontekście wykonawczym strony, dokładnie tak jak każdy inny skrypt:
function handleClick() {
const result = expensiveCalculation();
setResult(result);
}
Żaden framework nie może sprawić, że intensywnie wykorzystujący CPU kod JavaScript zniknie. Możesz natomiast traktować Workera jako warstwę obliczeniową za twoimi komponentami: interfejs użytkownika wysyła dane, Worker je przetwarza, a wynik wraca, by zostać zapisany w stanie i wyświetlony:
React UI
│
│ send data
▼
Web Worker
│
│ process data
▼
React UI
To przynosi korzyści szczególnie w interfejsach, w których użytkownicy pracują z dużymi zestawami danych, edytatorami, wizualizacjami lub innymi złożonymi pod względem obliczeniowym widokami. Framework nie jest tu kluczowy; istotne jest poznanie która wątek wykonywa kosztowne operacje. W komponencie oznacza to zazwyczaj utworzenie Workera w ramach efektu, wywołanie metody setResult z jego obsługownika wiadomości oraz zakończenie jego działania podczas czyszczenia efektu, aby komponenty, które przestały być używane, nie pozostawiały bieżących Workerów.
Workerzy nie zapewniają ogólnego przyspieszenia
Najczęstszym błędem jest używanie Workerów jako uniwersalnego rozwiązania problemów wydajności. Jeśli aplikacja działa wolno z powodu niepotrzebnego ponownego renderowania, zbyt wielu żądań sieciowych, zbyt dużych plików, nieefektywnych aktualizacji React lub skomplikowanego kodu, Worker nie pomoże w rozwiązaniu żadnego z tych problemów. Najpierw należy zidentyfikować rzeczywisty wąskie gardło, zwykle za pomocą narzędzia do analizy wydajności przeglądarki, a dopiero potem ocenić, czy Worker jest odpowiednim rozwiązaniem.
Pomaga krótka lista kontrolna:
- Czy zadanie faktycznie wymaga dużych zasobów CPU?
- Czy trwa na tyle długo, że wpływa to na szybkość reakcji aplikacji?
- Czy potrzebuje bezpośredniego dostępu do DOM?
- Czy dane wejściowe i wyjściowe mogą być przekazywane w formie wiadomości?
- Czy dodatkowa złożoność jest tego warta?
Gdy odpowiedzi wskazują we właściwym kierunku (duże obciążenie CPU, długi czas wykonywania, brak potrzeby dostępu do DOM, możliwość przekazywania danych w formie wiadomości, wartość dodana), Worker jest prawdopodobnie dobrym wyborem.
Pomaga to również w rozdzieleniu dwóch problemów, które łatwo pomylić. W pierwszym przypadku JavaScript wykonywa więcej zadań, niż jest to konieczne, a rozwiązaniem jest lepszy algorytm lub mniej pracy ogółem. W drugim przypadku praca jest rzeczywiście kosztowna, ale nie ma powodu, by blokować interfejs; właśnie wtedy przydaje się Worker. Worker nie zamienia nieskutecznego kodu na skuteczny – daje kosztownemu kodowi inne miejsce do wykonywania.
Główne wnioski
Nie musisz zapamiętywać całej API Worker. Kluczowe informacje mieści się w jednym obrazku:
MAIN THREAD
│
│ "Here is the work."
▼
WORKER
│
│ "Here is the result."
▼
MAIN THREAD
- Główny wątek kontroluje stronę; każde długie zadanie synchroniczne opóźnia obsługę wprowadzeń i aktualizacje ekranu.
- Dedykowany Worker uruchamia izolowany JavaScript i komunikuje się wyłącznie za pomocą
postMessageorazonmessage; nie ma dostępu do DOM.
onerror, wywołaj metodę terminate() w tymczasowych Workerach oraz rozważ użycie obiektów Transferable do przenoszenia dużych plików binarnych.Następnym razem, gdy strona zamarza podczas skomplikowanych obliczeń, zastanów się nie tylko nad tym, jak przyspieszyć funkcję, ale także nad tym, czy w ogóle musi ona być wykonywana na wątku głównym.
Literatura pokrewna
- Dedykowani, dzieleni i pracownicy usługowy: wybór odpowiedniej nici brauzera — Dowiedz się, w jaki sposób dedykowani, dzieleni i pracownicy usługowy różnią się pod względem zakresu działania, komunikacji i celu, oraz kiedy każdy z nich faktycznie poprawia aplikację internetową.
- Rób mniej przede wszystkim: lista sprawdzania wydajności React przed memoizacją — Zmniejsz koszty aplikacji React, unikając niepotrzebnych działań: używaj funkcji debounce do zadań wyszukiwania, paginuj treść, umieszczaj stan w tym samym miejscu, stosuj stabilne klucze, przenoś obliczenia na stronę serwera i używaj ładowania opóźnionego, a dopiero potem zastosuj memoizację w razie potrzeby.