Scheduling w React 16.x

Czym jest scheduler?

Może na początek trochę teorii. JavaScript to język jednowątkowy, a więc posiada jeden stos wywołań (call stack), który potrafi obsłużyć tylko jeden fragment kodu na raz. Oprócz obsługi Twojego kodu, przeglądarki wykonują szereg innych zadań, w tym:

  • obsługa zdarzeń (np kliknięcia myszą, przetwarzanie callbacków setTimeout, itp.),
  • obliczenia związane z layoutem (budowanie DOM / CSSOM) czy
  • rysowanie, czyli repaint (na podstawie DOM / CSSOM).

Skupmy się na tym ostatnim. W trakcie jednej sekundy, przeglądarki przerysowują zmiany 60-krotnie, czyli średnie co 16.6 milisekund (mniej więcej, zależnie od środowiska) pojawia się repaint.

Zatem, jeśli w czasie jednej sekundy przetwarzamy synchroniczny kod to pominiemy 60 frame, a tym samym opóźnimy proces rysowania. A co jeśli trwa to dłużej? W naszych aplikacjach pojawią się wyraźne opóźnienia
i ucierpi na tym UX.

Czym jest więc scheduler? Zadaniem schedulera jest wytworzenie “balansu” pomiędzy obsługą wszystkich aktywności przeglądarki (np. wykonywaniem naszego kodu) a procesu przerysowywania elementów drzewa DOM.

No dobrze, ale czy jako programiści, w ogóle powinniśmy się tym tematem przejmować? Bez wątpienia świadomość jego istnienia jest wskazana, ale niekoniecznie do nas powinno należeć zadanie rozwiązania tego problemu.

Wytwarzając oprogramowanie, sporo odpowiedzialności staramy się delegować do narzędzi wspomagających. Frameworki webowe robią za nas sporo, obsługują routing oraz stan aplikacji, zapewniają mechanizmy wykrywania zmian, two-way-data-binding (Angular / Vuejs), aktualizują drzewo DOM oraz wiele, wiele innych. Są swego rodzaju pośrednikiem pomiędzy nami a przeglądarką, a więc wydają się najlepszym miejscem do rozwiązywania wszelkich problemów, również tych związanych ze schedulingiem.

I faktycznie w tym pomagają… I React nie jest tutaj wyjątkiem.

Prosty mechanizm schedulingu

Zanim postaram się wytłumaczyć jak problem schedulingu został rozwiązany w React, celem zrozumienia źródło problemu, posłużmy się prostym fragment kodu. Zasymulujmy stosunkowo złożona aplikację:

setInterval(() => {
    document.body.appendChild(document.createTextNode('hi'))
}, 3)

Co 3 milisekundy dokonujemy zmiany w drzewie DOM, co powoduje częste repainty. Teraz dopiszmy funkcję render:

setInterval(() => {
    document.body.appendChild(document.createTextNode('hi'))
}, 3)

render()

function render() {
    for (let i = 0; i < 20; i++) {
        performUnitOfWork()
    }
}

function performUnitOfWork() {
    sleep(5)
}

function sleep(milliseconds) {
    // sleep for given {milliseconds} period (synchronous)
}

Jaki jest wpływ tego kodu na działanie mechanizmu rysowania w przeglądarce? Cóż, funkcja render jest synchroniczna, a więc blokuje ona przerysowywanie na około 100 milisekund (co wynika z fragmentu wyżej, 20 iteracji * 5ms = 100ms). W tym czasie przeglądarka jest całkowicie zablokowana, nie mogą być obsłużone eventy użytkownika, obliczenia związane z animacjami czy też same repainty. Przyjrzyjmy się zakładce „Performance tool” w przeglądarce Google Chrome:

enter image description here

W powyższym przykładzie blokujemy repainty przez 100ms (zwróć uwagę na pionowe przerywane linie, symbolizują one repaint). Jak wspomniałem wcześniej, przeglądarka robi repaint średnio co 16.6 ms (60 frames/sek). Żadna animacja nie zostanie obsłużona w tym czasie, obsługa eventów użytkownika również musi poczekać na koniec wykonywania naszej synchronicznej funkcji. Uroki środowisk jednowątkowych, to nie jest pożądane zachowanie, prawda?

Teraz wyobraźmy sobie, że nasza funkcja render to “uproszczona” funkcja renderującą naszą aplikację Reaktową. W trakcie tego procesu, dzieje się wiele rzeczy, Reakt wykrywa zmiany w drzewie, wykonuje metody life-cycle, porównuje propsy itd. To bardzo złożony proces, który zajmuje stosunkowo sporo czasu.

