Skip to content

حلقة الأحداث في Node.js

ما هي حلقة الأحداث؟

حلقة الأحداث هي ما يسمح لـ Node.js بأداء عمليات الإدخال/الإخراج غير المُعطلة - على الرغم من استخدام مؤشر ترابط واحد لـ JavaScript افتراضيًا - من خلال تفويض العمليات إلى نواة النظام كلما أمكن ذلك.

بما أن معظم نواة النظام الحديثة متعددة الخيوط، فيمكنها التعامل مع عمليات متعددة تُنفذ في الخلفية. عندما تكتمل إحدى هذه العمليات، تخبر نواة النظام Node.js حتى يمكن إضافة مُعامل الاستدعاء المناسب إلى قائمة الانتظار لاستطلاع للتنفيذ في النهاية. سنشرح هذا بمزيد من التفصيل لاحقًا في هذا الموضوع.

شرح حلقة الأحداث

عندما يبدأ Node.js، فإنه يقوم بتهيئة حلقة الأحداث، ويعالج البرنامج النصي المُقدم (أو ينتقل إلى REPL، والذي لم يتم تغطيته في هذه الوثيقة) والذي قد يقوم بإجراء مكالمات واجهة برمجة التطبيقات غير المُزامنة، أو جدولة المُوقتات، أو استدعاء process.nextTick()، ثم يبدأ في معالجة حلقة الأحداث.

يوضح الرسم التخطيطي التالي نظرة عامة مبسطة لترتيب عمليات حلقة الأحداث.

bash
   ┌───────────────────────────┐
┌─>           timers
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
     pending callbacks
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
       idle, prepare
  └─────────────┬─────────────┘      ┌───────────────┐
  ┌─────────────┴─────────────┐   incoming:
           poll<─────┤  connections,
  └─────────────┬─────────────┘   data, etc.
  ┌─────────────┴─────────────┐      └───────────────┘
           check
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
└──┤      close callbacks
   └───────────────────────────┘

TIP

سيتم الإشارة إلى كل مربع على أنه "مرحلة" من مراحل حلقة الأحداث.

يحتوي كل طور على قائمة انتظار FIFO من مُعاملات الاستدعاء للتنفيذ. في حين أن كل طور خاص بطريقته الخاصة، بشكل عام، عندما تدخل حلقة الأحداث طورًا معينًا، فإنها ستؤدي أي عمليات خاصة بهذا الطور، ثم تقوم بتنفيذ مُعاملات الاستدعاء في قائمة انتظار هذا الطور حتى يتم استنفاد القائمة أو يتم تنفيذ الحد الأقصى لعدد مُعاملات الاستدعاء. عندما يتم استنفاد القائمة أو يتم الوصول إلى حد مُعاملات الاستدعاء، ستنتقل حلقة الأحداث إلى الطور التالي، وهكذا.

بما أن أيًا من هذه العمليات قد تقوم بجدولة المزيد من العمليات والأحداث الجديدة التي تتم معالجتها في طور الاستطلاع يتم وضعها في قائمة الانتظار بواسطة نواة النظام، يمكن وضع أحداث الاستطلاع في قائمة الانتظار أثناء معالجة أحداث الاستطلاع. نتيجة لذلك، يمكن أن تسمح مُعاملات الاستدعاء طويلة التشغيل لطور الاستطلاع بالتشغيل لفترة أطول بكثير من عتبة المُؤقت. انظر أقسام المُوقتات والاستطلاع لمزيد من التفاصيل.

TIP

هناك اختلاف طفيف بين تنفيذ Windows وتنفيذ Unix/Linux، ولكن هذا ليس مهمًا لهذا العرض التوضيحي. الأجزاء الأكثر أهمية موجودة هنا. هناك في الواقع سبع أو ثماني خطوات، لكن الخطوات التي نهتم بها - تلك التي يستخدمها Node.js بالفعل - هي تلك المذكورة أعلاه.

نظرة عامة على المراحل

  • العدادات الزمنية (timers): تقوم هذه المرحلة بتنفيذ عمليات الاستدعاء المرتجعة المجدولة بواسطة setTimeout() و setInterval().
  • عمليات الاستدعاء المرتجعة المعلقة (pending callbacks): تقوم بتنفيذ عمليات الاستدعاء المرتجعة لمدخلات/مخرجات مؤجلة إلى تكرار الحلقة التالي.
  • الخامل، التحضير (idle, prepare): يستخدم داخليًا فقط.
  • الاستطلاع (poll): استرجاع أحداث مدخلات/مخرجات جديدة؛ تنفيذ عمليات الاستدعاء المرتجعة المتعلقة بمدخلات/مخرجات (جميعها تقريبًا باستثناء عمليات الاستدعاء المرتجعة للإغلاق، تلك المجدولة بواسطة العدادات الزمنية، و setImmediate() )؛ سيتعطل العقد هنا عند الاقتضاء.
  • التحقق (check): يتم استدعاء عمليات الاستدعاء المرتجعة لـ setImmediate() هنا.
  • عمليات الاستدعاء المرتجعة للإغلاق (close callbacks): بعض عمليات الاستدعاء المرتجعة للإغلاق، مثل socket.on('close', ...).

