جاوا اسکریپت چگونه کار می‌کند؟ بخش دوم: درون موتور V8 و 5 نکته در مورد نوشتن کد بهینه
صبا کاشی
برنامه نویسی
1399/10/3
12 دقیقه
مدتیست مجموعه‌ای را آغاز کردیم که هدف آن کاوش بیشتر در JavaScript و چگونگی عملکرد آن است؛ فکر کردیم که با دانستن عناصر سازنده JavaScript و نحوه تعامل آن‌ها با یکدیگر، می‌توانید کد و برنامه‌های بهتری بنویسید.
به ارائه نمای کلی از موتور، زمان اجرا (runtime) و پشته فراخوانی (call stack) اختصاص یافت. در این پست، به قسمت‌های داخلی موتور V8 جاوا اسکریپت گوگل می‌پردازیم. ما همچنین چند نکته در مورد چگونگی نوشتن کدهای بهتر جاوا اسکریپت ارائه خواهیم داد - بهترین روش‌هایی که تیم توسعه ما در ایمن پردازش هنگام ساخت محصول دنبال می‌کند.

بررسی اجمالی

یک موتور جاوا اسکریپت، برنامه یا مفسری (interpreter) است که کد جاوا اسکریپت را اجرا می‌کند. یک موتور جاوا اسکریپت می تواند به عنوان یک مفسر استاندارد یا کامپایلر پویا (just-in-time compiler) که به نوعی جاوا اسکریپت را به بایت کد کامپایل می‌کند، اجرا شود.
در اینجا لیستی از پروژه‌های معروف را داریم که یک موتور جاوا اسکریپت را اجرا می‌کنند:
  • V8- متن باز، ارائه شده توسط گوگل، نوشته شده با C++
  • Rhino- تحت مدیریت بنیاد Mozilla، متن باز، به طور کامل با جاوا نوشته شده
  • SpiderMonkey- اولین موتور جاوا اسکریپت، قبلا در Netscape استفاده می‌شد و الان در Firefox
  • JavaScriptCore- متن باز، با نام Nitro به بازار عرضه شد و توسط اپل برای Safari توسعه داده شد
  • KJS- موتور KDE در ابتدا توسط هری پورتن برای مرورگر Konqueror پروژه KDE ساخته شده است
  • Chakra (Jscript9)- Internet Explorer
  • Chakra (JavaScript)- Microsoft Edge
  • Nashron- متن باز، قسمتی از OpenJDK، با Oracle Java و Tool Group نوشته شده
  • JerryScript- یک موتور سبک برای اینترنت اشیا

موتور V8 چرا ساخته شد ؟

موتور V8 که توسط گوگل ساخته شده، متن باز است و به زبان C++ نوشته شده است. این موتور در داخل Google Chrome استفاده می‌شود. همچنین از V8 درون Node.js نیز استفاده می شود.
google V8 js engineموتور V8 گوگل
V8 ابتدا برای افزایش عملکرد اجرای JavaScript در داخل مرورگرهای وب طراحی شد. برای افزایش سرعت، V8 به جای استفاده از مفسر، کد جاوا اسکریپت را به کد ماشین (machine code)، که کارآمدتر است، تبدیل می‌کند. این موتور، کد جاوا اسکریپت را هنگام اجرا، با پیاده‌سازی یک کامپایلر پویا (JIT: Just-in-time)، مانند بسیاری از موتورهای مدرن جاوا اسکریپت دیگر، از جمله SpiderMonkey یا Rhino (Mozilla)، به کد ماشین در می‌آورد. تفاوت اصلی در اینجا این است که V8، بایت کد یا کد میانی تولید نمی‌کند.

V8 در گذشته دو کامپایلر داشته

قبل از انتشار نسخه 5.9 V8 ( که اوایل سال 2017 منتشر شد)، موتور از دو کامپایلر استفاده می‌کرد:
  • Full-codegen- یک کامپایلر ساده و پرسرعت که دستور العمل ماشین ساده و نسبتا کندی را تولید می‌کرد.
  • Crankshaft- یک کامپایلر بهینه پیچیده تر (پویا: JIT)، که کدی بسیار بهینه تولید می‌کرد.
موتور V8 همچنین از چندین رشته (thread) داخلی استفاده می کند:
  • رشته اصلی همان کاری را که شما انتظار دارید، انجام می‌دهد: کد خود را fetch کنید، آن را کامپایل کنید و سپس اجرا کنید.
  • همچنین یک رشته جداگانه برای کامپایل وجود دارد، بنابراین در هنگام بهینه سازی کد، رشته اصلی می‌تواند به اجرای خود ادامه دهد.
  • یک رشته پروفایلر (Profiler) که در زمان استفاده به ما می‌گوید که زمان زیادی را صرف چه روش‌هایی می کنیم تا CrankShaft بتواند آنها را بهینه کند.
  • چند رشته دیگر که کار Garbage collection را انجام می‌دهند.
هنگام اجرای کد جاوا اسکریپت برای اولین بار، V8 از full-codegen استفاده می‌کند که به طور مستقیم جاوا اسکریپت تجزیه شده را، بدون هیچ گونه تغییری، به کد ماشین ترجمه می‌کند. این کار باعث می‌شود که خیلی سریع کد ماشین را اجرا کند. توجه داشته باشید که V8 از بایت کد استفاده نمی‌کند و از این طریق، نیاز به مفسر را برطرف کرده است.
وقتی کد شما برای مدتی اجرا می‌شد، رشته پروفایلر داده‌های کافی را جمع آوری کرده است تا مشخص کند کدام روش باید بهینه شود.
در مرحله بعدی ، بهینه‌سازی CrankShaft در یک رشته دیگر شروع می‌شود. به این صورت که؛ درخت نحوی انتزاعی جاوا اسکریپت (the java script abstract syntax tree) را، به یک فرم تخصیص ایستای منفرد (SSA: single static-assignment form) سطح بالا، به نام هیدروژن تبدیل کرده و سعی می‌کند آن نمودار هیدروژن را بهینه کند. بیشتر بهینه‌سازی‌ها در این سطح انجام می‌شود.

Inline کردن

اولین بهینه‌سازی، inline کردن هرچه بیشتر کدها، از قبل است. Inline کردن، فرایند جایگزینی منطقه فراخوانده شده (خطی در کد که در آن تابع فراخوانی می‌شود) با بدنه تابع فراخوانی شده است. این مرحله ساده، این امکان را می‌دهد که بهینه‌سازی‌های بعدی، معنادارتر باشند.
inlining process in codeنحوه ی inline کردن کد ها

کلاس‌های مخفی (Hidden Classes)

جاوا اسکریپت یک زبان مبتنی بر نمونه اولیه است: هیچ کلاس و اشیایی با استفاده از یک فرآیند شبیه‌سازی (Cloning) ایجاد نمی‌شوند. جاوا اسکریپت همچنین یک زبان برنامه‌نویسی پویا است، به این معنی که می‌توان خصوصیات (Properties) را به راحتی بعد از نمونه‌سازی، به آبجکت ها اضافه یا از آن ها جدا کرد.
بیشتر مفسران جاوا اسکریپت، از ساختارهای دیکشنری مانند مبتنی بر تابع هش برای ذخیره مقادیر پراپرتی‌های اشیا در حافظه، استفاده می‌کنند. این ساختار، بازیابی مقدار یک پراپرتی در جاوا سکریپت را کندتر از بازیابی آن در یک زبان برنامه‌نویسی غیر پویا، مثل جاوا یا C#، می‌کند. در جاوا، تمام خصوصیات شی، قبل از کامپایل کردن، توسط یک چیدمان ثابت شئ، تعیین می‌شود و نمی‌توان آن‌ها را به صورت پویا در زمان اجرا، اضافه یا حذف کرد (البته C# نوع پویا هم دارد که این موضوع فعلا در بحث ما نمی‌گنجد). در نتیجه، مقادیر پراپرتی‌ها یا اشاره‌گرهای آن پراپرتی‌ها را می‌توان به عنوان یک بافر پیوسته در حافظه ذخیره کرد و برای رفتن به پراپرتی بعدی کافیست که نوع پراپرتی کنونی را بدانیم و به مقدار حجم آن (بر اساس بایت) به جلو برویم تا به پراپرتی بعدی برسیم (این از مزایای تایپ سیستم های static-strongاست)، در حالی که در جاوا اسکریپت این امکان وجود ندارد، چون نوع پراپرتی می‌تواند در طول اجرا تغییر کند.
از آنجا که استفاده از دیکشنری‌ها برای یافتن موقعیت پراپرتی اشیا در حافظه بسیار ناکارآمد است، V8 به جای آن، از روش دیگری استفاده می‌کند: کلاس‌های مخفی.
کلاس‌های مخفی، مثل چیدمان ثابت کلاس ها که در زبان‌هایی مانند جاوا استفاده می‌شوند، عمل می‌کنند؛ با این تفاوت که در زمان اجرا ساخته می‌شوند. اکنون ببینیم که دقیقا چگونه هستند:
1
function Point(x, y) {
2
    this.x = x;
3
    this.y = y;
4
}
5
 
6
const p1 = new Point(1, 2);
7
هنگامی که فراخوانی "point(1,2)" اتفاق می‌افتد ، V8 یک کلاس مخفی به نام "C0" ایجاد می‌کند.
start of make an instanceشروع ساخت instance
هنوز هیچ پراپرتی (property) برای Point تعریف نشده است بنابراین، "C0" خالی می‌باشد.
وقتی که اولین عبارت "this.x = x" اجرا شد (درون تابع "Point")، V8 یک کلاس مخفی دوم، به نام "C1" ایجاد می‌کند که بر اساس "C0" است. "C1" محلی در حافظه (نسبت به اشاره‌گر شیء) را توصیف می‌کند که پراپرتی x می‌تواند در آن باشد. در این حالت، "x" در فاصله 0 از ابتدای بلاک حافظه ی بافر ذخیره می‌شود، به این معنی که هنگام مشاهده یک شیء از تابع Point در حافظه، اولین فاصله با پراپرتی "x" مطابقت دارد. V8 همچنین "C0" را آپدیت می‌کند، به این صورت که می‌گوید اگر پراپرتی "x" به یک شیء از تابع Point اضافه شود، کلاس مخفی باید از "C0" به "C1" تغییر یابد. اکنون کلاس مخفی برای شیء تابع Point زیر، "C1" است.
create C1 hidden classساخت کلاس مخفی C1
این فرایند با اجرای عبارت "this.y = y"، تکرار می‌شود.
یک کلاس مخفی جدید به نام "C2" ایجاد می‌شودو سپس "C1" با “C2” جایگزین میشود.
replacing hidden classesجایگزین کردن کلاس های مخفی
جابه‌جایی بین کلاس‌های مختلف، به ترتیب افزودن پراپرتی‌ها به یک شی بستگی دارد. به قطعه کد زیر نگاهی بیندازید:
1
function Point(x, y) {
2
    this.x = x;
3
    this.y = y;
4
}
5
 
6
let p1 = new Point(1, 2);
7
p1.a = 5;
8
p1.b = 6;
9
 
10
let p2 = new Point(3, 4);
11
p2.b = 7;
12
p2.a = 8;
13
 
