Optymalizuj długie zadania

Wątpliwości dotyczące blokowania wątku głównego i dzielenia długich zadań

Data publikacji: 30 września 2022 r., ostatnia aktualizacja: 19 grudnia 2024 r.

Typowe wskazówki dotyczące utrzymywania szybkości aplikacji JavaScript sprowadzają się do tych porad:

  • „Nie blokuj głównego wątku”.
  • „Podziel długie zadania na mniejsze”.

To świetna rada, ale ile pracy to wymaga? Użycie mniejszej ilości kodu JavaScript jest dobre, ale czy automatycznie oznacza to szybsze interfejsy użytkownika? Może tak, a może nie.

Aby zrozumieć, jak optymalizować zadania w JavaScript, musisz najpierw wiedzieć, czym są zadania i jak przeglądarka je obsługuje.

Co to jest zadanie?

Zadanie to dowolna część pracy wykonywana przez przeglądarkę. Do tych zadań należy renderowanie, analizowanie kodu HTML i CSS, uruchamianie kodu JavaScript oraz inne rodzaje działań, nad którymi nie masz bezpośredniej kontroli. Spośród wszystkich tych zadań najwięcej czasu zajmuje prawdopodobnie pisanie kodu JavaScript.

Wizualizacja zadania w profilu wydajności w Narzędziach deweloperskich w Chrome Zadanie znajduje się u góry stosu, a pod nim są przetwarzane wywołanie metody i inne elementy. Zadanie obejmuje też renderowanie po prawej stronie.
Zadanie uruchomione przez click w obiekcie obsługującym zdarzenie w profilu wydajności w Narzędziach deweloperskich w Chrome.

Zadania związane z JavaScriptem wpływają na wydajność w kilka sposoby:

  • Gdy przeglądarka pobiera plik JavaScript podczas uruchamiania, umieszcza w kolejnych zadaniach zadania analizowania i kompilowania kodu JavaScript, aby można go było wykonać później.
  • W pozostałym czasie trwania strony zadania są umieszczane w kole, gdy działa JavaScript, np. gdy odpowiada on na interakcje za pomocą modułów obsługi zdarzeń, animacji obsługiwanych przez JavaScript czy aktywności w tle, takiej jak zbieranie danych analitycznych.

Wszystkie te czynności (z wyjątkiem instancji roboczych i podobnych interfejsów API) odbywają się w głównym wątku.

Co to jest główny wątek?

Główny wątek to miejsce, w którym wykonywane są w przeglądarce większość zadań i w którym wykonywany jest prawie cały napisany przez Ciebie kod JavaScript.

Wątek główny może przetwarzać tylko jedno zadanie naraz. Każde zadanie, które trwa dłużej niż 50 ms, jest długim zadaniem. W przypadku zadań, które trwają dłużej niż 50 ms, łączny czas trwania zadania pomniejszony o 50 ms jest nazywany okresem blokowania.

Podczas wykonywania zadania o dowolnej długości przeglądarka blokuje interakcje, ale nie jest to zauważalne dla użytkownika, o ile zadania nie są wykonywane zbyt długo. Gdy użytkownik próbuje wchodzić w interakcję ze stroną, na której jest wiele długich zadań, interfejs może nie odpowiadać, a w najgorszym przypadku może się nawet zawiesić, jeśli wątek główny jest zablokowany przez długi czas.

Długie zadanie w profilu wydajności w Narzędziach deweloperskich w Chrome. Część zadania, która blokuje działanie aplikacji (ponad 50 ms), jest oznaczona czerwonymi paskami.
Długie zadanie w profilu wydajności Chrome. Długie zadania są oznaczone czerwonym trójkątem w rogu zadania, a blokująca część zadania jest wypełniona czerwonymi paskami.

Aby wątek główny nie był zablokowany zbyt długo, możesz podzielić długie zadanie na kilka krótszych.

Jedno długie zadanie w porównaniu z tym samym zadaniem podzielonym na krótsze zadania. Długie zadanie to jeden duży prostokąt, a zadanie podzielone na fragmenty to 5 mniejszych pól, które razem mają taką samą szerokość jak długie zadanie.
Wizualizacja pojedynczego długiego zadania w porównaniu z tym samym zadaniem podzielonym na 5 krótszych zadań.

Jest to ważne, ponieważ gdy zadania są dzielone, przeglądarka może znacznie szybciej reagować na zadania o wyższym priorytecie, w tym na interakcje z użytkownikiem. Następnie pozostałe zadania są wykonywane do końca, co zapewnia, że zadania, które zostały początkowo umieszczone w kolejce, zostaną wykonane.

