AppDomain: چرا و چگونه

یکشنبه, ۲ شهریور ۱۳۹۹

.NET Framework
C#
AppDomain
MarshalByRefObject
Shane - Unsplash
Shane - Unsplash

مقدمه

اصولا استفاده از AppDomain توی پروژه ها چیز رایجی نیست. منم برام پیش نیومده بود که نیازی بهش داشته باشم، تا همین چند وقت پیش که برای انجام کاری مجبور شدم مستقیما باهاش سر و کله ی جانانه ای بزنم. البته به نظرم از AppDomain نوشتن تو سال 2020 آنچنان موضوعیتی نداره، به خصوص اینکه توی net core. مایکروسافت چیزی به اسم AppDomain ارائه نداده - چه بهتر - بنابراین می تونیم بگیم یه مفهوم منسوخ شده ست. و اگرم نباشه توی این بیست سال حتما به اندازه ی کافی مطلب در موردش نوشته شده. ولی به هر حال من تصمیم گرفتم هر چی بلدم رو جمع و جور کنم تا یه پستی از توش دربیارم؛ عشقِ Blogging!!

پیش نیاز

قبل از بررسیِ AppDomain می خوام یکم از Process بگم، چون به نظرم به درکِ موضوعِ اصلی کمک می کنه.

سیستم عامل برای مدیریتِ برنامه ها اونارو به ساختارهایی به نام Process تقسیم می کنه. بنابراین برای کارای مختلف مثل تخصیصِ منابع، برقراری امنیت و غیره طرف حسابش Process ِ . یعنی مثلا اگر قراره Memory به مساوات بین بندگان توزیع بشه، ملاک تشخیص سیستم عامل ماژول های داخل یک برنامه نیست، بلکه خود برنامه یا به عبارت دیگه Processییه که اون برنامه رو در بر داره. نکته ی مهم اینه که سیستم عامل به همه ی Processها به چشم یکسان نگاه می کنه، یعنی براش فرقی نداره طرفش با net. نوشته شده یا javascript.

در شکل زیر دو Process که با دو زبون مختلف نوشته شدن، نشون داده شده. درون هر Process علاوه بر چیزهای متعلق به اون تکنولوژی (net. یا node.js) یه تعدادی هم Thread قرار داره که کدها رو اجرا می کنه.

مدیریت Processها توسط سیستم عامل
مدیریت Processها توسط سیستم عامل

AppDomain چیست؟

AppDomain ساختاریه مختص NET Framework. برای ایزوله کردنِ Objectهای داخلِ یک برنامه، بنابراین ویندوز از وجودشون بی خبره. اکثرا ما هم از وجودشون بی خبریم! چون نیازی بهشون نداریم، اما واقعیت اینه که تمام برنامه های net. حداقل با یک AppDomain اجرا می شن که می شه از طریق AppDomain.Current بهش دسترسی پیدا کرد.

بنابراین حضور AppDomain برای برنامه های net. چیز انتخابی نیست، بلکه جبریه. هر assembly برای اجرا شدن به یک AppDomain نیاز داره. در زمانِ اجرای برنامه، CLR اول یک AppDomain ِ پیش فرض می سازه و اون assembly و تمام assembly های reference شده را داخلش لود می کنه. بعد کدها طبق ترتیب توسط Threadها اجرا می شن.

لود شدنِ assemblyها داخل یک AppDomain
لود شدنِ assemblyها داخل یک AppDomain

به رابطه ی assemblyها و AppDomain در تصویر بالا دقت کنید. تمام assembly ها در یک AppDomain ِ واحد اجرا می شن؛ این حالت به صورتِ عادی رفتارِ پیش فرضِ CLR ِ. یعنی اگر A.exe از طریقِ Reference ِ مستقیم یا با استفاده از Reflection،‏ B.dll رو لود کنه (و همین طور B هم C را) در نهایت این ساختار ایجاد می شه. اما برای ایجاد AppDomainهای مجزا CLR این امکان رو در اختیار برنامه نویس قرار داده تا به شکل مستقیم AppDomainهای دیگری بسازه و assemblyها رو داخلشون لود کنه. اما چرا؟ چرا باید Microsoft این مفهوم رو برای NET Framework. تعبیه کنه؟ جواب کوتاه: همون طور که در تعریف اومد؛ ایزوله سازی. اما منظور از ایزوله کردن چیه؟ ایزوله سازی در اینجا یعنی عدمِ امکانِ دسترسی به Objectهای یک AppDomain از AppDomain ِ دیگر. کلا دلیل وجودِ AppDomainها اینه که یک سری Object امکانِ دسترسی به یک سری Object ِ دیگه رو نداشته باشن. حالا می رسیم به چرای دوم: چرا باید این ایزوله سازی رو در برنامه ایجاد کرد؟ خب این سوال می تونه دلایل فنی مختلفی داشته باشه:

AppDomain بسازیم!

کار سختی نیست:

var newAppDomain = AppDomain.CreateDomain("the-new-appdomain");

AppDomain جدید داخل متغیر newAppDomain قرار داره. رشته ای که به متدِ CreateDomain پاس داده شده اسمیه برای AppDomain ِ جدید. در حقیقت همون نامی که داخلِ Property ِ‏ FriendlyName قرار می گیره. CLR برای AppDomain ِ پیش فرض نامِ assembly رو می نویسه:

Console.WriteLine(AppDomain.CurrentDomain.FriendlyName);

با اجرای این کد توقع اتفاقِ خاصی نداشته باشید. وقتی یک AppDomain ِ جدید می سازید هیچ اتفاقی نمی افته. نه کدی داخلش Run می شه، نه Threadی و نه هیچ چیز دیگه. برای اینکه اتفاق خاصی بیافته، اول باید یک assembly رو داخل اون AppDomain لود کنید و بعد یک Type ازش ایجاد کنید تا ماجرا شروع بشه.

ادامه ی بحث رو با مثال زیر دنبال کنید. بهتره قبلش کدها رو از اینجا بگیرید تا راحت تر باشید. در این مثال سه assembly وجود داره:

هدف نهایی این مثال لود کردن Target.dll داخل یک AppDomain ِ جدید در Process ِ‏ App.exe است.

به رابطه ی assemblyها دقت کنید:

هر دوی assemblyها (یعنی App.exe و Target.dll) با Shared.dll رابطه ی مستقیم (Reference) دارند اما بینشون هیچ رابطه ای برقرار نیست.

بعد از ساختنِ AppDomain می تونید با استفاده از متدِ CreateInstanceFromAndUnwrap یک assembly رو داخل AppDomain لود کنید و از یکی از Typeهای آن یک object بسازید.

1 2 3 4 5 6var newAppDomain = AppDomain.CreateDomain("the-new-appdomain"); var foo = (ICounter)newAppDomain.CreateInstanceFromAndUnwrap(@"..\..\..\Target\bin\debug\Target.dll", "Target.Counter"); Console.WriteLine(foo.Count()); Console.WriteLine(foo.Count()); Console.WriteLine(foo.Count()); Console.Read();

بعد از اجرای برنامه می بینید که کدها طبق انتظار اجرا می شن.

حالا یکم با این کدا بازی کنیم تا مفهوم بهتر دستگیرمون بشه؛ کد بالا رو به این شکل تغیر بدید:

1 2 3 4 5 6 7 8 9var domain1 = AppDomain.CreateDomain("domain1"); var foo1 = (ICounter)domain1.CreateInstanceFromAndUnwrap(@"..\..\..\Target\bin\debug\Target.dll", "Target.Counter"); var domain2 = AppDomain.CreateDomain("domain2"); var foo2 = (ICounter)domain2.CreateInstanceFromAndUnwrap(@"..\..\..\Target\bin\debug\Target.dll", "Target.Counter"); Console.WriteLine(foo1.Count()); Console.WriteLine(foo2.Count()); Console.WriteLine(foo1.Count()); Console.WriteLine(foo2.Count()); Console.Read();

قبل از اجرای کدِ بالا کلاس Counter در assembly ِ‏ Target رو مد نظر داشته باشید:

1 2 3 4 5 6 7 8 9public class Counter : MarshalByRefObject, ICounter { public static int Value; public int Count() { Value++; return Value; } }

همیشه از متغیرهای static توقع می ره که مقدار خودشون رو حفظ کنن، اما خروجی چیز دیگه ای می گه:

domain A : 1
domain B : 1
domain A : 2
domain B : 2

شاید الآن معنای ایزوله سازی براتون شفاف تر شده باشه. متغیرهای static حوزه ی کاریشون داخلِ یک AppDomain است. Instanceهای foo1 و foo2 هر کدام مربوط به Objectهایی موجود در یک AppDomain ِ مجزا هستند. بنابراین متغیرِ static ِ‏ Value در دو AppDomain و به صورت جداگانه مقادیر رو نگه می داره. با این کار ما یک assembly را (Target.dll) دو بار، در دو AppDomain ِ متفاوت لود و به صورت مجزا اجرا کردیم.

اگه متن رو تا اینجا با دقت خونده باشید این سوال براتون پیش میاد که "مگه قرار نبود AppDomainها به اشیای AppDomainهای دیگه دسترسی نداشته باشن!؟"

ارتباط Objectها بین AppDomainهای متفاوت

طبق تعریف، دلیل وجود AppDomainها ایزوله سازی قسمت های مختلف یک برنامه ست. حالا اگه قرار باشه به راحتی یک Object از یک AppDomain توسط AppDomainی دیگه فراخوانی و استفاده بشه، دیگه اصلا چه نیازی به AppDomain بود!؟ جواب اینه که کد بالا هیچ قانونی از ایزوله سازی رو نقض نکرده. Instanceی که از کلاس Counter در App.exe ایجاد می شه به هیچ وجه Object ِ اصلی نیست. Object ِ اصلی در DomainA و DomainB قرار داره و تنها یک اشاره-گر به اون در App.exe موجوده. به این Object ِ اشاره-گر Proxy می گن.

بنابراین با فراخوانی متدِ CreateInstanceFromAndUnwrap،‏ CLR چند تا حرکت اضافه انجام می ده تا مطمئن بشه AppDomainها از حالت ایزوله خارج نمی شن. به همین دلیله که کلاس Counter از کلاس MarshalByRefObject ارث بری کرده. وقتی CLR می بینه که یک Object از کلاس MarshalByRefObject ارث بری کرده متوجه می شه این Object قراره از یک AppDomain به AppDomain دیگه ارسال بشه. طبیعتا این ارسال به همین راحتی انجام نمی شه چون دغدغه ی اصلیِ CLR حفظ ساختارِ ایزوله شده ست. پس اول یک Object ِ جعلی در مقصد ایجاد می کنه که از این به بعد بهش می گیم Proxy.‏ Object ِ‏ Proxy دقیقا همون interfaceی رو داره که Object ِ اصلی داره. می تونید با RemotingServices.IsTransparentProxy بفهمید که آیا یک Object،‏ Proxy هست یا خیر.

ارتباط Proxyها با Objectهای اصلی در AppDomainهای دیگر
ارتباط Proxyها با Objectهای اصلی در AppDomainهای دیگر

در کلاسِ Counter ما با ارث بردن از MarshalByRefObject داریم به CLR اعلام می کنیم که Objectهای ساخته شده از این کلاس قراره خارج از AppDomain یا Process ِ جاری قابلیت فراخوانی داشته باشن. این به هیچ وجه به معنی دسترسی مستقیم نیست. هر متدی که در Proxy‏ فراخوانی می شه در نهایت باعث ارسال یک پیغام به Object ِ اصلی در AppDomain ِ خودش می شه و عملیات فراخوانی اصلی در اونجا شبیه سازی می شه.

در کل CLR برای حفظ ایزولگی دو تا راه حل داره: ارسال یک کپی از Object یا ایجاد یک Proxy. ارسال کپی به وسیله ی Serialization انجام می شه که به عنوان یه تمرین خودتون انجام بدید. اما Serialization همیشه هم شدنی نیست، بنابراین باید از روش Marshal by reference استفاده کرد. تنها استثنایی که نه از Serialization استفاده می کنه و نه از Marshaling ،‏ stringها هستن. CLR از string ها مقادیر Serial شده ارسال نمی کند بلکه خود Object را ارسال می کند چون مطمئنه که Immutable هستن. به همین خاطره که کلاس string‏ seal شده.

بررسی بیشتر چگونگی کارکرد Proxyها از حوصله ی این پست خارجه. برای آشنایی بیشتر با این حوزه به مطالب NET Remoting. مراجعه کنید.

نکته: زمانی از Marshaling استفاده می کنید سعی کنید به fieldها دسترسی نداشته باشید چون این کار با Reflection انجام می شه و باعث افت performance خواهد شد.

Unload کردن AppDomainها

این هم کار سختی نیست:

AppDomain.Unload(domainA);

domainA در مثال بالا رو Unload کنید و بعد متد Count از متغیر foo1 را call کنید. نتیجه یه exception ِ معروفه به نام AppDomainUnloadedException.

با Unload کردن AppDomain الگوریتم زیر اجرا می شود:

AppDomainها و Threadها

آخرین چیزی که نیاز داره بگم چگونگی اجرای Threadها در AppDomainهای متفاوته. همون طوری که در قسمت اول پست بیان شد Threadها به Process تعلق دارن. بنابراین یک Thread می تونه کدهای متعلق به تمام AppDomainهای متفاوت در یک برنامه رو اجرا کنه. توی مثال بالا تمام کدها توسطِ یک Thread ِ واحد اجرا می شن.

ختم کلام

ایجاد AppDomainهای اضافه در اکثر پروژه ها ضروری نیست. اما گاهی به دلایلی که در بالا ذکر شد از ساختن شون ناگزیریم. چیزی که در این پست زیاد بهش پرداخته نشد و اصولا در برنامه هایی که AppDomainهای متعدد دارن نیازه، راه های ارتباطی بین objectها در AppDomainهای متفاوته. پیشنهاد می کنم برای دسترسی به اطلاعات بیشتر در این باره به مباحث Remoting مراجعه کنید.