14
اکنون ممکن است فکر ‌کنید که برای هر دو شیء p1 و p2، از کلاس‌ها و انتقال‌های مخفی یکسانی استفاده می‌شود. ولی خوب اینطور نیست. برای "p1"، ابتدا پراپرتی "a" اضافه می‌شود و سپس پراپرتی "b". برای "p2"، ابتدا "b" اختصاص داده می‌شود، و سپس "a". بنابراین، "p1" و "p2" در نتیجه مسیرهای مختلف جابه‌جایی، در کلاس‌های پنهان مجزا قرار می‌گیرند. در چنین مواردی، خیلی بهتر است که پراپرتی های داینامیک را به همان ترتیب به آبجکت اضافه کنید تا بتوان از کلاس‌های پنهان، مجددا استفاده کرد.
درواقع اگر به “P2” ابتدا “a” و سپس “b” را اضافه میکردید کد شما بهینه تر بود زیرا موتور از کلاس های مخفی ای که برای “P1” ساخته مجددا استفاده میکرد و کلاس جدیدی نمیساخت.

ذخیره سازی درون خطی یا Inline caching

موتور V8 از تکنیک دیگری برای بهینه سازی استفاده می کند که inline caching نامیده می شود. این تکنیک با رصد نحوه ی فراخوانی توابع و مقادیر ورودی به آنها کار میکند.
موتور V8 کشی از شیء هایی را که اخیرا به عنوان پارامتر به تابع ارسال شده اند، نگهداری می کند و از این اطلاعات برای داشتن فرضیاتی در مورد نوع شیء هایی که قرار است در آینده به عنوان یک پارامتر به این توابع منتقل شود، استفاده می کند.
اگر V8 بتواند حدس خوبی در مورد نوع شیء ی که قرار است به تابع منتقل شود ، ارائه دهد، می تواند روند دستیابی به پراپرتی های شیء را دور بزند و در عوض، از اطلاعات ذخیره شده درباره ی کلاس مخفی شی قبلی استفاده کند که باعث سریع تر شدن فرآیند میشود.
حال سوالی پیش می‌آید که چگونه مفاهیم کلاسهای مخفی و کش کردن درون خطی به هم مرتبط هستند؟
هر زمان که یک تابع بر روی یک شی خاص فراخوانی می شود، موتور V8 برای تعیین دسترسی به یک پراپرتی خاص، باید جستجویی برای کلاس مخفی آن شی انجام دهد.
پس از دو فراخوانی موفقیت آمیز یک تابع از همان کلاس مخفی، V8 جستجوی کلاس مخفی را کنار میگذارد و به سادگی پراپرتی را به اشاره‌گر شیء اضافه می کند.
برای همه فراخوانی های آینده با این روش، موتور V8 فرض می کند که کلاس مخفی تغییر نکرده است و با استفاده از پراپرتی ذخیره شده از جستجوی قبلی، مستقیماً به آدرس حافظه ی آن پراپرتی خاص می پرد که این سرعت اجرا را بسیار افزایش می دهد.
حافظه پنهان درون خطی نیز به همین دلیل مهم است که شی های همنوع یک کلاس مخفی را به اشتراک می گذارند. اگر دو شی از یک نوع و با کلاسهای مخفی مختلف ایجاد کنید (همانطور که قبلاً در مثال انجام دادیم)، V8 نمی تواند از کش کردن خطی استفاده کند زیرا حتی اگر ابجکت ها از یک نوع باشند، کلاسهای مخفی متناظر با آنها، آدرس مختلفی برای پراپرتی های خود دارند.
hidden classes diffrenceتفاوت کلاس های مخفی ایجاد شده
دو شی اساساً یکسان هستند اما پراپرتی های "a" و "b" با ترتیب متفاوتی ایجاد شده اند که باعث میشود آدرس های متفاوتی در حافظه داشته باشند و موتور V8 نتواند انها را باهم تظبیق دهد و یکسان درنظر بگید.

کامپایل‌کردن به کد ماشین Compilation to machine code

