Event Loop a kolejność wykonywania kodu w JavaScript
JavaScript jest dosyć kontrowersyjnym językiem programowania. Jedni go uwielbiają, inni nienawidzą. Posiada wiele oryginalnych mechanizmów, które nie pojawiają się w innych popularnych językach i są charakterystyczne tylko dla niego. Jednym z nich jest momentami dosyć nieintuicyjna kolejność uruchamiania się kodu, powodująca mieszanie się świata synchronicznego i asynchronicznego. Rządzi tym główny bohater dzisiejszego artykułu: Event Loop.
Krzysztof Zbiciński. Programista wszelkich technologii, specjalizujący się w JavaScripcie. Fan podążania za nowymi trendami pod warunkiem zachowania zdrowego rozsądku. Organizator warsztatów NodeSchool w Łodzi, od czasu do czasu prelegent i bloger.
Spis treści
JavaScript to język jednowątkowy, prawda?
Zgadza się, JavaScript to jednowątkowy język programowania. Jeśli spróbujemy uruchomić nieskończoną pętlę `while(true);` przeglądarka bardzo szybko straci cierpliwość i zaproponuje nam zatrzymanie pracy danej karty. Dzieję się tak, ponieważ wątek karty jest zajęty ciągłym przechodzeniem przez pętlę i nie jest w stanie odświeżyć/przerenderować okna przeglądarki. Jest to jeden z powodów, dla którego JavaScript nie jest zbyt często wykorzystywany do przeprowadzania długich, kosztownych operacji, np. do przetwarzania obrazów.
Strony internetowe są jednak dosyć złożone, wiele rzeczy dzieje się w nich w tym samym momencie. Przeglądarka pobiera obrazki, parsuje style, jednocześnie wysyła zapytania HTTP, aby załadować nasze posty na wallu, a przy okazji renderuje stronę. Wszystko to jakimś cudem działa, mimo dostępności tylko jednego wątku. Pod maską musi się zatem kryć coś więcej…
Przeglądarka to nie tylko V8, SpiderMonkey czy Chakra
Aby przeglądarka mogła poradzić sobie z dość skomplikowanym zadaniem, jakim jest obsługa logiki dzisiejszych stron internetowych, niezbędne jest kilka współpracujących elementów. Głównym elementem napędowym jest silnik JavaScript. W przypadku Chrome i środowiska Node jest to V8, w Firefoksie — SpiderMonkey, zaś w Edge — Chakra. Oprócz tego do dyspozycji przeglądarki są jej własne API (to tu znajduje się np. implementacja funkcji setTimeout
, czy alert
) oraz trzy niezależne kolejki, z których elementy są zwalniane z różnym priorytetem.
Niestety świat nie jest idealny i przeglądarki różnią się w implementacji event loopa. W związku z tym poniższy artykuł będzie opisywał to zagadnienie z perspektywy silnika V8 używanego w przeglądarce Chrome oraz Node.js. Opis różnic pomiędzy implementacjami to materiał na kolejny artykuł.
Przyjrzyjmy się zatem poszczególnym elementom po kolei.
Stos wywołań
Stos wywołań (ang. Call stack) to „serce” przeglądarki, miejsce w którym zebrane są wywołania kolejnych funkcji kodu. Każde użycie funkcji powoduje umieszczenie kolejnego elementu na stosie, natomiast przy wyjściu z funkcji, odpowiadający jej element jest z tego stosu zdejmowany. Silnik JS wykonuje instrukcje zgromadzonych funkcji do momentu aż stos stanie się pusty.
Przykład:
function log(...args) { console.log(args); } function prepareMessageSync(name) { let now = new Date(); return `Witaj ${name}! Minęła właśnie ${now.toISOString()}!`; } let message = prepareMessageSync('justjoin.it'); log(message); // loguje "Witaj justjoin.it! Minęła właśnie 2018-06-16T08:25:06.641Z!" log('Koniec');
API przeglądarki
API przeglądarki (ang. Web APIs) — dodatkowe interfejsy udostępniane przez przeglądarkę, z których może skorzystać silnik. Są to m.in. metody związane z timerami (np. setTimeout
), wysyłaniem zapytań XHR (klasa XMLHttpRequest
) czy manipulacją oraz reagowaniem na zdarzenia z drzewa DOM. Mimo tego, że operujemy na nich z poziomu JavaScriptu to mogą one być asynchroniczne, gdyż działają w osobnych wątkach przeglądarki. Gdy zapytanie jest gotowe, callback podany do danej metody API jest umieszczany na kolejce zadań.
Kolejka zadań
Drugim elementem jest kolejka zadań (ang. Task queue) — tu umieszczane są wywołania funkcji wskazanych jako callback do asynchronicznych metod z Web API. Nic z tej kolejki nie może zostać zabrane, dopóki stos wywołań nie będzie pusty. W praktyce oznacza to tyle, że żaden callback nie zostanie obsłużony dopóki każda aktualnie wywoływana funkcja nie zostanie zakończona. Tłumaczy to działanie często spotykanej „sztuczki” wykorzystującej setTimeout(fn, 0)
. Bardzo mocno upraszczając, kod funkcji fn zostanie wywołany w momencie, gdy przeglądarka będzie miała wolne moce przerobowe.
Kolejka zadań spełnia zasadę FIFO (ang. First In First Out), zatem element który pojawi się w niej jako pierwszy zostanie również jako pierwszy obsłużony. Po zebraniu jednego elementu, event loop przechodzi do stosu wywołań a następnie do kolejnej kolejki — kolejki mikrozadań, o której za chwilę. Zatem na jeden „cykl” pętli, obsłużone może zostać tylko jedno zadanie z kolejki zadań.
Do poprzedniego przykładu dodajmy asynchroniczne zapytanie HTTP:
function prepareMessageXHR(name, callback) { let request = new XMLHttpRequest(); request.open('GET', `/api/message/${name}`); request.onreadystatechange = (e) => { if (request.readyState === 4 && request.status === 200) { callback(reqeust.responseText); } }; request.send(null); } prepareMessageXHR('justjoin.it', log); // loguje odpowiedź z /api/message/justjoin.it for(let i = 0; i<1000000; i++) {}; // długa operacja synchroniczna log('Koniec');
Mimo tego, że API może zdążyć zwrócić odpowiedź zanim długa operacja synchroniczna zostanie wykonana, to wywołanie funkcji logResponse
musi poczekać aż stos wywołań zostanie wyczyszczony, nawet jeśli zajmuje to sporo czasu.
Upraszczając powyższy przykład, moglibyśmy zasymulować błyskawiczne zapytanie HTTP korzystając z setTimeout
z opóźnieniem równym 0
, który praktycznie natychmiastowo powoduje umieszczenie podanego mu callbacka w kolejce zadań:
function prepareMessageTimeout(name, callback) { setTimeout(function() { let now = new Date(); callback(`Witaj ${name}! Minęła właśnie ${now.toISOString()}!`); }, 0); } prepareMessageTimeout('justjoin.it', log); // 2. loguje "Witaj justjoin.it! Minęła właśnie 2018-06-16T08:25:06.641Z! for(let i = 0; i<1000000; i++) {}; // długa operacja synchroniczna log('Koniec'); // 1. loguje "Koniec"
Kolejka mikrozadań
W wersji ES6 JavaScriptu otrzymaliśmy natywną obsługę dla tzw. Promise’ów. Jest to jedna w funkcjonalności języka korzystająca z osobnej kolejki — kolejki mikrozadań (ang. Microtask queue). Na schemacie pętli znajduje się ona tuż po stosie wywołań. W przeciwieństwie do kolejki zadań, gdzie silnik mógł zdjąć tylko jedno zadanie na każdy przebieg pętli, elementy kolejki mikrozadań są zdejmowane jeden po drugim aż do momentu gdy będzie ona pusta.
Z tej kolejki korzysta również MutationObserver
, setImmediate
czy też process.nextTick
obecny w Node.js.
function prepareMessageFetch(name) { return fetch(`/api/message/${name}`); } prepareMessageXHR('justjoin.it', log); // 3. loguje odpowiedź z /api/message/justjoin.it prepareMessageFetch('justjoin.it').then(log); // 2. loguje odpowiedź z /api/message/justjoin.it for(let i = 0; i<1000000; i++) {}; // długa operacja synchroniczna log('Koniec'); // 1. loguje "Koniec"
Lub ponownie wykorzystując fałszywy natychmiastowy request:
function prepareMessagePromise(name) { let now = new Date(); return Promise.resolve(`Witaj ${name}! Minęła właśnie ${now.toISOString()}!`); } prepareMessageTimeout('justjoin.it', log); // 3. loguje odpowiedź z setTimeout prepareMessagePromise('justjoin.it').then(log); // 2. loguje odpowiedź z Promise'a for(let i = 0; i<1000000; i++) {}; // długa operacja synchroniczna log('Koniec'); // 1. loguje "Koniec"
Kolejka renderowania
Przeglądarka aby zapewnić płynne działanie strony, musi ją odświeżać około 60 razy na sekundę. Kolejka renderowania (ang. Render queue) zbiera zadania, które chcemy aby zostały wykonane przed następnym wyrenderowaniem strony. Jeśli chcemy umieścić tam jakiś fragment kodu (np. aby przeliczyć pozycję animowanego obiektu) musimy skorzystać z funkcji requestAnimationFrame
. Tak samo jak kolejka mikrozadań, kolejka renderowania również jest przetwarzana do momentu aż pozostanie pusta.
prepareMessagePromise('justjoin.it').then(log); // 2. loguje odpowiedź z Promise'a prepareMessageTimeout('justjoin.it', log); // 5. loguje odpowiedź z setTimeout requestAnimationFrame(function() { log('requestAnimationFrame 1'); // 3. loguje odpowiedź z requestAnimationFrame }); requestAnimationFrame(function() { log('requestAnimationFrame 2'); // 4. loguje odpowiedź z requestAnimationFrame }); for(let i = 0; i<1000000; i++) {}; // długa operacja synchroniczna console.log('Koniec'); // 1. loguje "Koniec"
tl;dr
Podsumowując, całość można skondensować do tych czterech kroków:
1. Jeśli coś znajduje się w kolejce zadań to weź pierwszy element i wrzuć go na stos wywołań.
2. Wykonuj kolejne instrukcje ze stosu wywołań, do momentu aż będzie on pusty.
3. Wykonuj kolejne instrukcje z kolejki mikrozadań, do momentu aż będzie ona pusta.
4. Wykonaj instrukcje wskazane przez requestAnimationFrame
, przelicz style, wyrenderuj stronę.
W formie obrazkowej wyglądałoby to mniej więcej tak:
Metaforyczna ilustracja kolejności wykonywania zadań w Event Loopie. Autor: Filip Smulski
Zagadki
1. Co pojawi się w konsoli?
let foo = 1; let observer = new MutationObserver(function onChange() { console.log(foo); // ? }); observer.observe(document.body, { childList: true }); document.body.innerHTML = 'justjoin.it'; foo = 2; // Co pojawi się w konsoli?
Odpowiedź: 2
.
Dlaczego tak się dzieje? MutationObserver
korzysta z kolejki mikrozadań, która jest obsługiwana dopiero gdy cały stos wywołań będzie pusty. Przypisanie wartości 2
do zmiennej foo
nastąpi zanim zostanie uruchomiony callback onChange
.
2. W jakiej kolejności komunikaty pojawią się w konsoli?
setTimeout(() => console.log('timeout'), 0); requestAnimationFrame(() => console.log('frame')); Promise.resolve().then(() => console.log('promise')); // W jakiej kolejności komunikaty pojawią się w konsoli?
Odpowiedź: promise
, frame
, timeout
.
Dlaczego tak się dzieje? Po przetworzeniu całego stosu wywołań, w pierwszej kolejności obsługiwana jest kolejka mikrozadań (z której korzystają Promisy), następnie kolejka renderowania (z której korzysta requestAnimationFrame
), a dopiero na końcu kolejka zadań (z której korzysta setTimeout
). W związku z tym, komunikaty pojawią się w odwrotnej kolejności do tej, w której znajdują się w kodzie.
3. W jakiej kolejności komunikaty pojawią się w konsoli po naciśnięciu przycisku?
<button>Klik!</button> let button = document.querySelector('button'); button.addEventListener('click', function onClick1() { Promise.resolve().then(() => console.log('promise1')); console.log('click1'); }); button.addEventListener('click', function onClick2() { Promise.resolve().then(() => console.log('promise2')); console.log('click2'); }); // W jakiej kolejności komunikaty pojawią się w konsoli po naciśnięciu przycisku?
Odpowiedź: click1
, promise1
, click2
, promise2
.
Dlaczego tak się dzieje? Do przycisku, na zdarzenie click
są przypięte dwie funkcje, których wywołania zostaną umieszczone na kolejce zadań jeden pod drugim. W pierwszym przebiegu pętli, z kolejki zostanie zdjęte wywołanie funkcji onClick1
. Spowoduje ono umieszczenie funkcji logującej komunikat promise1
w kolejce mikrozadań, która z kolei zostanie obsłużona od razu, gdy tylko zwolniony zostanie stos wywołań. Dopiero gdy obsłużona zostanie kolejka mikrozadań, z kolejki zadań zostanie zdjęte wywołanie funkcji onClick2
.
3a. W jakiej kolejności komunikaty pojawią się w konsoli?
let button = document.querySelector('button'); button.addEventListener('click', function onClick1() { Promise.resolve().then(() => console.log('promise1')); console.log('click1'); }); button.addEventListener('click', function onClick2() { Promise.resolve().then(() => console.log('promise2')); console.log('click2'); }); button.click(); // W jakiej kolejności komunikaty pojawią się w konsoli?
Odpowiedź: click1
, click2
, promise1
, promise2
.
Dlaczego tak się dzieje? W przeciwieństwie do przykładu 3. funkcje onClick1
oraz onClick2
zostaną uruchomionę synchronicznie, zatem od trafią od razu na stos wywołań, a nie na kolejkę zadań.
Co dalej?
Mam nadzieję, że ten artykuł przybliżył wam ten jeden z ważniejszych konceptów w JavaScriptowym świecie. Jeśli chcecie zwizualizować sobie jak działa event loop w dowolnej sytuacji, polecam narzędzie Loupe autorstwa Philipa Robertsa oraz jego świetną prezentację z JSConf, którą znajdziecie poniżej.