Optymalizuj długie zadania

Usłyszałeś(-aś), „nie blokuj głównego wątku” i „podziel dłuższe zadania”. Ale co to oznacza?

Jeremy Wagner
Jeremy Wagner

Jeśli czytasz dużo informacji o wydajności w internecie, warto podpowiedzieć, jak szybko korzystać z aplikacji JavaScript:

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

Co to oznacza? Używanie mniej kodu JavaScript jest przydatne, ale czy to oznacza, że interfejs jest bardziej atrakcyjny w całym cyklu życia strony? Być może, ale niekoniecznie.

Aby zrozumieć, dlaczego tak ważne jest optymalizowanie zadań w języku JavaScript, musisz zrozumieć rolę zadań i sposób, w jaki je obsługuje przeglądarka. Najpierw musisz zrozumieć, czym jest zadanie.

Co to jest zadanie?

Zadanie to dowolna praca wykonywana przez przeglądarkę. Zadania obejmują wykonywanie takich czynności jak renderowanie, analizowanie kodu HTML i CSS, uruchamianie pisanego kodu JavaScript oraz wykonywanie innych zadań, nad którymi nie masz bezpośredniej kontroli. Przede wszystkim to kod JavaScript, który tworzysz i wdrażasz w internecie, jest głównym źródłem zadań.

Zrzut ekranu z zadaniem przedstawionym w narzędziu deweloperskim w Chrome. Zadanie znajduje się u góry stosu, a pod nim moduł obsługi zdarzeń kliknięcia, wywołanie funkcji i inne elementy. Zadanie obejmuje również renderowanie po prawej stronie.
Ilustracja przedstawiająca zadanie uruchomione przez moduł obsługi zdarzeń click w programie profilu wydajności w Narzędziach deweloperskich w Chrome.

Zadania wpływają na wydajność na kilka sposobów. Jeśli na przykład przeglądarka pobiera plik JavaScript podczas uruchamiania, dodaje zadania do analizy i skompilowania kodu JavaScript, aby umożliwić ich wykonanie. Na późniejszym etapie cyklu życia strony zadania są uruchamiane, gdy JavaScript zaczyna działać, np. generuje interakcje za pomocą modułów obsługi zdarzeń, animacji JavaScript i działań w tle, takich jak zbieranie danych analitycznych. Wszystko to z wyjątkiem instancji roboczych i podobnych interfejsów API odbywa się w wątku głównym.

Jaki jest główny wątek?

Wątek główny to miejsce, w którym większość zadań jest uruchamiana w przeglądarce. Nie bez powodu nosi on nazwę głównego wątku: jest to jeden wątek, w którym niemal cały napisany kod JavaScript wykonuje swoje zadanie.

Wątek główny może przetworzyć tylko 1 zadanie naraz. Zadania, które przekraczają określony czas (dokładnie 50 milisekund), są klasyfikowane jako długie zadania. Jeśli użytkownik będzie próbować wejść w interakcję ze stroną podczas długiego działania lub jeśli konieczna będzie ważna aktualizacja renderowania, przeglądarka będzie z tym opóźniona. Powoduje to opóźnienie interakcji lub renderowania.

Długie zadanie w programie profilu wydajności w Narzędziach deweloperskich w Chrome. Blokowanie (czas dłuższy niż 50 milisekund) zadania jest przedstawione w formie czerwonych ukośnych pasów.
Długie zadanie przedstawione w narzędziu profilującym wydajność Chrome. Długie zadania są oznaczone czerwonym trójkątem w rogu, a jej część blokująca jest wypełniona ukośnymi czerwonymi pasami.

Musisz podzielić zadania. Oznacza to, że trzeba będzie zająć się jednym długim zadaniem i podzielić je na mniejsze zadania, których każde uruchomienie jest czasochłonne.

