A process.nextTick() nem mikrotask: a Node event loop külön VIP-sora

A process.nextTick() nem ugyanaz, mint egy mikrofeladat: külön Node-sora van, és egy rossz rekurzióval elnémíthatja az I/O-t.

Van az a Node-hiba, amelyben a CPU nem pörög, a processz sem halt meg, mégis késik a timer, az I/O callback és vele együtt egy-egy HTTP-válasz. A stack trace többnyire semmitmondó. A nyom végén gyakran egy ártatlannak látszó process.nextTick() van: egy helper, amely „csak aszinkronra teszi” a callbacket. Csakhogy ez nem egy átlagos aszinkron sor. A Node saját VIP-sávot tart fenn neki.

A Node.js eseményhurkának három várakozási sora, ahol a nextTick sor megelőzi a mikro- és a normál feladatokat.

A Node jelenlegi API-dokumentációja a process.nextTick()-et már Legacy státuszúnak jelöli, és a legtöbb új alkalmazáskódhoz a queueMicrotask()-ot ajánlja. Nem azért, mert holnap eltűnik, hanem mert a név sokkal szelídebb viselkedést ígér, mint amit kapunk.

Nem a „következő körben” fut

A nextTick callbackje a jelenlegi JavaScript-hívási verem kiürülése után fut le, mielőtt a Node továbbengedné az eseményhurkot. A callbackeket a Node saját next tick queue-jába teszi, és ezt teljesen kiüríti. Csak azután ürül a V8 mikrotask-sora, ahol a Promise-ok then/catch/finally kezelői és a queueMicrotask() várnak. Ezt a két külön sort a globális API dokumentációja is külön nevezi meg.

Ez nem ugyanaz, mint a böngészős „majd rögtön” intuíció. A mikrofeladatok amúgy is elsőbbséget élveznek a következő normál task előtt; a mikrotask-modell ráadásul addig dolgozza a sort, amíg az üres nem lesz. Node-ban erre jön rá még egy, eléjük vágó sor. A nextTick tehát nem az event loop egyik fázisa, hanem a belépőnél álló biztonsági őr, aki mindenkit előreenged a listáról.

Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('microtask'));
process.nextTick(() => console.log('nextTick'));
setImmediate(() => console.log('immediate'));

// CommonJS: nextTick, promise, microtask, immediate

Ez a minta CommonJS-fájlként pontosan ezt írta ki Node 22-n. A setImmediate csak később kap esélyt, mert az már ténylegesen visszaadja a vezérlést az eseményhuroknak. A Node event loop útmutatója ezért nem kezeli a nextTick-et normál fázisként.

A meglepetés: ESM-ben más lesz a kimenet

Ugyanazt a négy sort .mjs-ként futtatva nálam ez jött: promise, microtask, nextTick, immediate. Na, ezt nem tudtam: az ESM modul kiértékelése maga is a mikrotask-sor része, ezért ott a már beütemezett Promise- és queueMicrotask-callbackek előbb futnak le. A hivatalos összehasonlítás külön leírja a CJS–ESM sorrendkülönbséget.

Ez jó ok arra, hogy könyvtárban ne építsünk publikus viselkedést arra, hogy „a nextTick biztosan minden Promise előtt jön”. A csomag fogyasztója lehet ESM-es, CommonJS-es, vagy a bundlere át is rendezheti a világot. Egy callback sorrendje ritkán olyan üzleti követelmény, amelyet érdemes egy modulformátumhoz kötni.

A valódi hiba: nem engeded levegőhöz jutni az I/O-t

A baj akkor lesz éles, ha egy nextTick-callback újabb nextTick-et tesz sorba. A Node előbb mindig kiüríti ezt a sort; ha közben újratöltjük, timer, socket, fájlolvasás és beérkező kérés nem jut el a JavaScriptig. Az API-dokumentáció egyenesen figyelmeztet a rekurzív hívásból létrejövő végtelen ciklusra. Kéréskezelőben ez különösen kellemetlen módja annak, hogy az egész processz egyszerre „lassú legyen”.

Fontos korrekció: a queueMicrotask()-ra csere nem CPU-yield. Rekurzívan sorba rakott mikrotaskok ugyanúgy nem engedik a következő taskot futni. Ha a cél a nagy, szinkron munka darabolása, olyan pontra kell átadni a vezérlést, amely tényleg visszatér az event loophoz:

import { setImmediate } from 'node:timers/promises';

let completed = 0;
setTimeout(() => console.log(`timer: ${completed}`), 0);

for (let batch = 0; batch < 3; batch += 1) {
  for (let i = 0; i < 100_000; i += 1) completed += 1;
  await setImmediate(); // itt az I/O is kap egy kört
}

console.log(`done: ${completed}`);

A Promise-alapú setImmediate() erre egy olvasható eszköz. Nem teszi párhuzamossá a CPU-munkát, csak alkalmat ad I/O-nak és más callbackeknek a futásra. Ha egy batch is érezhetően hosszú, a darabolás kozmetika: akkor Worker Thread vagy külön worker process a korrekt irány.

Mit használjak helyette?

  • Konzisztensen késleltetett callbackhez — például egy cache-hit és egy aszinkron betöltés azonos API-viselkedéséhez — queueMicrotask() az alapértelmezett, platformok között is hordozható választás.
  • Promise-kezelők elé szándékosan befurakodni — ritka, de Node-specifikus kompatibilitási eset — maradhat a process.nextTick(). Legyen korlátos, és a sorrend legyen dokumentált.
  • Fair ütemezéshez vagy CPU-daraboláshoz ne mikrotaskot használj, hanem setImmediate()-et; komoly számításhoz pedig ne az API-szerver event loopja végezze a nehézemelést.

Én production kódban új helyre már nem írnám be rutinból a process.nextTick()-et. Nem tiltandó relikvia, inkább nagyon éles szerszám: egy kis callback-sorrendet lehet vele szépen megoldani, de egy rossz rekurzióval minden kapcsolatot az ajtó előtt felejtünk.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük