Jednowątkowy, ale nie sekwencyjny
JavaScript działa w jednym wątku. Jest jeden stos wywołań i w każdej chwili wykonuje się dokładnie jedna funkcja. Żadne dwie linie twojego kodu nigdy nie działają równolegle w tym samym realmie.
Brzmi to jak ograniczenie, dopóki nie przypomnisz sobie, co JavaScript naprawdę robi: pobiera dane, czeka na kliknięcia użytkownika, czyta pliki. Większość tej „pracy” to czekanie. Event loop to sztuczka, dzięki której czekanie jest tanie: twój kod przekazuje zadanie przeglądarce lub Node, wraca do innych rzeczy i dostaje powiadomienie, gdy wynik jest gotowy.
pierwszy i drugi wypisują się po kolei. trzeci pojawia się później, mimo że timeout wynosi 0. Ta przerwa to event loop w działaniu, a zrozumienie, skąd się bierze, to cały sens tej strony.
Stos wywołań
Każde wywołanie funkcji odkłada ramkę na stos wywołań (call stack). Gdy funkcja zwraca wynik, jej ramka jest zdejmowana. Stos to po prostu stos: ostatni wszedł, pierwszy wychodzi.
Gdy wykonuje się outer(), Node odkłada outer, potem inner, następnie zdejmuje inner, gdy ta zwraca "gotowe", a potem zdejmuje outer. Stos znów jest pusty. Właśnie na tę chwilę „pustego stosu” czeka event loop.
Kod synchroniczny wykonuje się na stosie od początku do końca. Nic asynchronicznego nie może go przerwać. Jeśli napiszesz pętlę while (true), stos nigdy się nie opróżni, a strona się zawiesi: żadnych kliknięć, żadnych timerów, żadnych callbacków obietnic. Event loop nie ma nic do roboty, bo nie może dojść do głosu.
Gdzie naprawdę żyje praca asynchroniczna
Sam JavaScript nie wie, jak wysłać żądanie sieciowe ani jak odczekać 100 milisekund. Te API należą do środowiska: przeglądarki lub Node. Gdy wywołujesz setTimeout(fn, 100), dzieje się to:
- Timer zostaje zarejestrowany w środowisku.
setTimeoutnatychmiast zwraca wynik. Stos działa dalej.- Po 100 ms środowisko umieszcza
fnw kolejce. - Gdy stos jest pusty, event loop zdejmuje
fnz kolejki i ją uruchamia.
Callback timera nie może się wykonać, dopóki nie skończą się pętla for i console.log("koniec"), bo stos nie jest jeszcze pusty. Timery to minimalne opóźnienie, a nie gwarancja.
Dwie kolejki: zadania i mikrozadania
Nie ma jednej kolejki. Są dwie i to rozróżnienie wyjaśnia większość niespodzianek związanych z event loopem.
- Kolejka zadań (task queue, czasem nazywana kolejką makrozadań):
setTimeout,setInterval, callbacki wejścia/wyjścia, zdarzenia interfejsu. - Kolejka mikrozadań (microtask queue): callbacki obietnic (
.then,.catch,.finally), kontynuacje poawaiti wszystko, co zaplanowano przezqueueMicrotask.
Zasada, według której działa event loop:
- Wykonaj jedno zadanie z kolejki zadań.
- Opróżnij całą kolejkę mikrozadań: każde mikrozadanie, także te zaplanowane w trakcie opróżniania.
- W razie potrzeby wyrenderuj (w przeglądarkach).
- Wróć do punktu 1.
Mikrozadania zawsze wykonują się przed następnym zadaniem. Dlatego to zaskakuje:
Kolejność wyjścia: sync 1, sync 2, obietnica, timeout. Najpierw wykonuje się kod synchroniczny. Potem stos się opróżnia. Następnie event loop opróżnia mikrozadania (obietnica). Dopiero wtedy bierze zadanie timera (timeout).
Mikrozadania mogą zagłodzić zadania
Ponieważ kolejka mikrozadań opróżnia się w całości przed następnym zadaniem, mikrozadanie, które wciąż planuje kolejne mikrozadania, zablokuje kolejkę zadań na zawsze:
Timer nigdy by nie zadziałał, bo każde mikrozadanie kolejkuje kolejne mikrozadanie, a pętla nigdy nie pozwala kolejce się opróżnić. Łańcuchy obietnic są bezpieczne, bo każde .then planuje tylko jedną kontynuację, ale ręcznie pisane pętle mikrozadań to pułapka, o której warto wiedzieć.
await to lukier składniowy dla mikrozadania
Gdy używasz await na obietnicy, funkcja się wstrzymuje, a jej reszta zostaje zaplanowana jako mikrozadanie, które wykona się po rozstrzygnięciu obietnicy. Żadnej magii: pod spodem to po prostu .then.
Wyjście: A, C, B. await oddaje sterowanie kodowi wywołującemu. console.log("C") wykonuje się na bieżącym stosie. Potem kolejka mikrozadań się opróżnia i reszta demo wznawia działanie, wypisując B.
Pamiętaj o tym, czytając kod asynchroniczny. await nie blokuje, tylko ustępuje.
Przykład krok po kroku: kolejność wszystkiego
Połącz wszystkie elementy:
Kolejność:
1: sync: wykonuje się na stosie.6: sync: nadal na stosie.- Stos się opróżnia. Kolejka mikrozadań się opróżnia:
3: promise,5: microtask, a potem4: nested microtask(zaplanowane w trakcie opróżniania, ale i tak zostaje pobrane). - Wykonuje się następne zadanie:
2: timeout.
Końcowe wyjście: 1, 6, 3, 5, 4, 2. Jeśli potrafisz prześledzić ten przykład, rozumiesz event loop.
Node ma więcej faz
Event loop w Node to nadzbiór modelu przeglądarki. Ma odrębne fazy: timers, pending callbacks, poll, check i close, a mikrozadania opróżniają się między każdą fazą. setImmediate wykonuje się w fazie check, a process.nextTick wykonuje się przed zwykłymi mikrozadaniami (ma własną kolejkę o jeszcze wyższym priorytecie).
Pierwszego dnia nie musisz znać diagramu faz na pamięć. Najważniejszy wniosek jest taki sam jak w przeglądarce: kod synchroniczny wykonuje się do końca, potem opróżniają się mikrozadania, a potem pętla bierze następny zakolejkowany callback.
Dlaczego to ma znaczenie
Gdy ten model zaskoczy, wiele kodu asynchronicznego przestaje być tajemnicze:
- Długa pętla
forzamraża interfejs, bo event loop nie może dojść do głosu. setTimeout(fn, 0)to sposób na odłożenie pracy do chwili, gdy skończą się bieżące zadanie i mikrozadania.- Callback
.then, który uruchamia się „natychmiast” po już rozwiązanej obietnicy, i tak czeka na koniec bieżącego kodu synchronicznego. awaitwewnątrz pętli szereguje pracę, bo każda iteracja przed kontynuacją ustępuje miejsca kolejce mikrozadań.
Debugowanie kodu asynchronicznego sprowadza się głównie do pytania: „co jest teraz na stosie, a co czeka w kolejce?”. Odpowiedzią jest event loop.
Dalej: callbacki
Przed obietnicami i async/await jedynym narzędziem JavaScript do pracy asynchronicznej był callback: funkcja, którą przekazujesz API, żeby wywołało ją później. Callbacki wciąż są wszędzie (nasłuchiwacze zdarzeń, podstawowe API Node), a ich zrozumienie to fundament wszystkiego innego w tym rozdziale.
Najczęściej zadawane pytania
Czym jest event loop w JavaScript?
To mechanizm, który pozwala jednowątkowemu JavaScript wykonywać pracę asynchroniczną bez blokowania. Pętla obserwuje stos wywołań: gdy jest pusty, pobiera następny callback z kolejki i go uruchamia. Timery, operacje wejścia/wyjścia i kontynuacje obietnic trafiają do kolejek, które event loop opróżnia po jednym elemencie naraz.
Dlaczego JavaScript jest jednowątkowy?
Specyfikacja języka definiuje jeden stos wywołań na realm, więc twój kod działa w jednym wątku. Współbieżność zapewnia środowisko (przeglądarka lub Node), które przekazuje pracę do API działających w tle, takich jak timery, sieć czy operacje na plikach, i po ich zakończeniu kolejkuje callback. Nigdy nie zobaczysz dwóch fragmentów JS działających jednocześnie w tym samym kontekście.
Czym różnią się mikrozadania od makrozadań?
Mikrozadania pochodzą z obietnic (.then, await) i z queueMicrotask. Makrozadania pochodzą z setTimeout, setInterval, operacji wejścia/wyjścia i zdarzeń interfejsu. Po zakończeniu każdego makrozadania event loop opróżnia całą kolejkę mikrozadań, zanim uruchomi następne makrozadanie. Dlatego Promise.resolve().then(...) zawsze wykona się przed setTimeout(..., 0) zaplanowanym w tej samej chwili.
Dlaczego setTimeout z 0 ms nie wykonuje się od razu?
setTimeout(fn, 0) nie znaczy „uruchom teraz”, tylko „zakolejkuj fn jako makrozadanie, najwcześniej po 0 ms”. Bieżący kod synchroniczny musi się zakończyć, kolejka mikrozadań musi się opróżnić i dopiero wtedy event loop bierze callback twojego timera. Więc 0 to dolna granica, a nie obietnica.