Czy Reakt 15.x ma jakikolwiek mechanizm schedulingu? Niestety nie ma, a więc w zasadzie nie ma żadnej różnicy, pomiędzy powyższą funkcją render (synchroniczna) oraz funkcją render z Reakta 15.x (która, co gorsza, jest rekurencyjna).

W Internecie można znaleźć świetny przykład złożonej aplikacji w Reakcie 15.x, która z jednej strony zmusza przeglądarkę do częstych repaintów (poprzez poszerzanie i zwężanie widoku) a z drugiej strony wykonuje sporo synchronicznego kodu (wykrywanie zmian, wywołania metod life-cycle):

image.png

Całość do podglądu tutaj. Mało przyjazne użytkownikowi, prawda? Cofnijmy się do naszego fragmentu kodu. Co możemy zrobić, aby nieco odciążyć przeglądarkę zbombardowaną złożonością naszego synchronicznego kodu? Może setTimeout?

function render() {
    performUnitOfWork()
    setTimeout(render, 0)
}

Jaki to ma wpływ na repainty?

image.png

Nieco lepiej. Nasza funkcja performUnitOfWork jest wykonywana osobno, w każdym frame. Dzięki temu, repainty są regularne (co 13ms / 19ms), bez większych opóźnień. Super!

Ale czy to jest sposób, w jaki rozwiązano problem schedulingu w React 16.x? Nie do końca… Oprócz całkowitego przepisania wnętrza frameworka (React Fiber) pojawił się tam też nowy moduł – React Scheduler – który adresuje problemy związane z kolejkowaniem. Jak on działa? Aby to zrozumieć, musimy zapoznać się najpierw z Channel Messaging API.

Scheduling oparty o MessageChannel

Zatem czym jest MessageChannel? Przede wszystkim to API pozwala na komunikację pomiędzy różnymi kontekstami, np. pomiędzy naszym głównym kodem, a iframe. Albo pomiędzy iframe a kontekstem web-worker‚a.

Spójrzmy na przykład:

// index.html
<iframe src="iframe-page.html"></iframe>

<script>
var iframe = document.querySelector('iframe')
var channel = new MessageChannel()

iframe.addEventListener('load', () => {
    channel.port1.onmessage = e => console.log(e.data)
    iframe.contentWindow.postMessage('hi!', '*', [channel.port2])
})
</script>
// iframe-page.html
<script>
window.addEventListener('message', event => {
    console.log(event.data) // hi!
    event.ports[0].postMessage(
        'Message back from the IFrame'
    )
})
</script>

W miarę proste – musimy ustawić nasłuchiwanie na jednym porcie (port1) oraz przesłać referencję drugiego (port2, który będzie wykorzystywany przez wysyłającego wiadomości) do innego kontekstu (np. iframe) za pośrednictwem API postMessage. Dzięki temu możemy komunikować się w obu kierunkach.

Ok, ale czy w naszej aplikacji potrzebujemy komunikować się z iframe / web-worker aby rozwiązać problemy kolejkowania? Oczywiście nie! Ale to API (oprócz wspomnianych zdolności komunikacji pomiędzy kontekstami) posiada jeszcze jedną zaletę. Pozwala nam na scheduling zadań w sposób respektujący pozostałe aktywności przeglądarki, w tym proces przerysowywania czy obliczeń na rzecz budowy DOM. Jak? Poprzez tak zwany mechanizm message loop. Rozszerzmy fragment kodu z poprzedniego przykładu:

setInterval(() => {
    document.body.appendChild(document.createTextNode('hi'))
}, 3)

render()

function render() {
    const channel = new MessageChannel()

    function onMessageReceived(event) {
        performUnitOfWork();
        channel.port2.postMessage(null)
    }

    channel.port1.onmessage = onMessageReceived
    channel.port2.postMessage(null)
}

function performUnitOfWork() {
    sleep(5)
}

Wysyłając wiadomość z port2 wymuszamy wywołanie funkcji onMessageReceived, która to z kolei wywołuje nasz performUnitOfWork, a potem raz jeszcze wysyłamy wiadomość z port2, która znów wymusi wywołanie funkcji onMessageReceived, która to znów… ok, chyba widać skąd pomysł na nazwę message loop, prawda?

Przyjrzyjmy się jak to wygląda to z poziomu dev-tools:

image.png

Repainty są wykonywane regularnie co 14ms / 19ms. Podczas pojedynczego frame performUnitOfWork wykonuje się czasami częściej niż w przypadku podejścia z setTimeout. Ale co ważniejsze – przy podejściu z setTimeout mieliśmy więcej pustych przestrzeni w frame kiedy to przeglądarka się „nudziła”. W powyższym podejściu zarówno performUnitOfWork jak i layout / repaint są upakowane optymalnie – bez większych przestojów między kolejnymi wywołaniami.

Scheduling w React 16.x

I tutaj ciekawa kwestia: właśnie opisałem Wam jak na ogólnym poziomie działa Reaktowy scheduler. Wykorzystuje on właśnie MessageGlobal API. To podejście nieco lepsze niż prosta implementacja setTimeout, gdyż pozwala na wykonanie większej pracy w jednostce czasu.

Teraz ważna rzecz, te podejście wymaga podzielenia obliczeń na mniejsze części. W naszej implementacji render funkcja wykonująca obliczenia (performUnitOfWork) zajmuje 5ms. W przypadku implementacji Reakta nie jest inaczej – praca podzielona jest na mniejsze części i wykonywana w pięcio-milisekundowych oknach, ale o tym nieco później.

Przyjrzyjmy się jednemu z najważniejszych fragmentów kodu Reakta:

function workLoopConcurrent() {
   // Perform work until Scheduler asks us to yield
   while (workInProgress !== null && !shouldYield()) {
      workInProgress = performUnitOfWork(workInProgress);
   }
}

React 16.x (w przeciwieństwie to Reakta 15.x) zawiera mechanizm podziału pracy na mniejsze części (workInProgress). Każda z tych części jest wykonywana jedna po drugiej, w pętli while dopóty, dopóki

  1. pozostała nam jeszcze jakaś praca do wykonania (workInProgress !== null)
  2. oraz shouldYield() zwraca false.

W implementacji Reakta w ramach funkcji performUnitOfWork dzieje się bardzo wiele. Ten fragment kodu pochodzi z modułu reconciler. Jeżeli chciałbyś dowiedzieć się więcej na temat jego działania polecam poszukać dodatkowych informacji w sieci pod hasłem React Fiber. Na potrzeby tego artykułu ograniczmy się do informacji, że performUnitOfWork przegląda strukturę naszych komponentów, wykrywa zmiany, wywołuje metody life-cycle czy też oznacza side-effecty.

React Fiber został zaprojektowany w ten sposób, że każdy fragment wykonanej pracy jest zapisywany na stercie, dzięki czemu możemy w każdym momencie przerwać działanie workLoopConcurrent (na przykład, żeby obsłużyć akcję użytkownika, animację czy też wykonać repaint) oraz wrócić do kontynuowania pracy później. Jest to szczególnie istotne, aby nie blokować przeglądarki i pozwolić jej na wcześniej wspomniane przerysowywanie DOM czy też obsługę eventów użytkownika.

Ale skąd wiadomo, kiedy powinniśmy przerwać pracę?

Do tego zadania została przygotowana funkcja shouldYield, która jest częścią modułu Scheduler. Posiada ona jedną odpowiedzialność. Decyduje, kiedy powinniśmy przerwać obliczenia performUnitOfWork() i oddać wolną rękę przeglądarce (wtedy ta funkcja zwraca true), a kiedy możemy kontynuować swoje obliczenia (wtedy zwraca false).

Jak wygląda wnętrze funkcji shouldYield?

shouldYieldToHost = function() {
   return getCurrentTime() >= deadline
};

W podstawowej wersji jest to proste sprawdzenie, czy nie przekroczyliśmy limitu czasu. Czym jest ten limit czasowy? To po prostu currentTime + 5ms – a więc scheduler Reakta robi przerwę w obliczeniach co 5 ms, czyli identycznie, jak w przypadku naszej implementacji prostego schedulera. Zostało to też opisane w tym fragmencie kodu w formie komentarza:

// Scheduler periodically yields in case there is other work to the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't 
// need to be frame aligned; for those that do, use requestAnimationFrame.
let yieldInterval = 5;

No dobrze, ale gdzie jest fragment kodu wykorzystujący MessageChannel API?
enter image description here

Ten fragment kodu może wydawać się nieco zawiły, więc na początek proszę, abyście zwrócili uwagę na jego dolną część. Znajduje się tam wcześniej przedstawiony mechanizm message loop. Wewnątrz funkcji performWorkUntilDeadline znajduje się port.postMessage(null), który to podtrzymuje działanie pętli.

Mamy również fragment odpowiedzialny za ustawienie deadline (deadline = currentTime + yieldInterval;) czyli limitu czasu w obrębie którego wykonywane są obliczenia.