بين كل تشغيل لدورة الأحداث، يتحقق Node.js مما إذا كان ينتظر أي مدخلات/مخرجات غير متزامنة أو عدادات زمنية ويغلق بشكل نظيف إذا لم يكن هناك أي منها.

المراحل بالتفصيل

العدادات الزمنية (timers)

يحدد المؤقت العتبة التي بعدها يمكن تنفيذ عملية الاستدعاء المرتجعة المُقدمة بدلاً من الوقت الدقيق الذي يريده الشخص تنفيذه. ستُنفذ عمليات الاستدعاء المرتجعة للعدادات الزمنية في أقرب وقت ممكن بعد مرور المدة المحددة؛ ومع ذلك، قد يؤدي جدولة نظام التشغيل أو تشغيل عمليات استدعاء مرتجعة أخرى إلى تأخيرها.

TIP

من الناحية الفنية، تتحكم مرحلة الاستطلاع في وقت تنفيذ العدادات الزمنية.

على سبيل المثال، لنفترض أنك تقوم بجدولة مهلة للتنفيذ بعد عتبة 100 مللي ثانية، ثم يبدأ البرنامج النصي الخاص بك في قراءة ملف بشكل غير متزامن يستغرق 95 مللي ثانية:

js
const fs = require('node:fs')
function someAsyncOperation(callback) {
  // افترض أن هذا يستغرق 95 مللي ثانية لإكماله
  fs.readFile('/path/to/file', callback)
}
const timeoutScheduled = Date.now()
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled
  console.log(`${delay}ms have passed since I was scheduled`)
}, 100)
// قم ببعض العمليات غير المتزامنة التي تستغرق 95 مللي ثانية لإكمالها
someAsyncOperation(() => {
  const startCallback = Date.now()
  // قم بشيء سيستغرق 10 مللي ثانية...
  while (Date.now() - startCallback < 10) {
    // لا تفعل شيئًا
  }
})

عندما تدخل حلقة الأحداث مرحلة الاستطلاع، يكون لديها قائمة انتظار فارغة (fs.readFile() لم تكتمل)، لذلك ستنتظر عدد المللي ثوان المتبقية حتى يتم الوصول إلى عتبة أقرب مؤقت. أثناء انتظار مرور 95 مللي ثانية، ينتهي fs.readFile() من قراءة الملف ويتم إضافة عملية الاستدعاء المرتجعة التي تستغرق 10 مللي ثانية لإكمالها إلى قائمة انتظار الاستطلاع ويتم تنفيذها. عند انتهاء عملية الاستدعاء المرتجعة، لا توجد المزيد من عمليات الاستدعاء المرتجعة في قائمة الانتظار، لذا سترى حلقة الأحداث أن عتبة أقرب مؤقت قد تم الوصول إليها ثم تعود إلى مرحلة العدادات الزمنية لتنفيذ عملية الاستدعاء المرتجعة للمؤقت. في هذا المثال، سترى أن التأخير الإجمالي بين جدولة المؤقت وتنفيذ عملية الاستدعاء المرتجعة الخاصة به سيكون 105 مللي ثانية.

TIP

لمنع مرحلة الاستطلاع من تجويع حلقة الأحداث، يحتوي libuv (مكتبة C التي تُنفذ حلقة أحداث Node.js وجميع السلوكيات غير المتزامنة للمنصة) أيضًا على حد أقصى صعب (يعتمد على النظام) قبل أن يتوقف عن الاستطلاع للحصول على المزيد من الأحداث.

عمليات الاستدعاء المعلقة

تنفذ هذه المرحلة عمليات الاستدعاء لبعض عمليات النظام مثل أنواع أخطاء TCP. على سبيل المثال، إذا تلقى مقبس TCP ECONNREFUSED عند محاولة الاتصال، فإن بعض أنظمة *nix تريد الانتظار للإبلاغ عن الخطأ. سيتم وضع هذا في قائمة الانتظار للتنفيذ في مرحلة عمليات الاستدعاء المعلقة.

الاستطلاع