1 długie zadanie a to samo zadanie podzielone na krótsze. Długie zadanie to jeden duży prostokąt, a zadanie podzielone na fragmenty to 5 małych pól o długości takiej samej szerokości jak długie zadanie.
Wizualizacja pojedynczego długiego zadania z porównaniem tego samego zadania z podziałem na 5 krótszych zadań.

Ma to znaczenie, ponieważ podział zadań daje przeglądarce więcej możliwości reakcji na zadania o wyższym priorytecie, co obejmuje też interakcje użytkowników.

Ilustracja pokazująca, jak podział zadania może ułatwić użytkownikowi interakcję. Długie zadanie u góry blokuje moduł obsługi zdarzeń aż do jego zakończenia. Na dole zadanie podzielone na fragmenty zezwala modułowi obsługi zdarzeń na szybsze uruchomienie.
Wizualizacja, co dzieje się z interakcjami, gdy zadania są zbyt długie, a przeglądarka nie potrafi dostatecznie szybko reagować na interakcje, gdy dłuższe zadania są dzielone na mniejsze zadania.

Na górze poprzedniej ilustracji moduł obsługi zdarzeń ustawiony w kolejce przez interakcję użytkownika musiał czekać na jedno długie zadanie, zanim mogło zostać uruchomione. Powoduje to opóźnienia interakcji. U dołu moduł obsługi zdarzeń może uruchomić się szybciej. Moduł obsługi zdarzeń mógł działać między mniejszymi zadaniami, więc działa szybciej, niż gdyby musiał czekać na zakończenie długiego zadania. W pierwszym przykładzie użytkownik mógł zauważyć opóźnienie, a w dolnej części interakcja może być natychmiastowa.

Problem polega jednak na tym, że rada „podziel długie zadania” i „nie blokuj głównego wątku” nie jest wystarczająco szczegółowa, chyba że wiesz, jak to zrobić. Wyjaśniamy to w tym przewodniku.

Strategie zarządzania zadaniami

W przypadku architektury oprogramowania często warto podzielić pracę na mniejsze funkcje. Zyskujesz dzięki temu większą czytelność kodu i łatwość utrzymania projektu. Ułatwia to też pisanie testów.

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

W tym przykładzie znajduje się funkcja o nazwie saveSettings(), która wywołuje 5 funkcji, aby wykonać zadanie, np. zweryfikować formularz, wyświetlić wskaźnik postępu lub wysłać dane. Z założenia jest to dobrze zaprojektowana architektura. Jeśli musisz debugować jedną z tych funkcji, możesz przejrzeć drzewo projektu, aby dowiedzieć się, co robi każda z nich.

Problem polega jednak na tym, że JavaScript nie uruchamia każdej z tych funkcji jako osobnych zadań, ponieważ są one wykonywane w obrębie funkcji saveSettings(). Oznacza to, że wszystkie 5 funkcji działa jako jedno zadanie.

Funkcja SaveSettings jest pokazana w narzędziu profilującym wydajność Chrome. Funkcja najwyższego poziomu wywołuje 5 innych funkcji, ale cała praca odbywa się w ramach jednego długiego zadania, które blokuje wątek główny.
Pojedyncza funkcja saveSettings(), która wywołuje 5 funkcji. Praca jest realizowana w ramach 1 długiego zadania monolitycznego.

W najlepszym przypadku nawet jedna z tych funkcji może wydłużyć czas działania o co najmniej 50 milisekund. W przeciwnym razie więcej zadań może trwać dłużej – zwłaszcza w przypadku urządzeń z ograniczonymi zasobami. Poniżej przedstawiamy zestaw strategii, których możesz użyć do podziału zadań i ustalania ich priorytetów.

Ręczne odraczanie wykonania kodu

Jedna z metod używanych przez deweloperów do dzielenia zadań na mniejsze dotyczy: setTimeout(). W ten sposób przekazujesz funkcję do funkcji setTimeout(). Przełoży to wykonanie wywołania zwrotnego do osobnego zadania, nawet jeśli określisz czas oczekiwania wynoszący 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 dobre rozwiązanie, jeśli masz serię funkcji, które muszą być uruchamiane po kolei, ale kod nie zawsze jest zorganizowany w ten sposób. 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 masz miliony elementów.

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

Korzystanie z metody setTimeout() jest w tym przypadku problematyczne, ponieważ ergonomia tej metody utrudnia wdrożenie, a przetwarzanie całej tablicy danych może zająć bardzo dużo czasu, nawet jeśli każdy element można przetworzyć bardzo szybko. Wszystko się zgadza, a setTimeout() nie jest odpowiednim narzędziem do tych zadań – a przynajmniej nie wtedy, gdy jest używany w ten sposób.

Oprócz setTimeout() jest jeszcze kilka innych interfejsów API, które pozwalają odroczyć wykonanie kodu do kolejnego zadania. Jedna dotyczy użycia postMessage() w celu szybszego przekroczenia limitu czasu. Zadania możesz też przerywać przy użyciu requestIdleCallback(), ale zachowaj ostrożność: requestIdleCallback() planuje zadania przy najniższym możliwym priorytecie i tylko w czasie bezczynności przeglądarki. Gdy wątek główny jest przeciążony, zadania zaplanowane w requestIdleCallback() mogą nie zostać uruchomione.

Użyj async/await, aby utworzyć punkty zysku

W dalszej części tego przewodnika pojawi się komunikat „uzyskanie korzyści z wątku głównego”. Ale co to znaczy? Dlaczego warto to zrobić? Kiedy należy to zrobić?

Gdy zadania są podzielone, wewnętrzny schemat priorytetu przeglądarki może nadać priorytet innym zadaniom. Jednym ze sposobów przekazania wątku głównego jest użycie kombinacji atrybutu Promise, który kończy się wywołaniem metody setTimeout():

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

W funkcji saveSettings() możesz przejść do wątku głównego po każdym bitzie pracy, jeśli await funkcja yieldToMain() po każdym wywołaniu funkcji:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

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

W rezultacie jednorazowe zadanie zostało podzielone na osobne zadania.

Ta sama funkcja SaveSettings jest przedstawiona w narzędziu profilującym wydajność Chrome, ale tylko z wynikami. W rezultacie jednorazowe zadanie jest teraz podzielone na 5 osobnych zadań – po 1 dla każdej funkcji.
Funkcja saveSettings() wykonuje teraz swoje funkcje podrzędne jako osobne zadania.

Zaletą stosowania metody opartej na obietnicach zamiast ręcznego stosowania setTimeout() jest większa ergonomia. Punkty zysku stają się deklaracyjne, dzięki czemu łatwiej je pisać, odczytywać i rozumieć.

Uzyskuj zyski tylko wtedy, gdy jest to konieczne

Co zrobić, jeśli masz kilka zadań, ale ich wynik chcesz uzyskać tylko wtedy, gdy użytkownik spróbuje wejść w interakcję ze stroną? Po to właśnie powstała aplikacja isInputPending().

isInputPending() to funkcja, którą możesz uruchomić w dowolnym momencie, by sprawdzić, czy użytkownik próbuje wejść w interakcję z elementem strony. Wywołanie isInputPending() zwróci wartość true. W przeciwnym razie zwraca wartość false.

Załóżmy, że masz kolejkę zadań, które musisz uruchomić, ale nie chcesz przeszkadzać w korzystaniu z danych wejściowych. Ten kod, który wykorzystuje zarówno funkcję isInputPending(), jak i naszą niestandardową funkcję yieldToMain(), zapewnia, że dane wejściowe nie będą opóźnione, gdy użytkownik spróbuje wejść w interakcję ze stroną:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Podczas uruchamiania saveSettings() będzie zapętlać się nad zadaniami w kolejce. Jeśli isInputPending() zwróci wartość true podczas pętli, saveSettings() wywoła metodę yieldToMain(), aby umożliwić obsługę danych wejściowych użytkownika. W przeciwnym razie przesunie kolejne zadanie poza kolejkę i będzie je uruchamiać w sposób ciągły. Będzie to wykonywane do momentu, gdy nie będzie więcej zadań.

