اكتشف مراحل libuv، والفرق الحقيقي بين setImmediate وsetTimeout، وكيفية اكتشاف حلقة أحداث متوقّفة.
افتح هذا الدرس في Kodokonيفوّض Node.js حلقة أحداثه إلى libuv، التي تنظّمها في مراحل متمايزة: المؤقّتات (timers) (setTimeout، setInterval)، وردود النداء المعلّقة (pending callbacks)، وidle/prepare (داخلية)، وpoll (الدخل/الخرج: المقابس، الملفات)، وcheck (setImmediate)، وردود نداء الإغلاق (close callbacks). في كل نبضة (tick)، تمرّ الحلقة عبر هذه المراحل بهذا الترتيب. وهناك تفصيل يجهله كثير من المطوّرين: بين كل رد نداء وآخر، تُفرّغ Node أولًا طابور process.nextTick، ثم المهام الدقيقة (microtasks) الخاصة بالوعود - وهذا هو الحال منذ Node 11، بعد كل رد نداء وليس فقط بين المراحل.
import { readFile } from "node:fs";
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
readFile(new URL(import.meta.url), () => {
setTimeout(() => console.log("io timeout"), 0);
setImmediate(() => console.log("io immediate"));
});عندما يبدأ السكربت، يكون الترتيب بين setTimeout(fn, 0) وsetImmediate(fn) غير حتمي: إذ يُقيَّد setTimeout(fn, 0) فعليًا إلى 1 مِلّي ثانية، وإذا وصلت الحلقة إلى مرحلة المؤقّتات في أقل من مِلّي ثانية، لا يكون المؤقّت مؤهّلًا بعد. أما داخل رد نداء للدخل/الخرج، فأنت في مرحلة poll: تأتي مرحلة check (setImmediate) دائمًا قبل العودة إلى مرحلة المؤقّتات. لذا تُطبَع io immediate باستمرار قبل io timeout.
setTimeout(() => {
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));
console.log("sync");
}, 0);
// sync, nextTick, promise, microtaskمزلق دقيق: على المستوى الأعلى لوحدة ES، ينعكس هذا الترتيب - إذ تُنفَّذ المهام الدقيقة للوعود قبل process.nextTick. والسبب: أن تقييم وحدة ES يُنفَّذ هو نفسه كمهمة وعد، لذا يكون طابور المهام الدقيقة قيد التفريغ فعلًا عندما ينتهي كودك المتزامن. أما في CommonJS، فيحتفظ nextTick بأولويته حتى على المستوى الأعلى. تحقّق دائمًا من هذا النوع من الترتيب في صيغة الوحدة التي تنشرها فعليًا.
تعمل حلقة الأحداث على خيط واحد: فأيّ حساب متزامن طويل - JSON.parse لحمولة كبيرة، أو تعبير نمطي كارثي، أو تشفير متزامن - يُجمِّد كل الطلبات الجارية. في بيئة الإنتاج، قِس زمن استجابة الحلقة باستخدام monitorEventLoopDelay: فقيمة p99 التي تتجاوز بضع عشرات من المِلّي ثانية تدلّ على توقّف. يُطلِق الكود التالي توقّفًا عمدًا ثم يقيسه.
import { monitorEventLoopDelay }
from "node:perf_hooks";
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
const end = Date.now() + 200;
while (Date.now() < end) {}
setTimeout(() => {
h.disable();
const ms = h.max / 1e6;
console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);