Ilustracja pokazująca, jak podział zadania może ułatwić interakcję z użytkownikiem. U góry długie zadanie blokuje działanie metody obsługi zdarzenia, dopóki nie zostanie ono ukończone. U dołu zadanie podzielone na fragmenty pozwala obsłudze zdarzenia działać szybciej niż normalnie.
Wizualizacja tego, co dzieje się z interakcjami, gdy zadania są zbyt długie i przeglądarka nie może na nie odpowiednio szybko reagować, a także gdy dłuższe zadania są dzielone na mniejsze.

U góry poniższego rysunku widać, że przetwarzanie zdarzenia wywołane przez interakcję z użytkownikiem musiało poczekać na zakończenie jednego długiego zadania, zanim mogło się rozpocząć. To opóźnia interakcję. W tym przypadku użytkownik mógł zauważyć opóźnienie. W dolnej części interfejs może zacząć działać wcześniej, a interakcja może wydawać się natychmiastowa.

Teraz, gdy już wiesz, dlaczego warto dzielić zadania na mniejsze części, możesz dowiedzieć się, jak zrobić to w JavaScript.

Strategie zarządzania zadaniami

W architekturze oprogramowania często zaleca się dzielenie pracy na mniejsze funkcje:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

W tym przykładzie występuje funkcja o nazwie saveSettings(), która wywołuje 5 funkcji służących do sprawdzania poprawności formularza, wyświetlania Spinnera, wysyłania danych do aplikacji serwerowej, aktualizowania interfejsu użytkownika i wysyłania danych do Analytics.

Pod względem koncepcji saveSettings() ma dobrą architekturę. Jeśli chcesz debugować jedną z tych funkcji, możesz przejść przez drzewo projektu, aby dowiedzieć się, do czego służy każda z nich. Taki podział pracy ułatwia poruszanie się po projektach i ich utrzymywanie.

Potencjalny problem polega na tym, że JavaScript nie uruchamia każdej z tych funkcji jako osobnego zadania, ponieważ są one wykonywane w ramach funkcji saveSettings(). Oznacza to, że wszystkie 5 funkcji będzie wykonywanych jako jedno zadanie.

Funkcja saveSettings w profilu wydajności Chrome Chociaż funkcja najwyższego poziomu wywołuje 5 innych funkcji, cała praca odbywa się w ramach jednego długiego zadania, dzięki czemu widoczny dla użytkownika wynik działania funkcji jest widoczny dopiero po zakończeniu wszystkich.
Pojedyncza funkcja saveSettings(), która wywołuje 5 funkcji. Praca jest wykonywana w ramach jednego długiego zadania monolitycznego, które blokuje każdą odpowiedź wizualną, dopóki nie zostaną ukończone wszystkie 5 funkcji.

W najlepszym przypadku nawet jedna z tych funkcji może wydłużyć czas wykonania zadania o 50 ms lub więcej. W najgorszym przypadku więcej zadań może działać znacznie dłużej, zwłaszcza na urządzeniach o ograniczonych zasobach.

W tym przypadku funkcja saveSettings() jest wywoływana przez kliknięcie użytkownika, a ponieważ przeglądarka nie może wyświetlić odpowiedzi, dopóki nie wykona się cała funkcja, wynikiem tego długiego zadania jest wolno reagujące i nieresponsywne UI, które zostanie zmierzone jako niska wartość czasu od interakcji do kolejnego wyrenderowania (INP).

Ręczne opóźnianie wykonywania kodu

Aby mieć pewność, że ważne zadania związane z użytkownikiem i odpowiedzi interfejsu będą wykonywane przed zadaniami o niższym priorytecie, możesz przekazać kontrolę głównemu wątkowi, przerywając na chwilę swoją pracę, aby dać przeglądarce możliwość wykonania ważniejszych zadań.

Jedną z metod, której deweloperzy używają do dzielenia zadań na mniejsze, jest setTimeout(). W tej metodzie przekazujesz funkcję do funkcji setTimeout(). To opóźnia wykonanie wywołania zwrotnego w ramach osobnego zadania, nawet jeśli określisz limit czasu 0.

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

Nazywa się to zwracaniem i najlepiej sprawdza się w przypadku serii funkcji, które muszą być wykonywane sekwencyjnie.

Jednak kod nie zawsze jest uporządkowany w ten sposób. Możesz na przykład mieć dużą ilość danych, które wymagają przetworzenia w pętli, a to zadanie może zająć bardzo dużo czasu, jeśli jest wiele iteracji.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Użycie tutaj elementu setTimeout() jest problematyczne z powodu ergonomii programisty. Po 5 poziomach zagnieżdżonych setTimeout() przeglądarka zacznie nakładać opóźnienie co najmniej 5 milisekund na każdy dodatkowy element setTimeout().

setTimeout ma też inną wadę związaną z przekazywaniem: gdy przekażesz kod do wątku głównego, opóźniając wykonanie kodu w kolejnych zadaniach za pomocą setTimeout, zadanie zostanie dodane na końcu kolejki. Jeśli są inne oczekujące zadania, zostaną one wykonane przed opóźnionym kodem.

