أساسيات JavaScript: لماذا يجب أن تعرف كيف يعمل المحرك

هذا المقال متوفر أيضًا باللغة الإسبانية.

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

سترى أدناه دالة ذات سطر واحد تُرجع الخاصية lastName للوسيطة التي تم تمريرها. بمجرد إضافة خاصية واحدة لكل كائن ، ينتهي بنا الأمر مع انخفاض في الأداء بأكثر من 700٪!

كما سأشرح بالتفصيل ، فإن افتقار JavaScript للأنواع الثابتة يقود هذا السلوك. بمجرد النظر إليها على أنها ميزة على اللغات الأخرى مثل C # أو Java ، اتضح أنها أكثر من "صفقة فاوستية".

الكبح بأقصى سرعة

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

عظيم!

دع الآخرين يقومون برفع الأشياء الثقيلة. لماذا تهتم بالقلق بشأن كيفية عمل المحركات؟

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

(() => { const han = {firstname: "Han", lastname: "Solo"}; const luke = {firstname: "Luke", lastname: "Skywalker"}; const leia = {firstname: "Leia", lastname: "Organa"}; const obi = {firstname: "Obi", lastname: "Wan"}; const yoda = {firstname: "", lastname: "Yoda"}; const people = [ han, luke, leia, obi, yoda, luke, leia, obi ]; const getName = (person) => person.lastname;
 console.time("engine"); for(var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd("engine"); })();

في Intel i7 4510U ، وقت التنفيذ حوالي 1.2 ثانية. حتى الان جيدة جدا. نضيف الآن خاصية أخرى لكل كائن وننفذها مرة أخرى.

(() => { const han = { firstname: "Han", lastname: "Solo", spacecraft: "Falcon"}; const luke = { firstname: "Luke", lastname: "Skywalker", job: "Jedi"}; const leia = { firstname: "Leia", lastname: "Organa", gender: "female"}; const obi = { firstname: "Obi", lastname: "Wan", retired: true}; const yoda = {lastname: "Yoda"};
 const people = [ han, luke, leia, obi, yoda, luke, leia, obi];
 const getName = (person) => person.lastname;
 console.time("engine"); for(var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd("engine");})();

وقت التنفيذ لدينا الآن 8.5 ثانية ، وهو حوالي 7 أضعاف أبطأ من نسختنا الأولى. هذا يشبه الضغط على الفرامل بأقصى سرعة. كيف يمكن أن يحدث ذلك؟

حان الوقت لإلقاء نظرة فاحصة على المحرك.

القوات المشتركة: المترجم والمترجم

المحرك هو الجزء الذي يقرأ وينفذ الكود المصدري. كل بائع متصفح رئيسي لديه محركه الخاص. يحتوي Mozilla Firefox على Spidermonkey ، و Microsoft Edge لديه Chakra / ChakraCore وتسمي Apple Safari محركها JavaScriptCore. يستخدم Google Chrome V8 ، وهو أيضًا محرك Node.js.

كان إطلاق محرك V8 في عام 2008 بمثابة لحظة محورية في تاريخ المحركات. حل V8 محل تفسير المتصفح البطيء نسبيًا لجافا سكريبت.

يكمن السبب وراء هذا التحسن الهائل بشكل أساسي في الجمع بين المترجم والمترجم. اليوم ، تستخدم جميع المحركات الأربعة هذه التقنية.

يقوم المترجم الفوري بتنفيذ التعليمات البرمجية المصدر على الفور تقريبًا. ينشئ المترجم كود الآلة الذي ينفذه نظام المستخدم مباشرة.

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

الفكرة الرئيسية وراء المحركات الحديثة هي الجمع بين أفضل ما في العالمين:

  • بدء التطبيق السريع للمترجم.
  • التنفيذ السريع للمترجم.

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

نحن نسمي سلوك المترجم "في الوقت المناسب" أو ببساطة JIT.

عندما يعمل المحرك بشكل جيد ، يمكنك تخيل بعض السيناريوهات التي يتفوق فيها JavaScript على C ++. لا عجب أن يذهب معظم عمل المحرك إلى هذا "التحسين السياقي".

الأنواع الثابتة أثناء وقت التشغيل: التخزين المؤقت المضمّن

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

يقوم المحرك بتعيين كل كائن إلى "نوع" يقوم بإنشائه أثناء وقت التشغيل. يدعو V8 هذه "الأنواع" ، والتي ليست جزءًا من معيار ECMAScript أو الفئات المخفية أو أشكال الكائنات. لكي يشترك كائنان في نفس شكل الكائن ، يجب أن يكون لكلا الكائنين نفس الخصائص تمامًا وبنفس الترتيب. لذلك {firstname: "Han", lastname: "Solo"}سيتم تخصيص كائن لفئة مختلفة عن {lastname: "Solo", firstname: "Han"}.

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

ما يفعله Inline Caching هو التخلص من عمليات البحث. لا عجب أن هذا ينتج عنه تحسن كبير في الأداء.

وبالعودة إلى مثالنا السابق: جميع الكائنات في الجولة الأولى سوى اثنين من الخصائص، firstnameو lastname، في نفس الترتيب. لنفترض أن الاسم الداخلي لشكل هذا الكائن هو p1. عندما يطبق المترجم IC ، فإنه يفترض أن الدالة تحصل فقط على شكل الكائن p1وتعيد القيمة على lastnameالفور.

ومع ذلك ، في الجولة الثانية ، تعاملنا مع 5 أشكال مختلفة من الكائنات. كان لكل كائن خاصية إضافية yodaوكان مفقودًا firstnameتمامًا. ماذا يحدث بمجرد أن نتعامل مع أشكال متعددة للكائنات؟

تدخل البط أو أنواع متعددة

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

يعمل التخزين المؤقت المضمّن على التخلص من البحث المكلف عن موقع ذاكرة الخاصية. يعمل بشكل أفضل عندما يكون للكائن نفس شكل الكائن عند كل وصول للخاصية. وهذا ما يسمى IC أحادي الشكل.

إذا كان لدينا ما يصل إلى أربعة أشكال مختلفة للكائنات ، فنحن في حالة IC متعددة الأشكال. كما هو الحال في monomorphic ، فإن كود الآلة المحسّن "يعرف" بالفعل جميع المواقع الأربعة. ولكن يجب أن تتحقق من أحد الأشكال الأربعة الممكنة التي تنتمي إليها الوسيطة التي تم تمريرها. ينتج عن هذا انخفاض في الأداء.

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

متعدد الأشكال ومتحول في العمل

أدناه نرى ذاكرة تخزين مؤقت مضمنة متعددة الأشكال مع شكلين مختلفين للكائن.

و IC الضخم من مثال الكود لدينا مع 5 أشكال مختلفة للكائنات:

فئة JavaScript للإنقاذ

حسنًا ، كان لدينا 5 أشكال للكائنات وواجهنا دائرة متكاملة متضخمة. كيف يمكننا إصلاح هذا؟

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

بالنسبة للخصائص التي لم يتم تعريفها ، فإننا ببساطة نمررها nullأو نتجاهلها. يتأكد المنشئ من تهيئة هذه الحقول بقيمة:

(() => { class Person { constructor({ firstname = '', lastname = '', spaceship = '', job = '', gender = '', retired = false } = {}) { Object.assign(this, { firstname, lastname, spaceship, job, gender, retired }); } }
 const han = new Person({ firstname: 'Han', lastname: 'Solo', spaceship: 'Falcon' }); const luke = new Person({ firstname: 'Luke', lastname: 'Skywalker', job: 'Jedi' }); const leia = new Person({ firstname: 'Leia', lastname: 'Organa', gender: 'female' }); const obi = new Person({ firstname: 'Obi', lastname: 'Wan', retired: true }); const yoda = new Person({ lastname: 'Yoda' }); const people = [ han, luke, leia, obi, yoda, luke, leia, obi ]; const getName = person => person.lastname; console.time('engine'); for (var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd('engine');})();

عندما ننفذ هذه الوظيفة مرة أخرى ، نرى أن وقت التنفيذ لدينا يعود إلى 1.2 ثانية. تم انجاز العمل!

ملخص

تجمع محركات JavaScript الحديثة بين مزايا المترجم والمترجم: بدء تشغيل سريع للتطبيق وتنفيذ سريع للكود.

يعد Inline Caching تقنية تحسين فعالة. يعمل بشكل أفضل عندما ينتقل شكل كائن واحد فقط إلى الوظيفة المحسّنة.

أظهر المثال الجذري الذي قدمته تأثيرات الأنواع المختلفة لـ Inline Caching وعقوبات الأداء للمخابئ الضخمة.

يعد استخدام فئات JavaScript ممارسة جيدة. ترانزبيليرات ثابتة مكتوبة ، مثل TypeScript ، تزيد من احتمالية وجود IC أحادية الشكل.

قراءة متعمقة

  • ديفيد مارك كليمنتس: أدوات قتل الأداء لـ TurboShift والإشعال: //github.com/davidmarkclements/v8-perf
  • فيكتور فيلدر: فئات محركات جافا سكريبت المخفية

    //draft.li/blog/2016/12/22/javascript-engines-hidden-classes

  • Jörg W. Mittag: نظرة عامة على مترجم JIT ومترجم

    //softwareengineering.stackexchange.com/questions/246094/understanding-the-differences-traditional-interpreter-jit-compiler-jit-interp/269878#269878

  • فياتشيسلاف إيغوروف: ما الأمر مع Monomorphism

    //mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html

  • ويب كوميك يشرح جوجل كروم

    //www.google.com/googlebooks/chrome/big_00.html

  • Huiren Woo: الاختلافات بين V8 و ChakraCore

    //developers.redhat.com/blog/2016/05/31/javascript-engine-performance-comparison-v8-charkra-chakra-core-2/

  • Seth Thompson: V8 و Advanced JavaScript و Next Performance Frontier

    //www.youtube.com/watch؟v=EdFDJANJJLs

  • Franziska Hinkelmann - تحديد أداء محرك V8

    //www.youtube.com/watch؟v=j6LfSlg8Fig

  • Benedikt Meurer: مقدمة للتحسين التأملي في V8

    //ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8

  • Mathias Bynens: أساسيات محرك JavaScript: الأشكال و Inline Caches

    //mathiasbynens.be/notes/shapes-ics