Ilustracja przedstawiająca funkcję SaveSettings działającą w programie profilującym wydajność Chrome. Powstałe zadanie zablokuje wątek główny do momentu zwrócenia wartości „true” (prawda), w którym zadanie zwraca się do wątku głównego.
saveSettings() uruchamia kolejkę zadań dla 5 zadań, ale użytkownik kliknął, aby otworzyć menu, gdy był uruchomiony drugi element roboczy. Metoda isInputPending() przechodzi do wątku głównego, aby obsłużyć interakcję i wznowić wykonywanie pozostałych zadań.

Używanie funkcji isInputPending() w połączeniu z mechanizmem generowania plików to świetny sposób na umożliwienie przeglądarce zatrzymania przetwarzania wszystkich zadań i reagowania na kluczowe interakcje napotykane przez użytkownika. Dzięki temu w wielu sytuacjach, gdy masz zaplanowanych wiele zadań, możesz poprawić zdolność strony do reagowania na użytkownika.

Innym sposobem korzystania z metody isInputPending() – zwłaszcza jeśli niepokoi Cię możliwość korzystania z zastępczych przeglądarek, które jej nie obsługują, jest wskazywanie na podstawie czasu w połączeniu z opcjonalnym operatorem łańcucha:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

Takie podejście umożliwia użycie kreacji zastępczych w przypadku przeglądarek, które nie obsługują isInputPending(). W tym celu korzysta się z terminu (i koryguje ten termin) tak, aby w razie potrzeby podział pracy został podzielony według potrzeb użytkownika lub w określonym momencie.

Luki w aktualnych interfejsach API

Wspomniane do tej pory interfejsy API mogą pomóc w rozdzielaniu zadań, ale mają znaczącą wadę: gdy wydajesz wątek główny, opóźniając uruchomienie kodu w kolejnym zadaniu, jest on dodawany na samym końcu kolejki zadań.

Jeśli kontrolujesz cały kod na swojej stronie, możesz utworzyć własny algorytm szeregowania z możliwością nadawania priorytetu zadaniom, ale skrypty innych firm nie będą go używać. W rezultacie nie możesz tak naprawdę określać priorytety pracy w takich środowiskach. Możesz ją dzielić na fragmenty lub wprost generować interakcje z użytkownikami.

Na szczęście istnieje specjalny interfejs API algorytmu szeregowania, który jest obecnie w fazie opracowywania i rozwiązuje te problemy.

Wydzielony interfejs API algorytmu szeregowania

Interfejs Scheduler API obecnie oferuje funkcję postTask(), która w momencie pisania jest dostępna w przeglądarkach Chromium oraz w Firefoksie za flagą. postTask() umożliwia bardziej precyzyjne planowanie zadań i jest jednym ze sposobów na pomaganie przeglądarce w określaniu priorytetów, aby zadania o niskim priorytecie trafiały do wątku głównego. postTask() korzysta z obietnic i akceptuje ustawienie priority.

Interfejs postTask() API ma 3 priorytety, których możesz użyć:

  • 'background' dla zadań o najniższym priorytecie.
  • 'user-visible' w zadaniach o średnim priorytecie. Jest to ustawienie domyślne, jeśli nie ustawiono żadnej funkcji priority.
  • 'user-blocking' – na potrzeby krytycznych zadań, które muszą być uruchamiane z wysokim priorytetem.

Przeanalizujmy kod poniżej, na którym interfejs postTask() API służy do uruchamiania 3 zadań o najwyższym możliwym priorytecie, a pozostałych 2 – najniższym.

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

W tym przypadku priorytety zadań są zaplanowane w taki sposób, że zadania priorytetowe w przeglądarce – na przykład interakcje użytkowników – mogą działać w odpowiedni sposób.