Czym jest scheduledHostCallback? W skrócie – ta funkcja ostatecznie wywołuje funkcję workLoopConcurrent, z którą zapoznaliście się już wcześniej, a która to jest główną pętlą odpowiedzialną za wykonywanie pracy związanej z renderingiem naszych komponentów Reaktowych. Funkcja ta jest zamodelowana w ten sposób, aby zwracała informację, czy możemy zakończyć obliczenia, czy też mamy jeszcze jakąś dodatkową pracę do wykonania w następnej iteracji message loop (flaga hasMoreWork).

Pamiętacie aplikację trójkąta Sierpińskiego w React 15.x prezentuącą problemy ze schedulingiem?

image.png

Tutaj możecie sprawdzić jak ta sama aplikacja zachowuje się w Reakcie 16.x, czyli nowym podejściu React Fiber.

Zdecydowanie lepiej, prawda?

Reaktowy moduł schedulingu

shouldYield to nie jedyna funkcja dostarczana przez moduł schedulingu do dyspozycji deweloperów Reakta.

enter image description here

Weźmy pod uwagę konwencję nazewnictwa eksporowanych funkcji. Na pierwszy rzut oka widać, że moduł jest w fazie developmentu i jego API może się zmienić… Ale to nie problem, to API to nie jest przeznaczone do użytku przez deweloperów aplikacji, takich jak Ty czy ja. Jest to moduł wykorzystywany głównie przez kod React Fiber (a więc wnętrze Reakta).

Moduł ten, między innymi eksportuje funkcję runWithPriority oraz predefiniowane stałe (UserBlockingPriority, NormalPriority, LowPriority). runWithPriority nie jest szeroko wykorzystywany w Reakcie, ale jego celem jest umożliwienie kolejkowania zadań o różnych priorytetach. W zasadzie możemy się spodziewać szerszego zastosowania tej funkcji w przyszłych wersjach.

A w czym może ten mechanizm pomóc? Na przykład przy renderowaniu ważnych dla użytkownika elementów na stronie. W aplikacji facebookowej zdecydowanie ważniejszy dla użytkownika jest zobaczenie w pierwszej kolejności news feed niż stopkę, nagłówka czy informacji o zalogowanym użytkowniku. W takim przypadku można przypisać komponentom zasilającym news feeda wyższy priorytet renderingu.

Przynajmniej taki jest cel – w kolejnych wersjach możemy się spodziewać nowych API, które dadzą możliwość pośredniego korzystania z mechanizmu priorytetów.

No dobrze, ale jak to wygląda teraz? Gdzie wykorzystywany jest moduł schedulera?

Na przykład podczas kolejkowania hook effects.

Funkcja enqueuePendingPassiveHookEffectMount kolejkuje zadania przy użyciu scheduleCallback z NormalPriority.

Żeby łatwiej zrozumieć, za co jest ona odpowiedzialna, podzielmy jej nazwę (enqueuePendingPassiveHookEffectMount) na mniejsze frazy oraz opiszmy ich znaczenie:

  • enqueuePending...: Moduł schedulera zawiera listę zakolejkowanych funkcji (oraz przypisanych do nich priorytetów), które musi ostatecznie wywołać, a więc słowo enqueue jest jak najbardziej na miejscu.
  • ...PassiveHookEffect... I tu się zaczynają ciekawe rzeczy. Czym jest passive hook effects?

Wyróżniamy dwie kategorie hook effects: passive (useEffect) oraz layout (useLayout):

const App = () => {
    useEffect(() => {
        console.log('passive effect')
    }, [])

    useLayout(() => {
        console.log('layout effect')
    }, [])
}

I teraz szybkie pytanie – który z tych effects zostanie wywołany jako pierwszy?

Oczywiście layout effect. Dlaczego? Zgodnie z dokumentacją, layout effects są wykonywane zaraz po zaaplikowaniu zmian w DOM, ale przed renderowaniem przez przeglądarkę. Passive effects z kolei, są nieco opóźnione (poprzez mechanizm schedulingu), a więc są wykonywane po renderze zmian wizualnych w przeglądarce.

  • ...Mount: użycie mount, oraz unmount hook effect są zaprezentowane niżej:
useEffect(() => {
    // this is "mount" passive hook
    return () => {
        // this is "unmount" passive hook
    }
}, [])

W zależności od stanu komponentu Reaktowego, moduł React Reconcilier (czyli implementacja React Fiber) wykonuje odpowiednio mount albo unmount (ten ostatni w przypadku usuwania komponentów z widoku).

