Interpreter Ignition چیه و توی v8 چطوری کار میکنه؟ - قسمت ۳
توی قسمت قبل در مورد این صحبت کردیم که چطور میتونیم کد جاوااسکریپت مون رو به یک فرمت قابل فهم برای کامپایلر تبدیل کنیم. حالا کامپایلر از این فرمت چطور میتونه استفاده کنه و یک کد رو اجرا کنه؟
AST Interpreter
یک زبان ساده رو در نظر بگیریم که فقط عبارات ساده ریاضی توش کار میکنه. مثلا
1 + 2
و یا
(1 + 1 + 3) * 4
.
اگه برای مثال دومی بیایم یک AST درست کنیم، همچین چیزی ممکنه بدست بیاد:برای اینکه بتونیم یک درخت رو اجرا کنیم، باید از بالاترین Node که اینجا
Expression
هست شروع کنیم و مقدارش رو حساب کنیم.
بعضی از Node ها مثل همین Expression
برای اینکه بتونن اجرا بشن نیاز دارن فرزندانشون اول اجرا بشن.
این کار رو تا جایی انجام میدیم که به اعداد تنها برسیم که دیگه فرزندی ندارن، حالا اعداد حساب شده رو دونه دونه از پایین به بالا میبریم تا در نهایت جواب آخر رو داشته باشیم.
حتمالا این توضیح زیاد واضح نبوده پس بیایم مثال بالا رو مرحله به مرحله حل بریم ببینم چی میشه.
از Node ریشه که اینجا Expression
هست شروع میکنیم. این Node یک فرزند داره که میتونه یک عدد یا یک عبارت ریاضی باشه.
تو این مثال یک عبارت ریاضی هست به نام Mul
که کار ضرب رو انجام میده.
س باید اول ببینیم جواب Mul
چی میشه.
خود Mul
دو فرزند داره که از نوع Expression
هستن.
قبل از اینکه جواب Mul
رو داشته باشیم، باید جواب فرزند هارو بدست بیاریم و نتیجه رو در هم ضرب کنیم.
نتیجه میشه جواب Mul
. فرزند سمت چپ اون یک عبارت دیگست که فرزندش یک عملیات ریاضی جمع هست.
مانند Mul
دو فرزند داره و برای جوابش نیاز به جواب این دو فرزند داره. به فرزند دست چپ اگر نگاه کنیم یک عدد هست پس جواب فرزند دست چپ میشه همون عدد:حالا باید فرزند دست راست رو حل کنه که باز خودش یک عمل جمع دیگست که دو فرزند عدد داره. جواب اونو بدست میاریم و بجای خودش میزاریم:
حالا که جواب سمت چپ و راست رو داریم میتونیم جواب نهایی عملیات جمع رو هم بدست بیاریم:
حالا جواب عبارت و سمت چپ
Mul
هم داریم. فرزند سمت راست اون هم یک عدد هست پس جواب سمت راستش هم موجوده. کافیه این دوتا رو در هم ضرب کنیم تا جواب Mul
بدست بیاد:و در آخر چون جواب Node ریشه برابر با Mul هست، جواب آخر هم همان میشود:
به این روش اجرا کردن یک کد، AST Interpreter و یا Visitor Pattern میگن که میایم هر Node رو تا پایین حل میکنیم و جواب رو تا بالا بر میگردونیم.
همچین روشی برای زبان ساده بالا ممکنه خوب جواب بده اما برای زبان پیچیدهای مثل جاوااسکریپت ممکنه خیلی مناسب نباشه. دلایل ریز زیادی داره اما چنتا از دلایل مهمش این ها هستن:
- برای پیاده سازی یک AST Interpreter نیاز داریم که از تعداد زیادی recursive فانکشن استفاده کنیم که این برای برنامه های پیچیده میتونه خیلی راحت bottleneck بشه.
- فرمت کد درخت هست یعنی خوندن خود کد از مموری غیرخطیه. این باعث میشه زیاد نتونیم از Cache CPU برای نگه داشتن خود کد استفاده کنیم. مثلا اگر یک loop داشته باشیم که محاسبات سنگینی داخلش داره، بیشتر زمان محاسبه صرف این میشه که بریم کد رو از مموری بخونیم تا اینکه خود کد رو اجرا کنیم.
- درسته که AST یک فرمت سادهتر از javascript پارس نشده هست، اما خود این فرمت هم نسبت به فرمتی که در ادامه این مقاله بهش میپردازیم سطح بالا هستش و برای اجرای بعضی از کد های جاوااسکریپت نیاز هست یکسری تحلیل هارو runtime انجام بدیم. تو مقاله قبل گفتم که برای اینکه بتونیم یک چیزی رو سریع کنیم باید کار کمتری انجام بدیم پس هرچقدر کار پیچیده انجام بدیم، اجرامون هم کندتر میشه.
Bytecode Interpreter
احتمالا حتی اگر کد assembly نزدید، نمونه هاشو دیدید. کد زیر یک نسخه ساده شده یک کد assembly هست:
هر خط کد از دو قسمت OpCode یا کد دستور و پارامتر های اون تشکیل میشه. توی مثال بالا از ۳ دستور مختلف استفاده کردیم.
دستور اول یا
MOV
میاد توی یک مکان حافظه یک مقدار رو منتقل میکنه.
پس توی خط اول داریم مقدار 1 رو داخل مکان B میریزیم.
دستور ADD
هم میاد پارامتر ورودی که بهش دادیم رو با A جمع میکنه و جوابشو داخل A میریزه.
دستور JMP
هم مثل goto کار میکنه. وقتی بهش رسیدیم برمیگردیم به جایی که دستور اشاره کرده.
گر دقت کرده باشید یک چیزی به نام PC
داریم که یک اشاره گر به دستوری هست که قرارِ اجرا بشه.
بیام یک قدم بریم جلو:دستور اول اجرا شد و مقدار B برابر 1 شد. یک مرحله دیگه کد رو اجرا کنیم:
مقدار A با B جمع شد و حاصلش رو ریختیم داخل A. الان A مقدار 1 و B هم مقدار 1 رو داره. بریم بعدی:
مقدار A رو با عدد 2 جمع میکنیم. حاصل میشه 3 که اونو میریزیم توی A. حالا میرسیم به دستور . این دستور تنها کاری که میکنه PC (که خودش یک پوینتر هست و پوینتر هم یک عددِ) رو تغییر میده. با این کار مشخص میکنه کد بعدی که باید اجرا بشه چیه:
بر میگردیم بالا و اگه دو قدم بریم جلو دوباره به
JMP
میرسیم و A برابر با 6 میشه:کد بالا تا توی یک چرخه میتونه اجرا بشه و مقدار A مدام عوض میشه. حالا کد زیر رو نگاه کنید:
let A = 0;let B = 0;B = 1;while (true) {A += B;A += 2;}
کد اسمبلی مثال، مثل کد جاوااسکریپت بالا عمل میکنه.
اجرا کردن کد اسمبلی خیلی ساده هست. یکسری دستور هست که یکسری عدد رو تغییر میدن و نه بیشتر از این.
گفتیم هرچه سادهتر و کار کمتر انجام بدیم، کد سریعتری خواهیم داشت. حالا اگه بتونیم کد جاوااسکریپت از AST به اسمبلی تبدیل کنیم،
میتونیم خیلی سادهتر از AST Interpreting هم اجراش کنیم.
مشکلی که اسمبلی داره اینه که برای هر CPU ای دستورات و معماری متفاوتی داره.
مرورگری مثل کروم که روی دستگاه ها و CPU مختلفی اجرا میشه نمیتونه صرفا کد جاوااسکریپت رو به یک اسمبلی CPU خاص تبدیل کنه.
برای همین خیلی از زبان ها تصمیم گرفتن بجای اینکه مستقیم تبدیل به اسمبلی کنن کد رو به Bytecode تبدیل کنن.
این Bytecode یک زبانی مثل اسمبلی هست اما با این تفاوت که یک لایه Abstraction روی معماری CPU میکشه تا روی معماری مختلف بتونه اجرا بشه.
اگر همچین چیزی داشته باشیم موقع تبدیل کد جاوااسکریپت به این مدل، نیاز نیست به معماری CPU هم فکر کنیم.
زبان های مختلف Bytecode های مختلف خودشون رو دارن.
مثلا C# فرمت IL و جاوا فرمت JVM Bytecode رو داره. جاوا اسکریپت هم Bytecode خودش رو داره که جلوتر فرمت و اینکه چطور اجرا میشه رو میبینیم.
Ignition
بیایم برای کد جاوااسکریپت زیر ببینیم V8 چه Bytecode هایی براش درست میکنه. کد زیر رو داخل یک فایل با نام
test.js
ذخیره میکنیم:function test_fn() {let A = 0;let B = 0;B = 1;while (true) {A += B;A += 2;}}test_fn();
سپس با با استفاده از NodeJs میتونیم با اجرای دستور زیر، Bytecode های تابع رو بدست بیاریم.
$ node --print-bytecode --print-bytecode-filter=test_fn test.js
توی نگاه اول میبینیم یکسری تشابهاتی با کد اسمبلی فرضی مون داره. بیایم قسمت هایی که الان مهم نیستن رو پاک کنیم و ببینیم این Bytecode چه معنی ای داره.
دستور اول یا
LdaZero
میاد داخل رجیستر (بخوانید یک مکان حافظه در پردازنده با دسترسی سریع) A (بخوانید رجیستر Accumulator) مقدار 0 رو میریزه.
این رجیستر معمولا برای نگه داشتن نتیجه استفاده میشه.
اسم دستور از سه قسمت Ld به معنی Load
کردن، A
که نام رجیستر هست و Zero
که مقدار ثابت صفر هست تشکیل میشه.
یعنی بیا مقدار 0 رو بریز توی A. اینکه چرا برای این رجیستر خاص و این مقدار خاص یک دستور جدا مشخص شده به این دلیل هست که این الگو خیلی استفاده میشه و کاربردی هست.
ا ساخت یک دستور جدا میشه اونو بهینهتر هم اجراش کرد چون لازم نیست برای هر بار اجرا چک کنیم مکان حافظه تارگتمون چی هست و میدونیم که همیشه رجیستر A خواهد بود.اگر به دستور بعدی نگاه کنیم، ساختار مشابهی داره.
Star0
. میاد مقدار A
رو توی رجیستر r0
میریزه که توی دستور قبلی این مقدار صفر بود. دو خط بعدی هم همینکارو با رجیستر r1
انجام میدن.میرسیم به دستور
LdaSmi [1]
.
این دستور میاد داخل A یک عدد کوچیک میریزه که اینجا عدد یک هست.
دستور بعد هم مقدار A
رو داخل r1
میریزه.
اگر دقت کرده باشید این چند خط اول دقیقا کار چند خط اول تابع مارو انجام میدن.
اینجا رجیستر r0
همون A
(متغییر با نام A
و نه رجیستر A
) هست و رجیستر r1
همون B
.انجین تا جای ممکن سعی میکنه برای متغییر های اوکال از رجیستر استفاده کنه. اگه زیاد متغییر محلی یا برای تابع پارامتر داشتیم اونارو میاره توی استک.
دستور
Add
و AddSmi
اینطوری کار میکنن که پارامتری که بهشون میدیم رو با رجیستر A جمع میکنن و نتیجه رو داخل A میریزن. ادامه کدمون پس این معنی رو میده:دستور
JumpLoop
مثل JMP
که گفته بودیم عمل میکنه و میاد PC
یا IP
رو عوض میکنه.
با عوض کردن اون به قبل از دستور A += B
در اصل ما همون حلقه بینهیات تابعمون رو درست کردیم.
در نهایت میدونیم که اگه برای یک تابع return
نزاریم بیشفرض مقدار undefined
برمیگردونه، دو خط آخر همینکارو میکنن.این مواردی که گفته شد یعنی تبدیل کد جاوا اسکریپت از AST به Bytecode و اجرای اونو موتور Ignition یا Interpreter V8 انجام میده.
این نکته رو در نظر داشته باشید که اگه خودتون دستورات بالا رو اجرا کردید ممکنه خروجی متفاوتی دریافت کنید
چون Bytecode ها توی هر ورژن ممکنه تغییر کنن همچنین یک نسخه جدید V8 میتونه استراتژی تولید کد متفاوتی داشته باشه.
تو قسمت بعدی به این میپردازیم که v8 چطوری تصمیم میگیره یک قسمتی از کد رو optimize کنه.
منابع
لیست بایتکد ها اینجا میتونید ببینید:
اینکه هر دستور چه چیزی رو اجرا میکنه هم میتونید کلیتش رو از اینجا متوجه بشید:
اگر براتون این بایتکد ها جالب بود حتما پیشنهاد میکنم این مقاله رو بخونید: