قياس وعد Node.js

نشرت: 2022-03-11

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

آه ، وعود. لم يكن المفهوم الكامل للوعد ورد الاتصال منطقيًا بالنسبة لي عندما بدأت تعلم Node.js. لقد اعتدت على الطريقة الإجرائية لتنفيذ التعليمات البرمجية ، لكن مع مرور الوقت فهمت سبب أهميتها.

يقودنا هذا إلى السؤال ، لماذا تم تقديم الاسترجاعات والوعود على أي حال؟ لماذا لا يمكننا فقط كتابة التعليمات البرمجية المنفذة تسلسليًا في JavaScript؟

حسنًا ، من الناحية الفنية يمكنك ذلك. لكن هل يجب عليك ذلك؟

في هذه المقالة ، سأقدم مقدمة موجزة عن JavaScript ووقت تشغيلها ، والأهم من ذلك ، اختبار الاعتقاد السائد في مجتمع JavaScript بأن الكود المتزامن دون المستوى في الأداء ، وبمعنى ما ، مجرد شر واضح ، ولا ينبغي أبدًا يستخدم. هل هذه الأسطورة حقا؟

قبل أن تبدأ ، تفترض هذه المقالة أنك على دراية بالوعود في JavaScript ، ومع ذلك ، إذا لم تكن بحاجة إلى تجديد معلومات أو إذا لم تكن بحاجة إلى تحديث ، فالرجاء الاطلاع على وعود JavaScript: برنامج تعليمي مع أمثلة

ملاحظة تم اختبار هذه المقالة على بيئة Node.js ، وليست بيئة JavaScript خالصة. تشغيل Node.js الإصدار 10.14.2. ستعتمد جميع المعايير والنحو بشكل كبير على Node.js. تم إجراء الاختبارات على جهاز MacBook Pro 2018 بمعالج Intel i5 8th Generation رباعي النواة يعمل بسرعة أساسية تبلغ 2.3 جيجا هرتز.

حلقة الحدث

وعد Node.js المعياري: رسم توضيحي لحلقة حدث Node.js

مشكلة كتابة JavaScript هي أن اللغة نفسها مترابطة. هذا يعني أنه لا يمكنك تنفيذ أكثر من إجراء واحد في وقت واحد على عكس اللغات الأخرى ، مثل Go أو Ruby ، ​​التي لديها القدرة على إنتاج مؤشرات الترابط وتنفيذ إجراءات متعددة في نفس الوقت ، إما على سلاسل عمليات kernel أو على سلاسل العمليات .

لتنفيذ التعليمات البرمجية ، يعتمد JavaScript على إجراء يسمى حلقة الحدث والتي تتكون من مراحل متعددة. تمر عملية JavaScript بكل مرحلة ، وفي النهاية ، تبدأ من جديد. يمكنك قراءة المزيد حول التفاصيل في دليل node.js الرسمي هنا.

لكن JavaScript لديها شيء ما في جعبتها لمحاربة مشكلة الحجب. عمليات الاسترجاعات I / O.

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

تتفوق JavaScript في شيء واحد أكثر من أي عامل آخر ، وهو التعامل مع عمليات الإدخال / الإخراج. يتيح لك JavaScript استدعاء عملية الإدخال / الإخراج مثل طلب البيانات من قاعدة بيانات ، أو قراءة ملف في الذاكرة ، أو كتابة ملف إلى قرص ، أو تنفيذ أمر shell ، وما إلى ذلك. عند اكتمال العملية ، يمكنك تنفيذ رد اتصال. أو في حالة الوعود حل الوعد بالنتيجة أو رفضه بالخطأ.

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

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

المعيار

سيهدف الاختبار الذي سنجريه إلى تزويدنا بمعايير حول مدى سرعة تشغيل رمز المزامنة وغير المتزامن وما إذا كان هناك اختلاف في الأداء.

قررت اختيار قراءة ملف كعملية الإدخال / الإخراج للاختبار.

أولاً ، كتبت وظيفة ستكتب ملفًا عشوائيًا مليئًا بالبايتات العشوائية التي تم إنشاؤها باستخدام وحدة التشفير Node.js.

 const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )

سيكون هذا الملف بمثابة ثابت لخطوتنا التالية وهي قراءة الملف. ها هو الرمز

 const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()

أدى تشغيل الكود السابق إلى النتائج التالية:

يركض # مزامنة غير متزامن نسبة عدم التزامن / المزامنة
1 0.278 مللي ثانية 3.829 مللي ثانية 13.773
2 0.335 مللي ثانية 3.801 مللي ثانية 11.346
3 0.403 مللي ثانية 4.498 مللي ثانية 11.161

كان هذا غير متوقع. كانت توقعاتي الأولية أنها يجب أن تأخذ نفس الوقت. حسنًا ، ماذا لو نضيف ملفًا آخر ونقرأ ملفين بدلاً من ملف واحد؟

لقد قمت بنسخ الملف الذي تم إنشاؤه test.txt وسميته test2.txt. ها هو الرمز المحدث:

 function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }

لقد أضفت ببساطة قراءة أخرى لكل منهم ، وفي الوعود ، كنت أنتظر وعود القراءة التي يجب أن تعمل بالتوازي. كانت هذه النتائج:

يركض # مزامنة غير متزامن نسبة عدم التزامن / المزامنة
1 1.659 مللي ثانية 6.895 مللي ثانية 4.156
2 0.323 مللي ثانية 4.048 مللي ثانية 12.533
3 0.324 مللي ثانية 4.017 مللي ثانية 12.398
4 0.333 مللي ثانية 4.271 مللي ثانية 12.826

الأول له قيم مختلفة تمامًا عن الأشواط الثلاثة التالية. تخميني هو أنه مرتبط بمحول JavaScript JIT الذي يحسن الكود في كل تشغيل.

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

لذا فإن اختباري التالي يتضمن كتابة 100 ملف مختلف ثم قراءتها جميعًا.

أولاً ، قمت بتعديل الكود لكتابة 100 ملف قبل تنفيذ الاختبار. تختلف الملفات في كل مرة ، على الرغم من الحفاظ على نفس الحجم تقريبًا ، لذلك نقوم بمسح الملفات القديمة قبل كل تشغيل.

ها هو الرمز المحدث:

 let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }

وللتنظيف والتنفيذ:

 let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()

ودعونا نركض.

هنا جدول النتائج:

يركض # مزامنة غير متزامن نسبة عدم التزامن / المزامنة
1 4.999 مللي ثانية 12.890 مللي ثانية 2.579
2 5.077 مللي ثانية 16.267 مللي ثانية 3.204
3 5.241 مللي ثانية 14.571 مللي ثانية 2.780
4 5.086 مللي ثانية 16.334 مللي ثانية 3.213

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

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

ها هو الكود:

 function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
يركض # وعد واحد 100 وعود
1 1.651 مللي ثانية 3.293 مللي ثانية
2 0.758 مللي ثانية 2.575 مللي ثانية
3 0.814 مللي ثانية 3.127 مللي ثانية
4 0.788 مللي ثانية 2.623 مللي ثانية

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

كلمة أخيرة

إذن هل يجب أن تستخدم الوعود أم لا؟ سيكون رأيي كما يلي:

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

يمكنك العثور على رمز لجميع الوظائف في هذه المقالة في المستودع.

الخطوة المنطقية التالية في رحلة مطور JavaScript ، من الوعود ، هي بناء الجملة غير المتزامن / المنتظر. إذا كنت ترغب في معرفة المزيد عنها ، وكيف وصلنا إلى هنا ، فراجع JavaScript غير متزامن: من Callback Hell إلى Async و Await .