تتمتع مرحلة الاستطلاع بوظيفتين رئيسيتين:

  1. حساب المدة التي يجب أن تعمل فيها على الحظر والاستطلاع لإدخال/إخراج البيانات، ثم
  2. معالجة الأحداث في قائمة انتظار الاستطلاع.

عندما تدخل حلقة الأحداث مرحلة الاستطلاع ولا توجد مؤقتات مجدولة، سيحدث أحد أمرين:

  • إذا كانت قائمة انتظار الاستطلاع ليست فارغة، فستكرر حلقة الأحداث قائمة الانتظار الخاصة بها من عمليات الاستدعاء وتنفذها بشكل متزامن حتى يتم استنفاد القائمة، أو يتم الوصول إلى الحد الأقصى للنظام.

  • إذا كانت قائمة انتظار الاستطلاع فارغة، فسيحدث أحد أمرين آخرين:

    • إذا تم جدولة البرامج النصية بواسطة setImmediate()، ستنهي حلقة الأحداث مرحلة الاستطلاع وتستمر إلى مرحلة التحقق لتنفيذ تلك البرامج النصية المجدولة.

    • إذا لم يتم جدولة البرامج النصية بواسطة setImmediate()، ستنتظر حلقة الأحداث إضافة عمليات الاستدعاء إلى قائمة الانتظار، ثم تنفذها على الفور.

بمجرد أن تصبح قائمة انتظار الاستطلاع فارغة، ستتحقق حلقة الأحداث من المؤقتات التي تم الوصول إلى عتبات وقتها. إذا كان مؤقت واحد أو أكثر جاهزًا، فستعود حلقة الأحداث إلى مرحلة المؤقتات لتنفيذ عمليات استدعاء تلك المؤقتات.

التحقق

تتيح هذه المرحلة للشخص تنفيذ عمليات الاستدعاء على الفور بعد اكتمال مرحلة الاستطلاع. إذا أصبحت مرحلة الاستطلاع خاملة وتم وضع البرامج النصية في قائمة الانتظار باستخدام setImmediate()، فقد تستمر حلقة الأحداث إلى مرحلة التحقق بدلاً من الانتظار.

setImmediate() هو في الواقع مؤقت خاص يعمل في مرحلة منفصلة من حلقة الأحداث. يستخدم واجهة برمجة تطبيقات libuv التي تقوم بجدولة عمليات الاستدعاء للتنفيذ بعد اكتمال مرحلة الاستطلاع.

بشكل عام، أثناء تنفيذ التعليمات البرمجية، ستصل حلقة الأحداث في النهاية إلى مرحلة الاستطلاع حيث ستنتظر اتصالاً وارداً، أو طلباً، وما إلى ذلك. ومع ذلك، إذا تم جدولة عملية استدعاء باستخدام setImmediate() وأصبحت مرحلة الاستطلاع خاملة، فستنهي وتستمر إلى مرحلة التحقق بدلاً من انتظار أحداث الاستطلاع.

عمليات الإغلاق المُستدعَاة

إذا تم إغلاق مقبس أو مُعالِج بشكل مفاجئ (مثلًا: socket.destroy())، فسيتم إرسال حدث 'close' في هذه المرحلة. خلاف ذلك، سيتم إرساله عبر process.nextTick().

setImmediate() مقابل setTimeout()

setImmediate() و setTimeout() متشابهتان، لكنهما تتصرفان بطرق مختلفة اعتمادًا على وقت استدعائهما.

  • setImmediate() مُصممة لتنفيذ برنامج نصي بمجرد اكتمال مرحلة الاستطلاع الحالية.
  • setTimeout() تُحدد جدولًا لتنفيذ برنامج نصي بعد انقضاء حد أدنى من الوقت بالمللي ثانية.

سيتغير الترتيب الذي تُنفذ به المُؤقتات اعتمادًا على السياق الذي يتم استدعائها فيه. إذا تم استدعاء كليهما من داخل الوحدة الرئيسية، فسيكون التوقيت مُقيّدًا بأداء العملية (والتي يمكن أن تتأثر بالتطبيقات الأخرى التي تعمل على الجهاز).

على سبيل المثال، إذا قمنا بتشغيل البرنامج النصي التالي الذي ليس ضمن دورة إدخال/إخراج (أي الوحدة الرئيسية)، فإن ترتيب تنفيذ المُؤقتين غير حتمي، لأنه مُقيّد بأداء العملية:

js
// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout')
}, 0)
setImmediate(() => {
  console.log('immediate')
})
bash
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout

ومع ذلك، إذا نقلت الاستدعاءين داخل دورة إدخال/إخراج، فسيتم دائمًا تنفيذ المُستدعَاة الفورية أولاً:

js
// timeout_vs_immediate.js
const fs = require('node:fs')
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})
bash
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout

تكمن الميزة الرئيسية لاستخدام setImmediate() على setTimeout() في أن setImmediate() سيتم دائمًا تنفيذها قبل أي مؤقتات إذا تم جدولة داخل دورة إدخال/إخراج، بغض النظر عن عدد المؤقتات الموجودة.

process.nextTick()

فهم process.nextTick()

ربما لاحظت أن process.nextTick() لم يُعرض في الرسم التخطيطي، على الرغم من أنه جزء من واجهة برمجة التطبيقات غير المتزامنة. وذلك لأن process.nextTick() ليس جزءًا تقنيًا من حلقة الحدث. بدلاً من ذلك، سيتم معالجة nextTickQueue بعد اكتمال العملية الحالية، بغض النظر عن المرحلة الحالية من حلقة الحدث. هنا، تُعرّف العملية على أنها انتقال من مُعالِج C/C++ الأساسي، ومعالجة جافا سكريبت التي تحتاج إلى التنفيذ.

بالرجوع إلى الرسم التخطيطي الخاص بنا، في أي وقت تقوم فيه باستدعاء process.nextTick() في مرحلة معينة، سيتم حل جميع عمليات الاستدعاء العائدة المُمرّرة إلى process.nextTick() قبل استمرار حلقة الحدث. قد يؤدي هذا إلى بعض المواقف السيئة لأن إنه يسمح لك بـ "تجويع" مدخلات/مخرجاتك عن طريق إجراء مكالمات متكررة process.nextTick()، مما يمنع حلقة الحدث من الوصول إلى مرحلة الاستطلاع.

لماذا يُسمح بذلك؟

لماذا يُضم شيء كهذا في Node.js؟ جزء منه هو فلسفة تصميم حيث يجب أن تكون واجهة برمجة التطبيقات دائمًا غير متزامنة حتى عندما لا تضطر إلى ذلك. خذ مقتطف الكود هذا كمثال:

js
function apiCall(arg, callback) {
  if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string'))
}

يقوم المقتطف بفحص الوسائط، وإذا لم تكن صحيحة، فإنه سيرسل الخطأ إلى عملية الاستدعاء العائدة. تم تحديث واجهة برمجة التطبيقات مؤخرًا للسماح بنقل الوسائط إلى process.nextTick() مما يسمح لها بأخذ أي وسيطات مُمرّرة بعد عملية الاستدعاء العائدة ليتم نشرها كوسائط لعملية الاستدعاء العائدة حتى لا تضطر إلى تضمين الدوال.

ما نقوم به هو تمرير خطأ إلى المستخدم ولكن فقط بعد أن نسمح لبقية شفرة المستخدم بالتنفيذ. باستخدام process.nextTick() نضمن أن apiCall() يقوم دائمًا بتشغيل عملية الاستدعاء العائدة الخاصة به بعد بقية شفرة المستخدم وقبل السماح لحلقة الحدث بالمتابعة. لتحقيق ذلك، يُسمح لمكدس دعوات JS بالتفكيك ثم تنفيذ عملية الاستدعاء العائدة المُقدّمة على الفور، مما يسمح للشخص بإجراء مكالمات متكررة إلى process.nextTick() دون الوصول إلى RangeError: Maximum call stack size exceeded from v8.

قد تؤدي هذه الفلسفة إلى بعض المواقف التي قد تكون إشكالية. خذ هذا المقتطف كمثال:

js
let bar
// هذا لديه توقيع غير متزامن، لكنه يستدعي عملية الاستدعاء العائدة بشكل متزامن
function someAsyncApiCall(callback) {
  callback()
}
// يتم استدعاء عملية الاستدعاء العائدة قبل اكتمال `someAsyncApiCall`.
someAsyncApiCall(() => {
  // بما أن `someAsyncApiCall` لم يكتمل، لم يتم تعيين أي قيمة لـ bar
  console.log('bar', bar) // undefined
})
bar = 1

يُعرّف المستخدم someAsyncApiCall() ليكون له توقيع غير متزامن، ولكنه يعمل بالفعل بشكل متزامن. عند استدعائه، يتم استدعاء عملية الاستدعاء العائدة المُقدّمة إلى someAsyncApiCall() في نفس مرحلة حلقة الحدث لأن someAsyncApiCall() لا يقوم في الواقع بأي شيء بشكل غير متزامن. نتيجة لذلك، تحاول عملية الاستدعاء العائدة الرجوع إلى bar على الرغم من أنها قد لا تحتوي على هذا المتغير في النطاق بعد، لأن البرنامج النصي لم يتمكن من التشغيل حتى النهاية.

