Choć tworzenie zaawansowanego kodu produkcyjnego nierzadko wymaga głębokiej znajomości języków takich jak C++ czy C, w przypadku JavaScript często wystarczy podstawowa wiedza o jego możliwościach.
Zagadnienia takie jak przekazywanie funkcji zwrotnych (callback) czy pisanie kodu asynchronicznego nie są zazwyczaj trudne do zaimplementowania. Z tego powodu wielu programistów JavaScript nie zaprząta sobie głowy tym, co dzieje się „pod maską”. Nie skupiają się na zrozumieniu zawiłości, które język skutecznie przed nimi ukrywa.
Jednakże, dla każdego programisty JavaScript kluczowe staje się zrozumienie mechanizmów, które działają w tle, i tego, jak te wyabstrahowane elementy funkcjonują w rzeczywistości. Pozwala to na podejmowanie bardziej świadomych decyzji, co z kolei może znacząco wpłynąć na wydajność pisanego kodu.
Ten artykuł skupia się na jednym, ważnym, lecz często niezrozumianym aspekcie JavaScript: PĘTLI ZDARZEŃ!.
Pisanie kodu asynchronicznego w JavaScript jest nieuniknione, ale co tak naprawdę oznacza asynchroniczność? Odpowiedź kryje się właśnie w Pętli Zdarzeń.
Zanim jednak zagłębimy się w działanie pętli zdarzeń, musimy zrozumieć, czym jest sam JavaScript i jak funkcjonuje!
Czym jest JavaScript?
Zanim przejdziemy dalej, cofnijmy się do podstaw. Czym właściwie jest JavaScript? Możemy go zdefiniować jako:
JavaScript to język wysokopoziomowy, interpretowany, jednowątkowy, nieblokujący, asynchroniczny i współbieżny.
Chwila, co to za definicja? Brzmi jak wyjęta z podręcznika, prawda? 🤔
Rozłóżmy ją na czynniki pierwsze!
Kluczowe dla tego artykułu są określenia: jednowątkowy, nieblokujący, współbieżny i asynchroniczny.
Pojedynczy wątek
Wątek wykonania to najmniejsza sekwencja instrukcji programu, którą program planujący może zarządzać niezależnie. Język programowania, który jest jednowątkowy, oznacza to, że w danym momencie może wykonywać tylko jedno zadanie lub operację. Proces taki realizowany jest od początku do końca bez żadnych przerw czy zatrzymań.
W odróżnieniu od języków wielowątkowych, gdzie wiele procesów może być realizowanych równocześnie na różnych wątkach, bez wzajemnego blokowania.
Jak więc JavaScript może być jednocześnie jednowątkowy i nieblokujący?
Ale co właściwie oznacza „blokowanie”?
Bez blokowania
Nie istnieje jedna konkretna definicja „blokowania”. Najprościej mówiąc, oznacza to operacje, które spowalniają działanie wątku. Zatem „nieblokujący” opisuje zadania, które nie powodują jego spowolnienia.
Chwileczkę, czyż nie wspomniałem, że JavaScript działa w jednym wątku? Powiedziałem też, że jest „nieblokujący”, co oznacza szybką realizację zadań na stosie wywołań. Jak to możliwe? A co z timerami, pętlami?
Spokojnie! Wszystkiego dowiemy się za chwilę 😉.
Współbieżny
Współbieżność oznacza, że kod jest wykonywany równolegle, przy użyciu więcej niż jednego wątku.
Dobra, robi się trochę dziwne. Jak JavaScript może być jednowątkowy i jednocześnie współbieżny? Czy to znaczy, że wykonuje kod na więcej niż jednym wątku?
Asynchroniczny
Programowanie asynchroniczne oznacza, że kod działa w pętli zdarzeń. Gdy dochodzi do operacji „blokującej”, generowane jest zdarzenie. Kod „blokujący” działa niezależnie od głównego wątku wykonawczego. Po zakończeniu działania, wynik operacji „blokującej” trafia do kolejki, a następnie jest umieszczany z powrotem na stosie.
Ale przecież JavaScript ma tylko jeden wątek? Kto zatem wykonuje ten „blokujący” kod, jednocześnie umożliwiając wykonywanie innego kodu na wątku?
Zanim pójdziemy dalej, podsumujmy to, co już omówiliśmy.
- JavaScript jest jednowątkowy.
- JavaScript jest nieblokujący, czyli powolne procesy nie wstrzymują jego wykonania.
- JavaScript jest współbieżny, czyli wykonuje kod w sposób równoległy, wykorzystując więcej niż jeden wątek.
- JavaScript jest asynchroniczny, czyli uruchamia „blokujący” kod poza głównym wątkiem.
Ale to się jakoś nie zgadza. Jak język jednowątkowy może być jednocześnie nieblokujący, współbieżny i asynchroniczny?
Zagłębmy się trochę w temat i przyjrzymy się silnikom JavaScript, np. V8. Może kryją jakieś ukryte wątki, o których nie wiemy.
Silnik V8
Silnik V8 to wysokowydajny, otwartoźródłowy silnik środowiska uruchomieniowego JavaScript, stworzony przez Google przy użyciu C++. Większość przeglądarek korzysta z V8 do obsługi JavaScript. Używa go również popularne środowisko uruchomieniowe Node.js.
W prostych słowach, V8 to program w C++, który otrzymuje kod JavaScript, kompiluje go i wykonuje.
V8 wykonuje dwie podstawowe czynności:
- Alokację pamięci na stercie (heap).
- Zarządzanie kontekstem wykonania na stosie wywołań.
Niestety, nasze podejrzenia okazały się błędne. V8 posiada tylko jeden stos wywołań. Stos wywołań to tak jakby pojedynczy wątek.
Jeden wątek === jeden stos wywołań === jedno zadanie wykonywane w danym momencie.
Obraz – Haker w południe
Skoro V8 ma tylko jeden stos wywołań, jak JavaScript może działać współbieżnie i asynchronicznie, nie blokując przy tym głównego wątku?
Spróbujmy to zrozumieć, analizując prosty, lecz typowy przykład kodu asynchronicznego.
JavaScript wykonuje kod linijka po linijce (jednowątkowy). Pierwsza linia, zgodnie z oczekiwaniami, jest wypisywana w konsoli. Ale dlaczego ostatnia linia pojawia się przed kodem z timera? Czemu proces nie czeka na wykonanie timera (blokowanie), zanim przejdzie do ostatniej linii?
Wygląda na to, że jakiś inny wątek pomógł nam w obsłudze timera, skoro jesteśmy pewni, że pojedynczy wątek może wykonywać tylko jedno zadanie naraz.
Rzućmy okiem na kod źródłowy V8.
Co?!!! W V8 nie ma funkcji timera, DOM? Brak obsługi zdarzeń, AJAX-a?!! Żeeeeeeeeeeeeeeeeeeeeeeeeeeeeee!
Zdarzenia, DOM, timery itp. nie są częścią podstawowej implementacji JavaScript. JavaScript jest zgodny ze specyfikacją EcmaScript, a różne wersje języka są często określane zgodnie ze specyfikacją EcmaScript (ES X).
Przepływ Pracy Wykonania
Zdarzenia, timery, żądania AJAX są dostarczane przez przeglądarki po stronie klienta, i są one często określane mianem Web API. Dzięki nim jednowątkowy JavaScript może działać jako nieblokujący, współbieżny i asynchroniczny! Ale jak?
W przepływie pracy każdego programu JavaScript można wyróżnić trzy główne sekcje: stos wywołań, Web API oraz kolejkę zadań.
Stos Wywołań
Stos to struktura danych, w której element dodany jako ostatni jest usuwany jako pierwszy. Można go porównać do stosu talerzy, z którego zawsze usuwamy talerz, który został położony na wierzchu. Stos wywołań to po prostu struktura danych, na której wykonywane są kolejne zadania i kod.
Rozważmy poniższy przykład:
Źródło – https://youtu.be/8aGhZQkoFbQ
Gdy wywołujemy funkcję `printSquare()`, jest ona umieszczana na stosie wywołań. Funkcja `printSquare()` wywołuje funkcję `square()`, która również jest umieszczana na stosie. Następnie funkcja `square()` wywołuje funkcję `multiple()`. Ta ostatnia również trafia na stos. Ponieważ funkcja `multiple()` jako ostatnia została dodana do stosu i jest pierwszą, która kończy swoje działanie, jest rozwiązywana i usuwana ze stosu jako pierwsza. Następnie to samo dzieje się z funkcją `square()` i `printSquare()`.
Web API
To tutaj wykonywany jest kod, który nie jest obsługiwany przez silnik V8, aby nie „blokować” głównego wątku wykonawczego. Gdy stos wywołań napotka funkcję Web API, proces ten jest natychmiast przekazywany do Web API, gdzie jest wykonywany. W tym czasie stos wywołań może wykonywać inne operacje.
Wróćmy do naszego przykładu z `setTimeout`:
Po uruchomieniu kodu pierwsza linia `console.log` zostaje umieszczona na stosie i niemal od razu widzimy jej wynik. Następnie dochodzimy do `setTimeout`. Timery nie są częścią podstawowej implementacji V8, lecz są obsługiwane przez przeglądarkę i są przenoszone do Web API, co zwalnia stos i umożliwia wykonywanie innych operacji.
Podczas gdy timer nadal działa, stos przechodzi do kolejnej linii kodu i wykonuje ostatni `console.log`, co wyjaśnia, dlaczego ten wynik widzimy wcześniej. Gdy timer zakończy działanie, dzieje się coś ciekawego. Funkcja `console.log` i timer w magiczny sposób pojawiają się znowu na stosie wywołań!
Jak to możliwe?
Pętla Zdarzeń
Zanim przejdziemy do pętli zdarzeń, omówmy najpierw kolejkę zadań.
Wracając do naszego przykładu z timerem. Gdy Web API zakończy wykonywanie zadania, nie umieszcza go automatycznie z powrotem na stosie wywołań. Zamiast tego przenosi je do kolejki zadań.
Kolejka to struktura danych, która działa zgodnie z zasadą „pierwsze weszło, pierwsze wyszło”. Czyli zadania są umieszczane w kolejce i wychodzą z niej w tej samej kolejności. Zadania, które zostały wykonane przez Web API, trafiają do kolejki zadań, a następnie wracają na stos wywołań, aby wypisać wynik.
Chwileczkę. Czym DO CHOLERY JEST PĘTLA ZDARZEŃ???
Źródło – https://youtu.be/8aGhZQkoFbQ
Pętla zdarzeń to proces, który czeka, aż stos wywołań będzie pusty, zanim przeniesie funkcje zwrotne z kolejki zadań na stos wywołań. Gdy stos jest pusty, pętla zdarzeń sprawdza kolejkę zadań. Jeśli w kolejce są jakieś funkcje zwrotne, przenosi je na stos wywołań. Potem czeka, aż stos ponownie będzie pusty, i powtarza ten proces.
Źródło – https://www.quora.com/How-does-an-event-loop-work/answer/Timothy-Maxwell
Powyższy diagram przedstawia podstawowy przepływ pracy między pętlą zdarzeń i kolejką zadań.
Podsumowanie
Choć to bardzo podstawowe wprowadzenie, koncepcja programowania asynchronicznego w JavaScript daje wgląd w to, co dzieje się „pod maską”. Wyjaśnia, jak JavaScript może działać współbieżnie i asynchronicznie, mając tylko jeden wątek.
JavaScript jest zawsze poszukiwany, a jeśli chcesz się go nauczyć, polecam ten kurs Udemy.
newsblog.pl