Asynchroniczność w JavaScript cz. 4: Event Loop

Cover Image for Asynchroniczność w JavaScript cz. 4: Event Loop
Paweł
Paweł

Event Loop

JavaScript jest jednowątkowym synchronicznym językiem programowania. Zgodnie z tym co było opisane w poprzedniej części, oznacza to, że nie pozwala na wykonywanie kilku operacji jednocześnie. Najpierw musi zakończyć się jedno zadanie, dopiero potem możemy przystąpić do następnego. JavaScript z pomocą API przeglądarki (lub innego środowiska uruchomieniowego) potrafi zasymulować wielowątkowość. Co to oznacza? Tego dowiecie się, czytając ten artykuł.

Kolejność wykonania

Wyobraźmy sobie sytuację, że mamy trzy funkcje i ich wywołania:

const firstExample = () => (console.log("first")); const secondExample = () => (console.log("second")); const thirdExample = () => (console.log("third")); firstExample(); secondExample(); thirdExample();

Myślę, że tutaj nie będziesz miał czytelniku większych wątpliwości, co dostaniesz na ekran (chyba że jesteś mocno podejrzliwy :) ).

Wynikiem będzie oczywiście:

"first" "second" "third"

Ok, to było łatwe. Wrzućmy coś bardziej skomplikowanego.

Co według Ciebie wypisze kod poniżej ?

const firstExample = () => (console.log("first")); const secondExample = () => { setTimeout(() => console.log("second"), 0) }; const thirdExample = () => (console.log("third")); firstExample(); secondExample(); thirdExample();

Jeżeli odpowiedziałeś:

"first" "third" "second"

Brawo!

Ok, niektórzy mogą teraz zadać pytanie, dlaczego?

Przecież timeout był ustawiony na 0 !

No i tutaj zaczyna się magia…

Event Loop

We wstępie wspomniałem, że JS jest jednowątkowy, a asynchroniczne działanie musimy “zasymulować”. Żeby to zrobić, JavaScript wykorzystuje takie mechanizmy jak:

  • Heap (sterta) -> miejsce, gdzie trafiają wszystkie obiekty
  • Call Stack (stos) -> miejsce, gdzie trafiają wszystkie wywołania funkcji i wartości prymitywne. Jest to struktura LIFO (last in-first out), czyli element, który trafił do niej jako ostatni, opuści go jako pierwszy
  • Queue (kolejka) -> na kolejkę lądują wszystkie wywołania funkcji, które trafiają jako callbacki do asynchronicznych metod udostępnionych przez Web API. W przeciwieństwo do stosu kolejka jest strukturą FIFO (first in-first out), czyli element, który trafił do niej jako pierwszy, opuści ją również jako pierwszy.
  • Browser/Web API -> API przeglądarki, które udostępnia różnego rodzaju asynchroniczne funkcje jak na przykład użyty wyżej setTimeout czy setInterval

Popatrzmy jeszcze raz na przykład powyżej i na jego podstawie zobaczmy, jak to jest ze sobą połączone:

  1. Wołana jest funkcja firstExample, jej wywołanie trafia na stos. W środku znajduje się console.log. On również trafia na stos. Spójrzmy na obrazek, jak obecnie on wygląda:
Process 1

Następnie zgodnie z zasadą LIFO, ściągamy console.log i wyświetlamy napis "first" , a następnie ze stosu wylatuje firstExample.

  1. Wołamy funkcje secondExample, analogicznie jak w poprzednim przykładzie, wywołanie trafia na stos. W środku tej funkcji mamy wywołanie metody z WebApi, czyli setTimeout. Jako callback został przekazany tam () => console.log("second") , ponownie zobaczymy, jak to wygląda na wizualizacji:
Process 2

Jak widać na stosie mamy wywołanie secondExample oraz setTimeout. Wraz z wywołaniem setTimeout odpalany jest timer z Web API, a na Queue trafia callback, który w tym przypadku jest console logiem. Callback zostanie ściągnięty dopiero wtedy kiedy stos będzie całkowicie pusty. Jeżeli chodzi o stos, to ściągane są z niego setTimeout i secondExample.

  1. Lecąc dalej, wołana jest funkcja thirdExample, co przekłada się na następujący stan:
Process 3