Podsumowując – wszystkie passive effect hooks są wykonywane asynchronicznie, zaraz po renderze w przeglądarce. To zdecydowanie inne podejście niż pierwotna implementacja componentDidMount czy componentDidUpdate, które w zasadzie wykonywały się przez renderowaniem przez przeglądarkę. Warto również zwrócić uwagę na to, że w świecie hooków nie mamy alternatyw dla componentWillMount oraz componentWillUpdate. Te metody okazały się popularnym miejsce do wprowadzania side-effects przez programistów (które to znów, opóźniały proces renderowania w przeglądarce).

Czym jest isInputPending?

Ta ciekawa funkcja to efekt prac zespołu Facebooka w celu zmniejszenia czasu obsługi eventów użykownika (np. kliknięcia myszy, naciśnięcia przycisków).

Przyjrzyjmy się przykładowi:

while (workQueue.length > 0) {
    if (navigator.scheduling.isInputPending()) {
        // Stop doing work if we have to handle an input event.
        break;
    }

    let job = workQueue.shift();
    job.execute();
}

Za pośrednictwem navigator.scheduling.isInputPending() możemy dowiedzieć się, czy istnieje jakiś nieobsłużony event użytkownika. Jeśli tak, to możemy przerwać działanie, a tym samym „oddać pałeczkę” przeglądarce (poprzez break w przykładzie wyżej) co przyspieszy proces obsługi eventów.

Oczywiście, to tylko eksperyment, a nie oficjalny standard. W zasadzie z tej funkcji mogliśmy korzystać w przeglądarce Google Chrome między wersjami 74 i 78 (aż do 4 grudnia 2019) w ramach programu Chrome Origin Trials.

Zachęcam do przeczytania więcej na Gihub oraz na blogu technicznym Facebooka.

Ale dlaczego o tym piszę? W ramach ciekawostki, aktualna implementacja modułu schedulera wykorzystuje właśnie ten mechanizm:

enter image description here

Po spełnieniu kilku warunków:

  • nasza aplikacja ma włączoną flagę enableIsInputPending
  • używamy Google Chrome z włączonym eksperymentem navigator.scheduling.isInputPending

nasz moduł schedulera wykorzysta ten mechanizm w implementacji funkcji shouldYield, która to przerwie „pracę” w momencie, gdy pojawi się eventy użytkownika do przetworzenia – co pozwoli na szybszą ich obsługę – oraz powróci do wcześniej zaczętej pracy. Wszystko po to, aby skrócić czas obsługi eventów w przeglądarce.

Trzeba przyznać, że cel szczytny!

Trochę historii…

Głównym problemem Reakta w wersji 15.x było to, że cały proces renderingu był synchroniczny. Jeden, całkiem spory, czasochłonny, synchroniczny i co gorsza rekurencyjny fragment kodu. Trudno sobie wyobrazić coś trudniejszego do zoptymalizowania. To nie może dobrze wpływać na renderowanie w przeglądarce…

Ale te czasy minęły, miejmy nadzieję bezpowrotnie. React 16.x rządzi się nieco innymi prawami. Opisany wyżej mechanizm oparty o message loop oraz 5-milisekundowe okna obliczeń to aktualna wersja, ale nie była ona z nami od początku…

We wczesnej implantacji React Fiber (16.x) zespół Reakta korzystał z requestIdleCallback, ale, jak to stwierdził Dan, nie było to wystarczająco “agresywne” rozwiązanie.

image.png

Kolejnym krokiem była próba symulacji tego co powinien robić wydajnie requestIdleCallback z wykorzystaniem requestAnimationFrame. I to rozwiązanie działało przez dłuższą chwilę, dopóki Andrew Clark nie pozbył się tych zmian na rzecz obecnej implementacji, która wydaje się prostszą, przewidywalną i bardziej oczywistą niż próba synchronizacji renderingu Reakta z renderingiem przeglądarki przy wykorzystaniu requestAnimationFrame.

Podsumowanie

Jak przedstawiłem wyżej, mamy dostępnych kilka API, które są pomocne przy próbie rozwiązywania problemów z kolejkowaniem. Mowa o requestAnimationFrame, requestIdleCallback, message loop (MessageChannel), czy chociażby setTimeout.

Ale chwila! Czyż te API nie zostały dodane do specyfikacji celem rozwiązywania innych problemów?

Bez wątpienia problem kolejkowania jest znany i dyskutowany wśród decydentów. Kilka koncepcji zostało nawet ogłoszonych szerzej jakiś czas temu. Jeśli jesteś zainteresowany tym tematem zachęcam do przejrzenia repozytorium WICG/main-thread-scheduling, a w szczególności sekcji „Further Reading„.