Yusefnejad

یوسف نژاد

Yusefnejad

یوسف نژاد

در قسمتهای قبلی (1 و 2 و 3) درباره اسمبلی و کامپایل اون و همچنین بارگذاری CLR بحث شد. تو این قسمت درباره اجرای یه اسمبلی توسط CLR بحث میشه.

همونطور که قبلا هم اشاره شد، اسمبلی های مدیریت شده حاوی متادیتا و کد IL هستن. IL یک زبان سطح ماشین مستقل از معماری پردازنده هست که توسط مایکروسافت پس از ماهها تحقیق و بررسی و مشورت و همکاری با چندین شرکت تجاری یا آکادمیک تولیدکننده زبانهای برنامه نویسی و کامپایلر تولید شده.

از لحاظ ساختاری، IL از بیشتر زبانهای ماشین رایج سطح بالاتری داره. IL میتونه به انواع اشیا موجود در حافظه به طور مستقیم دسترسی پیدا کنه و اونا رو مثل یه زبان برنامه نویسی سطح بالا تغییر بده، یا نمونه های دلخواهی از اونا رو ایجاد کنه. تو این زبان میشه متدهای virtual رو صدا زد و یا به صورت مستقیم اعضای یه آرایه رو دستکاری کرد. حتی میشه استثناها رو تو این زبان تولید و منتشر کرد. درواقع به IL میشه به عنوان یه زبان ماشین شی گرا نگاه کرد.

