Skip to content

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

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

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

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

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

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

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

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

TIP

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

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

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

TIP

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

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

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

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

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

المؤقتات

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

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);
// قم بتشغيل someAsyncOperation الذي يستغرق 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() وأصبحت مرحلة الاستقصاء خاملة، فستنتهي وتستمر إلى مرحلة التحقق بدلاً من انتظار أحداث الاستقصاء.

close callbacks

إذا تم إغلاق مقبس أو معالج بشكل مفاجئ (على سبيل المثال، 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++ الأساسي، ومعالجة JavaScript التي تحتاج إلى التنفيذ.

بالنظر إلى الرسم التخطيطي الخاص بنا، في أي وقت تقوم فيه باستدعاء 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); // غير معرف
});
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!');
});