Dedykowany interfejs API z funkcją yielding: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not supported.
  • Safari: not supported.

Source

scheduler.yield() to interfejs API zaprojektowany specjalnie do uruchamiania głównego wątku w przeglądarce.

Nie jest to składnia na poziomie języka ani konstrukcja specjalna. scheduler.yield() to tylko funkcja zwracająca Promise, która zostanie rozwiązana w przyszłym zadaniu. W przyszłym zadaniu zostanie uruchomiony kod, który ma być wykonywany po rozwiązaniu tego problemu (w łańcuchu funkcji .then() lub po jej wywołaniu w funkcji asynchronicznej).Promiseawait

W praktyce: wstaw await scheduler.yield(), a funkcja wstrzyma w tym miejscu wykonywanie i przekaże kontrolę wątkowi głównemu. Wykonywanie pozostałej części funkcji, czyli ciąg dalszy, zostanie zaplanowane w ramach nowego zadania pętli zdarzeń. Gdy to zadanie się rozpocznie, oczekiwana obietnica zostanie spełniona, a funkcja będzie kontynuować wykonywanie od miejsca, w którym zostało przerwane.

async function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
Funkcja saveSettings przedstawiona w profilu wydajności Chrome, teraz podzielona na 2 zadania. Pierwsze zadanie wywołuje 2 funkcje, a następnie zwraca wyniki, co umożliwia wykonanie pracy przez układ i wyrenderowanie, a także wyświetlenie użytkownikowi widocznej odpowiedzi. W efekcie zdarzenie kliknięcia kończy się znacznie szybciej, bo w 64 milisekundy. Drugie zadanie wywołuje 3 ostatnie funkcje.
Wykonanie funkcji saveSettings() jest teraz podzielone na 2 zadania. W efekcie układ i wyświetlanie mogą być wykonywane między zadaniami, co zapewnia użytkownikowi szybszą odpowiedź wizualną, co jest mierzone przez znacznie krótszą interakcję z wskaźnikiem.

Prawdziwą zaletą funkcji scheduler.yield() w porównaniu z innymi metodami yieldingu jest to, że jej kontynuacja jest priorytetowa. Oznacza to, że jeśli yielding zostanie wywołany w połowie zadania, jego kontynuacja zostanie wykonana przed rozpoczęciem innych podobnych zadań.

Dzięki temu kod z innych źródeł zadań nie będzie zakłócać kolejności wykonywania kodu, np. zadań ze skryptów innych firm.

3 diagramy przedstawiające zadania bez zwracania wartości, z zwracaniem wartości i z zwracaniem wartości oraz kontynuacją. Bez stosowania Yielding występują długie zadania. W przypadku yielding jest więcej krótszych zadań, które mogą być przerywane przez inne, niezwiązane z nimi zadania. Dzięki rezygnacji i kontynuacji jest więcej krótszych zadań, ale ich kolejność wykonania jest zachowana.
Po użyciu scheduler.yield() kontynuacja rozpocznie się od miejsca, w którym przerwano pracę nad innymi zadaniami.

Obsługa w różnych przeglądarkach

scheduler.yield() nie jest jeszcze obsługiwana we wszystkich przeglądarkach, dlatego potrzebna jest opcja zapasowa.

Jednym z rozwiązań jest dodanie do wersji scheduler-polyfill, a następnie bezpośrednie użycie funkcji scheduler.yield(). Rozszerzenie polyfill obsłuży inne funkcje planowania zadań, dzięki czemu będzie działać podobnie w różnych przeglądarkach.

Zamiast tego można napisać mniej zaawansowaną wersję w kilku liniach, używając tylko setTimeout zawiniętego w obietnice jako zastępczego, jeśli scheduler.yield() jest niedostępny.

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Chociaż przeglądarki bez obsługi scheduler.yield() nie będą korzystać z priorytetowego kontynuowania, nadal będą wczytywać się w taki sposób, aby zachować responsywność.

W końcu mogą wystąpić przypadki, w których kod nie może ustąpić wątkowi głównemu, jeśli jego kontynuacja nie jest priorytetowa (np. strona, która jest zajęta, a ustąpienie spowoduje, że przez jakiś czas nie będzie można dokończyć pracy). W takim przypadku scheduler.yield() może być traktowane jako rodzaj stopniowego ulepszania: w przypadku przeglądarek, w których scheduler.yield() jest dostępne, stosować scheduler.yield(), a w przeciwnym razie kontynuować.

Można to zrobić, korzystając z funkcji wykrywania i przechodząc do oczekiwania na wykonanie pojedynczego mikrozadania w przydatnym poleceniu jednowierszowym:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

Przerywanie długotrwałych zadań za pomocą scheduler.yield()

