Optymalizuj długie zadania

Zapewne zdarzyło Ci się usłyszeć, że nie należy blokować wątku głównego i że trzeba 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 utrzymywania szybkości aplikacji JavaScript sprowadzają się do tych wskazówek:

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

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

Aby dowiedzieć się, jak optymalizować zadania w języku JavaScript, musisz najpierw zrozumieć, czym są zadania i jak przeglądarka je obsługuje.

Czym jest zadanie?

Zadanie to dowolna odrębna czynność wykonywana przez przeglądarkę. Obejmuje to renderowanie, analizowanie kodu HTML i CSS, uruchamianie kodu JavaScript oraz inne rodzaje zadań, nad którymi możesz nie mieć bezpośredniej kontroli. Spośród wszystkich tych elementów kod JavaScript, który piszesz, jest prawdopodobnie największym źródłem zadań.

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

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

  • Gdy przeglądarka pobiera plik JavaScript podczas uruchamiania, kolejkuje zadania, aby przeanalizować i skompilować ten kod JavaScript, aby można go było później wykonać.
  • W innych momentach cyklu życia strony zadania są dodawane do kolejki, gdy JavaScript wykonuje takie czynności jak reagowanie na interakcje za pomocą modułów obsługi zdarzeń, animacje oparte na JavaScript i działania w tle, np. zbieranie danych analitycznych.

Wszystkie te elementy – z wyjątkiem procesów roboczych i podobnych interfejsów API – są wykonywane w głównym wątku.

Czym jest wątek główny?

Wątek główny to miejsce, w którym przeglądarka wykonuje większość zadań i 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, łączny czas zadania pomniejszony o 50 milisekund jest nazywany okresem blokowania zadania.

