Wróć

Event Loop po ludzku, po raz milionowy

Event Loop to niezła zagwozdka, nawet dla starych wyjadaczy JavaScriptu. W tym wpisie postaram się – jak wielu przede mną – wyjaśnić ten arcyważny mechanizm ludzkim językiem i raz na zawsze rozprawić się z mitem, że jest to coś trudnego. Do dzieła!

Event Loop - prosta definicja

Event Loop w JavaScript to fundamentalny mechanizm, bez którego nie mógłby efektywnie wykonywać kodu przy zachowaniu jednowątkowości. Krótko mówiąc - to on pozwala na to, aby fragmenty naszego kodu wykonywały się w sposób asynchroniczny.

To właśnie on pozwala językowi JavaScript reagować na zdarzenia, oddelegowywać zadania w czasie oraz obsługiwać operacje sieciowe i wczytywać pliki "w tle", sprawiając, że wiele operacji odbywa się bez zawieszania aplikacji czy interfejsu na stronach internetowych.

Ale jak to w końcu działa?

Aby zrozumieć, jak działa event loop w JS, musimy zdawać sobie sprawę z istnienia kilku istotnych w programowaniu struktur danych, z których on korzysta.

Stos

Pierwszy z nich to Stos (ang. stack), w którym możemy zapisywać informacje na zasadzie LIFO (last-in, first-out) - działa on podobnie, jak stos talerzy: ostatni, który położymy na górze stosu, będzie pierwszym, który potem wyjmiemy.

I tak samo informacje, które zapisujemy w Stosie jako ostatnie, muszą być z założenia pierwszymi, które wyciągniemy z niego później. Ze Stosu nie da się wyjmować danych w inny sposób, niż tylko przez ostatni element, i jest on dokładnie tak zaimplementowany.

Kolejka

Druga z istotnych struktur danych w Event Loopie to Kolejka (ang. queue), która w pewnym sensie działa odwrotnie do Stosu - mianowicie, możemy go porównać do prawdziwej kolejki w sklepie. Pierwszy klient, który wszedł do sklepu i znajduje się na początku kolejki, jest tym, który pierwszy ją opuści - na zasadzie FIFO (first-in, first-out).

Podobnie jak w przypadku Stosu, kolejka jest zaprojektowana w taki sposób, aby uniemożliwiać wyjmowanie z niej danych w inny sposób, niż tylko z jej początku.

Jak to się ma do Event Loopa

To proste - Event Loop to mechanizm, bardzo konkretnie i skutecznie wykorzystujący te dwa typy danych w praktyce. Spójrzmy po kolei:

Przykład pierwszy - uproszczony

W tym przykładzie po prostu wywołamy zadeklarowaną funkcję i przyjrzymy się, jaką "drogę" pokonuje aż do wykonania się w przeglądarce.

Krok pierwszy: JavaScript natrafia na wywołanie funkcji w kodzie źródłowym, który interpretuje.

function log(text) {
    console.log(`Log: ${text}`);
}

log('Witaj!');

Krok drugi: JavaScript przesuwa wywołaną funkcję do stosu wywołań:

Stos wywołań:

  • > log('Witaj!')

Krok trzeci: Funkcja, jako jedyna, a jednocześnie ostatnia dodana do stosu, znajduje się na jego szczycie - zostaje z niego zdjęta jako pierwsza (zasada LIFO) i wykonana w przeglądarce.

  • > Log: Witaj!

Przykład drugi - funkcje zagnieżdżone

Funkcje wywoływane wewnątrz innych funkcji, trafiają na stos odwrotnie do kolejności występowania.

function first() {
    second();
}

function second() {
    third();
}

function third() {
    return 'Hello!';
}

first();

W tym przykładzie, stos wywołań będzie wyglądał następująco:

  • third()

  • second()

  • first()

Funkcje zostaną wykonane w kolejności od góry do dołu - zgodnie z zasadą LIFO i działaniem stosu, zdejmujemy je po kolei, ze szczytu.

Co z kolejką?

W przypadku operacji asynchronicznych, takich jak np. setTimeout, czy obsługa zdarzeń, pojawia się dodatkowy element: kolejka zadań. Kolejka, jako struktura danych, została opisana w sekcji powyżej.

Operacje asynchroniczne nie trafiają bezpośrednio na stos wywołań - najpierw są umieszczane w kolejce, gdzie czekają na wykonanie.

console.log('Log pierwszy');

setTimeout(() => console.log('Log ostatni'), 0);

console.log('Log drugi');

Przebieg:

  1. console.log('Log pierwszy'); trafia na stos wywołań jako operacja synchroniczna.

  2. Funkcja setTimeout jest wywoływana synchronicznie, ale jej zawartość (callback) zostaje przesunięta do kolejki zadań.

  3. console.log('Log drugi'); zostaje wywołana synchronicznie w stosie wywołań.

  4. Silnik przeglądarki, lub dowolne inne środowisko wykonywania JavaScript, np. Node.js, przejmuje pieczę nad callbackiem w setTimeout i w odpowiednim czasie (w tym przypadku: zero milisekund po wykonaniu reszty kodu synchronicznego) przekazuje do kolejki zadań, którą następnie sprawdza Event Loop.

  5. Gdy stos jest pusty, Event Loop sprawdza kolejkę zadań i przesuwa callback z setTimeout na szczyt stosu wywołań, gdzie zostaje wykonany. Nie jest to jednak dokładnie "zero milisekund później" - stos wywołań może zostać opróżniony kilka milisekund później, więc czas wykonania się kodu asynchronicznego nigdy nie jest dokładnie znany.

Mikrotaski i ich własna kolejka

W "standardowej" kolejce zadań umieszczane są te operacje asynchroniczne, które są wywoływane przez callbacki. Jednak w świecie JavaScript mamy też inne ważne mechanizmy do wywoływania asynchronicznych operacji - na przykład Promise. Kod asynchroniczny zarezerwowany dla nich ma swoją własną kolejkę zadań, zwaną "kolejką mikrozadań" (ang. microtask queue).

Ma ona pierwszeństwo nad standardową kolejką zadań, co oznacza, że callbacki zawsze wykonują się dopiero wtedy, kiedy kolejka mikrozadań zostaje opróżniona - w skrócie, pierwszeństwo ma zawsze kod synchroniczny, potem asynchroniczny wywołany z użyciem Promise, a dopiero na końcu standardowe callbacki.

Na zakończenie

Event Loop umożliwia JavaScript efektywną obsługę operacji asynchronicznych w jednowątkowym środowisku - dzięki stosowi wywołań i kolejce zadań, może zarządzać wykonywaniem funkcji oraz operacji w tle, zapewniając płynność działania aplikacji.

Mam nadzieję, że w swoim poście dałem radę opisać ten mechanizm w prosty sposób - najpierw opisując dokładnie wykorzystywane przez niego struktury danych, a następnie opisując na przykładach, jak działa. Jest to jeden z najważniejszych aspektów programowania w JavaScript, o ile nie najważniejszy, gdyż jego zrozumienie pozwala lepiej radzić sobie z kodem asynchronicznym i unikać typowych problemów, takich jak blokowanie czy wyścigi danych.