Javascript Workers - wprowadzenie do frontowej wielowątkowości

AMD zapowiada kolejne 16 wątkowe procesory dla szarego ludu, Firefox wydał wersję Quantum, gdzie skupia się na rozkładaniu obliczeń na wiele rdzeni, a NodeJS w końcu na poważnie zajął się workerami. Chciałoby się powiedzieć: nareszcie! W Javascriptcie też nie brakuje wielowątkowości i o tym będzie ten post :)

Problem: Kolorowanie składni na stronie internetowej

Genezą problemu jest wybór kolorowania składni kodu na tym blogu. Wybrałam popularną bibliotekę, motyw kolorystyczny i przyszedł czas, żeby zaimplementować to w szablonie. Hightlight.js jest bardzo popularnym rozwiązaniem, które oferujemy multum możliwości, a przede wszystkich wybór języków.

Tak więc będąc optymalizacyjnym freakiem krzyknęłam, gdy zobaczyłam instrukcję implementacji:

<link rel="stylesheet" href="/path/to/styles/default.css">
<script src="/path/to/highlight.pack.js"></script>
<script>hljs.initHighlightingOnLoad();</script>

Trudno zostawić coś takiego we własnym kodzie. Jednak scrollowanie pomogło, twórcy zaproponowali użycie Workerów :) Zapis oraz użyteczność rozwiązania pozostawiają wiele do życzenia, więc użyjemy tylko koncepcji. No to jedziemy!

Nie mam pojęcia o czym mówisz - sekcja dla świeżaków

Jeśli miałeś kiedykolwiek do czynienia z programowaniem współbieżnym, możesz spokojnie ominąć tę sekcję. Żeby nie wykluczać nikogo, zaraz w ogromnym skrócie wyjaśnię o co chodzi. Jeśli miałeś na studiach programowanie równoległe, twoja wiedza jest już większa niż to co opiszę w tej sekcji :)

Co to jest wątek (w programowaniu) ?

Kiedy napiszemy prostą funkcję, wykona się od początku do końca (sekwencyjnie).

Ta liniowość, synchroniczność jest podstawą programowania, a wątek to właśnie ta część programu, którą zadeklarowaliśmy jako sekwencyjną. W przypadku stron internetowych jest to zwykle... prawie wszystko. Niestety.

Dlaczego niestety? Bo niektóre obliczenia można by wykonywać równolegle :) Prostym językiem: Jeśli mamy tragarza, który niesie kosz z owocami 2 minuty to 2 kosze będzie niósł 4 minuty. Jeśli nie chcemy czekać to zatrudniamy 2 tragarzy, którzy wykonają tę samą pracę w 2 minuty :) W programowaniu nie jest to tak piękne i proste, ale warto korzystać z więcej niż jednego wątku.

Minimum potrzebnej wiedzy

Wyobraź sobie pracę na budowie (chociaż może być to bardzo śliska analogia, bo na budownictwie się nie znam). Mamy robotnika A, który remontuje mieszkanie. Mieszkanie jest duże, roboty sporo, więc wzywa pomocnika B (który jest uczniem i podwładnym).

Co wiemy o tym remoncie:

  • istnieje tylko jeden szef budowy, który ma pełną wiedzę o projekcie i celach - robotnik A
  • pewne części remontu są od siebie zależne, a pewne nie. Na przykład: najpierw musisz położyć gładź, potem pomalować ścianę. Nie istnieje jednak zagrożenia dla jakości remontu w zamianie kolejności skręcania stołu i malowania ścian.
  • pomocnik B dla bezpieczeństwa remontu nie jest proaktywny. Wykonuje tylko to co robotnik A mu powie, kończy i czeka na dalsze instrukcje.

Teraz przełożymy to na wielowątkowość :)

Robotnik A jest wątkiem głównym. Gdy uruchamiamy program.exe to on odpala się jako pierwszy. Na stronie internetowej wątek główny to ten (w gigantycznym uproszczeniu), który inicjalizuje pobranie zasobów, analizę kod, interpretuje, renderuje stronę.

Pomocnik B jest wątkiem roboczym. Uruchamia się na polecenie robotnika A, na jego życzenie może wykonywać zadania oraz zakończyć swoją pracę w każdej chwili. Komunikują się poprzez wiadomości/wydarzenia. Wątek roboczy nie ma wpływu na wątek główny, może jedynie wysyłać wiadomość, a wątek główny nawet nie musi jej odebrać, gdy tego sobie nie zażyczymy.

To powinno wystarczyć na razie. Jeśli chcesz pogłębić wiedzę na ten temat szukaj haseł: programowanie równoległe, programowanie współbieżne, semafor, monitor.

Frontowa wielowątkowość

Powróćmy to świata frontu :) Najważniejszą rzeczą, którą musicie wiedzieć to jest to, że wątek główny sekwencyjnie inicjalizuje kolejne etapy prowadzące do renderingu strony.  

Podświadomie czujemy, że niekoniecznie wszystkie elementy są zależne od siebie, a część z nich można by oddelegować na później albo do równoległego wątku, żeby nie hamować wywołania najważniejszej funkcji: renderowania strony, po zakończeniu której użytkownik jest w stanie wykonać akcje.

Co to jest Worker?

Worker - a dokładnie Web Worker - to interfejs umożliwiający uruchamianie skryptów Javascript w subwątkach (wątkach roboczych). Jego konstruktor przyjmuje url skryptu, a zwraca obiekt z którym można się komunikować w wątku głównym.

Spójrzmy na prostą implementację kolorowania składni, mamy trzy pliki:

  • onloadInit.js - jest normalnym plikiem, który wstawimy do <head>
  • codeHighlight.js - worker, który będzie kolorował kod w subwątku
  • highlight.pack.js - biblioteka, którą wczytamy i wywołamy w workerze

Zacznijmy od podstawowej wersji w której mamy jeden kontener z kodem.

//onloadInit.js
window.onload = () => {
    //wybieramy pierwszy kontener z kodem, który się nawinie, dla uproszczenia
    const codeElements = document.querySelector('.post-full-content pre code');
    //jeśli znajdziemy element code
    if (codeElements) {
        //deklarujemy nowy obiekt Worker
        const worker = new Worker('/assets/js/codeHighlight.js');
        //dodajemy do workera nasłuchiwanie wiadomości z workera
        worker.addEventListener('message', (event) => {
            //gdy worker skończy pracę, odbieramy dane od niego i przypisujemy do kontenera
            codeElements.innerHTML = event.data;
        });
        //wysyła kodu do subwątku
        worker.postMessage(codeElements.textContent);
    }
}

Co tu się dzieje? Najpierw szukamy elementów z kodem we wpisie, jeśli jakiś znajdziemy, to tworzymy Worker. W następnej linijce dodajemy nasłuchiwanie na wiadomości z subwątku, a w kolejnej wysyłamy element do workera (dokładnie: wysyłamy wiadomość z elementem).

Praktycznie - tak podpowiada nam intuicja - najpierw rozpoczniemy pracę wątku dając mu tekst z kodem do pokolorowania, a potem dopiero dostaniemy wiadomość. Rejestrując listener jako pierwszy chcemy mieć pewność, że wątek roboczy nie skończy się jako pierwszy i nie stracimy wiadomości :)

Ale w sumie i tak wygląda na przerost formy nad treścią.Dlaczego tak? Wątki robocze nie mają dostępu do DOM. Normalnie, jeśli zmodyfikujesz obiekt codeElements, zmiana się pojawi również na stronie. Metoda postMessage wysyła kopię tych danych.

Podsumowując: bierzemy element <code> i wysyłamy jego zawartość do subwątku, a on zwraca nam pokolorowany tekst.

Teraz spójrzmy na Worker:

//codeHighlight.js
onmessage = function(event) {
    //załadowanie skryptu  do tego namespace'a
    importScripts('/assets/js/highlight.pack.js');	
    //wykorzystanie funkcji z wyżej wczytanje biblioteki
    var result = self.hljs.highlightAuto(event.data);
    //wysłanie wiadomości o zakończeniu przetwarzania HTMLa
    postMessage(result.value);
}

Kolorowanie składni jest typowym przykładem w którym zanim nie dostaniemy zawartości elementu <code>, to nie mamy nic do roboty, dlatego wszystko co musimy zrobić to zadeklarować funkcję onmessage.

Przyjmuje ona zmienną event, w której event.data to będzie codeElements.textContent, który wysłaliśmy w postMessage.

Najbardziej zaskakujący jest tutaj element self. (Na hljs nie zwracajcie uwagi, to namespace z biblioteki wczytanej w importScripts, która nadaje klasy). Omawianie WorkerGlobalScope zajęłoby mi mnóstwo czasu, jedyne co musicie wiedzieć, że chociaż w wątkach nie mamy dostępu do DOM, to istnieje zakres zmiennych związanych z wątkiem globalnym, który nie musicie przekazywać. Tworzą się one podczas uruchamiania wątku.

Do nich należy self - dosłownie jest to zmienna w której worker przechowuje samego siebie.

Finalna wersja

Podwyższe rozwiązanie obsługuje tylko jedno pole <code>, więc zmienimy jedynie inicjalizację workerów. Liczba mnoga bierze się z faktu, że każde pole z kodem będzie kolorowało się równolegle :)

//nowy onloadInit.js obsługując wiele bloków z kodem
window.onload = () => {
    //wyszukujemy WSZYSTKIE elementy z kodem we wpiście
    const codeElements = document.querySelectorAll('.post-full-content pre code');
    //dla każdego uruchamiamy oddzielny Worker
    Array.from(codeElements).forEach((element) => {
            const worker = new Worker('/assets/js/codeHighlight.js');
            worker.addEventListener('message', (event) => {
                element.innerHTML = event.data;
            });
            worker.postMessage(element.textContent);
    });
}

Prawdę mówiąc to nie jest tak, że jeśli mamy kilkanaście workerów to wszystkie będą działać jednocześnie. Proces nie uruchomi tylu wątków. Ale będzie się to wykonywać w miarę możliwości sprzętu oraz implementacji przeglądarki, a przede wszystkim nie będziemy blokować strony :)

Podsumowanie

Workery to temat rzeka. W tym, i tak długim, poście nie omówiliśmy nawet wszystkich metod (nasłuchiwanie na błędach to podstawa), nie mówiąc o typach Workerów, dostępnych interfejsach czy samym obiekcie. Temat na pewno nie jest zamknięty!

Rozwiązaliśmy jednak problem w całkiem przywoity sposób :) Cel został osiągnięty; wydaje mi się, że mam świadomość zagrożeń wynikających z tego rozwiązania: nie posiada jeszcze fallbacku dla IE11, które nie obsługuje Workerów :) Jeszcze tylko martwi mnie wczytywanie tych skryptów w każdym wątku, pomimo iż przeglądarka po pierwszym razie cachuje ten plik. To pozostaje do dyskusji :)

Mam nadzieję, że się podobało! Zapraszam do zgłaszania uwag i pytań w komentarzach, a ja zajmę się deployem :) Całość kodu można zobaczyć w źródle tego bloga oraz na githubie :)

Kamila

Dziękuję za poświęcony czas, będzie mi bardzo miło jak zostawisz komentarz :)