Zaletą korzystania z dowolnej z tych metod użycia funkcji scheduler.yield() jest to, że możesz ją await w dowolnej funkcji async.

Jeśli na przykład masz do wykonania wiele zadań, które często prowadzą do długiego zadania, możesz wstawić yield, aby je podzielić.

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

Kontynuacja runJobs() będzie miała wyższy priorytet, ale nadal będzie umożliwiać wykonywanie zadań o wyższym priorytecie, takich jak wizualna reakcja na dane wejściowe użytkownika, bez konieczności oczekiwania na zakończenie potencjalnie długiej listy zadań.

Nie jest to jednak efektywne wykorzystanie funkcji yielding. scheduler.yield() jest szybki i wydajny, ale ma pewne koszty. Jeśli niektóre zadania w jobQueue są bardzo krótkie, nakłady mogą szybko wzrosnąć do poziomu, w którym czas poświęcony na tworzenie i wznawianie będzie większy niż czas poświęcony na wykonanie rzeczywistej pracy.

Jednym z podejść jest grupowanie zadań i przerywanie ich tylko wtedy, gdy od ostatniego przerwania minęło wystarczająco dużo czasu. Typowy limit czasu to 50 milisekund, aby uniknąć długich zadań, ale można go dostosować, aby znaleźć kompromis między szybkością działania a czasem potrzebnym na wykonanie kolejki zadań.

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

W rezultacie zadania są dzielone na części, aby nigdy nie zajmowały zbyt dużo czasu, ale runner oddaje wątek główny tylko co 50 milisekund.

Seria funkcji zadania wyświetlana w panelu wydajności Narzędzi deweloperskich w Chrome, której wykonanie jest podzielone na kilka zadań
Zadania uporządkowane w grupy obejmujące kilka zadań.

Nie używaj aplikacji isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

Interfejs API isInputPending() umożliwia sprawdzenie, czy użytkownik próbował wchodzić w interakcję ze stroną, i zwraca dane tylko wtedy, gdy dane wejściowe są oczekujące.

Dzięki temu JavaScript może kontynuować działanie, jeśli nie ma oczekujących danych wejściowych, zamiast przekazać kontrolę i przeskakiwać na koniec kolejki zadań. Może to przynieść znaczne zwiększenie wydajności, jak szczegółowo opisano w intencji wysyłki w przypadku witryn, które w innym przypadku mogłyby nie wrócić do głównego wątku.

Jednak od czasu uruchomienia tego interfejsu API nasze zrozumienie yielding wzrosło, zwłaszcza dzięki wprowadzeniu INP. Nie zalecamy już używania tego interfejsu API. Zamiast tego zalecamy zwracanie wartości niezależnie od tego, czy dane wejściowe są oczekujące czy nie, z kilku powodów:

  • W niektórych okolicznościach funkcja isInputPending() może zwracać wartość false, mimo że użytkownik wszedł w interakcję z reklamą.
  • Dane wejściowe to nie jedyny przypadek, w którym zadania powinny się poddawać. Animacje i inne regularne aktualizacje interfejsu użytkownika mogą być równie ważne dla zapewnienia strony internetowej w wersji responsywnej.
  • Od tego czasu udostępniliśmy bardziej kompleksowe interfejsy API do generowania danych, które rozwiązują problemy związane z generowaniem danych, takie jak scheduler.postTask()scheduler.yield().

Podsumowanie

Zarządzanie zadaniami jest trudne, ale dzięki temu Twoja strona szybciej reaguje na interakcje użytkowników. Nie ma jednej rady dotyczącej zarządzania zadaniami i ich priorytetyzacji, ale istnieje kilka różnych technik. Oto najważniejsze kwestie, które należy wziąć pod uwagę podczas zarządzania zadaniami:

  • Oddawanie głównego wątku w przypadku kluczowych zadań dotyczących użytkownika.
  • Użyj elementu scheduler.yield() (z obsługą w różnych przeglądarkach) do ergonomicznego uzyskiwania priorytetowych kontynuacji.
  • Na koniec zrób jak najmniej w swojej funkcji.

Więcej informacji o interfejsie scheduler.yield(), jego jawnym interfejsie do planowania zadań scheduler.postTask() i priorytetach zadań znajdziesz w dokumentacji interfejsu API do priorytetowego planowania zadań.

Dzięki tym narzędziom możesz skonfigurować pracę w aplikacji tak, aby priorytetowo traktować potrzeby użytkownika, a jednocześnie zapewnić, że mniej istotne zadania też będą wykonywane. Dzięki temu użytkownicy będą mogli wygodniej korzystać z Twojej witryny.

Specjalne podziękowania dla Philipa Waltona za sprawdzenie techniczne tego przewodnika.

Obraz miniatury pochodzący z Unsplash, dzięki uprzejmości Amirali Mirhashemian.