Działanie SaveSettings działa w sposób przedstawiony w programie profilu wydajności Chrome, ale za pomocą narzędzia postTask. PostTask dzieli wszystkie uruchomione funkcje SaveSettings i traktuje je priorytetowo, tak aby interakcja użytkownika mogła działać bez blokowania.
Po uruchomieniu saveSettings() funkcja planuje poszczególne funkcje za pomocą metody postTask(). Kluczowe zadania dla użytkowników są planowane z wysokim priorytetem, a te, o których użytkownik nie wie, mają być uruchamiane w tle. Przyspiesza to wykonywanie działań przez użytkowników, ponieważ praca jest podzielona i na odpowiedni priorytet.

To uproszczony przykład użycia właściwości postTask(). Istnieje możliwość tworzenia instancji różnych obiektów TaskController o wspólnych priorytetach między zadaniami. Obejmuje to też możliwość zmiany priorytetów różnych instancji TaskController w razie potrzeby.

Wbudowany zysk z kontynuacją przez scheduler.yield

Jedną z proponowanych elementów interfejsu API algorytmu szeregowania jest scheduler.yield, czyli interfejs API zaprojektowany specjalnie do przekazywania głównego wątku w przeglądarce, który jest obecnie dostępny do wypróbowania w ramach testowania origin. Użycie przypomina funkcję yieldToMain() omówioną wcześniej w tym artykule:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

Powyższy kod jest w dużym stopniu znany, ale zamiast używać yieldToMain(), zadzwonisz i wybierzesz metodę await scheduler.yield().

3 diagramy przedstawiające zadania bez ponoszenia zbędnych zobowiązań oraz z podawaniem i kontynuowaniem. Przede wszystkim są długie zadania. Dzięki temu jest więcej zadań, które są krótsze, ale mogą być przerywane innymi, niepowiązanymi zadaniami. W przypadku upływu i kontynuacji jest więcej zadań, które są krótsze, ale ich kolejność wykonywania jest zachowywana.
Wizualizacja wykonania zadania bez udawania, z zyskami oraz z zyskami i kontynuacją. Gdy używany jest scheduler.yield(), wykonywanie zadania jest wznawiane w miejscu, w którym zostało przerwane, nawet po osiągnięciu punktu zysku.

Zaletą funkcji scheduler.yield() jest kontynuacja, co oznacza, że jeśli wygenerujesz w trakcie zestawu zadań, po osiągnięciu punktu zysku pozostałe zaplanowane zadania będą kontynuowane w tej samej kolejności. Pozwala to uniknąć przywłaszczenia kolejności wykonywania kodu przez skrypty innych firm.

Podsumowanie

Zarządzanie zadaniami jest trudne, ale w ten sposób możesz szybciej reagować na interakcje użytkowników. Nie ma jednej wskazówki, jak zarządzać zadaniami i nadawać im priorytety. Jest to szereg różnych technik. Podczas zarządzania zadaniami musisz pamiętać o tych najważniejszych kwestiach:

  • Przekazywanie w wątku głównym zadań o znaczeniu krytycznym, które są widoczne dla użytkowników.
  • Użyj metody isInputPending(), aby przejść do wątku głównego, gdy użytkownik próbuje wejść w interakcję ze stroną.
  • Nadawaj zadaniom priorytety za pomocą narzędzia postTask().
  • Na koniec rób jak najmniejszą pracę nad funkcjami.

Korzystając z co najmniej jednego z tych narzędzi, możesz uporządkować swoją pracę w aplikacji w taki sposób, aby traktowała ją priorytetowo pod kątem potrzeb użytkownika, a jednocześnie zadbać o wykonanie mniej istotnych zadań. W ten sposób poprawisz wygodę użytkowników, szybciej zareagują na Twoje reklamy i będą chętniej z nich korzystać.

Specjalne dziękujemy Philipowi Waltonowi za weryfikację techniczną tego artykułu.

Baner powitalny pochodzący ze strony Unsplash dzięki uprzejmości Amirali Mirhashemian