Przeglądarka blokuje interakcje podczas wykonywania zadania o dowolnej długości, ale nie jest to zauważalne dla użytkownika, o ile zadania nie trwają zbyt długo. Jeśli jednak użytkownik spróbuje wejść w interakcję ze stroną, gdy jest wiele długich zadań, interfejs będzie wydawał się nie reagować, a nawet może być 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. Blokująca część zadania (dłuższa niż 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, a blokująca część zadania jest wypełniona wzorem z ukośnych czerwonych 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 i to samo zadanie podzielone 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 i tego samego zadania podzielonego na 5 krótszych zadań.

Jest to ważne, ponieważ gdy zadania są podzielone, przeglądarka może znacznie szybciej reagować na zadania o wyższym priorytecie, w tym na interakcje użytkownika. Następnie pozostałe zadania są wykonywane do końca, co gwarantuje, że praca, która została początkowo dodana do kolejki, zostanie wykonana.

Ilustracja pokazująca, jak podzielenie zadania może ułatwić interakcję z użytkownikiem. U góry długie zadanie blokuje uruchomienie modułu obsługi zdarzeń do czasu zakończenia zadania. Na dole podzielone zadanie pozwala modułowi obsługi zdarzeń uruchomić się szybciej, niż byłoby to możliwe 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 reagować na interakcje, w porównaniu z sytuacją, gdy dłuższe zadania są dzielone na mniejsze.

U góry poprzedniego rysunku moduł obsługi zdarzeń dodany do kolejki przez interakcję użytkownika musiał poczekać na zakończenie pojedynczego długiego zadania, zanim mógł się uruchomić. To opóźnia interakcję. W tym scenariuszu użytkownik mógł zauważyć opóźnienie. Na dole moduł obsługi zdarzeń może zacząć działać wcześniej, a interakcja może wydawać się natychmiastowa.

Teraz, gdy już wiesz, dlaczego ważne jest dzielenie zadań, możesz dowiedzieć się, jak to zrobić w języku JavaScript.

Strategie zarządzania zadaniami

Częstą poradą dotyczącą architektury oprogramowania jest dzielenie pracy na mniejsze funkcje:

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

W tym przykładzie funkcja o nazwie saveSettings() wywołuje 5 funkcji, aby zweryfikować formularz, wyświetlić wskaźnik oczekiwania, wysłać dane do backendu aplikacji, zaktualizować interfejs i wysłać dane analityczne.

Pod względem koncepcji saveSettings() jest dobrze zaprojektowany. Jeśli chcesz debugować jedną z tych funkcji, możesz przejść przez drzewo projektu, aby dowiedzieć się, co robi każda funkcja. Taki podział pracy ułatwia poruszanie się po projektach i ich utrzymanie.

Potencjalnym problemem jest jednak to, że JavaScript nie uruchamia każdej z tych funkcji jako osobnego zadania, ponieważ są one wykonywane w 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 jednym długim zadaniu, co sprawia, że wynik działania funkcji nie jest widoczny dla użytkownika, dopóki nie zostaną wykonane wszystkie zadania.
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ć zadanie 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 z ograniczonymi zasobami.

W tym przypadku funkcja saveSettings() jest wywoływana przez kliknięcie użytkownika. Ponieważ przeglądarka nie może wyświetlić odpowiedzi, dopóki nie zakończy się cała funkcja, wynikiem tego długiego zadania jest powolny i niereagujący interfejs, co zostanie zmierzone jako słaba interakcja do kolejnego wyrenderowania (INP).

Ręczne odraczanie wykonywania kodu

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

Jedną z metod, które deweloperzy stosują do dzielenia zadań na mniejsze, jest setTimeout(). W tej technice przekazujesz funkcję do setTimeout(). Spowoduje to odroczenie wykonania wywołania zwrotnego do osobnego zadania, nawet jeśli określisz czas oczekiwania na poziomie 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. ustępowanie, które 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 muszą zostać przetworzone 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 setTimeout() jest tutaj problematyczne ze względu na ergonomię programisty, a po 5 rundach zagnieżdżonych setTimeout() przeglądarka zacznie nakładać minimalne opóźnienie wynoszące 5 milisekund na każde dodatkowe setTimeout().

setTimeout ma też inną wadę, jeśli chodzi o przekazywanie: gdy przekazujesz do głównego wątku, odraczając kod do uruchomienia w kolejnym zadaniu za pomocą setTimeout, to zadanie jest dodawane na końcu kolejki. Jeśli są inne zadania oczekujące, zostaną one wykonane przed odroczonym kodem.

Specjalny interfejs API do przekazywania: scheduler.yield()

Browser Support

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

Source

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

Nie jest to składnia na poziomie języka ani specjalna konstrukcja. scheduler.yield() to po prostu funkcja, która zwraca obiekt 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 await scheduler.yield(), a funkcja wstrzyma wykonywanie w tym miejscu i przekaże sterowanie do wątku głównego. Wykonanie pozostałej części funkcji, czyli jej kontynuacji, 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ł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 profilerze wydajności Chrome, teraz podzielona na 2 zadania. Pierwsze zadanie wywołuje 2 funkcje, a następnie przekazuje sterowanie, umożliwiając wykonanie operacji związanych z układem i renderowaniem, co daje użytkownikowi widoczną reakcję. W rezultacie zdarzenie kliknięcia kończy się znacznie szybciej, bo w 64 milisekundy. Drugie zadanie wywołuje 3 ostatnie funkcje.
Wykonywanie funkcji saveSettings() jest teraz podzielone na 2 zadania. Dzięki temu układ i renderowanie mogą być wykonywane między zadaniami, co zapewnia użytkownikowi szybszą odpowiedź wizualną, mierzoną przez znacznie krótszą interakcję ze wskaźnikiem.

Jednak prawdziwą zaletą funkcji scheduler.yield() w porównaniu z innymi metodami przekazywania jest to, że jej kontynuacja ma priorytet. Oznacza to, że jeśli przekażesz zadanie w jego trakcie, kontynuacja bieżącego zadania zostanie uruchomiona przed rozpoczęciem innych podobnych zadań.

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

Trzy diagramy przedstawiające zadania bez przekazywania, z przekazywaniem oraz z przekazywaniem i kontynuacją. Bez przekazywania kontroli występują długie zadania. W przypadku przekazywania jest więcej zadań, które są krótsze, ale mogą być przerywane przez inne, niepowiązane zadania. Dzięki przekazywaniu i kontynuacji jest więcej zadań, które są krótsze, ale ich kolejność wykonywania jest zachowana.
Gdy używasz scheduler.yield(), kontynuacja zaczyna się od miejsca, w którym została przerwana, zanim przejdziesz do innych zadań.

Obsługa różnych przeglądarek

Właściwość scheduler.yield() nie jest jeszcze obsługiwana we wszystkich przeglądarkach, dlatego potrzebna jest wartość zastępcza.

Jednym z rozwiązań jest dodanie scheduler-polyfill do kompilacji, a następnie bezpośrednie użycie scheduler.yield(). Polyfill będzie obsługiwać przełączanie się 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 kreacji zastępczej, 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);
  });
}