هنگامی که گراف هیدروژن بهینه شد، کامپایلر Crankshaft آن را به پیاده سازی سطح پایین تری به نام Lithium تبدیل میکند. بیشتر پیاده سازی Lithium مختص معماری است. ثبت تخصیص های حافظه هم در این سطح اتفاق میافتد .
در پایان، Lithium به کد ماشین تبدیل می شود. سپس اتفاق دیگری به نام OSR رخ می دهد.
اما OSR یا on-stack replacement چیست؟
به صورت خلاصه، OSR یک تکنیک برای جابجایی بین پیاده سازی های مختلف یک تابع یکسان است. به عنوان مثال، به محض اتمام کامپایل می توانید از OSR برای جبجا کردن کد تفسیر شده یا غیربهینه با کد بهینه شده استفاده کنید.
موتور V8 اجرای اولیه که آرام است را کنار نمی‌گذارد تا مستقیما سراغ نسخه ی بهینه شده برود . در عوض تمام کانتکست های ما (پشته، رجیسترها) را تغییر می دهد تا بتوانیم در اواسط اجرا به نسخه بهینه شده برویم. این یک وظیفه بسیار پیچیده است.
همچنین حفاظت هایی به نام deoptimization وجود دارند که تبدیل معکوس ایجاد کرده و کدهای بهینه شده را به کد غیر بهینه باز می گرداند. این فرآیند زمانی انجام میشود که حدس موتور در مورد رفتار کد و فراخوانی ها، دیگر درست نباشد.

بازیافت حافظه Garbage collection

برای بازیابی حافظه V8 از روش سنتی و مرسوم mark-and-sweep استفاده می کند. در این روش و در زمان علامت گذاری اشیا انتظار میرود که اجرای برنامه مدتی متوقف شود به همین دلیل برای کنترل هزینه های GC و پایدارتر کردن اجرا، V8 به جای اینکه کل پشته را بپیماید، از علامت گذاری افزایشی استفاده می کند، سعی می کند هر شی ممکن را علامت گذاری کند تا فقط بخشی از پشته را پیمایش کند، سپس اجرای طبیعی را از سر می گیرد. به این صورت مدت زمان بازگردانی حافظه به سیستم کمتر خواهد شد.
همچنین V8 گام های بعدیGC را از جایی که پیمایش قبلی (در حافظه) متوقف شده است ادامه خواهد داد. بدین صورت مکث های بوجود آمده تغییرات چشمگیری در زمان اجرای برنامه بوجود نخواهند آورد و همه چیز طبیعی جلوه میکند.
در ادامه نیز همانطور که در ابتدای پست اشاره شد، مرحله sweep توسط نخهای جداگانه انجام می شود.

Ignition and TurboFan

با عرضه نسخه ی 5.9 از موتور V8 در اوایل سال 2017، پایپ لاین اجرایی جدیدی معرفی شد. این پایپ لاین جدید حتی در برنامه های کاربردی جاوا اسکریپت در دنیای واقعی به پیشرفت های بیشتر و صرفه جویی قابل توجهی در حافظه دست یافته است.
پایپ لاین جدید بر اساس Ignition، مفسر V8 و TurboFan، جدیدترین کامپایلر بهینه سازی V8 ، ساخته شده است.
از زمان انتشار نسخه ی 5.9 از موتور V8 ، دیگر از full-codegen و Crankshaft (فناوری هایی که از 2010 به V8 خدمت می کنند) در V8 استفاده نشده است زیرا تیم V8 برای همگام شدن با ویژگی های جدید زبان JavaScript و بهینه سازی مورد نیاز این ویژگی ها تلاش کرده است.
این بدان معنی است که V8 به طور کلی معماری ساده تر و قابل نگهداری بیشتری خواهد داشت.
improve and development of web and node.jsتوسعه و بهبود وب و شاخه های node js
این پیشرفت ها فقط شروع کار است. پایپ لاین جدید Ignition و TurboFan راه را برای بهینه سازی بیشتر هموار می کند که باعث افزایش عملکرد جاوا اسکریپت و کوچک شدن اندازه ی V8 در مرورگر Chrome و محیط اجرای Node.js در سال های آینده می شود.
در نهایت ، چند نکته و ترفند را به شما معرفی می‌کنیم که با رعایت آنها میتوانید کد های بهینه تری بنویسید، ضمن اینکه به راحتی می توانید آن ها را از مطالب بالا استخراج کنید، اما در اینجا خلاصه ای را برای راحتی شما آورده ایم.

