حلقة الحدث هي آلية Node.js للتعامل مع عمليات الإدخال/الإخراج غير المحظورة - على الرغم من أن JavaScript ذات خيط واحد - عن طريق تفريغ العمليات إلى نواة النظام عندما يكون ذلك ممكنًا.
نظرًا لأن معظم النوى اليوم متعددة الخيوط، فيمكنها التعامل مع مجموعة متنوعة من العمليات في الخلفية. عند اكتمال إحدى العمليات، تقوم النواة بإخطار Node.js بإضافة وظيفة رد الاتصال المناسبة إلى قائمة انتظار الاستقصاء وانتظار الفرصة للتنفيذ. وسنعرضها بالتفصيل لاحقاً في هذا المقال.
عند بدء تشغيل Node.js، فإنه سيبدأ حلقة الحدث ويعالج نص الإدخال المقدم (أو يرميه في REPL، وهو ما لم يتم تناوله في هذه المقالة، وقد يستدعي بعض واجهات برمجة التطبيقات غير المتزامنة، وجدولة الموقتات،). أو اتصل بـ process.nextTick()
ثم ابدأ في معالجة حلقة الحدث.
يوضح الرسم البياني أدناه نظرة عامة مبسطة على تسلسل عمليات حلقة الحدث.
┌─────────────────────────┐ ┌─>│ الموقتات │ │ └───────────┬─────────────┘ │ ┌───────────┴─────────────┐ │ │ عمليات الاسترجاعات المعلقة │ │ └───────────┬─────────────┘ │ ┌───────────┴─────────────┐ │ │ خاملاً ، استعد │ │ └─────────┬─────────┘ ┌────────── ──────┐ │ ┌───────────┴────────────┐ │ واردة: │ │ │ استطلاع │<─────┤ اتصالات ، │ │ └───────────┬─────────────┘ │ البيانات ، إلخ. │ │ ┌───────────┴───────────────── ──────┘ │ │ تحقق │ │ └───────────┬─────────────┘ │ ┌───────────┴─────────────┐ └──┤ إغلاق عمليات الاسترجاعات │ └─────────────────────────┘
ملاحظة: يسمى كل صندوق بمرحلة من مراحل آلية حلقة الحدث.
تحتوي كل مرحلة على قائمة انتظار FIFO لتنفيذ عمليات الاسترجاعات. على الرغم من أن كل مرحلة خاصة، إلا أنه بشكل عام عندما تدخل حلقة الحدث مرحلة معينة، فإنها ستنفذ أي عمليات خاصة بتلك المرحلة ثم تنفذ عمليات الاسترجاعات في قائمة انتظار تلك المرحلة حتى يتم استنفاد قائمة الانتظار أو تنفيذ الحد الأقصى لعدد عمليات الاسترجاعات. عند استنفاد قائمة الانتظار أو الوصول إلى حد رد الاتصال، تنتقل حلقة الحدث إلى المرحلة التالية، وهكذا.
نظرًا لأن أيًا من هذه العمليات قد تقوم بجدولة المزيد من العمليات والأحداث الجديدة التي تم وضعها في قائمة الانتظار بواسطة kernel لتتم معالجتها أثناء مرحلة الاستقصاء ، فقد يتم وضع أحداث الاستقصاء في قائمة الانتظار أثناء معالجة الأحداث في مرحلة الاستقصاء. لذلك، يمكن أن يسمح رد الاتصال طويل الأمد لمرحلة الاستقصاء بالعمل لفترة أطول من وقت عتبة المؤقت. راجع قسم الموقتات والاستطلاع لمزيد من المعلومات.
ملاحظة: هناك اختلافات طفيفة بين تطبيقات Windows وUnix/Linux، لكن هذا ليس مهمًا لغرض العرض التوضيحي. الجزء الأكثر أهمية هنا. هناك في الواقع سبع أو ثماني خطوات، ولكن ما يهمنا هو أن Node.js يستخدم بالفعل بعض الخطوات المذكورة أعلاه.
مؤقتsetTimeout()
و setInterval()
.setImmediate()
)، وفي الحالات الأخرى، سيتم حظر العقدة هنا عندما يكون ذلك مناسبًا.setImmediate()
هنا.socket.on('close', ...)
.بين كل تشغيل لحلقة الحدث، يتحقق Node.js لمعرفة ما إذا كان ينتظر أي إدخال/إخراج أو مؤقتات غير متزامنة، وإذا لم يكن الأمر كذلك، فسيتم إيقاف تشغيله تمامًا.
نظرة عامة تفصيلية علىتحدد المؤقتات الحد الأدنى الذي يمكن عنده تنفيذ رد الاتصال المقدم، بدلاً من الوقت المحدد الذي يريد المستخدم تنفيذه. بعد الفاصل الزمني المحدد، سيتم تشغيل رد اتصال المؤقت في أقرب وقت ممكن. ومع ذلك، قد تتأخر بسبب جدولة نظام التشغيل أو عمليات الاسترجاعات الجارية الأخرى.
ملاحظة : تتحكم مرحلة الاقتراع عند تنفيذ المؤقت.
على سبيل المثال، لنفترض أنك قمت بجدولة مؤقت ينتهي بعد 100 مللي ثانية، ثم يبدأ البرنامج النصي الخاص بك بشكل غير متزامن في قراءة ملف يستغرق 95 مللي ثانية:
const fs = require('fs'); وظيفة someAsyncOperation(رد الاتصال) { // افترض أن هذا يستغرق 95 مللي ثانية حتى يكتمل fs.readFile('/path/to/file', رد الاتصال); } const timeoutScheduled = Date.now(); setTimeout(() => { تأخير ثابت = Date.now() - timeoutScheduled; console.log(`لقد مرت مللي ثانية منذ أن تم جدولتي`); }, 100); // قم بإجراء someAsyncOperation الذي يستغرق 95 مللي ثانية لإكماله someAsyncOperation(() => { const startCallback = Date.now(); // افعل شيئًا سيستغرق 10 مللي ثانية ... بينما (Date.now() - startCallback < 10) { // لا تفعل شيئًا } });
عندما تدخل حلقة الحدث مرحلة الاستقصاء ، فإنها تحتوي على قائمة انتظار فارغة (لم يكتمل fs.readFile()
بعد)، لذا ستنتظر العدد المتبقي من المللي ثانية حتى يتم الوصول إلى الحد الأقصى للمؤقت. عندما ينتظر 95 مللي ثانية حتى ينتهي fs.readFile()
من قراءة الملف، ستتم إضافة رد الاتصال الخاص به، والذي يستغرق 10 مللي ثانية لإكماله، إلى قائمة انتظار الاستقصاء وتنفيذه. عند اكتمال رد الاتصال، لن يكون هناك المزيد من عمليات رد الاتصال في قائمة الانتظار، لذلك ستنظر آلية حلقة الحدث إلى المؤقت الذي وصل إلى العتبة بشكل أسرع ثم ستعود بعد ذلك إلى مرحلة المؤقت لتنفيذ رد اتصال المؤقت. في هذا المثال، سترى أن إجمالي التأخير بين المؤقت الذي تتم جدولته وتنفيذ رد الاتصال الخاص به سيكون 105 مللي ثانية.
ملاحظة: لمنع مرحلة الاستقصاء من تجويع حلقة الحدث، فإن libuv (مكتبة C التي تنفذ حلقة أحداث Node.js وكل السلوك غير المتزامن للنظام الأساسي) لديها أيضًا حد أقصى ثابت (يعتمد على النظام).
تنفذ هذه المرحلة عمليات رد الاتصال لبعض عمليات النظام (مثل أنواع أخطاء TCP). على سبيل المثال، تريد بعض أنظمة *nix الانتظار للإبلاغ عن خطأ إذا تلقى مقبس TCP ECONNREFUSED
عند محاولة الاتصال. سيتم وضع هذا في قائمة الانتظار للتنفيذ أثناء مرحلة رد الاتصال المعلقة .
مرحلة الاستقصاء لها وظيفتان مهمتان:
حساب المدة التي يجب فيها حظر الإدخال/الإخراج والاستقصاء عنه.
ثم قم بمعالجة الأحداث في قائمة انتظار الاستقصاء .
عندما تدخل حلقة الحدث مرحلة الاستقصاء ولا توجد مؤقتات مجدولة، سيحدث أحد أمرين:
إذا لم تكن قائمة انتظار الاستقصاء فارغة
، فسوف تتكرر حلقة الحدث عبر قائمة انتظار رد الاتصال وتنفذها بشكل متزامن حتى يتم استنفاد قائمة الانتظار أو تم الوصول إلى الحد الأقصى المتعلق بالنظام.
إذا كانت قائمة انتظار الاستقصاء فارغة ، فسيحدث شيئين آخرين:
إذا تمت جدولة البرنامج النصي بواسطة setImmediate()
، فستنهي حلقة الحدث مرحلة الاستقصاء وتستمر في مرحلة التحقق لتنفيذ تلك البرامج النصية المجدولة.
إذا لم تتم جدولة البرنامج النصي بواسطة setImmediate()
، فستنتظر حلقة الحدث حتى تتم إضافة رد الاتصال إلى قائمة الانتظار ثم تنفذه على الفور.
بمجرد أن تصبح قائمة انتظار الاستقصاء فارغة، تقوم حلقة الحدث بالتحقق من وجود مؤقت وصل إلى الحد الزمني الخاص به. إذا كان مؤقت واحد أو أكثر جاهزًا، فستلتف حلقة الحدث مجددًا إلى مرحلة المؤقت لتنفيذ عمليات الاسترجاعات الخاصة بتلك المؤقتات.
تسمح هذه المرحلة بتنفيذ رد اتصال مباشرة بعد اكتمال مرحلة الاستقصاء. إذا أصبحت مرحلة الاستقصاء خاملة وتم وضع البرنامج النصي في قائمة الانتظار بعد استخدام setImmediate()
، فقد تستمر حلقة الحدث في مرحلة التحقق بدلاً من الانتظار.
setImmediate()
هو في الواقع مؤقت خاص يعمل في مرحلة منفصلة من حلقة الحدث. ويستخدم واجهة برمجة تطبيقات libuv لجدولة عمليات رد الاتصال ليتم تنفيذها بعد اكتمال مرحلة الاستقصاء .
عادةً، عند تنفيذ التعليمات البرمجية، تصل حلقة الحدث في النهاية إلى مرحلة الاستقصاء، حيث تنتظر الاتصالات الواردة والطلبات وما إلى ذلك. ومع ذلك، إذا تمت جدولة رد الاتصال باستخدام setImmediate()
وأصبحت مرحلة الاستقصاء خاملة، فسوف تنهي هذه المرحلة وتستمر إلى مرحلة التحقق بدلاً من الاستمرار في انتظار حدث الاستقصاء.
إذا تم إغلاق المقبس أو المعالج فجأة (على سبيل المثال، socket.destroy()
)، فسيتم إطلاق الحدث 'close'
في هذه المرحلة. وإلا فسيتم إصداره عبر process.nextTick()
.
setImmediate()
و setTimeout()
متشابهان جدًا، لكنهما يتصرفان بشكل مختلف بناءً على وقت استدعائهما.
setImmediate()
لتنفيذ البرنامج النصي بمجرد اكتمال مرحلة الاستقصاء الحالية.setTimeout()
بتشغيل البرنامج النصي بعد تجاوز الحد الأدنى (بالمللي ثانية).سيختلف الترتيب الذي يتم به تنفيذ المؤقتات اعتمادًا على السياق الذي يتم استدعاؤها فيه. إذا تم استدعاء كليهما من داخل الوحدة الرئيسية، فسيكون المؤقت مرتبطًا بأداء العملية (والتي قد تتأثر بالتطبيقات الأخرى قيد التشغيل على الكمبيوتر).
على سبيل المثال، إذا قمت بتشغيل البرنامج النصي التالي الذي ليس داخل دورة الإدخال / الإخراج (أي الوحدة الرئيسية)، فإن الترتيب الذي يتم به تنفيذ المؤقتين غير حتمي لأنه محدد بأداء العملية:
// timeout_vs_immediate.js setTimeout(() => { console.log('المهلة'); }, 0); setImmediate(() => { console.log('فوري'); }); $ العقدة timeout_vs_immediate.js نفذ الوقت مباشر $ العقدة timeout_vs_immediate.js مباشر ومع
ذلك، إذا قمت بوضع هاتين الوظيفتين في حلقة الإدخال/الإخراج واستدعتهما، فسيتم دائمًا استدعاء setImmediate أولاً:
// timeout_vs_immediate.js const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('المهلة'); }, 0); setImmediate(() => { console.log('فوري'); }); }); $ العقدة timeout_vs_immediate.js مباشر نفذ الوقت $ العقدة timeout_vs_immediate.js مباشرالميزة الرئيسية لاستخدام setImmediate()
للمهلة
setImmediate()
setTimeout()
هي أنه إذا تمت جدولة setImmediate()
أثناء دورة الإدخال/الإخراج، فسيتم تنفيذها قبل أي مؤقت فيها، اعتمادًا على عدد المؤقتات غير المرتبطة
ربما لاحظت process.nextTick()
لا تظهر في الرسم التخطيطي، على الرغم من أنها جزء من واجهة برمجة التطبيقات غير المتزامنة. وذلك لأن process.nextTick()
ليس جزءًا من حلقة الحدث من الناحية الفنية. بدلاً من ذلك، سيتعامل مع nextTickQueue
بعد اكتمال العملية الحالية، بغض النظر عن المرحلة الحالية من حلقة الحدث. تعتبر العملية هنا انتقالًا من معالج C/C++ الأساسي، وتتعامل مع كود JavaScript الذي يجب تنفيذه.
بالنظر إلى الرسم البياني الخاص بنا، في أي وقت يتم فيه استدعاء process.nextTick()
في مرحلة معينة، سيتم حل جميع عمليات الاسترجاعات التي تم تمريرها إلى process.nextTick()
قبل استمرار حلقة الحدث. يمكن أن يخلق هذا بعض المواقف السيئة، لأنه يسمح لك "بتجويع" الإدخال/الإخراج الخاص بك عبر استدعاءات process.nextTick()
، مما يمنع حلقة الحدث من الوصول إلى مرحلة الاستقصاء .
لماذا يتم تضمين شيء كهذا في Node.js؟ جزء منها هو فلسفة التصميم حيث يجب أن تكون واجهة برمجة التطبيقات دائمًا غير متزامنة، على الرغم من أنها لا يجب أن تكون كذلك. خذ مقتطف الكود هذا كمثال:
function apiCall(arg, callback) { إذا (نوع الوسيطة !== 'سلسلة') عملية الإرجاع.nextTick( أتصل مرة أخرى، خطأ TypeError جديد ("يجب أن تكون الوسيطة عبارة عن سلسلة") ); }
مقتطف التعليمات البرمجية لفحص المعلمة. إذا كان غير صحيح، فسيتم تمرير الخطأ إلى وظيفة رد الاتصال. تم تحديث واجهة برمجة التطبيقات (API) مؤخرًا للسماح بتمرير الوسائط إلى process.nextTick()
مما سيسمح لها بقبول أي وسيطة بعد موضع وظيفة رد الاتصال وتمرير الوسائط إلى وظيفة رد الاتصال كوسيطات إلى وظيفة رد الاتصال حتى لا يكون لديك إلى وظيفة العش.
ما نقوم به هو تمرير الخطأ مرة أخرى إلى المستخدم، ولكن فقط بعد تنفيذ بقية التعليمات البرمجية الخاصة بالمستخدم. باستخدام process.nextTick()
، نضمن أن apiCall()
ينفذ دائمًا وظيفة رد الاتصال الخاصة به بعد بقية كود المستخدم وقبل السماح بمتابعة حلقة الحدث. ولتحقيق ذلك، يُسمح لمكدس استدعاءات JS بالاسترخاء ثم تنفيذ رد الاتصال المقدم على الفور، مما يسمح بإجراء استدعاءات متكررة إلى process.nextTick()
دون الضغط على RangeError: 超过V8 的最大调用堆栈大小
.
يمكن أن يؤدي مبدأ التصميم هذا إلى بعض المشاكل المحتملة. خذ مقتطف الشفرة هذا كمثال:
Let bar; // يحتوي هذا على توقيع غير متزامن، ولكنه يستدعي رد الاتصال بشكل متزامن وظيفة someAsyncApiCall(رد الاتصال) { أتصل مرة أخرى()؛ } // يتم استدعاء رد الاتصال قبل اكتمال `someAsyncApiCall`. someAsyncApiCall(() => { // منذ اكتمال someAsyncApiCall، لم يتم تعيين أي قيمة للشريط console.log('bar', bar); }); bar = 1؛
يعرّف المستخدم someAsyncApiCall()
على أنه يحتوي على توقيع غير متزامن، ولكنه في الواقع يعمل بشكل متزامن. عندما يتم استدعاؤه، يتم استدعاء رد الاتصال المقدم إلى someAsyncApiCall()
في نفس المرحلة من حلقة الحدث لأن someAsyncApiCall()
لا يفعل أي شيء بشكل غير متزامن. نتيجة لذلك، تحاول وظيفة رد الاتصال الرجوع إلى bar
، لكن المتغير قد لا يكون في النطاق بعد لأن البرنامج النصي لم ينته من التشغيل بعد.
من خلال وضع رد الاتصال في process.nextTick()
، يظل البرنامج النصي قادرًا على التشغيل حتى الاكتمال، مما يسمح بتهيئة جميع المتغيرات والوظائف وما إلى ذلك قبل استدعاء رد الاتصال. كما أن لديها ميزة عدم السماح بمتابعة حلقة الحدث، وهي مناسبة لتحذير المستخدم عند حدوث خطأ قبل السماح بمتابعة حلقة الحدث. هذا هو المثال السابق باستخدام process.nextTick()
:
Let bar; وظيفة someAsyncApiCall(رد الاتصال) { Process.nextTick(callback); } someAsyncApiCall(() => { console.log('bar', bar); }); bar = 1
وهذا مثال حقيقي آخر:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
فقط عند تمرير المنفذ، سيتم ربط المنفذ على الفور. لذلك، يمكن استدعاء رد الاتصال 'listening'
على الفور. المشكلة هي أن رد الاتصال .on('listening')
لم يتم تعيينه في ذلك الوقت.
للتغلب على هذه المشكلة، يتم وضع الحدث 'listening'
في قائمة الانتظار داخل nextTick()
للسماح بتشغيل البرنامج النصي حتى الاكتمال. يتيح ذلك للمستخدم تعيين معالجات الأحداث التي يريدها.
بقدر ما يتعلق الأمر بالمستخدم، لدينا مكالمتين متشابهتين، لكن أسمائهما مربكتان.
process.nextTick()
فورًا في نفس المرحلة.setImmediate()
عند التكرار أو "العلامة" التالية لحلقة الحدث.بشكل أساسي، يجب تبديل الاسمين لأن process.nextTick()
تعمل بشكل أسرع من setImmediate()
، ولكن هذا إرث من الماضي وبالتالي من غير المرجح أن يتغير. إذا قمت بتبديل الأسماء بشكل متهور، فسوف تؤدي إلى كسر معظم الحزم الموجودة على npm. تتم إضافة المزيد من الوحدات الجديدة كل يوم، مما يعني أنه يتعين علينا الانتظار كل يوم، كلما زاد احتمال حدوث الضرر. وعلى الرغم من أن هذه الأسماء مربكة، إلا أن الأسماء نفسها لن تتغير.
نوصي المطورين باستخدام setImmediate()
في جميع المواقف لأنه أسهل في الفهم.
هناك سببان رئيسيان:
السماح للمستخدم بمعالجة الأخطاء، أو تنظيف أي موارد غير ضرورية، أو إعادة محاولة الطلب قبل متابعة حلقة الحدث.
في بعض الأحيان يكون من الضروري تشغيل رد الاتصال بعد فتح المكدس ولكن قبل استمرار حلقة الحدث.
فيما يلي مثال بسيط يلبي توقعات المستخدم:
const server = net.createServer(); server.on('connection', (conn) => {}); server.listen(8080); server.on('listening', () => {});
افترض أن listen()
يعمل في بداية حلقة الحدث، ولكن يتم وضع رد الاتصال في setImmediate()
. ما لم يتم تمرير اسم المضيف، سيتم ربط المنفذ على الفور. لكي تستمر حلقة الحدث، يجب أن تصل إلى مرحلة الاستقصاء ، مما يعني أنه من الممكن أن يتم تلقي اتصال وتم إطلاق حدث الاتصال قبل حدث الاستماع.
مثال آخر يقوم بتشغيل مُنشئ دالة يرث من EventEmitter
ويريد استدعاء المُنشئ:
const EventEmitter = require('events'); const util = require('util'); الدالة MyEmitter() { EventEmitter.call(this); this.emit('event'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log("حدث حدث!"); });
لا يمكنك تشغيل الحدث مباشرة من المُنشئ لأن البرنامج النصي لم تتم معالجته بعد إلى النقطة التي يقوم فيها المستخدم بتعيين وظيفة رد اتصال للحدث. لذلك، في المنشئ نفسه، يمكنك استخدام process.nextTick()
لإعداد رد اتصال بحيث يتم إرسال الحدث بعد اكتمال المنشئ، وهو ما هو متوقع:
const EventEmitter = require('events'); const util = require('util'); الدالة MyEmitter() { EventEmitter.call(this); // استخدم nextTick لإصدار الحدث بمجرد تعيين المعالج عملية.nextTick(() => { this.emit('event'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log("حدث حدث!"); });
المصدر: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/