Przeglądarki, które nie obsługują scheduler.yield(), nie będą korzystać z priorytetowej kontynuacji, ale będą nadal reagować na działania użytkownika.

Wreszcie mogą wystąpić przypadki, w których kod nie może przekazać kontroli wątkowi głównemu, jeśli jego kontynuacja nie ma priorytetu (np. znana strona o dużym obciążeniu, na której przekazanie kontroli grozi nieukończeniem pracy przez pewien czas). W takim przypadku scheduler.yield() można traktować jako rodzaj progresywnego ulepszania: zwracać dane w przeglądarkach, w których scheduler.yield() jest dostępny, a w przeciwnym razie kontynuować.

Można to zrobić, wykrywając funkcję i czekając na pojedyncze mikrozadanie w jednej linijce:

// 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 tych metod używania funkcji scheduler.yield() jest to, że możesz ją 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ć 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 traktowana priorytetowo, ale nadal będzie możliwe 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 przekazywania. scheduler.yield() jest szybki i wydajny, 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ć, przez co więcej czasu będzie poświęcane na oddawanie i wznawianie niż na wykonywanie rzeczywistej pracy.

Jednym z podejść jest grupowanie zadań i przekazywanie ich tylko wtedy, gdy od ostatniego przekazania minęło wystarczająco dużo czasu. Typowy termin to 50 milisekund, aby zapobiec przekształcaniu się zadań w długie zadania, ale można go dostosować, aby znaleźć kompromis między czasem reakcji a czasem potrzebnym na ukończenie 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 moduł uruchamiający przekazuje sterowanie do wątku głównego tylko co około 50 milisekund.

Seria funkcji zadania wyświetlana w panelu wydajności Narzędzi deweloperskich w Chrome, których wykonywanie jest podzielone na wiele zadań.
Zadania pogrupowane w wiele 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ł wejść w interakcję ze stroną, i przekazanie kontroli tylko wtedy, gdy dane wejściowe oczekują na przetworzenie.

Dzięki temu JavaScript może kontynuować działanie, jeśli nie ma oczekujących danych wejściowych, zamiast przekazywać sterowanie i trafiać na koniec kolejki zadań. Może to prowadzić do znacznego zwiększenia wydajności, jak opisano w Intent to Ship, w przypadku witryn, które w przeciwnym razie nie przekazywałyby danych z powrotem do głównego wątku.

Jednak od czasu wprowadzenia tego interfejsu API nasza wiedza na temat oddawania kontroli wzrosła, zwłaszcza po wprowadzeniu INP. Nie zalecamy już używania tego interfejsu API. Zamiast tego zalecamy przekazywanie danych niezależnie od tego, czy dane wejściowe oczekują na przetworzenie, z kilku powodów:

  • W niektórych okolicznościach funkcja isInputPending() może nieprawidłowo zwracać wartość false, mimo że użytkownik wszedł w interakcję.
  • Dane wejściowe nie są jedynym przypadkiem, w którym zadania powinny się poddawać. Animacje i inne regularne aktualizacje interfejsu mogą być równie ważne dla zapewnienia responsywności strony internetowej.
  • Od tego czasu wprowadzono bardziej kompleksowe interfejsy API, które rozwiązują problemy z oddawaniem zasobów, takie jak scheduler.postTask() i scheduler.yield().

Podsumowanie

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

  • Przekaż kontrolę głównemu wątkowi w przypadku zadań krytycznych dla użytkownika.
  • Używaj scheduler.yield() (z funkcją zastępczą w różnych przeglądarkach), aby ergonomicznie przekazywać i uzyskiwać priorytetowe kontynuacje
  • Na koniec: ogranicz do minimum liczbę operacji wykonywanych przez funkcje.

Więcej informacji o scheduler.yield(), powiązanym z nim interfejsie scheduler.postTask() do jawnego planowania zadań oraz o priorytetyzacji zadań znajdziesz w dokumentacji interfejsu Prioritized Task Scheduling API.

Za pomocą co najmniej jednego z tych narzędzi możesz ustrukturyzować pracę w aplikacji, tak aby priorytetowo traktowała potrzeby użytkownika, a jednocześnie zapewniała wykonywanie mniej krytycznych zadań. Dzięki temu użytkownicy będą mogli wygodniej korzystać z tych stron.

Szczególne podziękowania dla Philipa Waltona za sprawdzenie tego przewodnika pod kątem technicznym.