چگونه کد جاوااسکریپت بهینه بنویسیم

ترتیب پراپرتی های شی:
همیشه پراپرتی هایی که به شی ها میدهید به یک ترتیب باشند تا کلاسهای پنهان، و متعاقباً کد بهینه شده، به اشتراک گذاشته شود.
1
/* ***** bad code ***** */
2
 
3
function Point(x, y) {
4
    this.x = x;
5
    this.y = y;
6
}
7
 
8
let p1 = new Point(1, 2);
9
p1.a = 5;
10
p1.b = 6;
11
 
12
let p2 = new Point(3, 4);
13
p2.b = 7;
14
p2.a = 8;
15
 
1
/* ***** better code ***** */
2
  
3
function Point(x, y) {
4
    this.x = x;
5
    this.y = y;
6
}
7
  
8
let p1 = new Point(1, 2);
9
p1.a = 5;
10
p1.b = 6;
11
 
12
let p2 = new Point(3, 4);
13
p2.a = 8;
14
p2.b = 7;
15
 
پراپرتی های داینامیک:
افزودن پراپرتی جدید بعد از ساختن شی باعث اجبار در تغییر کلاس مخفی میشود و هر تابعی که برای کلاس پنهان قبلی بهینه سازی شده دوباره نیاز به بهینه سازی پیدا میکند .به جای آن همه ی پراپرتی های یک شی را به موقع ساخت شی به آن اختصاص دهید .
متدها:
کد هایی که یک تابع را بار ها فراخوانی میکنند سریعتر از کدهایی هستند که تابع های مختلفی را فقط یک بار فراخوانی میکنند ( بخاطر کش کردن درون خطی ).
آرایه ها:
از آرایه های پراکنده که کلید های افزایشی ( 0, 1, 2, ...) ندارند، مانند استفاده از آبجکت ها برای نخیره سازی داده های پشت سر هم دوری کنید زیرا این مدل از ذخیره سازی استفاده از hash table را به دنبال دارد که دسترسی به المان های ذخیره شده را بسیار کند تر میکند. همچنین بهتراست از آرایه هایی که طول از پیش مشخصی دارند هم دوری کنیم. بهتراست هروقت نیاز شد آرایه را رشد بدیم. و در نهایت؛ المانی را در آرایه حذف نکنید. ( ترجیحا فقط pop کنید )
مقدار های تگ شده:
موتور V8 اشیا و اعداد را با ۳۲ بیت نشان میدهد. از یک بیت استفاده میکند تا ببیند متغیر شی است ( flag = 1) و یا اینتجر ( flag = 0) و متغییر را در 31 بیت بعدی ذخیره میکند که اگر عدد باشد به دلیل 31 بیتی بودنش SMI - Small Integer نامیده میشود . اگر متغیر بیشتر از ۳۱ بیت باشد موتور V8 آن را به اصطلاح box میکند؛ عدد را به double یا همان (64-bit floating-point) تبدیل می کند و یک شی جدید ایجاد می کند تا عدد را درون آن قرار دهد. سعی کنید همیشه عدد های ۳۱ بیتی استفاده کنید تا هزینه سنگین این باکس کردن را نداشته باشید.
در ادامه ی این مجموعه به بررسی چگونگی اجرای کد ها و ساختار runtime جاوا اسکریپ می‌پردازیم.
در تهیه ی این مجموعه، از آموزش های وبسایت بهره برده ایم.
خطا

خروج
از 5
امتیاز دهید:
خطا

خروج
کامنت

برای ارسال کامنت وارد شوید

مشاهده پاسخ ها
مشاهده پاسخ ها