JavaScript jest językiem dość mocno polaryzującym środowisko deweloperskie. Powiedzieć, że pewne mechanizmy w nim zawarte są specyficzne, to jakby nie powiedzieć nic.
I tak też jest ze sposobem, w jaki przetwarza kod. W tym przypadku mowa o oryginalnym podejściu do łączenia świata synchronicznego z asynchronicznym. Ale nie dajmy się zwieść: JavaScript jest językiem stricte jednowątkowym, a złudzenie wielowątkowości uzyskujemy dzięki bohaterowi dzisiejszego artykułu, czyli mechanizmowi Event Loop.
Przeglądarka kontra JavaScript
Współczesne strony internetowe są dość złożone, przez co, aby zachować płynność ich renderowania, przeglądarka musi się napocić – formatuje style, wysyła/odbiera zapytania, ładuje posty, pobiera i wyświetla obrazki. Głównym elementem przeglądarki jest jej silnik JavaScript:
- w Chromie i Node.js znajdziemy V8,
- FireFox posiada SpiderMonkey,
- Edge ma Chakrę,
- Safari implementuje JavaScriptCore.
Przeglądarki dysponują również swoim własnym API (z implementacjami poszczególnych, witalnych funkcji jak np. setTimeout czy alert).
Zasada działania mechanizmu Event Loop
Każdy z wymienionych wcześniej silników implementuje Event Loop w odrobinę inny sposób. Zacznijmy od zapoznania się z bazową implementacją i ogólną zasadą działania:
Aby zrozumieć Event Loop, musimy zrozumieć koncepcję stosu wywołań (Call Stack) oraz kolejki zdarzeń (Event Queue).
Stos wywołań (Call Stack)
Stos wywołań jest strukturą danych, która przechowuje kontekst wywołań funkcji. Spełnia on zasadę LIFO (ang. Last In, First Out). Kiedy funkcja jest wywoływana, jej kontekst jest umieszczany na szczycie stosu. Następnie program przechodzi do wykonywania kodu wewnątrz wywołanej funkcji. Jeśli w tej funkcji występuje inne wywołanie funkcji, jej dane kontekstowe są umieszczane na szczycie stosu, a program przechodzi do wykonania kodu w nowo wywołanej funkcji.
Kiedy funkcja kończy swoje wykonanie, jej dane kontekstowe są usuwane ze stosu wywołań, a program wraca do miejsca, w którym nastąpiło wywołanie funkcji i tak aż do opróżnienia stosu.
Kolejka zdarzeń (Callback / Event Queue)
Kolejka zdarzeń przechowuje zdarzenia i zadania do wykonania. To tu umieszczane są wywołania wskazane jako funkcje zwrotne (ang. callback) do asynchronicznych metod z Web API. Elementy z kolejki są pobierane na stos dopiero, gdy jest on pusty, czyli wywołana aktualnie funkcja zostanie zakończona. Powyższym strukturom ciężko by się żyło, gdyby nie Web APIs.
Web APIs
Dzięki tym dobrodziejstwom zapewnianym przez przeglądarkę otrzymujemy potężne możliwości. Mowa tutaj o DOM i jego zdarzeniach, o AJAX, XMLHttpRequest, Fetch API, o timerach (setTimeout, setInterval) czy requestAnimationFrame, służącym chociażby do przeliczenia pozycji animowanego obiektu.
Przeglądarka przejmuje pieczę nad oczekiwaniem na zakończenie tych asynchronicznych operacji i dopiero wtedy wypycha je do Call Stacka, a w międzyczasie mogą wykonywać się inne fragmenty kodu, zapewniając płynność całości.
Działanie Event Loop
Głównym zadaniem Event Loop jest monitorowanie stosu wywołań i kolejki zdarzeń. Jeśli stos wywołań jest pusty, a kolejka zdarzeń zawiera jakieś zdarzenia, Event Loop przenosi zdarzenie z kolejki na stos i wykonuje je. To oznacza, że JavaScript może obsługiwać wiele zdarzeń asynchronicznie, bez blokowania wykonywania innych zadań.
Jego pracę można podsumować w 3 krokach:
- Pobranie zdarzenia – Event Loop oczekuje na wystąpienie zdarzenia takiego jak kliknięcie myszy, przesłanie żądania sieciowego czy zakończenie operacji asynchronicznej.
- Wywołanie odpowiednich funkcji – po wystąpieniu zdarzenia Event Loop wywołuje odpowiednie funkcje zwrotne (callback) lub operacje asynchroniczne powiązane z tym zdarzeniem. Na przykład, jeśli użytkownik kliknie przycisk na stronie internetowej, Event Loop może wywołać funkcję zwrotną, która obsługuje ten event.
- Powrót do oczekiwania – po wykonaniu funkcji zwrotnej, Event Loop powraca do oczekiwania na kolejne zdarzenie. Proces ten kontynuuje się w nieskończoność, zapewniając ciągłą obsługę zdarzeń i asynchroniczność.
Event Loop w JavaScript opiera się na jednym wątku wykonawczym, co oznacza, że wszystkie operacje są wykonywane jednowątkowo. Z tego powodu ważne jest, aby unikać blokowania operacji, które mogą spowodować zatrzymanie Event Loop i tym samym wpływać na responsywność aplikacji.
Kiedy zdarzenie jest przetwarzane przez Event Loop, może mieć różne skutki, przykładowo:
- jeśli zdarzenie jest kliknięciem przycisku na stronie internetowej, Event Loop może wywołać funkcję obsługującą to zdarzenie, tak, aby reagować na kliknięcie użytkownika,
- jeśli zdarzenie jest żądaniem sieciowym, Event Loop może przetworzyć odpowiedź serwera i wywołać funkcję zwrotną, która będzie obsługiwać tę odpowiedź.
Praktyczna strona działania Event Loop
Przykład działania w praktyce możecie zobaczyć poniżej:
Na pierwszy rzut oka wszystkie logi wylistują się w kolejności od pierwszego do trzeciego, wszak ustawiliśmy timeout na 0. Jednakże, zgodnie z tym, co wcześniej napisałem, timer został oddelegowany do Web APIs, które po upływie ‘0’, czyli de facto od razu, umieściło go na Callback Queue (kolejce zdarzeń). Aczkolwiek, aby ponownie z niej trafić na Call Stack, ten Stack musi zostać wyczyszczony z bieżących zadań.
Jak zatem widzimy, bardzo ważne jest zrozumienie działania Event Loop i korzystanie z niego we właściwy sposób, aby uniknąć problemów z wydajnością. Przy projektowaniu aplikacji JavaScript należy rozważyć wykorzystanie funkcji asynchronicznych, obietnic (promises) i async/await, które pozwalają na bardziej czytelne i wydajne zarządzanie asynchronicznym kodem.
Implementacja mechanizmu Event Loop w silniku V8
Jak już wspomniałem, każdy silnik przeglądarki implementuje Event Loop w nieco innych sposób, co może prowadzić do pewnych różnic w działaniu. Zapoznajmy się z jego implementacją w silniku V8 (Chrome, Node.js):
Możemy zauważyć, że schemat implementacyjny jest bardzo podobny do ogólnego schematu implementacji Event Loop, z tym, że w Callback Queue rozpada się na trzy podkolejki definiujące swoje własne odpowiedzialności. Kolejno są to:
- Microtask Queue – realizuje operacje odpowiedzialne za Promises, zatem mówimy tutaj o wszelkich metodach resolve / reject. Z tej kolejki korzysta także setImmediate w Node.js (gdy chcemy wykonać fragment kodu asynchronicznie, ale tak szybko, jak to jest możliwe) oraz MutationObserver (stosowany w celu obserwacji zmian struktury HTML danych elementów).
- Render Queue – odpowiada za zebranie wszystkich zadań, które muszą się wykonać przed następnym renderowania się strony. Do tej kolejki trafi chociażby wspomniana wcześniej funkcja requestAnimationFrame.
- Task Queue – ostatnia kolejka, jednocześnie zachowująca się odrobinę odmiennie od poprzednich dwóch. Odpowiedzialna jest za zebranie wszystkich callbacków do WebAPI (np. setTimeout). Odmienność zachowania tej kolejki polega na tym, iż proceduje tylko jeden element, po którym następuje wywołanie Event Loop i przenosi nas na Call Stack. Zatem, nawet jeżeli jakieś zadania pozostały w Task Queue, może ona wykonać tylko jedną operację ‘per loop’. Poprzednie dwie kolejki wykonują swoje zadania w pętli, aż do opróżnienia swojej kolejki.
Podsumowanie
Cieszę się, że doczytaliście artykuł do końca! Jak widzicie, Event Loop jest niebywale istotnym elementem każdego przeglądarkowego silnika.
Jest on zaprojektowany w taki sposób, aby maksymalnie wykorzystywać zasoby dostępne w środowisku. Dzięki temu, aplikacje mogą działać wydajnie i skutecznie zarządzać zasobami. Znajomość jego specyfiki pozwala wszystkim programistom tworzącym w środowisku JavaScript pisać bardziej wydajne, responsywne i skalowalne aplikacje asynchroniczne.
***
Jeśli interesuje Cię tematyka stosu i RAM-u, polecamy artykuł naszego eksperta.
Chakra była w starszych wersjach Edge, a odkąd Microsoft przeszedł na Chromium to w przypadku Edge mamy również silnik JavaScript V8