Event loop w Node.js to pętla, która decyduje, który callback wykona się następny, gdy twój kod synchroniczny już się skończył. Przechodzi przez fazy w stałym cyklu: timers (setTimeout, setInterval), pending callbacks, poll (I/O, np. odczyt plików i dane z sieci), check (setImmediate) i close callbacks. Po każdym callbacku Node najpierw opróżnia kolejkę process.nextTick, a potem kolejkę promise, więc one zawsze wykonują się, zanim pętla pójdzie dalej.
Wypisuje sync, nextTick, promise, setTimeout, czyli odwrotnie niż w kodzie. Kod synchroniczny zawsze kończy się pierwszy, potem Node opróżnia kolejkę nextTick, potem kolejkę promise, i dopiero wtedy pętla rusza i dochodzi do fazy timers.
Fazy event loop
Pętla Node pochodzi z libuv, biblioteki w C, której Node używa do I/O. Przed pierwszym obrotem uruchamia timery, których czas już nadszedł, a potem każdy obrót przechodzi przez te fazy po kolei:
- Pending callbacks: kilka callbacków systemowych odłożonych z poprzedniego obrotu, np. niektóre błędy sieci.
- Poll: nowe zdarzenia I/O i ich callbacki (skończył się odczyt pliku, na sockecie pojawiły się dane). Gdy nic innego nie jest zaplanowane, pętla czeka właśnie tutaj.
- Check: callbacki
setImmediate. - Close callbacks: zdarzenia
'close', np. zniszczony socket. - Timers: callbacki
setTimeoutisetInterval, których czas już nadszedł.
Wiele poradników stawia timery na początku każdego obrotu, bo tak działał Node przed wersją 20 (libuv 1.45). Callbacki i tak wykonują się w tej samej kolejności: po timerach jednego obrotu przychodzi poll następnego.
Dwie kolejki są poza fazami: kolejka nextTick i kolejka mikrozadań (callbacki promise i queueMicrotask). Node opróżnia obie po każdym pojedynczym callbacku, w każdej fazie, najpierw nextTick. Pętla kręci się dalej, dopóki jest na co czekać (timer, otwarty plik albo socket, serwer), a proces kończy się, gdy nic nie zostało.
To jest własna pętla Node. Przeglądarka ma tę samą ideę tasków i mikrozadań, ale nie ma faz, setImmediate ani process.nextTick; strona o event loop w JavaScript pokazuje obraz na poziomie języka.
process.nextTick vs promise
Oba wykonują się, zanim pętla pójdzie dalej, ale nie są wymieszane. Node wykonuje całą kolejkę nextTick, łącznie z tickami dodanymi w trakcie, i dopiero potem kolejkę promise. Przewidź wynik tego przykładu:
Kolejność to tick 1, tick 2, tick from tick, promise 1, promise 2. Tick zaplanowany wewnątrz ticka trafia do tej samej kolejki i nadal wykonuje się przed każdym promise, chociaż promise powstał pierwszy.
Na najwyższym poziomie modułu ES (pliku, który używa import) te dwie kolejki zamieniają się miejscami: kod modułu sam wykonuje się wewnątrz zadania promise, więc jego callbacki promise kończą się, zanim Node dojdzie do kolejki nextTick. Wewnątrz callbacków reguła "najpierw nextTick" obowiązuje w obu systemach modułów.
setImmediate vs setTimeout(fn, 0)
setTimeout(fn, 0) tak naprawdę znaczy "po co najmniej 1 ms, w fazie timers". setImmediate(fn) znaczy "w następnej fazie check". To, co wykona się pierwsze, zależy od miejsca, w którym je wywołasz.
Wewnątrz callbacku I/O kolejność jest stała. Callback działa w fazie poll, faza check przychodzi zaraz po niej, a faza timers dopiero potem:
Za każdym razem wypisuje nextTick, setImmediate, setTimeout. Dlatego setImmediate to dobry wybór, gdy chcesz "wykonać to po bieżącej pracy I/O".
W module głównym, poza jakimkolwiek callbackiem, kolejność tych dwóch nie jest ustalona:
// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Jeśli przy starcie pętli minęła co najmniej 1 ms, timer jest już gotowy i wypisuje się pierwszy; w przeciwnym razie wygrywa setImmediate. Nie pisz kodu, który zależy od któregokolwiek z tych wyników.
| Kiedy się wykonuje | Do czego | |
|---|---|---|
process.nextTick(fn) | Zaraz po bieżącej operacji, przed promise | Wyemitowanie zdarzenia lub błędu, gdy wywołujący już podpiął listenery |
Promise.then, queueMicrotask | Gdy kolejka nextTick jest pusta | Zwykły kod asynchroniczny |
setImmediate(fn) | Faza check, po callbackach I/O | Oddanie pętli obrotu między kawałkami pracy |
setTimeout(fn, 0) | Faza timers, co najmniej 1 ms później | Opóźnienia, gdy dokładny moment nie ma znaczenia |
Blokowanie event loop
Ponieważ jest tylko jeden wątek JavaScript, callback, który działa długo, zatrzymuje wszystko inne: timery, callbacki I/O i na serwerze każde inne żądanie. Opóźnienie timera to minimum, a nie gwarancja.
Najpierw wypisuje się loop finished, potem true: timer był gotowy po 10 ms, ale nie mógł się wykonać, dopóki pętla zajmująca procesor nie oddała wątku. Na serwerze ta sama pętla w handlerze trasy zamraża każde inne żądanie na pół sekundy.
Rozwiązanie dla długiej pracy to podzielić ją i między kawałkami oddać pętli obrót przez setImmediate:
Timer wchodzi po części 1. Zamień setImmediate(() => work(part + 1)) na work(part + 1) i uruchom ponownie: wszystkie trzy części wykonują się za jednym razem, a timer wypisuje się na końcu.
Przy pracy, która jest ciężka od początku do końca, np. przetwarzaniu obrazów, dzielenie nie wystarczy: przenieś ją z głównego wątku za pomocą worker_threads.
Częste błędy
Rekurencyjne wywoływanie process.nextTick. Callback nextTick, który ciągle planuje kolejny nextTick, nigdy nie pozwala pętli pójść dalej, więc timery i I/O głodują. Do powtarzanej pracy używaj setImmediate.
Synchroniczne API na serwerze. fs.readFileSync, crypto.pbkdf2Sync i duże wywołania JSON.parse blokują pętlę dla wszystkich użytkowników. W handlerach żądań używaj wersji asynchronicznych.
Oczekiwanie, że setTimeout(fn, 0) wykona się od razu. Wykonuje się po całym kodzie synchronicznym, każdym nextTick i każdym callbacku promise, i dopiero wtedy, gdy pętla dojdzie do fazy timers.
Najczęściej zadawane pytania
Czy Node.js jest jednowątkowy?
Twój JavaScript działa w jednym wątku, więc dwa callbacki nigdy nie wykonują się jednocześnie. Sam Node używa więcej wątków: libuv trzyma pulę (domyślnie cztery wątki) do pracy z systemem plików, zapytań DNS i części crypto, a wyniki oddaje z powrotem do pętli.
Kiedy program w Node.js się kończy?
Gdy event loop nie ma już na co czekać: żadnych timerów, żadnego oczekującego I/O, żadnych otwartych serwerów ani socketów. Działający setInterval albo nasłuchujący serwer utrzymuje proces przy życiu, dopóki go nie wyczyścisz lub nie zamkniesz.
Czy event loop w Node.js to to samo co w przeglądarce?
Idea jest ta sama, jeden wątek wykonuje callbacki z kolejki, ale szczegóły się różnią. Przeglądarka nie ma setImmediate ani process.nextTick i renderuje między taskami, a Node przechodzi przez fazy libuv. Strona o event loop w JavaScript opisuje wspólną część na poziomie języka.
Jak sprawdzić, czy event loop jest zablokowany?
Zmierz, jak bardzo się spóźnia: monitorEventLoopDelay() z perf_hooks zapisuje opóźnienie, a średnia rzędu kilkudziesięciu milisekund oznacza, że jakiś callback za długo trzyma wątek. Uruchom Node z --cpu-prof, żeby zobaczyć, która to funkcja.