عن طريق وضع عملية الاستدعاء العائدة في process.nextTick()، لا يزال البرنامج النصي لديه القدرة على التشغيل حتى النهاية، مما يسمح بتهيئة جميع المتغيرات والدوّال وما إلى ذلك قبل استدعاء عملية الاستدعاء العائدة. كما أن لديها ميزة عدم السماح لحلقة الحدث بالاستمرار. قد يكون من المفيد للمستخدم أن يتم تنبيهه بخطأ قبل السماح لحلقة الحدث بالمتابعة. فيما يلي المثال السابق باستخدام process.nextTick():

js
let bar
function someAsyncApiCall(callback) {
  process.nextTick(callback)
}
someAsyncApiCall(() => {
  console.log('bar', bar) // 1
})
bar = 1

إليك مثال آخر من العالم الحقيقي:

js
const server = net.createServer(() => {}).listen(8080)
server.on('listening', () => {})

عندما يتم تمرير منفذ فقط، يتم ربط المنفذ على الفور. لذلك، يمكن استدعاء عملية الاستدعاء العائدة 'listening' على الفور. المشكلة هي أنه لن يتم تعيين عملية الاستدعاء العائدة .on('listening') بحلول ذلك الوقت.

للتغلب على ذلك، يتم وضع حدث 'listening' في قائمة انتظار nextTick() للسماح للبرنامج النصي بالتشغيل حتى النهاية. هذا يسمح للمستخدم بتعيين أي معالجات أحداث يرغب بها.

process.nextTick() مقابل setImmediate()

لدينا دعتان متشابهتان من وجهة نظر المستخدمين، لكن أسماؤهما مُربكة.

  • process.nextTick() تُنفذ على الفور في نفس المرحلة
  • setImmediate() تُنفذ في التكرار أو 'tick' التالي لحلقة الأحداث

في جوهر الأمر، يجب تبديل الأسماء. process.nextTick() تُنفذ بشكل أسرع من setImmediate()، لكن هذا نتيجة تاريخية من غير المرجح أن تتغير. سيؤدي إجراء هذا التبديل إلى تعطيل نسبة كبيرة من الحزم على npm. يتم إضافة المزيد من الوحدات النمطية الجديدة يوميًا، مما يعني أنه كلما انتظرنا، زادت احتمالية حدوث الأعطال. وبالرغم من أنها مُربكة، فلن تتغير الأسماء نفسها.

TIP

نوصي المطورين باستخدام setImmediate() في جميع الحالات لأنها أسهل في الفهم.

لماذا استخدام process.nextTick()؟

هناك سببان رئيسيان:

  1. السماح للمستخدمين بمعالجة الأخطاء، وتنظيف أي موارد غير ضرورية، أو ربما إعادة المحاولة قبل استمرار حلقة الأحداث.

  2. في بعض الأحيان، يكون من الضروري السماح بتشغيل وظيفة الاستدعاء بعد إلغاء تراكم المكالمات، ولكن قبل استمرار حلقة الأحداث.

مثال واحد هو مطابقة توقعات المستخدم. مثال بسيط:

js
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})

لنقل أن listen() يتم تشغيلها في بداية حلقة الأحداث، لكن وظيفة الاستدعاء للاستماع موضوعة في setImmediate(). ما لم يتم تمرير اسم المضيف، فسيتم ربط المنفذ على الفور. لكي تستمر حلقة الأحداث، يجب أن تصل إلى مرحلة الاستطلاع، مما يعني أن هناك احتمالًا غير صفري بأن يكون اتصال قد تم استقباله مما يسمح بإطلاق حدث الاتصال قبل حدث الاستماع.

مثال آخر هو توسيع EventEmitter وإصدار حدث من داخل المُنشئ:

js
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
  constructor() {
    super()
    this.emit('event')
  }
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
  console.log('an event occurred!')
})

لا يمكنك إصدار حدث من المُنشئ على الفور لأن البرنامج النصي لن يكون قد تم معالجته إلى النقطة التي يقوم فيها المستخدم بتعيين وظيفة استدعاء لذلك الحدث. لذلك، ضمن المُنشئ نفسه، يمكنك استخدام process.nextTick() لتعيين وظيفة استدعاء لإصدار الحدث بعد انتهاء المُنشئ، مما يوفر النتائج المتوقعة:

js
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
  constructor() {
    super()
    // use nextTick to emit the event once a handler is assigned
    process.nextTick(() => {
      this.emit('event')
    })
  }
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
  console.log('an event occurred!')
})