Analogicznie jak dla firstExample ściągamy console.log i wyświetlamy napis "third" , a następnie ze stosu wylatuje thirdExample.

  1. Podczas procesu wyżej cały czas działał wspomniany event loop. Czym on jest? Jest to mechanizm działający jak zwykła pętla. Sprawdza przez cały czas, czy stos wywołań jest pusty, jeśli tak to ściąga pierwsze zadanie z kolejki. Gdy ten stan zostaje osiągnięty, wtedy sięgamy po callback, który zostaje przerzucony na stos wywołań. Jako wynik dostajemy "second".

Dla podsumowania jeszcze raz spójrzmy, jak wygląda końcowy rezultat:

"first" "third" "second"

Teraz już wiesz, skąd on wynika :)

Mikro i makrotaski

Żeby nie było za łatwo. Kolejność działań komplikuje się trochę bardziej, kiedy do akcji wjeżdżają promise’y. Wraz z callbackami dodają one do mechanizmu event loopa takie pojęcie jak mikrotaski/mikrozadania.

Mikrotaski to nic innego jak zadania, które powinny być wykonane natychmiast po zakończeniu bieżącej operacji lub funkcji. Najczęściej mikrotaski to właśnie callbacki lub obietnice, które zostały dodane do kolejki mikrotasków. Przykłady mikrotasków to np. Promise.then(), Promise.catch(), Promise.finally(). Są one również powiązane z await, tak jak już wiemy, jest to kolejna forma obsługi Promise API.

Z kolei macrotaski to również zadania, które są dodawane do kolejki zadań. Jednak ich wykonanie następuje po zakończeniu bieżącej operacji lub funkcji i dopiero po wykonaniu wszystkich mikrotasków.

Przykładami makrotasków są m.in funkcje setTimeout(), setInterval(), a także obsługa zdarzeń (event handlers), takich jak kliknięcie myszą, naciśnięcie klawisza, itp.

Natychmiast po każdym zakończonym mikrotasku silnik JS wykonuje kolejne zadania z kolejki mikrotasków. Dopiero po zakończeniu wszystkich przechodzi w następnej kolejności do makrozadań.

Jak zwykle najlepiej tłumaczyć na przykładzie:

setTimeout(() => console.log("Timeout")); console.log("Code Line"); Promise.resolve() .then(() => console.log("Promise"));

Pomyśl chwilę, jak według Ciebie zadziała kod powyżej?

Już?

Ok, więc prawidłową kolejnością jaką powinieneś zobaczyć na ekranie, powinno być:

Code Line Promise Timeout

Dlaczego?

Code Line zostało wyświetlone na początku, ponieważ jest to zwykłe wywołanie synchroniczne.

Następnie mamy Promise, ponieważ then przechodzi przez kolejkę mikrozadań i zadziała po wykonaniu synchronicznego kodu.

Ostatni jest Timeout, ponieważ tak jak było wspomniane wyżej, jest to jeden z rodzajów makrotasków, więc wykonuje sie jako ostatni.

Cały algorytm działania event loop można streścić następująco:

  • Usuń z kolejki i odpal najstarsze makrotaski
  • Wykonuj wszystkie mikrotaski, dopóki ich kolejka nie będzie pusta
  • Renderuj zmiany, jeśli takie istnieją.
  • Jeśli kolejka makrozadań jest pusta, poczekaj, aż pojawi się makrozadanie.
  • Przejdź do kroku 1.

Czy teraz wygląda to jaśniej? Jeżeli nie, śmiało pisz, pomożemy 🙂

Zakończenie

Ok, kolejny artykuł z naszego cyklu o asynchroniczności w JavaScript za nami. Nie jest to może wiedza, która jest niezbędna na początku Twojej drogi na frontendzie. Na pewno warto znać ten mechanizm działania, pomoże Ci to w lepszym zrozumieniu, jak działa Twój kod i pozwoli uniknąć błędów. Jeżeli już jakiś czas programujesz aktywnie, to myślę, że jest to wiedza, która potencjalnie może dość często pojawiać się na rozmowach, dlatego powinieneś zwrócić uwagę na to zagadnienie.

Jeżeli na pierwszy rzut oka coś było niejasne, nie przejmuj się! Spróbuj przeczytać powyższy opis jeszcze raz lub napisz do Nas i daj znać który fragment jest dla Ciebie kłopotliwy.

Podobne Tematy

Przydatne linki