در دنیای دات، برنامه نویسا معمولا با یکی از زبانهای سطح بالای موجود (مثل #C و VB.NET و #F) اسمبلی ها رو تولید میکنن و کامپایلر این زبانها کد IL متناظر رو تولید میکنه. هرچند میشه این اسمبلی ها رو مستقیما با استفاده از خود IL نوشت. برای تولید یه اسمبلی با استفاده مستقیم از IL، مایکروسافت یه اسمبلر به نام ILAsm.exe تولید کرده. همچنین یه Disassembler هم به نام ILDasm.exe توسط این شرکت تولید شده.


نکته: باید توجه داشت که در زبانهای سطح بالای نامبرده شده، تنها بخشی از تمام ویژگیهای CLR در دسترس برنامه نویسان هست و هر کدوم از این زبانها یکسری از ویژگیهای منحصر به فرد CLR رو به همراه ندارن. تنها زبانی که امکان بهره برداری از تمامی ویژگیهای CLR رو برای برنامه نویسا فراهم میکنه، IL هست.

اجرای متد
برای اجرای یه متد، کد IL اون باید اول به کد native پردازنده مقصد ترجمه بشه. همونطور که قبلا هم اشاره شد بوسیله بخشی از CLR به نام JIT compiler در زمان اجرا انجام میشه.
برای نمونه اجرای متد زیر رو در نظر بگیرین:
private static void Main()
{
  Console.WriteLine("Hello");
  Console.WriteLine("Goodbye");
}
تصویر زیر فرایند کلی که در این عملیات (اجرای متد Main) انجام میشه رو نشون میده.
درست قبل از اجرای متد Main، تمامی ریفرنسهای استفاده شده تو این متد توسط CLR شناسایی میشه. این عملیات درنهایت منجر به تولید یه ساختار داده ای داخل محیط اجرای دات نت میشه که برای مدیریت دسترسی و اجرای این منابع شناسایی شده استفاده میشه.
تو مثال جاری، این متد تنها یه ریفرنس به یه نوع داره، کلاس System.Console، که باعث میشه CLR یه ساختار داخلی تنها برای همین به ریفرنس تولید کنه. این ساختار داده ای برای هر متد تعریف شده تو کلاس Console یه ورودی داره. هر کدوم از این ورودیها آدرس پیاده سازی متد مربوطه رو نگه میداره.
همچنین در هنگام تولید این ساختار داده ای داخلی، CLR برای هر ورودی یه متد داخلی اختصاص میده. این متد وظیفه اجرای JIT برای کامپایل متد موجود تو ریفرنس مربوطه رو برعهده داره. هنگامیکه متد Main برای اولین بار اجرا میشه، CLR این متد داخلی مخصوص رو برای کامپایل کد صدا میزنه. 
در سیستم های 32 بیتی و نیز برنامه هایی که تحت WOW64 اجرا میشن، JIT دستورالعملهای مربوط به پردازنده 32 بیتی رو در زمان کامپایل تولید میکنه. برای سیستمهای 64 بیتی هم دستورالعملهای مختص پردازنده های 64 بیتی تولید میشه. برای سیستمهای ARM هم دستورالعملهای مخصوص به پردازنده های ARM تولید میشه.
همونطور که تو تصویر بالا نشون داده شده، هنگام اجرای متد، کامپایلر JIT میدونه که کدوم متد فراخوانی شده و این متد به چه نوعی تعلق داره. متد JIT مربوط به ورودی متد موردنظر در ساختار داده ای داخلی CLR، متادیتای اسمبلی ای که نوع مربوطه تو اون تعریف شده رو برای یافتن کد IL متد درخواستی جستجو میکنه.
پس از یافتن کد IL، ابتدا عملیات تایید و بررسی کد (Verify) انجام شده و سپس کامپایل به کد native اجرا میشه. کد native تولید شده تو یه بلوکی از حافظه که به صورت داینامیک در زمان کامپایل برای اینکار اختصاص داده شده، ذخیره میشه.
سپس مقدار ریفرنس داده شده تو ورودی متد درخواستی تو ساختار داده ای داخلی CLR، به آدرس این بلوک حافظه تنظیم میشه. اینکار باعث میشه تا تو دفعات بعدی فراخوانی این متد به جای اجرای متد JIT، کدهای native تولید شده در بار اول اجرا استفاده بشن.
درنهایت اجرای کد native تولیدی در دستور کار قرار میگیره و کنترل به خطی از برنامه که این متد رو فراخوانی کرده برمیگرده.
حالا متد Main برای بار دوم  متد WriteLine رو فراخوانی میکنه. اینبار چون این متد قبلا کامپایل شده، به جای اجرای عملیات JIT، کد native تولید شده تو مرحله قبل مستقیما اجرا میشه. فرایند مربوطه تو تصویر زیر نشون داده شده:
بنابراین تنها تو بار اول اجرای یه متد، به دلیل اجرای کامپایلر JIT، کاهش کارایی و سرعت اجرا وجود داره و دفعات بعدی فراخوانی، اجرای متد با سرعت اجرای یه کد native بدون کاهشی در کاهشی در کارایی اون انجام میشه.

نکته: کد native کامپایل شده تو حافظه داینامیک سیستم ذخیره میشه، بدین معنی که پس از بسته شدن برنامه این کد تولیدشده ازبین میره و تو اجرای بعدی برنامه فرایندهای شرح داده شده در بالا دوباره انجام میشه. همچنین اگه دو نسخه از برنامه (یا بیشتر) به صورت همزمان اجرا بشن، عملیات کامپایل JIT برای هر نسخه به صورت جداگانه انجام میشه که این کار باعث افزایش مصرف حافظه و پردازش های انجام شده توسط پردازنده میشه. درمقابل برنامه هایی که کلا با کد native کامپایل شدن، چون کد نهایی به صورت فقط خواندنی از فایل اجرایی برنامه استخراج میشه، این نقطه ضعف رو نداره.

تو اکثر قریب به اتفاق برنامه ها، این کاهش کارایی در اثر اجرای کامپایلر JIT ناچیزه. بیشتر برنامه ها در زمان اجرا متدهای موجود رو بیشتر از یکبار فراخوانی میکنن. بنابراین به جز بار اول اجرا تو بقیه موارد سرعت اجرای متدها بالا خواهد بود. همچنین بیشتر زمان برنامه تو اجرای محتوای متدها میگذره تا فراخوانی متدها که باعث میشه اثر این کاهش کارایی کمتر هم بشه.

بهینه سازی در کامپایل

کامپایلر JIT عملیات کامپایل رو مثل کامپایلر native زبان ++C با بیشترین بهبود و بهینه سازی (optimization) ممکن براساس معماری کامپیوتر مقصد انجام میده. هرچند ممکنه اجرای این بهینه سازی کمی زمان کامپایل رو افزایش بده اما درنهایت سرعت و کارایی کد تولید شده به مراتب بیشتر از کدهای بهبود نیافته هست.

کامپایلر #C دو سوییچ مخصوص داره که روی بهینه سازی کد IL تولید شده در زمان کامپایل اولیه و نیز در زمان اجرا و کامپایل JIT تاثیر میذاره: optimize/ و debug/. جدول زیر نحوه اثرگذاری این دو سوییچ رو بطور خلاصه نمایش میده.
سوییچ کامپایلر کیفیت کد IL کیفیت کد native
/optimize- /debug- Unoptimized Optimized
/optimize- /debug(+/full/pdbonly) Unoptimized Unoptimized
/optimize+ /debug(-/+/full/pdbonly) Optimized Optimized

با استفاده از سوییچ -optimize/ کد غیربهینه تولیدی توسط کامپایلر #C حاوی دستورالعملهای غیرعملیاتی (No-Operation یا بطور خلاصه NOP) زیادیه که بیشتر برای زمان دیباگ بکار میرن، مثل پشتیبانی از ویژگی edit-and-continue ویژوال استودیو در زمان دیباگ، ساده کردن دیباگ کدهای مختلف به خصوص درون دستورالعملهای کنترل جریان اجرا (مثل if  و for و try-catch و ...) و از این قبیل.
در زمان تولید کدهای بهینه، این دستورالعملها تولید نمیشن، همچنین کدهای مربوط به بلوکهای کنترلی برای بهینه سازی، دچار تغییرات زیادی میشن که باعث میشه عملا اجرای فرایند دیباگ و اجرای خط به خط کد غیرممکن بشه. اما کد IL تولید کم حجم تر و فایل اسمبلی نهایی کوچکتر میشه ( اطلاعات بیشتر درباره nopها: ! و ! و ! ).
همچنین کامپایلر تنها زمانی فایلهای pdb (یا Program Database) رو تولید میکنه که سوییچ (debug(+/full/pdbonly/ بکار رفته باشه. این فایلها به دیباگر در زمینه هایی مثل پیداکردن راحتتر متغیرهای محلی و انطباق کد IL به سورس کد برنامه، کمک میکنن ( اطلاعات بیشتر راجع به pdbها: ! و ! و ! ).
کد تولید شده با سوییچ debug:full/ درواقع به JIT میگه که برنامه در حالت دیباگ اجرا میشه، بنابراین JIT تمامی کدهای native تولیدشده از کامپایل کدهای IL رو بررسی و تعقیب! میکنه. این ویژگی باعث میشه تا بشه از امکان just-in-time debugger ویژوال استودیو برای اتصال یه دیباگر به یه برنامه درحال اجرا استفاده کرد. بدون این سوییچ، JIT بدون بررسی و تحت نظر قرار دادن کد native، عملیات رو انجام میده که باعث میشه تا هم برنامه سریعتر اجرا بشه و هم حافظه کمتری برای اجرا کدها مصرف بشه.
پروژه های #C در ویژوال استودیو به صورت پیش فرض دو تنظیم مختلف برای اجرا دارن. تنظیم Debug از سوییچهای -optimize/ و debug:full/ استفاده میکنه و تنظیم Release از سوییچهای +optimize/ و debug:pdbonly/. تو تصاویر زیر این موارد نشون داده شدن:
برای اطلاعات تکمیلی و مفصلتر درباره این سوییچها و بهینه سازی کد به اینا سر بزنین: ! و !.
با توضیحات بالا ممکنه به نظر برسه که با توجه به فرایندهای درگیر در کامپایل و اجرا، برنامه های مدیریت شده بسیار کندتر از برنامه های native و مدیریت نشده اجرا میشن. واقعیت اینه که فرایندهای اشاره شده، مخصوصا اونایی که در زمان اجرا و در حین کامپایل JIT انجام میشن، هم روی کارایی و سرعت برنامه و هم روی میزان حافظه مصرفی توسط برنامه تاثیر منفی دارن. اما این نکته رو باید درنظر داشت که مایکروسافت در پیاده سازی این فرایندها، بهینه سازی های بسیار بسیار زیادی! رو انجام داده.
این بهینه سازی ها در برخی موارد اونقدر خوب انجام شده که سرعت اجرا قابل رقابت یا حتی بهتر از برنامه های native هست. دلایل زیادی برای این افزایش کارایی وجود داره. مثلا ازاونجایی که کامپایل به کد native در زمان اجرا و بر روی ماشین مقصد انجام میشه، کامپایلر JIT اطلاعات بیشتر و دقیقتری از محیط اجرا داره و بنابراین میتونه کدهای بهینه بهتری تولید کنه. مواردی که کد مدیریت شده سریعتر از کد مدیریت نشده اجرا میشن رو میشه به صورت زیر آورد:
- کامپایلر JIT میتونه تشخیص بده که برنامه داره روی پردازنده خاصی اجرا میشه و کدهایی تولید کنه که از دستورالعملهای مخصوص این پردازنده استفاده میکنه و افزایش قابل ملاحظه ای در اجرای برنامه ارائه کنه. در مقابل کدهای مدیریت نشده برای اونکه بتونن در تمام سیستمها اجرا بشن معمولا از پایینترین دستورالعملهای مشترک و استاندارد استفاده میکنن و از بکارکیری دستورات خاص پردازنده های مختلف که باعث افزایش چشمگیر کارایی میشه پرهیز میکنن.
- کامپایلر JIT در برخی موارد با توجه به مشخصات محیط اجرا میتونه تشخیص بده که برخی از قسمتهای کد بدون بررسی اجرا بشن، مثلا برخی از تستها و شرطها بدون تولید کد native صرفنظر میشن. برای نمونه اگه یه متد، شامل کدی مشابه زیر باشه:
if (noOfCpu > 1)
{
  ...
}
با توجه به سخت افزار ماشین مقصد، اگه پردازنده تنها یه هسته اجرایی داشته باشه، JIT اصلا کدی برای این قسمت از برنامه تولید نمیکنه و کلا از اون صرفنظر میکنه. در نتیجه کد native نهایی کوچکتر بوده و سریعتر هم اجرا میشه.
- ویژگی دیگه ای که فعلا تو نسخه های جاری CLR وجود نداره اما امکان پیاده سازیش تو چنین محیطهایی هست، اینه که CLR میتونه نحوه اجرای یه قطعه از کد رو بررسی کنه و الگوی اجرای اونو در بیاره. با استفاده از داده های این الگو میتونه کد IL رو با بهینه سازیهایی مبتنی بر اجراهای قبلی که برای موارد خاص تهیه شده در برنامه درحال اجرا، دوباره کامپایل کنه تا کارایی و سرعت اجرا رو افزایش بده. احتمال پیاده سازی چنین ویژگی ای در نسخه های آتی دات نت فریمورک وجود داره.

این موارد برخی از دلایلی هستن که نشون میده چرا در آینده باید از کدهای مدیریت شده انتظار داشت تا سریعتر از کدهای مدیریت نشده امروزی اجرا بشن. هرچند امروزه هم کدهای مدیریت شده کارایی بسیار خوبی دارن و هرچه زمان میگذره کارایی بهتری هم پیدا میکنن.

البته اگه با این حال اجرای کد مدیریت شده به نظر کند میاد، میشه با استفاده از ابزاری به نام NGen.exe (یا Native Image Generator) کدهای IL رو به کدهای native در زمان کامپایل تبدیل کرد. با اینکار CLR عملا از اجرای کامپایلر JIT در زمان اجرا صرفنظر میکنه. یعنی در زمان اجرا، CLR اول چک میکنه که آیا کد native تولیدشده توسط NGen.exe برای برنامه موجوده یا نه. درصورت یافتن چنین کدی بدون فراخوانی JIT کد native موجود رو به حافظه بارگذاری میکنه. ازاونجاکه ابزار NGen.exe کد native تولید میکنه، بنابراین مثل کامپایلرهای مدیریت نشده باید از ساختار ماشین مقصد کاملا مطلع باشه و کدی هم که تولید میکنه تنها تو همون معماری قابل اجراست و در ضمن این کد از بهینه سازی هایی که JIT در زمان اجرا انجام میده بی بهره میمونه.

درضمن، برای افزایش بیشتر بازدهی میشه از کلاس System.Runtime.ProfileOptimization هم استفاده کرد. با استفاده از این کلاس CLR متدهایی که تو روند اجرای برنامه JIT کامپایل میشن رو یه جا (تو یه فایل) ذخیره میکنه و تو اجرای بعدی با استفاده از این داده ها سعی میکنه که این متدهای موردنیاز رو با استفاده از چندین ثرد و به صورت موازی کامپایل کنه که باعث افزایش سرعت کمپایل JIT میشه. نکته ای که باید بهش اشاره بشه اینه که استفاده از این کلاس فقط تو سیستمهایی که از پردازنده های چند هسته ای استفاده میکنن امکان پذیره. برای اطلاعات بیشتر به اینجا سر بزنین.

  • یوسف نژاد

NET Framework.

CLR

نظرات  (۰)

هیچ نظری هنوز ثبت نشده است

ارسال نظر

ارسال نظر آزاد است، اما اگر قبلا در بیان ثبت نام کرده اید می توانید ابتدا وارد شوید.
شما میتوانید از این تگهای html استفاده کنید:
<b> یا <strong>، <em> یا <i>، <u>، <strike> یا <s>، <sup>، <sub>، <blockquote>، <code>، <pre>، <hr>، <br>، <p>، <a href="" title="">، <span style="">، <div align="">
تجدید کد امنیتی
آخرین نظرات