Optymalizuj długie zadania

Pewnie słyszysz często, że „nie należy blokować wątku głównego” i „należy dzielić długie zadania”, ale co to właściwie oznacza?

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

Typowe porady dotyczące utrzymania szybkości działania aplikacji JavaScript sprowadzają się do tych wskazówek:

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

To świetna rada, ale co trzeba zrobić, aby ją zastosować? Wysyłanie mniejszej ilości kodu JavaScript jest korzystne, ale czy automatycznie przekłada się to na bardziej responsywne interfejsy użytkownika? Może tak, a może nie.

Aby dowiedzieć się, jak optymalizować zadania w JavaScript, musisz najpierw poznać ich definicję i sposób, w jaki przeglądarka je obsługuje.

Co to jest zadanie?

Zadanie to dowolna odrębna część pracy wykonywanej przez przeglądarkę. Obejmuje to renderowanie, analizowanie kodu HTML i CSS, uruchamianie JavaScriptu oraz inne rodzaje działań, nad którymi możesz nie mieć bezpośredniej kontroli. Największym źródłem zadań jest prawdopodobnie napisany przez Ciebie kod JavaScript.

Wizualizacja zadania przedstawiona w profilerze wydajności Narzędzi deweloperskich w Chrome. Zadanie znajduje się na górze stosu i zawiera procedurę obsługi zdarzenia kliknięcia, wywołanie funkcji i inne elementy. Zadanie obejmuje też renderowanie po prawej stronie.
Zadanie rozpoczęte przez procedurę obsługi zdarzenia click w profilerze wydajności Narzędzi deweloperskich w Chrome.

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

  • Gdy przeglądarka pobiera plik JavaScript podczas uruchamiania, umieszcza w kolejce zadania związane z jego analizowaniem i kompilowaniem, aby można go było później wykonać.
  • W innych momentach życia strony zadania są umieszczane w kolejce, gdy JavaScript wykonuje działania takie jak reagowanie na interakcje za pomocą funkcji obsługi zdarzeń, animacje oparte na JavaScript oraz aktywność w tle, np. zbieranie danych analitycznych.

Wszystkie te działania – z wyjątkiem instancji roboczych i podobnych interfejsów API – odbywają się w głównym wątku.

Czym jest główny wątek?

Na głównym wątku przeglądarki działa większość zadań i 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 milisekund, jest długim zadaniem. W przypadku zadań, które trwają dłużej niż 50 milisekund, całkowity czas trwania zadania pomniejszony o 50 milisekund to okres blokowania zadania.

Przeglądarka blokuje interakcje, gdy trwa zadanie o dowolnej długości, ale użytkownik nie zauważa tego, dopóki zadania nie trwają zbyt długo. Gdy użytkownik próbuje wejść w interakcję ze stroną, na której występuje wiele długich zadań, interfejs będzie sprawiał wrażenie, że nie odpowiada, a nawet może się wydawać uszkodzony, jeśli wątek główny jest blokowany przez bardzo długi czas.

Długie zadanie w profilerze wydajności w Narzędziach deweloperskich w Chrome. Część zadania, która blokuje działanie (ponad 50 milisekund), jest oznaczona wzorem czerwonych ukośnych pasków.
Długie zadanie przedstawione w profilerze wydajności Chrome. Długie zadania są oznaczone czerwonym trójkątem w rogu zadania, a część zadania, która blokuje inne zadania, jest wypełniona wzorem czerwonych ukośnych pasków.

Aby zapobiec zbyt długiemu blokowaniu wątku głównego, możesz podzielić długie zadanie na kilka mniejszych.

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 podzielone zadanie to 5 mniejszych prostokątów, które łącznie mają taką samą szerokość jak długie zadanie.
Wizualizacja jednego długiego zadania w porównaniu z tym samym zadaniem podzielonym na 5 krótszych.

Ma to znaczenie, ponieważ po podzieleniu zadań przeglądarka może znacznie szybciej reagować na pracę o wyższym priorytecie, w tym na działania użytkownika. Następnie pozostałe zadania są wykonywane do końca, dzięki czemu praca, którą pierwotnie umieściłeś w kolejce, zostanie wykonana.

