حلقة الأحداث (event loop) في Node.js هي الحلقة التي تقرر أي callback يعمل بعد أن تنتهي شيفرتك المتزامنة. تمر بمراحل في دورة ثابتة: المؤقتات (setTimeout وsetInterval)، والـ callbacks المعلّقة، وpoll (الإدخال والإخراج مثل قراءة الملفات وبيانات الشبكة)، وcheck (setImmediate)، وcallbacks الإغلاق. بعد كل callback، يفرّغ Node أولًا طابور process.nextTick ثم طابور الوعود، فيعمل هذان دائمًا قبل أن تنتقل الحلقة.
يطبع sync وnextTick وpromise وsetTimeout، أي عكس ترتيب كتابتها. الشيفرة المتزامنة تنتهي دائمًا أولًا، ثم يفرّغ Node طابور nextTick، ثم طابور الوعود، وعندها فقط تبدأ الحلقة وتصل إلى مرحلة المؤقتات.
مراحل حلقة الأحداث
تأتي حلقة Node من libuv، مكتبة C التي يستخدمها Node للإدخال والإخراج. قبل الدورة الأولى تشغّل المؤقتات التي حان وقتها بالفعل، ثم تشغّل كل دورة هذه المراحل بالترتيب:
- Pending callbacks: بعض callbacks النظام المؤجلة من الدورة السابقة، مثل بعض أخطاء الشبكة.
- Poll: أحداث الإدخال والإخراج الجديدة ودوال callback الخاصة بها (انتهت قراءة ملف، وصلت بيانات إلى مقبس). حين لا يكون شيء آخر مجدولًا، تنتظر الحلقة هنا.
- Check: دوال callback الخاصة بـ
setImmediate. - Close callbacks: أحداث
'close'، مثل مقبس أُتلف. - Timers: دوال callback الخاصة بـ
setTimeoutوsetIntervalالتي حان وقتها.
كثير من الأدلة تضع المؤقتات أولًا في كل دورة، وهكذا كان Node يعمل قبل الإصدار 20 (libuv 1.45). وتعمل دوال callback بالترتيب نفسه في الحالتين: بعد مؤقتات دورة ما تأتي مرحلة poll في الدورة التالية.
يقع طابوران خارج المراحل: طابور nextTick وطابور المهام الدقيقة (microtasks: دوال callback الخاصة بالوعود وqueueMicrotask). يفرّغ Node الاثنين بعد كل callback منفرد، في أي مرحلة، وnextTick أولًا. وتستمر الحلقة في الدوران ما دام هناك ما ينتظره (مؤقت، أو ملف أو مقبس مفتوح، أو خادم)، وتنتهي العملية حين لا يبقى شيء.
هذه حلقة Node الخاصة. يشارك المتصفح فكرة المهام والمهام الدقيقة، لكن ليست لديه مراحل ولا setImmediate ولا process.nextTick؛ وتغطي صفحة حلقة الأحداث في JavaScript الصورة على مستوى اللغة.
process.nextTick مقابل الوعود
كلاهما يعمل قبل أن تتابع الحلقة، لكنهما لا يختلطان. يشغّل Node طابور nextTick كاملًا، بما فيه الـ ticks المضافة أثناء تشغيله، ثم طابور الوعود بعد ذلك فقط. توقّع ناتج هذا:
الترتيب هو tick 1 وtick 2 وtick from tick وpromise 1 وpromise 2. الـ tick المجدول داخل tick ينضم إلى الطابور نفسه ويعمل مع ذلك قبل أي وعد، رغم أن الوعد أُنشئ أولًا.
في المستوى الأعلى من ES module (ملف يستخدم import) ينقلب الاثنان: شيفرة الوحدة نفسها تعمل داخل مهمة وعد، فتنتهي دوال callback الخاصة بوعودها قبل أن يصل Node إلى طابور nextTick. أما داخل دوال callback، فقاعدة nextTick أولًا تبقى صحيحة في نظامي الوحدات.
setImmediate مقابل setTimeout(fn, 0)
setTimeout(fn, 0) تعني فعليًا "بعد 1 ms على الأقل، في مرحلة timers". وsetImmediate(fn) تعني "في مرحلة check التالية". أيهما يعمل أولًا يعتمد على المكان الذي تستدعيهما منه.
داخل callback إدخال وإخراج يكون الترتيب ثابتًا. يعمل الـ callback في مرحلة poll، وتأتي مرحلة check بعدها مباشرة، ولا تأتي مرحلة timers إلا بعد ذلك:
يطبع nextTick وsetImmediate وsetTimeout، في كل مرة. وهذا يجعل setImmediate الخيار الصحيح لـ "شغّل هذا بعد عمل الإدخال والإخراج الحالي".
في الوحدة الرئيسية، خارج أي callback، ترتيب الاثنين غير ثابت:
// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
إذا مرّ 1 ms على الأقل حين تبدأ الحلقة، يكون المؤقت مستحقًا فيُطبع أولًا؛ وإلا يفوز setImmediate. لا تكتب شيفرة تعتمد على أي من النتيجتين.
| يعمل | استخدمه لـ | |
|---|---|---|
process.nextTick(fn) | مباشرة بعد العملية الحالية، قبل الوعود | إطلاق حدث أو خطأ بعد أن يربط المستدعي المستمعين |
Promise.then وqueueMicrotask | بعد أن يفرغ طابور nextTick | الشيفرة غير المتزامنة العادية |
setImmediate(fn) | مرحلة check، بعد callbacks الإدخال والإخراج | إفساح المجال للحلقة بين أجزاء العمل |
setTimeout(fn, 0) | مرحلة timers، بعد 1 ms على الأقل | التأخيرات التي لا تهم فيها اللحظة الدقيقة |
حجب حلقة الأحداث
لأن هناك خيط JavaScript واحدًا، فإن callback يعمل وقتًا طويلًا يوقف كل شيء آخر: المؤقتات، ودوال callback الخاصة بالإدخال والإخراج، وفي الخادم كل طلب آخر. تأخير المؤقت حد أدنى، لا ضمان.
يُطبع loop finished أولًا، ثم true: كان المؤقت مستحقًا بعد 10 ms، لكنه لم يستطع العمل حتى أعادت الحلقة المشغولة الخيط. في الخادم، الحلقة نفسها داخل معالج مسار تجمّد كل طلب آخر لنصف ثانية.
الحل للعمل الطويل هو تقسيمه وإعطاء الحلقة دورًا بين الأجزاء عبر setImmediate:
يدخل المؤقت بعد الجزء الأول. استبدل setImmediate(() => work(part + 1)) بـ work(part + 1) وشغّل من جديد: تعمل الأجزاء الثلاثة دفعة واحدة ويُطبع timer في النهاية.
أما العمل الثقيل من أوله إلى آخره، مثل معالجة الصور، فلا يكفيه التقسيم: انقله خارج الخيط الرئيسي عبر worker_threads.
أخطاء شائعة
استدعاء process.nextTick بشكل متكرر. الـ callback الذي يجدول nextTick آخر باستمرار لا يسمح للحلقة بالمتابعة أبدًا، فتجوع المؤقتات والإدخال والإخراج. استخدم setImmediate للعمل المتكرر.
استخدام الواجهات المتزامنة في الخادم. fs.readFileSync وcrypto.pbkdf2Sync واستدعاءات JSON.parse الكبيرة تحجب الحلقة عن كل المستخدمين. استخدم النسخ غير المتزامنة داخل معالجات الطلبات.
توقّع أن setTimeout(fn, 0) يعمل فورًا. يعمل بعد كل الشيفرة المتزامنة، وكل nextTick، وكل callback لوعد، وفقط بعد أن تصل الحلقة إلى مرحلة timers.
الأسئلة الشائعة
هل Node.js أحادي الخيط؟
شيفرة JavaScript الخاصة بك تعمل على خيط واحد، فلا يعمل callback اثنان في الوقت نفسه أبدًا. أما Node نفسه فيستخدم خيوطًا أكثر: تحتفظ libuv بمجموعة خيوط (أربعة خيوط افتراضيًا) لعمليات نظام الملفات واستعلامات DNS وبعض عمليات التشفير، وتعيد النتائج إلى الحلقة.
متى ينتهي برنامج Node.js؟
حين لا يبقى شيء تنتظره حلقة الأحداث: لا مؤقتات، ولا إدخال وإخراج معلّق، ولا خوادم أو مقابس (sockets) مفتوحة. يُبقي setInterval العامل أو الخادم المستمع العملية حية حتى تلغيه أو تغلقه.
هل حلقة الأحداث في Node.js هي نفسها في المتصفح؟
الفكرة واحدة، خيط واحد يشغّل دوال callback المصطفة، لكن التفاصيل تختلف. لا يوجد في المتصفح setImmediate ولا process.nextTick، وهو يدرج الرسم بين المهام، بينما يشغّل Node مراحل libuv. تغطي صفحة حلقة الأحداث في JavaScript الجزء المشترك على مستوى اللغة.
كيف أعرف أن حلقة الأحداث محجوبة؟
قِس مقدار تأخرها: تسجّل monitorEventLoopDelay() من perf_hooks هذا التأخير، والمتوسط الذي يبلغ عشرات الميلي ثانية يعني أن callback ما يمسك الخيط وقتًا طويلًا. شغّل Node مع --cpu-prof لترى أي دالة هي.