Menu

Event loop w Node.js: fazy, nextTick i setImmediate

Event loop w Node.js wykonuje twoje callbacki w fazach (timers, poll, check, close), a między kolejnymi callbackami opróżnia najpierw kolejkę process.nextTick, a potem kolejkę promise. Uruchom przykłady i zobacz dokładną kolejność.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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:

  1. Pending callbacks: kilka callbacków systemowych odłożonych z poprzedniego obrotu, np. niektóre błędy sieci.
  2. 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.
  3. Check: callbacki setImmediate.
  4. Close callbacks: zdarzenia 'close', np. zniszczony socket.
  5. Timers: callbacki setTimeout i setInterval, 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ę wykonujeDo czego
process.nextTick(fn)Zaraz po bieżącej operacji, przed promiseWyemitowanie zdarzenia lub błędu, gdy wywołujący już podpiął listenery
Promise.then, queueMicrotaskGdy kolejka nextTick jest pustaZwykły kod asynchroniczny
setImmediate(fn)Faza check, po callbackach I/OOddanie pętli obrotu między kawałkami pracy
setTimeout(fn, 0)Faza timers, co najmniej 1 ms późniejOpóź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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

Zacznij