Kodokon kodokon.com

حلقة أحداث Node: المراحل والمزالق

اكتشف مراحل libuv، والفرق الحقيقي بين setImmediate وsetTimeout، وكيفية اكتشاف حلقة أحداث متوقّفة.

10 دقيقة · 3 أسئلة

افتح هذا الدرس في Kodokon

يفوّض Node.js حلقة أحداثه إلى libuv، التي تنظّمها في مراحل متمايزة: المؤقّتات (timers) (setTimeout، setInterval)، وردود النداء المعلّقة (pending callbacks)، وidle/prepare (داخلية)، وpoll (الدخل/الخرج: المقابس، الملفات)، وcheck (setImmediate)، وردود نداء الإغلاق (close callbacks). في كل نبضة (tick)، تمرّ الحلقة عبر هذه المراحل بهذا الترتيب. وهناك تفصيل يجهله كثير من المطوّرين: بين كل رد نداء وآخر، تُفرّغ Node أولًا طابور process.nextTick، ثم المهام الدقيقة (microtasks) الخاصة بالوعود - وهذا هو الحال منذ Node 11، بعد كل رد نداء وليس فقط بين المراحل.

JAVASCRIPT
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.

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
داخل رد النداء، يُنفَّذ nextTick قبل المهام الدقيقة.

مزلق دقيق: على المستوى الأعلى لوحدة ES، ينعكس هذا الترتيب - إذ تُنفَّذ المهام الدقيقة للوعود قبل process.nextTick. والسبب: أن تقييم وحدة ES يُنفَّذ هو نفسه كمهمة وعد، لذا يكون طابور المهام الدقيقة قيد التفريغ فعلًا عندما ينتهي كودك المتزامن. أما في CommonJS، فيحتفظ nextTick بأولويته حتى على المستوى الأعلى. تحقّق دائمًا من هذا النوع من الترتيب في صيغة الوحدة التي تنشرها فعليًا.

تعمل حلقة الأحداث على خيط واحد: فأيّ حساب متزامن طويل - JSON.parse لحمولة كبيرة، أو تعبير نمطي كارثي، أو تشفير متزامن - يُجمِّد كل الطلبات الجارية. في بيئة الإنتاج، قِس زمن استجابة الحلقة باستخدام monitorEventLoopDelay: فقيمة p99 التي تتجاوز بضع عشرات من المِلّي ثانية تدلّ على توقّف. يُطلِق الكود التالي توقّفًا عمدًا ثم يقيسه.

JAVASCRIPT
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);
يكشف المدرّج التكراري عن التوقّف الذي دام 200 مِلّي ثانية.

اختبار المعرفة

تأكّد من أنك تذكّرت النقاط الأساسية في هذا الدرس.

  1. داخل رد نداء للدخل/الخرج (مثل fs.readFile)، تجدول setTimeout(fn, 0) وsetImmediate(fn). أيّهما يُنفَّذ أولًا؟
    • setTimeout، دائمًا
    • setImmediate، دائمًا
    • الترتيب عشوائي، كما هو الحال عند بدء تشغيل السكربت
  2. بعد رد النداء، بأيّ ترتيب تُفرّغ Node طوابير الأولوية؟
    • المهام الدقيقة للوعود أولًا
    • يُفرَّغ الطابوران بالتناوب الصارم
    • يُفرَّغ طابور process.nextTick أولًا، ثم المهام الدقيقة
  3. أيّ أداة أصيلة تقيس بدقّة توقّفات حلقة الأحداث في بيئة الإنتاج؟
    • console.time حول كل طلب
    • monitorEventLoopDelay من node:perf_hooks
    • process.memoryUsage المُستدعاة داخل setInterval
    • الوحدة node:trace_events