Ilustracja pokazująca, jak podzielenie zadania może ułatwić interakcję z użytkownikiem. U góry długie zadanie blokuje działanie procedury obsługi zdarzeń do czasu jego zakończenia. U dołu podzielone zadanie umożliwia wcześniejsze uruchomienie procedury obsługi zdarzeń niż w innym przypadku.
Wizualizacja tego, co dzieje się z interakcjami, gdy zadania są zbyt długie i przeglądarka nie może wystarczająco szybko na nie reagować, w porównaniu z sytuacją, gdy dłuższe zadania są dzielone na mniejsze.

U góry poprzedniego rysunku widać, że obsługa zdarzeń umieszczona w kolejce przez interakcję użytkownika musiała poczekać na wykonanie jednego długiego zadania, zanim mogła się rozpocząć. Opóźnia to interakcję. W takim przypadku użytkownik mógł zauważyć opóźnienie. U dołu obsługa zdarzeń może zacząć działać wcześniej, a interakcja może wydawać się natychmiastowa.

Wiesz już, dlaczego warto dzielić zadania na mniejsze części. Teraz dowiesz się, jak to zrobić 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 jest funkcja o nazwie saveSettings(), która wywołuje 5 funkcji w celu zweryfikowania formularza, wyświetlenia spinnera, wysłania danych do backendu aplikacji, zaktualizowania interfejsu użytkownika i wysłania danych analitycznych.

Koncepcja saveSettings() jest dobrze przemyślana. Jeśli musisz debugować jedną z tych funkcji, możesz przejść przez drzewo projektu, aby dowiedzieć się, co robi każda z nich. Taki podział pracy ułatwia poruszanie się po projektach i ich utrzymywanie.

Potencjalny problem polega jednak 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 zostanie uruchomionych jako jedno zadanie.

Funkcja saveSettings przedstawiona w profilerze wydajności Chrome. Funkcja najwyższego poziomu wywołuje 5 innych funkcji, ale cała praca odbywa się w ramach jednego długiego zadania, co oznacza, że wynik działania funkcji widoczny dla użytkownika jest dostępny dopiero po zakończeniu wszystkich zadań.
Pojedyncza funkcja saveSettings(), która wywołuje 5 funkcji. Praca jest wykonywana w ramach jednego długiego, monolitycznego zadania, które blokuje wszelkie odpowiedzi wizualne do momentu ukończenia wszystkich 5 funkcji.

W najlepszym przypadku nawet jedna z tych funkcji może wydłużyć czas trwania zadania o 50 milisekund lub więcej. W najgorszym przypadku więcej takich 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 cała funkcja nie zostanie wykonana, wynikiem tego długotrwałego zadania jest powolny i niereagujący interfejs, co zostanie zmierzone jako słaby wskaźnik interakcji do kolejnego wyrenderowania (INP).

Ręczne odraczanie wykonania kodu

Aby mieć pewność, że ważne zadania i odpowiedzi interfejsu użytkownika są wykonywane przed zadaniami o niższym priorytecie, możesz przekazać kontrolę do głównego wątku, przerywając na chwilę pracę, aby umożliwić przeglądarce uruchomienie ważniejszych zadań.

Jedną z metod, których deweloperzy używają do dzielenia zadań na mniejsze, jest setTimeout(). W tej technice przekazujesz funkcję do setTimeout(). Opóźnia to wykonanie wywołania zwrotnego w osobnym zadaniu, 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);
}

Jest to tzw. przekazywanie sterowania, które najlepiej sprawdza się w przypadku serii funkcji, które muszą być wykonywane kolejno.

Nie zawsze jednak kod jest tak uporządkowany. Możesz na przykład mieć dużą ilość danych, które muszą być przetwarzane w pętli, a to zadanie może zająć dużo czasu, jeśli jest wiele iteracji.

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

Używanie setTimeout() w tym miejscu jest problematyczne ze względu na ergonomię dewelopera, a po 5 rundach zagnieżdżonych wywołań setTimeout() przeglądarka zacznie narzucać minimalne opóźnienie wynoszące 5 milisekund w przypadku każdego dodatkowego wywołania setTimeout().

setTimeout ma też inną wadę, jeśli chodzi o przekazywanie sterowania: gdy przekazujesz sterowanie do głównego wątku, odkładając wykonanie kodu do późniejszego zadania za pomocą setTimeout, to zadanie jest dodawane na koniec kolejki. Jeśli są inne zadania do wykonania, zostaną one uruchomione przed odroczonym kodem.

Dedykowany interfejs API do optymalizacji przychodów: scheduler.yield()

Browser Support

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

Source

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

Nie jest to składnia na poziomie języka ani specjalna konstrukcja. scheduler.yield() to po prostu funkcja, która zwraca Promise, który zostanie rozwiązany w przyszłym zadaniu. Każdy kod połączony w łańcuch, który ma zostać uruchomiony po rozwiązaniu tego Promise (w łańcuchu .then() lub po await w funkcji asynchronicznej), zostanie uruchomiony w tym przyszłym zadaniu.

W praktyce: wstaw znak await scheduler.yield(), a funkcja wstrzyma wykonanie w tym miejscu i przekaże sterowanie do wątku głównego. Wykonanie pozostałej części funkcji, zwanej kontynuacją funkcji, zostanie zaplanowane w nowym zadaniu 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ła przerwana.

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 profilerze wydajności Chrome, podzielona teraz na 2 zadania. Pierwsze zadanie wywołuje 2 funkcje, a potem ustępuje, umożliwiając wykonanie pracy związanej z układem i wyrenderowaniem, aby użytkownik mógł zobaczyć odpowiedź. W rezultacie zdarzenie kliknięcia kończy się znacznie szybciej, bo w 64 milisekundach. Drugie zadanie wywołuje 3 ostatnie funkcje.
Wykonanie funkcji saveSettings() jest teraz podzielone na 2 zadania. Dzięki temu układ i wyrenderowanie mogą być wykonywane między zadaniami, co zapewnia użytkownikowi szybszą reakcję wizualną, mierzoną przez znacznie krótszą interakcję wskaźnika.

Prawdziwą zaletą scheduler.yield() w porównaniu z innymi metodami przekazywania jest to, że ma ona priorytet, co oznacza, że jeśli przekazujesz w trakcie zadania, kontynuacja bieżącego zadania zostanie uruchomiona przed rozpoczęciem innych podobnych zadań.

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

Trzy diagramy przedstawiające zadania bez przekazywania, z przekazywaniem i z przekazywaniem oraz kontynuacją. Bez przekazywania sterowania występują długie zadania. W przypadku wywłaszczenia jest więcej krótszych zadań, które mogą być przerywane przez inne, niezwiązane z nimi zadania. W przypadku przekazywania sterowania i kontynuacji jest więcej krótszych zadań, ale kolejność ich wykonywania jest zachowana.
Gdy używasz scheduler.yield(), kontynuacja zaczyna się w miejscu, w którym została przerwana, zanim przejdzie do innych zadań.

Obsługa różnych przeglądarek

scheduler.yield() nie jest jeszcze obsługiwane we wszystkich przeglądarkach, więc potrzebne jest rozwiązanie zastępcze.

Jednym z rozwiązań jest umieszczenie w kompilacji scheduler-polyfill, a następnie bezpośrednie użycie scheduler.yield(). Polyfill zajmie się przełączaniem na inne funkcje planowania zadań, dzięki czemu będzie działać podobnie w różnych przeglądarkach.

Można też napisać mniej zaawansowaną wersję w kilku wierszach, używając tylko setTimeout opakowanego w Promise jako rezerwy, jeśli scheduler.yield() nie jest dostępny.

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

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

Przeglądarki bez obsługi scheduler.yield() nie będą miały priorytetu w kontynuowaniu działania, ale nadal będą ustępować miejsca, aby zachować responsywność.

Może się też zdarzyć, że kod nie może ustąpić miejsca głównemu wątkowi, jeśli jego kontynuacja nie jest priorytetowa (np. na stronie, która jest znana z dużego obciążenia, ustąpienie miejsca grozi niedokończeniem pracy przez pewien czas). W takim przypadku scheduler.yield() można traktować jako rodzaj stopniowego ulepszania: zwracać wartość w przeglądarkach, w których jest dostępna, a w pozostałych kontynuować działanie.scheduler.yield()

Można to zrobić, wykrywając funkcję i wracając do oczekiwania na jedno mikrozadanie w wygodnym jednolinijkowym kodzie:

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

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

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

Jeśli na przykład masz tablicę zadań do wykonania, które często składają się na długie zadanie, możesz wstawić instrukcje yield, aby podzielić to zadanie.

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

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

Kontynuacja runJobs() będzie traktowana priorytetowo, ale nadal będzie umożliwiać wykonywanie zadań o wyższym priorytecie, takich jak wizualne reagowanie na dane wejściowe użytkownika, bez konieczności czekania na zakończenie potencjalnie długiej listy zadań.

