جاوا اسکریپت چه جوری کار میکنه؟ دل و روده ی V8 engine و ۵ تا نکته در مورد کد بهینه نوشتن
سری "جاوا اسکریپت چه جوری کار میکنه؟" بازگردن مقاله های Alexander Zlatkov به زبون خودم هست. هدف از این کار در مرحله ی اول یادگیری خودم و انتشار این مقاله به فارسیه.
قرارم بر این نیست که همه کلمات رو به فارسی بگم به این دلیل که اگر خواستید بیشتر جستو جو داشته باشید در مورد کلمات کلیدیش, بتونید راحت تر این کارو انجام بدین.
اهمیت این نوشته برای من محتواش هست نه طرز بیان یا نگارشش. ممنون میشم اگر که هم در محتوا هم در طرز بیان یا نگارش اشکالی هست بیان کنید تا بتونم بهترش کنم.
بازگردانی شده از : How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code
هفته ها پیش, ما یه سری رو در مورد درک عمقی تر از جاوا اسکریپت و چه جوری کار کردنش رو شروع کردیم: ما فکر میکردیم که با دونستن اجزای تشکیل دهنده جاوا اسکریپت و تعاملشون با هم شما میتونید کدها و برنامه های بهتری بنویسید.
اولین مطلب این سری تمرکزش روی مرور رو موتور جاوا اسکریپت, رانتایم و پشته اجرایی بوده. اما تو این یکی پست شیرجه میزنیم تو دل و روده ی موتور V8. همچنین یه سری نکته در مورد بهتر کد نوشتن هم فراهم میکنیم ( بست پرکتیس هایی که تیم توسعه ی SessionStack موقع ساخت این محصول پیروی میکردن ).
بررسی کوتاه
موتور جاوا اسکریپت یه برنامه یا یه مترجم هست که کدهای جاوا اسکریپت رو اجرا میکنه. این موتور میتونه به دو صورت عمل کنه. مترجم استاندارد یا یه کامپایلر در لحظه (just in time compiler) که کد هارو تبدیل به یه نوع بایت کد میکنه.
این یه لیست از پروژه های معروفی هست که موتور جاوا اسکریپت رو پیاده سازی کردن:
- موتور V8 - اوپن سورس, توسعه داده شده توسط گوگل, نوشته شده با ++c
- موتور Rhino - توسط سازمان موزیلا مدیریت میشه, اوپن سورس, به طور کامل با جاوا نوشته شده
- موتور SpiderMonkey - اولین موتور جاوا اسکریپت, که قبل ها توسط نت اسکیپ استفاده میشده و الان هم فایرفاکس
- موتور JavascriptCore - اوپن سورس, به اسم Nitro معرفی شده و توسط اپل توسعه داده میشه برای مرورگر سافاری
- موتور KJS - موتور KDE که در حقیقت توسط Harri Porten برای یه پروژه KDE به نام مرورگر Konqueror توسعه داده شده
- موتور (Chakra (JScript9 - اینترنت اکسپلورر
- موتور (Chakra (JavaScript - مایکروسافت اج
- موتور Nashron - اوپن سورس که قسمتی از OpenJDK هست که با Oracle Java و Tool Group نوشته شده
- موتور JerryScript - یه موتور سبک برای اینترنت اشیا.
چرا موتور V8 ساخته شد ؟
این موتور توسط گوگل ساخته شده و با سی پلاس پلاس نوشتنشو داخل گوگل کروم و nodejs استفاده میشه.
موتور V8 اولین بار برای افزایش کارایی اجرایی جاوا اسکریپت داخل مرورگر ساخته شده. برای افزایش سرعت, موتور V8 کد های جاوا اسکریپت رو تبدیل به کدهای ماشین بهینه تری میکرد تا این که فقط اون هارو ترجمه کنه. موتور V8 مثل اکثر موتور های مدرن جاوا اسکریپتی مثل Rhino و SpiderMonkey کد های جاوا اسکریپت رو به کد های ماشی تو زمان اجرا با تکنین (JIT (Just-In-Time کامپایلر تبدیل میکنه. اما تفاوت اصلی اینجاست که موتور V8 بایت کد یا کد میانی تولید نمیکنه.
موتور V8 قبلا دو کامپایلر داشته
قبل از نسخه ی 5.9 موتور V8 که اولای ۲۰۱۷ اومده از دو تا کامپایلر سود میبرده:
- کامپایلر full-codegen - کامپایلری ساده و سریع که کدهای ماشین ساده و کند تولید میکنه
- کامپایلر CrankShaft - کامپایلری بهینه تر و پیچیده تر که از تکنین Just-In-Time برای تولید کد های بسیار بهینه استفاده میکنه.
موتور V8 ترد های داخلی متعددی رو هم به کار میگیره:
- ترد اصلی کاری که ازش انتظار داریم رو انجام میده: کد هارو میگیره, کامپایل و اجرا میکنه
- یه ترد دیگه هم برای کامپایل وجود داره که ترد اصلی بتونه به کارش ادامه بده تا قبلیه بتونه کار بهینه سازی رو همزمان پیش ببره
- یه ترد پروفایلر که به رانتایم میگه ما رو کدوم تابع زیاد وقت گذاشتیم تا کامپایلر CrankShaft بتونه بهینش کنه
- چند تا ترد دیگه که کار گاربج کالکشن رو انجام میدن
اولین باری که کد جاوا اسکریپت شما اجرا میشه, موتور V8 از کامپایلر full-codegen استفاده میکنه که کد جاوا اسکریپت پارس شده رو مستقیما به کد ماشین ترجمه میکنه اون هم بدون هیچ تغییری. این کار اجازه میده تا شروع اجرای کدهای ماشین خیلی سریع بشه. این نکته رو هم در نظر داشته باشید که موتور V8 از بایت کدهای واسط استفاده نمیکنه و با این روش نیاز به مفسر رو حذف کرده.
وقتی کد شما برای مدتی تو مرحله ی اجرا باشه, ترد پروفایلر اطلاعات کافی رو پیدا کرده تا بگه کدوم تابع ها باید بهینه بشن.
تو مرحله ی بعدی, کامپایلر CrankShaft کار بهینه سازیشو روی ترد دیگه ای شروع میکنه. این کامپایلر میاد درخت انتزاعی سینتکس abstract syntax tree - AST رو به تک تخصیصی استاتیک static single-assignment - SSA (خیلی سخته ترجمشون :)) ) سطح بالا تبدیل میکنه که به نام Hydrogen شناخته میشه. و بعد تلاش میکنه بهینه سازی رو روی گراف هیدروژن انجام بده.اکثر این بهینه سازی ها روی همین گراف رخ میده.
خطی سازی
اولین بهینه سازی خطی کردن روی خطوط واجد شرایط میباشد. خطی سازی پروسه ی جابه جایی منطقه ی فراخوان شده ( لاین خط کدی که تابع فراخوانی شده) با بدنه اون تابع هست. این روش ساده اجازه میده تا بهینه سازی های بعدی بهتر معنی پیدا کنن.
کلاس های مخفی - Hidden Classes
( قبل از این که این پاراگراف رو به فارسی برگردونم لطفا کنسول خودتون رو باز کنید و این تیکه کد رو اجرا کنید. ممکنه براتون سوال بشه که چرا میگه جاوا اسکریپت پروتو تایپ بیس هست درصورتی که ما کیورد class رو هم داریم. اریک الیوت یه مقاله خیلی طولانی در موردش نوشته که جزو سری "تو مصاحبه های جاوا اسکریپت حرفه ای بشید" هستش )
class Foo {}
typeof Foo
جاوا اسکریپت زبان پروتوتایپ بیسی هست و این به این معنیه که کلاسی و آبجکتی وجود نداره که با کلون کردن درسته بشه. جاوا اسکریپت یه زبون داینامیکه, این یعنی اینکه میشه بعد از ساخت یه آبجکت پراپرتی اضافه کم بشه.
اکثر مترجم های جاوا اسکریپتی از یه ساختمون دیکنشری طور (hash function based) برای نگه داری پراپرتی های یک آبجکت توی حافظه استفاده میکنن. این استراکچر باعث میشه تا دسترسی به مقدار یه پراپرتی تو جاوا اسکریپت هزینه محاسباتی بیشتری داشته باشه نسبت به زبان های غیر داینامیک مثل جاوا و سی شارپ. تو جاوا همه ی پراپرتی های یه آبجکت قبل از کامپایل توسط یه لایه آبجکت ثابت مشخص شدن و موقع رانتایم نمیشه به صورت داینامیک اون هارو کم یا زیاد کرد. ( البته سی شارپ داینامیک تایپ داره که یه موضوع دیگست اون). در نهایت مقدار پراپرتی ها (یا پوینتر اون پراپرتی ها) رو میشه به تو بافر روی مموری بایه فاصله مشخص نگه داری کرد. مقدار فاصله رو میشه به راحتی بر اساس نوع پراپرتی ها مشخص کرد اما برای جاوا اسکریپت امکان پذیر نیست چون میشه ماهیت یه پراپرتی تو رانتایم عوض بشه.
با توجه به این که نگه داشتن لوکیشن پراپرتی ها با دیکشنری روی رم هزینه بر هست موتور V8 روش دیگری رو به نام کلاس های مخفی استفاده میکنه. کلاس های مخفی از لحاظ عملکرد شبیه لایه ثابت آبجکتی تو زبون جاوا و زبون های مشابهش هستن با این تفاوت که تو زمان رانتایم ساخته میشن. خب حالا ببینیم که واقعا چه جوری کار میکنن:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);
وقتی که فراخوانی”new Point(1,2)“ اتفاق میوفته, موتو V8 یه کلاس مخفی به نام C0 درسته میکنه :
تو این مرحله هیچ پراپرتی برای Point معرفی نشده پس C0 خالیه.
وقتی که اولین عبارت "this.x = x" اجرا شد(داخل تابع Point), موتور V8 کلاس مخفی دومی میسازه به نام "C1" که بر اساس "C0" بنا شده. "C1" اشاره میکنه به مکانی تو حافظه که ( مرتبط با آبجکت پوینتر ) پراپرتی x رو میشه پیدا کرد. تو این مورد x تو خونه 0 ذخیره شده, این به این معنیه که وقتی داریم آبجکتی که از تابع point رو میبینم اولین خونه از اون آبجکت تو کانتینیوس بافری که روی رم داره پراپرتی x هست. موتور V8 هم "C0" رو با "جابهجایی کلاس" به روز میکنه که بیان میکنه پراپرتی "x" به آبجکت point اضافه شده که در این حالت موتور V8 باید کلاس مخفی "C0" به "C1" سوییچ کنه. کلاس مخفی حال حاضر آبجکت point سی وان هست.
این پروسه ادامه پیدا میکنه وقتی عبارت "this.y = y" اجرا میشه (دوباره توی تابع Point دقیقا بعد از عبارت "this.x = x").
یه کلاس مخفی دیگه به نام "C2" ساخته شده, جابهجایی بین کلاس به "C1" اضافه شده که بیان میکنه اگر پراپرتی "y" به آبجکت Point اضافه شده ( که البته پراپرتی "x" رو از قبل داشته ). بعد از این تغییر کلاس مخفی باید "C2" باشه و آبجکت point باید به "C2" اشاره کنه.
جابهجایی بین کلاس های مخفی مرتبط با ترتیب اضافه شدن پراپرتی به یه آبجکت هست. به تکه کد زیر نگاه کنید:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;
احتمالا تصور میکنید که "p1" و "p2" از یه کلاس مخفی و یه جابهجایی استفاده میکنه. خب در حقیقت اینجوری نیست. برای آبجکت "p1" اول پراپرتی "a" و بعد پراپرتی "b" اضافه میشه در صورتی که برای "p2" اول پراپرتی "b" و بعدش پراپرتی "a" اضافه میشه و به همین دلیل هست که p1 و p2 از کلاس مخفی متفاوتی استفاده میکنن و مسیر جابهجایی متفاوتی دارن. پس : تو این موارد بهتره که پراپرتی های داینامیک رو با یک ترتیب انجام بدیم تا کلاس های مخفی بینشون به اشتراک گذاشته بشه.
کش کردن خطی
موتور V8 از یه روش بهینه سازی دیگه ای هم برای زبان های داینامیک استفاده میکنه به نام کش کردن خطی. این روش تکیه زده بر بررسی و مشاهده فرایند فراخوانی مکرر یک تابع رو یه سری آبجکت مشابه. اطلاعات بیشتر در مورد نحوه ی کار این تکنیک رو اینجا بخونید.
ما در باره ی مفهوم کلی کش کردن خطی تو این مقاله هم صحبت میکنیم البته اگه حوصله خوندن اون متن رو ندارید.
خب چه جوری کار میکنه؟ موتور V8 یه کشی رو نگه میداره که حاوی نوع آبجکت هایی هست که اخیرا به تابع ها پاس داده شده و از همین اطلاعات استفاده میکنه تا فرضیاتی در مورد نوع آبجکتی هایی که قرار هست به عنوان پارامتر در فراخوانی های آینده در کد به این تابع ها پاس داده بشه داشته باشه. اگر موتور V8 امکان این رو که بتونه فرض خوبی رو نسبت به نوع آرگیومنتی (آبجکتی) که قرار هست به تابع پاس بده داشته باشه, میتونه فرایند پیدا کردن پراپرتی های یه آبجکت رو رد کنه و به جاش از اطلاعاتی که به دست آورده از مشاهدات قبلیش استفاده کنه.
خب مفهوم کلاس مخفی و کش کردن خطی چه جوری به هم مرتبط میشن؟ هر وقت یه متود روی آبجکت خاصی فراخوانی میشه, موتور V8 باید به کلاس مخفی اون آبجکت نگاه کنه تا خونه ی مورد نظر برای تابع رو پیدا کنه. بعد از دو بار صدا کردن موفق یک تابع از یک کلاس مخفی, موتور V8 فرایند پیدا کردن خونه ی مورد نظر رو کنار میزاره و مستقیم جای اون خونه رو به پوینتر آبجکت اضافه میکنه. برای هر فراخوانی تابع در آینده موتور V8 این پیش فرض رو داره که کلاس مخفی اون آبجکت تغییری نداشته و در نتیجه مستقیم میره سراغ آدرس حافظه ی اون پراپرتی (متود) که قبلا به پوینتر خود آبجکت اضافه کرده بوده که این باعث میشه سرعت اجرا رو خیلی بالا بره.
همچنین کش کردن خطی دلیل اهمیت زیاد به اشتراک گذاری یک کلاس مخفی بین آبجکت های هم نوع رو نشون میده. اگر شما دو تا آبجکت از یه نوع رو بسازید با دو تا کلاس مخفی متفاوت ( همون طور که تو مثال چند خط بالاتر این کارو کردیم ), موتور V8 نمیتونه از کش کردن خطی استفاده کنه هرچند که نوع آبجکت ها یکی باشه, به این دلیل که : کلاس مخفی مرتبط با هر آبجکت, آدرس متفاوتی برای پراپرتی های خودشون دارن.
کامپایل به زبان ماشین
بعد از این که گراف هیدروژن برای اولین بار بهینه شده, کامپایلر Crankshaft اون رو به پیاده سازی سطح پایین تری به نام Lithium تبدیل میکنه. اکثر پیاده سازی لیثیوم مختص آرکیتکچر هست. رجیستر الوکیشن هم تو همین مرحله رخ میده.
در آخر, لیثیوم به کد های ماشین تبدیل میشه. و بعد چیزی به نام OSR: on-stack replacement اتفاق میوفته. قبل از این که ما شروع به کامپایل و بهینه سازی یه متود طولانی بکنیم, اون رو اجرا میکنیم (اشاره میکنه به دو کامپایلری بودن موتور V8 که وظایف اجرا و بهینه سازی رو بین دو کامپایلر تقسیم کرده). موتور V8 اجرای کند اولیشو به کل کنار نمیزاره بره سراغ نسخه ی بهینه شدش و در عوض کانتکست برنامه ( استک, رجیستری ها) رو تبدیل میکنه تا بتونه وسط اجرا به کد های بهینه شده اونها سوییچ کنه. این یه کار خیلی پیچیدست, با این توجه که موتور V8 تو مرحله آغازین کار جایگزاری کد هارو انجام داده. موتور V8 تنها موتوری نیست که قادر به انجام همچین کاریه.
البته حفاظت هایی وجود داره به نام deoptimization های وجود داره که کد های بهینه شده رو به حالت قبلی برمیگردونه برای زمانی که فرضیات موتور V8 از مثلا لوکیشن یه پراپرتی روی یه کلاس مخفی دیگه درست از آب در نیاد دیگه.
فرایند Garbage Collection
برای گاربج کالکت کردن, موتور V8 از روش قدیمی و عمومی mark-and-sweep برای پاکر کردن نسل قدیمی استفاده میکنه. انتظار میره فرایند مارک کردن اجرای جاوا اسکریپت رو متوفق کنه. برای کنترل کردن هزینه های گاربج کالکت کردن و پایدار کردن اجرا, موتور V8 از روش مارک کردن افزایشی استفاده میکنه: به جای پیمایش کردن کل پشته, و تلاش برای مارک کردن هر آبجکت واجد شرایط, فقط قسمتی از پشته رو پیمایش میکنه و مجددا اجرای پشته رو ادامه میده. نقطه ی شروع پیمایش بعدی گاربج کالکتور روی پشته از همون جایی بوده که دفعه قبل تموم کرده. این روش کمک میکنه تا مکث های کوتاه تری بین اجرای برنامه رخ بده. همون طور که قبلا اشاره شده مرحله پاک کردن روی ترد جدا گانه ای صورت میگیره.
مفسر Ignition و کامپایلر TurboFan
با ریلیز نسخه 5.9 در اوایل 2017, پایپ لاین اجرایی جدیدی معرفی شده. این پایپ لاین پرفورمنس بهتری داره و ریسورس حافظه ی کمتری تو برنامه های جاوا اسکریپتی استفاده میکنه.
پایپ لاین جدید بر اساس مفسر Ignition و جدیدترین کامپایلر بهینه ساز موتور V8 به نام TurboFan ساخته شده.
میتونید در مورد این موضوع تو بلاگ تیم V8 بیشتر بخونید.
از وقتی نسخه 5.9 اومده, full-codegen و Crankshaft ( تکنولوژی هایی که در موتور V8 از 2010 استفاده میشده ) دیگه تو موتور V8 برای اجرای جاوا اسکریپت استفاده نشده چون تیم V8 درگیر اضافه کردن فیچر های جدید جاوا اسکریپت و بهینه سازی اون ها شده.
این بهتر شدن ها تازه اول راهه. مفسر Ignition و کامپایلر TurboFan راه رو برای بهینه سازی و افزایش کارایی جاوا اسکریپت صاف کردن و دارن کم کم حضور موتور V8 رو تو وب و Node.js کمرنگ میکنن.
در نهایت, این هم لیستی از نکته هایی در مورد این که چه جوری کد بهینه و خوب بنویسیم. شما میتونید این نکته هارو از نوشته بالا استخراج کنید ولی خب این هم یه خلاصه واسه راحتی کار:
چه جوری کد جاوا اسکریپت بهینه بنویسیم
- ترتیب پراپرتی های آبجکت: همیشه پراپرتی هایی که به آبجکت ها میدین به یک ترتیب باشه تا از کلاس مخفی و بهینه سازی اون ها به طور اشتراکی استفاده بشه.
- پراپرتی های داینامیک: اضافه کردن پراپرتی جدید بعد از ساختن آبجکت باعث اجبار در بروز رسانی کلاس مخفی میشه و تابع هایی که برای کلاس مخفی قبلی بهینه سازی شدن کند میشن. بجاش همه ی پراپرتی هارو تو کانستراکتور ها ست کنید.
- متودها: کد هایی که یک تابع رو بار ها فراخوانی میکنن سریعتر از کدهایی هستن که تابع های مختلفی رو فقط یک بار فراخوانی میکنن ( بخاطر کش کردن خطی ).
- آرایه ها: از آرایه های اسپارسی که کلید های افزایشی ندارن ( 0, 1, 2, ...) دوری کنید. آرایه های اسپارسی که کلید های افزایشی ندارن المان های درونی اون ها hash table هست که دسترسی به این المان ها بسیار هزینه بره. همچنین بهتره از آرایه هایی که طول از پیش مشخصی دارن هم دوری کنیم. بهتره هروقت نیاز هست آرایه رو رشد بدیم. و در نهایت: المانی در آرایه رو حذف نکنید. باعث ایجاد ارایه اسپارسی میشه.
- مقدار های تگ شده: موتور V8 آبجکت ها و عدد هارو با ۳۲ بیت نشون میده. یه بیت استفاده میکنه تا ببینه متغییره آبجکته (flag = 1) و یا اینتجر هست (flag = 0) که SMI - SMall Integer بخاطر ۳۱ بیتی بودنش صدا میشه. اگر متغیر بیشتر از ۳۱ بیت باشه موتور V8 اون (به اصطلاح box میکنه) رو داخل یه دابل یا یه آبجکت میریزه. سعی کنید همیشه عدد های ۳۱ بیتی استفاده کنید تا هزینه سنگین این باکس کردن رو نداشته باشید.
پایان :)
مطلبی دیگر از این انتشارات
Linter و کاربرد های اون
مطلبی دیگر از این انتشارات
توضیح ساده prototype در جاوا اسکریپت
مطلبی دیگر از این انتشارات
استخدام توسعه دهنده Front-End یا چی؟