ماژول 3: بررسی اندرونیات موتور جاوااسکریپت SpiderMonkey
در این سند به مطالعه معماری موتور SpiderMonkey میپردازیم و طراحی بخشهای مختلف آن را مورد بررسی قرار میدهیم
نویسنده: رضا احمدی
تاریخ انتشار: 5 مرداد 1404
فهرست
معرفی موتور SpiderMonkey
اسپایدرمانکی یک موتور متنباز جاوااسکریپت و وباسمبلی است که توسط بنیاد موزیلا توسعه داده شده و نگهداری میشود. این موتور به عنوان هسته اصلی موتور جاوااسکریپت مرورگر فایرفاکس عمل میکند و نقش مهمی در اجرای سریع و کارآمد صفحات و اپلیکیشنهای پیچیده وب ایفا میکند. کاربرد اسپایدرمانکی تنها به فایرفاکس محدود نمیشود، بلکه به دلیل داشتن طراحی انعطاف پذیر، امکان امبد شدن در محیطهای مختلف را دارد، از جمله سیستم پایگاه داده MongoDB، Adobe Acrobat و محیط دسکتاپ GNOME. از ادغامهای جدیدتر آن میتوان به پروژههایی مانند WinterJS و PythonMonkey اشاره کرد، که به ترتیب از اسپایدرمانکی برای اجرای جاوااسکریپت در محیطهای سمت سرور و محیطهای جاوااسکریپت/پایتون قابل تعامل (Interactive) استفاده میکنند. این استفاده گسترده در خارج از محیط مرورگر، نشان دهنده این است که اسپایدرمانکی به عنوان یک مولفه بنیادی بهصورت ماژولار مهندسی شده است و صرفا به یک محصول خاص وابسته نیست. همچنین ماهیت متنباز آن مشارکتهای جامعه را به همراه دارد. شایان ذکر است که اسپایدرمانکی به عنوان اولین موتور جاوااسکریپت شناخته میشود. این موتور ابتدا توسط Brendan Eich در Netscape Communications طراحی و نوشته شد و بعدها به صورت متنباز منتشر شد.
معماری اسپایدرمانکی عمدتا با زبان C/C++ پیادهسازی شده است و ادغام فزایندهای از Rust و جاوااسکریپت برای اجزای مختلف دارد. این رویکرد چندزبانه منعکسکننده یک استراتژی توسعه مدرن است که عملکرد خام (مانند C++)، ایمنی حافظه و مزایای همزمانی (مانند Rust) و قابلیتهای تکرار سریع یا خودمیزبانی (جاوااسکریپت) را بهطور متعادل استفاده میکند. هسته موتور شامل یک مفسر سریع، یک سیستم کامپایل در لحظه (Just In-Time – JIT) چندسطحی و یک زبالهروب (Garbage Collector) قدرتمند است. این ترکیب از یک مفسر، کامپایلرهای JIT و یک زبالهر وب، در حالی که همزمان حافظه را بهطور خودکار مدیریت میکند، نشاندهنده طراحی بهینهشده برای سرعت اجرای اولیه و عملکرد پایدار بلندمدت است. موتور اسپایدرمانکی همچنین میتواند بهعنوان یک شل (Shell) جاوااسکریپت مستقل اجرا شود که توسعه تعاملی و اجرای فایلهای جاوااسکریپت از طریق خط فرمان را تسهیل میکند. در دسترس بودن این شل ابزاری حیاتی برای توسعهدهندگانی است که روی خود موتور کار میکنند. این قابلیت، تست سریع، دیباگ و بررسی عملکرد را بهصورت مجزا و بدون سربار یا پیچیدگیهای یک محیط مرورگر کامل، امکانپذیر میسازد. ساختار کلی موتور اسپایدرمانکی در تصویر زیر قابل مشاهده است.
حال که ساختار موتورهای جاوااسکریپت را به صورت کلی مورد بررسی قرار دادیم، در ادامه به بررسی و پیادهسازی این مفاهیم در موتور اسپایدرمانکی خواهیم پرداخت.
خطلوله (Pipeline) اجرای جاوااسکریپت
اجرای کد جاوااسکریپت در اسپایدرمانکی از یک خط لوله پیچیده پیروی میکند که از طریق تجزیه (Parsing)، تولید بایتکد، تفسیر (Interpretation) و کامپایل JIT چندسطحی، پیش میرود. تصویر پایین نشان دهنده نمای کلی خطلوله موتور جاوااسکریپت است.
پارزر جاوااسکریپت
فاز اولیه شامل استفاده سورسکد توسط پارزر جاوااسکریپت است. این فرآیند با یک اسکنر واژگانی (Lexical) و یک پارزر بازگشتی-نزولی آغاز میشود که در نهایت یک درخت Abstract Syntax Tree (AST) تولید میکنند. در طول تجزیه، از مکانیزمهای بازخورد معنایی (Semantic) و واژگانی برای حل ابهامات ذاتی در سینتکس جاوااسکریپت (مواردی مانند مدیریت سمیکالنهای حذفشده) استفاده میشود. یک ویژگی قابل توجه کامپایلر اسپایدرمانکی، رویکرد آن در مدیریت خطا است. این کامپایلر تلاشی برای بازیابی خطا نمیکند و با مواجهه با اولین خطا، فرآیند کامپایل را متوقف خواهد کرد. این انتخاب در طراحی، بر دقت و عملکرد در طول کامپایل تاکید دارد و بار منطق پیچیده و کُند تصحیح خطا را به ابزارهای توسعهدهنده یا تحلیلگرهای ایستا خارجی منتقل میکند.
پس از تولید AST، مولد بایتکد (BytecodeEmitter – BCE)، AST را به فرمت بایتکد داخلی اسپایدرمانکی همراه با متادیتای مربوطه، تبدیل میکند. این نمایش میانی، Stencil نامیده میشود. یکی از ویژگیهای کلیدی طراحی Stencil عدم استفاده از GC است، که در طول فاز اولیه کامپایل، این فرمت را از نظر مصرف حافظه بسیار کارآمد میکند. این جزئیات به این دلیل که تخصیص و آزادسازی حافظه برای نمایشهای میانی میتواند مشکلات عملکردی ایجاد کند، بسیار مهم است. اسپایدرمانکی با مستقل کردن Stencil از GC، تضمین میکند که مرحله اولیه کامپایل تا حد امکان سریع و از نظر حافظه کارآمد باشد و مدیریت GC را به فاز اجرا موکول میکند. پس از آنکه Stencil تولید شد، برای فاز بعدی اجرا، اجزای زماناجرا (مانند موتور)، آن را در سلول های GC نمونهسازی (Instantiate) میکنند. به زبان ساده، سیستم، نقشه یا الگوی آمادهای به نام Stencil را برمیدارد و بر اساس آن، آبجکتهای مورد نیاز را در حافظه ایجاد میکند تا برنامه بتواند اجرا شود.
اسپایدرمانکی بهطور پیشفرض از یک استراتژی به نام Lazy Parsing یا Syntax Parsing استفاده میکند. این رویکرد از تولید کامل بایتکد برای توابع داخلی تا زمانی که برای اولین بار فراخوانی شوند، اجتناب میکند؛ فرآیندی که Delazification نامیده میشود. این بهینهسازی بهطور قابلتوجهی در زمان CPU و حافظه صرفهجویی میکند، زیرا در طول بارگذاری یک صفحه وب، تعداد قابلتوجهی از توابع ممکن است هرگز اجرا نشوند. Lazy Parsing همچنان بررسیهای ضروری برای خطاهای اولیه را طبق الزامات ECMAScript انجام میدهد. این یک بهینهسازی عملکردیِ حیاتی برای مرور وب است که بارگذاری اولیه صفحه را بر کامپایل تمام کدها اولویت میدهد. تاخیر جزئی در اولین اجرای یک تابع پارز شده با این رویکرد، برای به دست آوردن عملکرد بالا در طول رندر اولیه صفحه، پذیرفته شده است.
مفسر جاوااسکریپت
مفسر اسپایدرمانکی یک مولفه سریع مبتنی بر C++ است که مسئول اجرای بایتکد تولید شده است. این مفسر عمدتا به صورت یک تابع واحد و گسترده پیادهسازی شده است که دستورالعملهای بایتکد را یک به یک طی میکند و معمولا از یک عبارت switch برای اجرای کارآمد دستورالعملها استفاده میکند. مفسر به گونهای طراحی شده است که قابل بازگشت (Reentrant) باشد. این یک ویژگی حیاتی برای پشتیبانی از محیطهای پیچیده جاوااسکریپت است که در آن فراخوانیهای همزمان میتوانند از کد C++ دوباره وارد کد جاوااسکریپت شوند (مواردی مانند عملیات DOM). این امر رفتار صحیح پشته را تضمین کرده و از مشکلاتی مانند سرریز پشته در C جلوگیری میکند. بخش قابل توجهی از وضعیت ضمنی (Implicit State) یک نمونه مفسر، از طریق یک اشارهگر JSContext مدیریت میشود، که بهطور مداوم بهعنوان اولین آرگومان به تقریبا تمام توابع در اسپایدرمانکی (چه بخشی از API عمومی باشند و چه توابع داخلی)، ارسال میشود. این استفاده فراگیر از JSContext نشاندهنده یک مدل مدیریت وضعیت متمرکز است که میتواند در فرآیند دیباگ کمک کرده و دسترسی مداوم به محیط زماناجرا را تضمین کند.
مفسر اسپایدرمانکی، به عنوان یک مفسر ترکیبی Hybrid JIT Interpreter (JIT) عمل میکند و قطعات کوچکی از کد، به نام IC (Inline Cache)ها را مستقیما در آپکدهای بایتکد گنجانده است. این ICها برای تسریع اجرای بعدی همان آپکد طراحی شدهاند، مشروط بر اینکه نوعداده و ساختار آبجکت درگیر در عملیات، ثابت باشند. این ماهیت ترکیبی، باعث ایجاد یک لایهی بهینهسازی میشود، که در آن صرفا مسیرهای کد Hot، بهطور کامل کامپایل میشوند.
این ماهیت ترکیبی، باعث ایجاد یک لایهی بهینهسازی میشود، که در آن صرفا مسیرهای کد Hot، بهطور کامل کامپایل JIT میشوند.
سیستم کامپایل JIT
به منظور افزایش عملکرد و سرعت بخشیدن به اجرای بایتکد، اسپایدرمانکی از یک سیستم کامپایل JIT چندسطحی استفاده میکند. این سیستم، کد اختصاصی ماشینی (مانند x86، ARM و غیره) را تولید میکند که بهشدت با کد جاوااسکریپت و دادههای خاص پردازششده در زماناجرا سازگار شده است. کدی که بهطور مکرر اجرا میشود، باعنوان Hot شناخته میشود. هرچه کد به اصطلاح Hotتر باشد، از طریق کامپایلرهای مختلف JIT ارتقاء سطح پیدا میکند و برای افزایش عملکرد اجرایی، زمان بیشتری صرف کامپایل آنها میشود. شایان ذکر است که فرآیند کامپایل JIT، از نظر محاسباتی سنگین است، به همین دلیل در مسیرهای کد Hot (جایی که انتظار میرود در بلندمدت عملکرد بالایی وجود داشته باشد) کامپایل اولیه صورت میگیرد، با وجود اینکه این عمل یک سربار اولیه ایجاد میکند. تصویر پایین نمای کلی از خطلوله کامپایل JIT را نمایش میدهد.
کامپایلر پایه (Baseline Compiler)، اولین سطح JIT در خطلوله اسپایدرمانکی است. این کامپایلر از همان مکانیزم ICها که در مفسر پایه استفاده میشود، بهره میبرد. اما گام اضافهای برداشته و کل بایتکد یک تابع را به کد اختصاصی ماشین ترجمه میکند. این فرآیند به طور موثری سربار ناشی از تفسیر دستور به دستور بایتکد را حذف کرده و بهینهسازیهایی را اعمال میکند. کد ماشین تولید شده بسیار سریع است، هرچند برای انجام عملیاتهای پیچیدهتر، ممکن است لازم باشد توابعی را در C++ فراخوانی کند. با این وجود، خروجی این فرآیند که BaselineScript نام دارد، برای اجرا نیازمند عملیات سیستمی مانند فراخوانیهای mprotect و پاکسازی کش پردازنده است. تصویر پایین نشاندهنده روند تکامل کامپایلرهای JIT اسپایدرمانکی است.
موتور WarpMonkey
موتور بهینهساز فعلی، WarpMonkey نام دارد. این کامپایلر JIT پیشرفتهترین موتور بهینهساز در اسپایدرمانکی است و برای اسکریپتهایی به کار میرود که به طور مکرر اجرا میشوند (اسکریپتهای Hot). WarpMonkey بهطور پیشفرض از نسخه 83 فایرفاکس فعال بوده و جایگزین موتور قدیمیتر IonMonkey شده است. از قابلیتهای اصلی WarpMonkey میتوان به موارد زیر اشاره کرد:
- درونخطیسازی (Inlining): میتواند کدهای چندین اسکریپت را با هم ترکیب کند تا مانند یک تابع واحد، سریعتر اجرا شوند.
- تخصصیسازی (Specializing): کد ماشین را بر اساس انواع دادهها و آرگومانهایی که در زمان اجرا پردازش میشوند، کاملا اختصاصی و بهینه میسازد.
بزرگترین تفاوت معماری در WarpMonkey نسبت به معماریهای قبلی، روشی است که برای جمعآوری اطلاعات جهت بهینهسازی به کار میگیرد. موتور قبلی (IonMonkey) تلاش میکرد تا انواع دادهها را در کل برنامه به صورت سراسری پیشبینی کند (فرآیندی به نام Global Type Inference). اما WarpMonkey این روش پیچیده را کنار گذاشته و تنها به دادههایی به نام CacheIR متکی است. CacheIR یک دستورالعمل بهینهسازی است، اما اگر بخواهیم دقیقتر آن را تشریح کنیم، میتوان گفت که CacheIRها اطلاعات ساده و محلی هستند که توسط سطوح پایه (مفسر/کامپایلر پایه) در حین اجرای کد، جمعآوری شده و به WarpMonkey میگویند که کدام بخشها را چگونه بهینهسازی کند.
بخش بزرگی از فرآیند کامپایل WarpMonkey خارج از ترد اصلی (Off-thread) انجام میشود. این نوع طراحی موجب حفظ پاسخگویی (Responsive) مرورگر میشود. با انجام کامپایل در یک ترد جداگانه، از مسدود شدن ترد اصلی (که مسئول اجرای رابط کاربری و پاسخ به کاربر است) توسط عملیات سنگین و زمانبر کامپایل JIT، جلوگیری میشود. این فرآیند شامل سه فاز اصلی است:
- WarpOracle: این فاز به سرعت بر روی ترد اصلی اجرا میشود و یک اسنپشات از وضعیت فعلی، شامل دادههای Baseline CacheIR، ایجاد میکند.
- WarpBuilder: این بخش خارج از ترد اصلی عمل کرده و با استفاده از آن اسنپشات، نمایش Ion MIR (Mid-level Intermediate Representation) را میسازد. این فرآیند در واقع یک ترجمه ماشینی است، به این معنی که دستورالعملهای CacheIR را به طور مستقیم به دستورالعملهای معادل در MIR تبدیل میکند.
- بکاند JIT: عملیات بکاند نیز خارج از ترد اصلی اجرا میشوند. در این فاز ابتدا گراف Ion MIR، بهینهسازی شده و سپس به نمایش Ion LIR (Low-level Intermediate Representation)، که یک نمایش سطح پایینتر است تبدیل میشود. در نهایت وظایف حیاتی مانند تخصیص رجیستر انجام میشود و کد اختصاصی ماشین را تولید میکند.
بهینهسازیهای وارپمانکی بر این فرض استوارند که نوع دادهها و مسیرهای اجرایی کد، با اطلاعاتی که توسط ICها در سطوح پایینتر جمعآوری شده، یکسان اند. حال اگر اسکریپتی که توسط Warp کامپایل شده، با دادهای ناشناس مواجه شود (یعنی یکی از این فرضیات اساسی نقض شود)، عملیات خروج اضطراری (Bailout) رخ میدهد. این مکانیزم شامل بازسازی فریم پشته مربوط به کد اختصاصی ماشین است، تا دقیقا با اطلاعات مورد استفاده توسط مفسرپایه مطابقت پیدا کند و در نهایت کنترل اجرا به مفسر بازگردانده میشود. به این عمل، بهینهزدایی (Deoptimization) کد نیز گفته میشود. برای بازسازی مقادیری که در حالت عادی در دسترس نیستند، Warp از جداول مربوطه که قبلا ذخیره کرده، استفاده میکند. ضرورت وجود مکانیزم خروج اضطراری، چالش اساسی در بهینهسازی زبانهای پویا مانند جاوااسکریپت ایجاد میکند. در واقع کامپایلر برای دستیابی به بهینهسازی حداکثری باید فرضیاتی را در نظر بگیرد، اما این فرضیات ممکن است در زمان اجرا اعتبار خود را از دست بدهند. بنابراین وجود چنین مکانیزمی باعث تعادل (Trade-off) بین عملکرد و انعطافپذیری در زماناجرا میشود.
وباسمبلی
اسپایدرمانکی، علاوه بر اجرای جاوااسکریپت، بهعنوان یک موتور وباسمبلی نیز عمل میکند و از اجرای باینریهای Web Assembly (WASM) پشتیبانی میکند. این موتور دارای یک خط لوله WASM چندسطحی است که برای عملکرد بهینه و اجرای اولیه با تاخیر کم، طراحی شده است:
- RabaldrMonkey (همان WASM-Baseline در اسپایدرمانکی): این موتور بهسرعت WASM را به کد ماشین ترجمه میکند تا تاخیر را برای اولین اجرای آن به حداقل برساند.
- BaldrMonkey (همان WASM-Ion در اسپایدرمانکی): برای اجرای WASM با بهینهسازی بالا، این موتور ورودی WASM را به همان فرم Ion MIR که توسط WarpMonkey استفاده میشود، ترجمه میکند. سپس از IonBackend جهت بهینهسازیهای پیشرفته، از جمله تخصیص رجیستر، برای تولید کد اختصاصی ماشین استفاده میکند. ادغام وباسمبلی که از زیرساخت JIT موجود (Ion MIR/LIR) استفاده مجدد میکند، اسپایدرمانکی را بهعنوان یک محیط اجرایی با کارایی بالا برای کدهای غیر جاوااسکریپتی در محیط وب مطرح میسازد.
موزیلا بهطور فعال در توسعه و پیادهسازی پروپوزال WASM GC مشارکت دارد. این پروپوزال، نوع دادههای آرایه و ساختار (Struct) را مستقیما به وباسمبلی اضافه میکند. این قابلیت به زبانهای برنامهنویسی سطح بالا (که به WASM کامپایل میشوند) اجازه میدهد تا به جای استفاده از GC اختصاصی خود، از GC بومی (Native) مرورگر استفاده کنند. هدف این نوآوری، کاهش نشتهای حافظه است که ممکن است به دلیل ایجاد چرخههای غیرقابلجمعآوری (Uncollectible Cycles) بین GC مخصوص هر زبان و GC مرورگر، بهوجود آیند. بهطور کلی، پروپوزال WASM GC چالشهایی را در زمینه مدیریت حافظه برطرف میکند که منجر به ساخت وب اپلیکیشنهای استوار، با بهرهوری حافظه بالاتر (در زبانهایی مانند دارت یا کاتلین) میشود.
مدل آبجکت و نمایش حافظه
درک نمایش داخلی مقادیر و آبجکتهای جاوااسکریپت در اسپایدرمانکی، برای فهم استراتژیهای بهینهسازی آن حیاتی است.
نمایش مقدار جاوااسکریپت
تمام مقادیر جاوااسکریپت در اسپایدرمانکی به صورت داخلی توسط نوع داده (Data-type) JS::Value نمایش داده میشوند. این نوع (Type)، یک مقدار 64 بیتی دارای برچسبِ نوع (Type-tagged) است که قادر به نمایش مقادیر اولیه (Undefined، Null، Boolean، Number، BigInt، String، Symbol) و ارجاعات به آبجکتها است.
اسپایدرمانکی در تمام پلتفرمها برای نمایش JS::Value از تکنیکی به نام NaN-boxing استفاده میکند. این تکنیک به طور هوشمندانهای از استاندارد اعداد ممیز شناور IEEE-754 بهره میبرد. در این استاندارد، تعداد بسیار زیادی (2^53 – 2) از الگوهای بیتی، میتوانند معرف مقدار NaN (Not a Number) باشند. این ویژگی به موتور اجازه میدهد تا اعداد ممیز شناور (Floating-point) را به صورت مقادیر Double استاندارد C++، کدگذاری (Encode) کند و همزمان از سایر الگوهای NaN هم برای گنجاندن برچسب نوع و هم داده اصلی (برای مقادیر غیر Double مانند اشارهگرها، Integer، Boolean، Null و Undefined) در همان فضای 64-بیتی استفاده نماید. این یک بهینهسازی استاندارد و بسیار کارآمد در موتورهای جاوااسکریپت مدرن است که نمایش فشرده انواع دادههای متنوع را ممکن میسازد، بدون آنکه برای موارد رایج نیاز به فضای ذخیرهسازی جداگانه یا عملیات پرهزینه Boxing/Unboxing باشد.
فرمت دقیق NaN-boxing وابسته به معماری پلتفرم است:
- Nunboxing (در پلتفرمهای 32-بیتی): در معماریهای 32 بیتی مانند x86 و ARM، مقادیر غیر Double با یک برچسب نوع 32 بیتی و داده اصلی 32 بیتی نمایش داده میشوند. داده اصلی معمولا یک اشارهگر یا یک عددصحیح علامتدار (Signed Integer) 32 بیتی است. مقادیر خاصی مانند NullValue()، UndefinedValue()، TrueValue() و FalseValue() نیز به طور موثری در این ساختار کدگذاری میشوند.
- Punboxing (در پلتفرمهای 64-بیتی): در پلتفرمهای 64 بیتی مانند x64، از یک برچسب 17 بیتی به همراه داده اصلی 47 بیتی استفاده میشود که برای پشتیبانی از اندازههای بزرگتر اشارهگر ضروری است.
این تفاوتهای وابسته به پلتفرم، نشاندهنده دقت اسپایدرمانکی در بهینهسازی عملکرد و بهرهوری حافظه است که در آن، نمایش مقادیر با ویژگیهای سختافزار زیرین تطبیق داده میشود. اگرچه کد JIT برای دستیابی به عملکرد بهینه مستقیما به این چینش (Layout) بیتی سطح پایین متکی است، اما بیشتر بخشهای موتور از طریق توابع دسترسی (مانند val.isDouble()) با JS::Value تعامل دارند. این انتزاعیسازی (Abstraction)، نمایش پیچیده زیرین را پنهان میکند و منجر به افزایش قابلیت نگهداری (Maintainability) کد خواهد شد. همچنین باعث میشود تا در آینده نمایش داخلی، بدون تاثیرگذاری بر بخشهای بزرگی از کدبیس، تغییر کند.
آبجکتهای جاوااسکریپت
نوع JS::Value میتواند به آبجکتهای جاوااسکریپت اشاره کند. والد تمام این آبجکتها در موتور، کلاسی به نام JSObject است، اما اغلب آبجکتها (مانند آبجکتهای سادهای که شما میسازید)، از نوع خاصتری به نام NativeObject هستند. NativeObjectها مسئولیت حیاتی ذخیره کردن خصوصیات (Properties) به صورت Key-Value را بر عهده دارند و مانند یک جدول هش کار میکنند. جداسازی NativeObjectها از دیگر آبجکتها، به موتور اسپایدرمانکی اجازه میدهد تا NativeObjectها را برای افزودن و حذف داینامیک خصوصیات (که به طور رایجی انجام میشود)، بهینهسازی کند.
برای درک ساختار یک آبجکت، آن را مانند یک ساختمان در نظر بگیرید. هر آبجکت دو بخش اصلی دارد:
- نقشه (Map) یا طرح کلی (Scope): این بخش مانند نقشه مهندسی یک ساختمان است. آبجکتهایی که ساختار یکسانی دارند (مثلا همه دارای خصوصیات x و y هستند)، از یک نقشه مشترک استفاده میکنند. این کار باعث صرفهجویی در حافظه میشود.
- فضاهای ذخیرهسازی (Slots): اینها فضاهای ذخیرهسازی خصوصی هر ساختمان هستند که دادههای واقعی خصوصیات (مثلا مقادیر x و y برای یک آبجکت خاص) در آن نگهداری میشوند. این بخش غیرقابل اشتراک است.
جداسازی این دو بخش در آبجکتها، یک الگوی بهینهسازی رایج در زبانهای پویا است. این به کامپایلر JIT اجازه میدهد که کدهای فوقالعاده سریعی تولید کند. بهعنوان مثال وقتی JIT میداند که یک آبجکت از نقشه A استفاده میکند، از قبل میداند که مثلا خصوصیت age همیشه در فضای ذخیرهسازی شماره 3 قرار دارد. بنابراین در هر بار دسترسی، به جای جستجوی کند نام “age”، مستقیما به سراغ آفست شماره 3 میرود. این دسترسی مستقیم، دلیل اصلی سرعت بالای کدهای JIT شده است.
در این رویکرد، نام خصوصیات با استفاده از یک ID مشخص میشود. این ID میتواند یک عدد یا اتم (Atom) باشد. اتم روش هوشمندانه موتور برای مدیریت رشتهها است. بهعنوان مثال بهجای ذخیره کردن صدها کپی از رشته “name”، موتور یک اتم برای آن میسازد و هر جا به این رشته نیاز باشد، از همان یک اتم استفاده میکند. این کار حافظه را به شدت بهینه کرده و مقایسه رشتهها را بسیار سریع میکند. اتمها سه نقش کلیدی در موتور دارند:
- به عنوان مقادیر ثابتی عمل میکنند که بایتکد به آنها ارجاع میدهد.
- به عنوان شناسههای منحصربهفرد برای نام خصوصیات به کار میروند. این باعث سرعت بخشیدن به عملیات هشکردن میشود.
- بخشی از مجموعه GC Root هستند و دقت زبالهروب را افزایش میدهند.
Shapeها
بهطور کلی Shape، مکانیزم اصلی اسپایدرمانکی برای توصیف چیدمان ساختاری مشترک آبجکتها است. این مفهوم که نقشی مشابه کلاسهای پنهان (Hidden Classes) در موتور V8 دارد، به عنوان نقشه یک آبجکت عمل میکند. هر آبجکت value خصوصیات خود را نگه میدارد و به یک Shape اشاره میکند که مشخصکننده مجموعه دقیق keyها و ترتیب آنهاست. از مزیتهای اصلی استفاده از Shape میتوان به موارد زیر اشاره نمود:
- صرفهجویی در حافظه: به آبجکتهای متعددی که ساختار یکسانی دارند (یعنی خصوصیات یکسان با ترتیب تعریف یکسان) اجازه میدهد تا از یک نمونه Shape مشترک استفاده کنند. این کار از تکرار اطلاعات ساختاری جلوگیری میکند.
- افزایش سرعت JIT: به کامپایلرهای JIT امکان میدهد تا با آبجکتهای مشابه به سرعت کار کنند. با رفتار کردن با آبجکتهایی که Shape یکسانی دارند (گویی که ساختارهای ثابت C++ هستند)، JITها میتوانند دسترسی مستقیم به خصوصیت از طریق آفست را انجام دهند که به مراتب سریعتر از جستجو در جدول هش است. این قابلیت باعث میشود Shapeها برای افزایش عملکرد JIT حیاتی باشند.
تکنیک Shape Teleportaion، یک بهینهسازی پیچیده مرتبط با Shapeها است. این تکنیک برای سرعت بخشیدن به جستجوی خصوصیات در زنجیره پروتوتایپ (Prototype Chain) اعمال میشود. بهطور معمول، برای پیدا کردن یک خصوصیت (مثلا obj.toString()) در زنجیره، موتور باید Shape آبجکت اصلی، سپس Shape پروتوتایپ آن و سپس پروتوتایپ بعدی را یکییکی بررسی کند که فرآیندی کند است. در این تکنیک به جای بررسی تمام آبجکتهای واسط، تنها Shape آبجکت receiver (آبجکت اولیهای که جستجو روی آن شروع شده) و آبجکت holder (آبجکتی در زنجیره که نهایتا خصوصیت مورد نظر را داراست) توسط ICها بررسی و ثبت میشوند. این کار باعث تسریع جستجوی خصوصیات، در زنجیره پروتوتایپهای طولانی میشود.
شایان ذکر است که ماهیت پویای جاوااسکریپت میتواند بهینهسازیها را با چالش مواجه کند. تغییر در ساختار یک آبجکت، مانند افزودن یا حذف یک خصوصیت، یا تغییر دادن پروتوتایپ آن، باعث تغییر Shape آن آبجکت میشود. وقتی Shape یک آبجکت تغییر میکند، تمام IC هایی که فرضیات خود را بر اساس Shape قدیمی بنا کرده بودند، اعتبار خود را از دست میدهند. این امر موتور را مجبور میکند تا روند کندی را برای جستجوی خصوصیت طی کند، یا یک IC جدید ایجاد نماید. برای حفظ صحت عملکرد، اسپایدرمانکی از استراتژیهای مختلفی استفاده میکند:
- تغییرشکل (Reshaping): موتور ممکن است به صراحت Shape آبجکتهای موجود در زنجیره پروتوتایپ را تغییر دهد تا با وضعیت جدید هماهنگ شوند.
- حالت دیکشنری (Dictionary Mode): برای آبجکتهایی که به طور مکرر تغییر میکنند، موتور ممکن است آنها را به حالت دیکشنری منتقل کند. در این حالت، آبجکت دیگر Shape مشترکی ندارد و خصوصیات آن در یک ساختار داده شبیه به جدول هش ذخیره میشوند. این حالت کندتر است اما در برابر تغییرات مکرر، بهینهتر عمل میکند.
- فلگ InvalidatedTeleporting: برای بخشهایی از کد که در آنها تغییر خصوصیات به طور مکرر رخ میدهد، این فلگ فعال میشود تا از تلاش مداوم و بیثمر برای بهینهسازی با تلهپورت Shape جلوگیری کند.
این مکانیزمها، ضعف ذاتی بهینهسازی حداکثری در محیطهای پویا را نشان میدهند. با اینکه تلهپورت Shape قدرتمند است، اما کارایی آن به شدت به ثبات ساختار آبجکتها در زمان اجرا وابسته است. استراتژیهای معرفی شده، نشاندهنده توانایی موتور در مدیریت خطا و داشتن برنامه جایگزین است تا صحت عملکرد همیشه حفظ شود، حتی اگر این کار به قیمت کاهش موقت عملکرد تمام شود.
نمایش رشته
در گذشته اسپایدرمانکی کاراکترهای رشته را به صورت دنبالهای از واحدهای UTF-16 ذخیره میکرد که به ازای هر کاراکتر به دو بایت حافظه نیاز داشت. یک بهینهسازی قابل توجه برای ذخیرهسازی بهینهتر رشتههای Latin1 (رشتههایی که کاراکترهای آنها در 256 شماره اول یونیکد قرار دارند) معرفی شده است که تنها از یک بایت به ازای هر کاراکتر استفاده میکند. این تغییر مستقیما مصرف حافظه را برای بخش رایجی از رشتهها بهینه کرده است.
تصمیم برای عدم استفاده از UTF-8 به عنوان نمایش داخلی رشته در موتور، به دلیل چندین نقطه ضعف کلیدی اتخاذ شد. یکی از این دلایل، تلاش گسترده بود که برای تبدیل کردن کل کدبیس Gecko (موتور رندرینگ فایرفاکس) به UTF-8 نیاز بود. علاوه بر این، ریسکهای عملکردی نیز مطرح بود، چرا که مثلا دسترسی به کاراکتر n-ام در UTF-8 (اندیسگذاری با زمان خطی (Lenier-time indexing)) برخلاف دسترسی با زمان ثابت در Latin1/UTF-16، میتوانست به کندی منجر شود. این موارد چالشهای مهندسی و trade-offهای موجود در بهینهسازی یک کدبیس بزرگ را نشان میدهد که در آن، سازگاری و زیرساخت موجود نقشی حیاتی ایفا میکنند.
برای رشتههای کوتاه، اسپایدرمانکی از مکانیزمهای ذخیرهسازی درونخطی (Inline storage) استفاده میکند. این رویکرد با ذخیره کردن کاراکترها مستقیما درون خود آبجکت JSString، از سربار فراخوانیهای malloc جلوگیری میکند. در مجموع این ریز-بهینهسازی، با کاهش سربار تخصیص حافظه برای رشتههای کوچک پرکاربرد، دستاوردهای عملکردی قابل توجهی به همراه دارد.
معماری GC
زبالهروب اسپایدرمانکی، به اصطلاح یک Tracing Collector ترکیبی و پیچیده است. مسئولیت اصلی آن، تخصیص بهینهی حافظه برای ساختاردادههای جاوااسکریپت و بازپسگیری آن حافظه پس از اتمام استفاده است. هدف نهایی، جمعآوری بیشترین حجم ممکن از زباله، در کوتاهترین زمان ممکن است. این GC چندوجهی ویژگیهای پیشرفتهای را در خود جای داده است:
- جمعآوری دقیق (Precise Collection): این جمعآورنده دانش کاملی از چیدمان حافظه تخصیصداده شده دارد، که به آن اجازه میدهد آبجکتهای فرزند را به درستی تشخیص داده و جمع آوری کند. دقت بالای این GC در مقایسه با جمعآورندههای محافظهکار، احتمال نشت حافظه یا نگهداری نادرست آبجکتها را به شدت کاهش میدهد.
- جمعآوری تدریجی (Incremental Collection): برای کاهش وقفههای طولانی که در جمعآورندههای سنتی رخ میدهد، GC اسپایدرمانکی عملیات خود را به بخشهای کوچکتر تقسیم میکند. این رویکرد تدریجی، تاثیر عملیات جمعآوری را بر تجربه کاربری، به طور قابل توجهی کاهش میدهد. اگرچه بیشتر فرآیند به صورت تدریجی انجام میشود، برخی عملیات (مانند فشردهسازی و فاز اولیه پاکسازی) همچنان نیازمند انجامشدن به صورت اتمی هستند.
- جمعآوری نسلی (Generational Collection): از لحاظ تجربی، اکثر تخصیصهای حافظه یا عمر بسیار کوتاهی دارند یا برای مدت طولانی باقی میمانند. اسپایدرمانکی با جداسازی تخصیصها به دو بخش، یک رویکرد نسلی را پیادهسازی میکند: یک فضای موقت برای آبجکتهای تازه ایجاد شده (نسل جدید) و یک هیپ دائمی برای آبجکتهای با عمر طولانی (نسل قدیم). Minor GC ها، که صرفا فضای موقت را پاکسازی میکنند، به طور مکرر و سریع اجرا میشوند. Major GC ها، که کل هیپ (شامل حافظه موقت) را پاکسازی میکنند، با تکرار کمتری اجرا میشوند.
- جمعآوری همزمان (Concurrent Collection): در اسپایدرمانکی، عملیات جمعآوری همزمان با اجرای برنامه اصلی و با بهرهگیری از چندین هسته CPU انجام میشود. GC اسپایدرمانکی درحال حاضر از همزمانی در فازهای محدودی (عمدتا برای کارهای تخصیص و آزادسازی بلوکهای حافظه)، استفاده میکند.
- جمعآوری موازی (Parallel Collection): در جمعآوری موازی، عملیات جمعآوری در خود GC به صورت موازی انجام میشوند. برای این کار، از چندین ترد استفاده میکند تا عملیات GC را سرعت بخشد.
- جمعآوری فشردهساز (Compacting Collection): اسپایدرمانکی دادههای همنوع و هماندازه را در فضاهایی بهنام Arena یا Slab تخصیص میدهد. با گذشت زمان و آزاد شدن حافظههای تخصیص داده شده، فضای خالی زیادی در قسمتهای مختلف این Arenaها ایجاد میشود. عملیات فشردهسازی، با جابجا کردن تخصیصهای موجود بین Arenaها، فضای خالی را یکپارچه میکند. این فرآیند تدریجی نیست و نیازمند پیمایش کل هیپ برای بهروزرسانی اشارهگرها به دادههای جابجاشده است، بنابراین به ندرت و در مواردی مانند کمبود حافظه انجام میشود.
ساختماندادههای کلیدی
GC اسپایدرمانکی برای عملیات خود به چندین ساختار داده بنیادین متکی است:
- سلول (Cell): این واحد بنیادین حافظه است که GC آن را تخصیص داده و آزاد میکند. Cell به عنوان کلاس پایه برای تمام کلاسهای تخصیصیافته توسط GC در موتور (از جمله JSObject)، عمل میکند.
- نوع تخصیص (AllocKind): این یک نوع شمارشی (Enumeration) است که سلولها را بر اساس اندازه و رفتار نهاییسازی (Finalization) دستهبندی میکند. در حقیقت Arenaها برای نگهداری آبجکتهایی با AllocKind یکسان طراحی شدهاند. این باعث مدیریت بهینهی آبجکتهای با سایز یکسان میشود. سیستم Cell و AllocKind، در ترکیب با Arenaها یک استراتژی تخصیص حافظه بسیار ساختاریافته و بهینه است، که به سادهسازی مدیریت حافظه در GC کمک میکند.
- بازه آزاد (FreeSpan): این ساختار، یک دنباله پیوسته از سلولهای آزاد درون یک Arena است و توابعی را برای تخصیص بهینه حافظه از این بخشها ارائه میکند.
- بیتمپ نشانگذاری (Mark Bitmap / ChunkBitmap): این ساختار داده از دو بیت (به ازای هر سلول GC) برای نمایش وضعیت حافظههای In Use و حافظههای Free (جهت ردیابی آبجکتهای جمعآوری شده در چرخه (Cycle-collected)) استفاده میکند. این یک رویکرد دقیق و ظریف در ردیابی است که قادر به مدیریت گرافهای پیچیده آبجکتها میباشد و از نشت حافظه جلوگیری میکند.
- خانواده Heap<T> (شامل HeapValue, HeapSlot, HeapId): اینها کلاسهای پوششی (Wrapper) C++ هستند که برای کپسولهسازی انواع اشارهگرهای GC (مانند T*, Value, jsid) طراحی شدهاند. این کلاسها به گونهای طراحی شدهاند که هر زمان یک اشارهگر درون آنها تغییر میکند، به طور خودکار یک مکانیزم اطلاعرسانی یا Write Barrier را فعال میکنند. این کار تضمین میکند که حتی اگر GC به صورت تدریجی کار کند، همه چیز به درستی ردیابی شود و هیچگاه ارتباط بین آبجکتهای In Use را از دست ندهد. خانواده کلاسهای Heap<T>، به گونهای طراحی شده اند که توسعهدهنده C++ را از دانستن پیچیدگیهای زیرین (مانند Write Barrierها) بینیاز کرده و استفاده صحیح از آنها را به طور خودکار تضمین میکند.
مفاهیم مدیریت حافظه
بهطور کلی Write Barrier یک قطعه کد کوتاه است که هر بار یک اشارهگر تغییر میکند، اجرا میشود تا این تغییر را به GC اطلاع دهد. اسپایدرمانکی از نوع خاصی از Write Barrier به نام “اسنپشات در ابتدا” و “تخصیص-سیاه” استفاده میکند.
- اسنپشات در ابتدا (Snapshot-at-the-Beginning): این بدان معناست که تمام آبجکتهایی که در ابتدای چرخه GC در حالت In Use بودهاند، علامتگذاری شده و در آن چرخه خاص جمعآوری نمیشوند، حتی اگر تمام ارجاعات به آنها توسط برنامه حذف شود. در این روش، بهطور فیزیکی اسنپشاتی از هیپ گرفته نمیشود، بلکه این ویژگی از طریق رفتار Write Barrier تضمین میشود.
- تخصیص سیاه (Allocate-Black): این تضمین میکند که هر آبجکت جدیدی که در طول زبالهروبی تدریجی تخصیص مییابد (و در زمان اسنپشات وجود نداشته)، بلافاصله در لحظه تخصیص علامتگذاری شود. این کار مانع از آن میشود که چنین آبجکتهای درحال استفادهای پیش از موعد جمعآوری شوند.
عملکرد اصلی Write Barrier در هنگام جاینویسی (Overwrite) یک اشارهگر مشخص میشود. وقتی برنامه میخواهد یک اشارهگر را که به آبجکت A اشاره دارد، با اشارهگری به آبجکت B جایگزین کند، Write Barrier درست قبل از این جایگزینی فعال میشود. این تضمین میکند که آبجکت A (که ارتباطش در حال قطع شدن است) و تمام آبجکتهای وابسته به آن، در لیست بررسی GC قرار گیرند. این کار حیاتی است زیرا در صورتی که این آخرین ارجاع به آبجکت A بوده باشد، از حذف شدن آن جلوگیری میکند. جمعآوری تدریجی با حذف وقفههای طولانی، تجربه کاربری را روان میکند، اما این خطر را ایجاد میکند که برنامه در حین اجرا، ارجاعات را به گونهای تغییر دهد که آبجکتهای In Use از دید GC پنهان بمانند. Write Barrierها راهحل این مشکل هستند، اما در عوض یک سربار محاسباتی کوچک به هر عملیات تغییر اشارهگر اضافه میکنند. این طراحی، یک موازنه دقیق بین پذیرش هزینهای جزئی در ازای جلوگیری از وقفههای مخرب و طولانی در برنامه است.
بهینهسازیها و عوامل موثر بر عملکرد
عملکرد بالای اسپایدرمانکی حاصل مجموعهای از بهینهسازیهای پیچیده است، که ارتباط مستقیمی با الگوهای کدنویسی جاوااسکریپت و نحوه اجرای آن دارد.
Inline Cacheها
همانطور که پیشتر بررسی شد، ICها یک تکنیک بهینهسازی پویا هستند که برای سرعت بخشیدن به عملیاتهای تکراری در کد طراحی شدهاند. روند کار به این صورت است که پس از اولین اجرای یک دستورالعمل بایتکد، موتور اطلاعات مربوط به آن اجرا (مانند انواع دادهها و Shape آبجکتها) را ثبت میکند. سپس بر اساس این اطلاعات، یک قطعه کد جدید و بسیار بهینه که مختص همان الگوی مشاهدهشده است، تولید میشود. این قطعه کد تخصصی همان IC است، که به بایتکد اصلی متصل میشود تا در اجراهای بعدی به عنوان یک مسیر سریع عمل کند. زمانی که الگوهای داده ورودی ثابت باقی بمانند، این مسیر سریع فعال شده و از کدهای عمومی و کندتر که باید انواع داده مختلف را مدیریت کنند، صرفنظر میشود.
ICها بنیاد استراتژی بهینهسازی چندسطحی اسپایدرمانکی هستند. آنها در حین اجرای کد، اطلاعاتی جمعآوری کرده در اختیار سطوح بالاتر JIT قرار میدهند، تا امکان تولید کد اختصاصی ماشین فراهم شود. این اطلاعات که مانند یک دستورالعمل بهینهسازی عمل میکنند، CacheIR نامیده میشود. CacheIR به طور دقیق، انواع داده و Shapeهای یک دستورالعمل بایتکد (که در اجراهای قبلی با آنها کار کرده) را ثبت میکند. به این ترتیب میتوان گفت، CacheIR خلاصهای قابل اعتماد از رفتار گذشته کد را به سطوح بالاتر JIT ارائه میدهد. به همین دلیل، ICها برای JITهای سطح بالاتر مانند WarpMonkey حیاتی هستند، زیرا به آنها کمک میکند تا بهینهسازیهای دقیق و حداکثری را اعمال کنند. شایان ذکر است که JägerMonkey (یکی از JITهای قدیمیتر)، ICهای چندریختی (Polymorphic) را پیادهسازی کرده بود که قادر به مدیریت چندین نوع داده برای یک عملیات واحد بود.
اثربخشی ICها و در نهایت بهینهسازی کلی JIT، بهشدت به ثبات انواع داده و Shapeهای آبجکت در کد جاوا اسکریپت وابسته است. هنگامی که این الگوها پایدار هستند، ICها میتوانند به طور قابل توجهی سرعت اجرا را افزایش دهند. در غیر این صورت، موتور مجبور به اجرای مسیرهای کندتر میشود.
بهینهسازیهای JIT
JITهای بهینهساز اسپایدرمانکی (IonMonkey و WarpMonkey)، طیف گستردهای از بهینهسازیهای کامپایلر را پیادهسازی میکنند. بسیاری از این تکنیکها از زبانهای برنامهنویسی قدیمی الگو برداری شدهاند و با ترجمه بایتکد به نمایشهای میانی (Ion MIR و Ion LIR) برای ماهیت پویای جاوااسکریپت، تطبیق داده شدهاند. این بهینهسازیها عبارتند از:
- تخصصیسازی نوع: که در آن کد به طور خاص برای انواع مشاهده شده در زمان اجرا تولید میشود.
- درونخطیسازی تابع: که فراخوانیهای تابع را با کد تابع، جایگزین میکند و سربار فراخوانی را کاهش میدهد.
- تخصیص رجیستر با اسکن خطی: که روشی کارآمد برای تخصیص متغیرها به رجیسترهای پردازنده است.
- حذف کد مرده (Dead Code): که اگر بخشی از کد هیچ تاثیری بر خروجی برنامه نداشته باشد را حذف میکند.
- انتقال کد حلقه: که محاسباتی را که نتایج آنها در طول تکرارهای حلقه تغییر نمیکند، به خارج از حلقهها منتقل میکند.
- بهینهسازیهای خاص برای دسترسی به خصوصیات در حلقههای for-in: که عملکرد را در الگوهای رایج جاوا اسکریپت بهبود میبخشد.
- مدیریت بهینه حالت Megamorphic: زمانی رخ میدهد که کار یکسانی بر روی انواع بسیار متفاوتی از آبجکتها انجام میشود.
- فرآیندهای اتمیسازی (Atomization): که به موتور اجازه میدهد تا برای رشتههای Rope، بدون نیاز به عملیات پرهزینه مسطح کردن (Flattening)، شناسه منحصربهفرد ایجاد کند، که در نهایت مقایسه و استفاده از آنها سریعتر میشود.
- استفاده از مسیرهای سریع: که کدهای ماشین بسیار بهینهای برای اعمال خاص و پرتکرار (مانند تبدیل اعداد اعشاری در معماری ARM64 و یا بازسازی آبجکتها طی فرآیند Structured Clone) فراهم میکنند.
- پشتیبانی پیشرفته از مقایسههای رابطهای: که با استفاده از دادههای CacheIR، امکان بهینهسازی انواع مقایسهها را (فراتر از مقایسههای عددی ساده) فراهم میکند.
تاثیر الگوهای کدنویسی
عملکرد کد جاوااسکریپت که توسط اسپایدرمانکی اجرا میشود، به شدت تحت تاثیر الگوهای کدنویسی توسعهدهندگان قرار دارد. بر روی الگوهایی که دارای پایداری نوع و ساختار آبجکتهای یکسان هستند، بهینهسازی حداکثری JIT، اعمال میشود. همانطور که اشاره شد، JITها برای تولید کدهای اختصاصی و سریع، بر فرضیات خود در مورد انواع و Shapeها تکیه میکنند. تغییرات مکرر در Shape آبجکتها (مانند افزودن یا حذف خصوصیات، یا تغییر دادن زنجیره پروتوتایپ یک آبجکت)، همچون مانعی در بهینهسازیهای JIT عمل میکنند. چنین تغییراتی اغلب نیازمند ایجاد Shapeهای جدید (معادل کلاسهای پنهان V8) یا وادار کردن آبجکتها به استفاده از حالت دیکشنری است که منجر به افت عملکرد میشود. به طور خاص، حذف کردن خصوصیات میتواند مشکلساز باشد و اغلب به بهینهزدایی منجر میشود. بهینهزدایی، که در اسپایدرمانکی اغلب به آن Bailout گفته میشود، یک بخش ذاتی و ضروری از کامپایل JIT در زبانهای پویا است. شایان ذکر است که ماهیت پویای جاوااسکریپت (ویژگیهایی مانند closureها) نیز میتواند بهینهسازی را پیچیده کند و بهطور مداوم چالشهایی در برقراری تعادل میان انعطافپذیری و عملکرد بالا، ایجاد میکند.
ارجاعات
- https://firefox-source-docs.mozilla.org/js/index.html
- https://firefox-source-docs.mozilla.org/js/gc.html
- https://udn.realityripple.com/docs/Mozilla/Projects/SpiderMonkey/Internals
- https://medium.com/@amirhossein_dehghaniazar/javascript-engine-internals-how-v8-and-spidermonkey-parse-optimize-and-execute-your-code-3f0ccf6116e9
- https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation
- https://phrack.org/issues/70/3
- https://grosskurth.ca/papers/browser-refarch.pdf
- https://spidermonkey.dev/assets/pdf/SpiderMonkey%20Byte-sized%20Architectures.pdf
- https://udn.realityripple.com/docs/Mozilla/Projects/SpiderMonkey/Internals/Garbage_collection
- https://mathiasbynens.be/notes/shapes-ics