Nie jest to jednak efektywne wykorzystanie funkcji yieldingu. scheduler.yield() jest szybkie i wydajne, ale wiąże się z pewnymi kosztami. Jeśli niektóre zadania w jobQueue są bardzo krótkie, narzut może szybko się zwiększyć i spowodować, że czas poświęcony na przekazywanie i wznawianie będzie dłuższy niż czas wykonywania rzeczywistej pracy.

Jednym ze sposobów jest grupowanie zadań i przekazywanie sterowania tylko wtedy, gdy od ostatniego przekazania minęło wystarczająco dużo czasu. Typowy limit czasu to 50 milisekund, aby zapobiec przekształceniu się zadań w długie zadania. Można go jednak dostosować, aby znaleźć kompromis między szybkością reakcji 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 tak, aby nigdy nie trwały zbyt długo, ale wykonawca przekazuje kontrolę do wątku głównego tylko co około 50 milisekund.

Seria funkcji zadań wyświetlanych na panelu Wydajność w Narzędziach deweloperskich w Chrome, których wykonanie jest podzielone na kilka zadań.
Zadania podzielone na kilka zadań.

Nie używaj isInputPending()

Browser Support

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

Source

Interfejs isInputPending() API umożliwia sprawdzenie, czy użytkownik próbował wejść w interakcję ze stroną, i zwraca wynik tylko wtedy, gdy oczekuje się danych wejściowych.

Dzięki temu JavaScript może kontynuować działanie, jeśli nie ma oczekujących danych wejściowych, zamiast ustępować i trafiać na koniec kolejki zadań. Może to przynieść znaczne zwiększenie wydajności, co zostało szczegółowo opisane w Intent to Ship w przypadku witryn, które w inny sposób nie mogłyby przywrócić wątku głównego.

Od czasu wprowadzenia tego interfejsu API nasza wiedza o uzyskiwaniu przychodów wzrosła, zwłaszcza po wprowadzeniu INP. Nie zalecamy już używania tego interfejsu API. Zamiast tego zalecamy przekazywanie wyników niezależnie od tego, czy dane wejściowe są oczekiwane z kilku powodów:

  • W niektórych przypadkach funkcja isInputPending() może nieprawidłowo zwracać wartość false, mimo że użytkownik wykonał interakcję.
  • Dane wejściowe to nie jedyny przypadek, w którym zadania powinny ustępować. Animacje i inne regularne aktualizacje interfejsu użytkownika mogą być równie ważne dla zapewnienia responsywności strony internetowej.
  • Wprowadziliśmy bardziej kompleksowe interfejsy API do optymalizacji przychodów, które rozwiązują problemy z optymalizacją, np. scheduler.postTask()scheduler.yield().

Podsumowanie

Zarządzanie zadaniami jest trudne, ale dzięki temu strona szybciej reaguje na interakcje użytkowników. Nie ma jednej uniwersalnej porady dotyczącej zarządzania zadaniami i ustalania ich priorytetów, ale istnieje wiele różnych technik. Podsumowując, oto najważniejsze kwestie, które warto wziąć pod uwagę podczas zarządzania zadaniami:

  • Przekazywanie sterowania do głównego wątku w przypadku kluczowych zadań, z którymi użytkownik ma bezpośredni kontakt.
  • Używaj scheduler.yield() (z wersją zastępczą dla innych przeglądarek), aby ergonomicznie przekazywać sterowanie i uzyskiwać kontynuacje o wyższym priorytecie.
  • Na koniec wykonuj w funkcjach jak najmniej pracy.

Więcej informacji o scheduler.yield(), jego jawnym harmonogramie zadań w stosunku do scheduler.postTask() i priorytetach zadań znajdziesz w dokumentacji interfejsu Prioritized Task Scheduling API.

Za pomocą co najmniej jednego z tych narzędzi możesz tak zorganizować pracę w aplikacji, aby priorytetem były potrzeby użytkownika, a mniej istotne zadania były nadal wykonywane. Zapewni to lepsze wrażenia użytkownikom, którzy będą mogli korzystać z aplikacji w sposób bardziej responsywny i przyjemny.

Specjalne podziękowania dla Philipa Waltona za weryfikację techniczną tego przewodnika.

Miniatura pochodzi z Unsplash, dzięki uprzejmości Amiraliego Mirhashemiana.