فصل ۱۲: IDisposable، Finalizer، Garbage Collection و مدیریت حافظه
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
فصل ۱۲: آزادسازی منابع و جمعآوری زباله (Disposal and Garbage Collection)
تصویر تزئینی آغاز فصل ۱۲
برخی اشیا برای آزادکردن منابعی مانند فایلهای باز، قفلها، Handleهای سیستمعامل و اشیای مدیریتنشده (Unmanaged Objects) به کد صریحِ جمعکردن یا Tear-down نیاز دارند. در اصطلاح .NET، این کار Disposal نام دارد و از طریق رابط IDisposable پشتیبانی میشود. حافظهٔ مدیریتشدهای که اشیای بلااستفاده اشغال کردهاند نیز باید در مقطعی بازیابی شود؛ این کار Garbage Collection نام دارد و CLR آن را انجام میدهد.
Disposal با Garbage Collection تفاوت دارد: Disposal معمولاً بهصورت صریح آغاز میشود، اما Garbage Collection کاملاً خودکار است. به بیان دیگر، برنامهنویس مسئول کارهایی مانند آزادکردن Handleهای فایل، قفلها و منابع سیستمعامل است، در حالی که CLR مسئول آزادکردن حافظه است.
این فصل هم Disposal و هم Garbage Collection را بررسی میکند و همچنین Finalizerهای C# و الگویی را توضیح میدهد که با آن میتوانند بهعنوان پشتیبان Disposal عمل کنند. در پایان، جزئیات Garbage Collector و دیگر گزینههای مدیریت حافظه را بررسی میکنیم.
IDisposable، Dispose و Close
.NET برای نوعهایی که به یک متد Tear-down نیاز دارند، رابط ویژهای تعریف میکند:
public interface IDisposable
{
void Dispose();
}
دستور using در C# یک میانبُر نحوی برای فراخوانی Dispose روی اشیایی است که IDisposable را پیادهسازی میکنند؛ این کار با یک بلوک try/finally انجام میشود:
using (FileStream fs = new FileStream ("myFile.txt", FileMode.Open))
{
// ... Write to the file ...
}
Compiler این کد را به شکل زیر تبدیل میکند:
FileStream fs = new FileStream ("myFile.txt", FileMode.Open);
try
{
// ... Write to the file ...
}
finally
{
if (fs != null) ((IDisposable)fs).Dispose();
}
بلوک finally تضمین میکند که حتی اگر Exception رخ دهد یا کد زودتر از بلوک خارج شود، متد Dispose فراخوانی خواهد شد.
به همین شکل، Syntax زیر تضمین میکند بهمحض اینکه fs از Scope خارج شد، Disposal انجام شود:
using FileStream fs = new FileStream ("myFile.txt", FileMode.Open);
// ... Write to the file ...
در سناریوهای ساده، برای نوشتن نوع Disposable خودتان کافی است IDisposable را پیادهسازی و متد Dispose را بنویسید:
sealed class Demo : IDisposable
{
public void Dispose()
{
// Perform cleanup / tear-down.
...
}
}
معنای استاندارد Disposal
.NET در منطق Disposal خود مجموعهای از قواعد عملی و De Facto را دنبال میکند. این قواعد به هیچ شکل در خود .NET یا زبان C# Hardwire نشدهاند؛ هدف آنها تعریف یک Protocol یکدست برای مصرفکنندگان است:
- بعد از Dispose شدن یک Object، دیگر قابل احیا نیست. نمیتوان آن را دوباره فعال کرد و فراخوانی متدها یا Propertyهای آن ـ بهجز
Dispose ـ باعث ObjectDisposedException میشود.
- فراخوانی چندبارهٔ متد
Dispose یک Object نباید خطا ایجاد کند.
- اگر Object قابل Dispose با نام
x مالک Object قابل Dispose دیگری با نام y باشد، متد Dispose در x بهطور خودکار Dispose در y را فراخوانی میکند، مگر اینکه خلاف آن مشخص شده باشد.
این قواعد هنگام نوشتن Typeهای خودتان نیز مفیدند، هرچند اجباری نیستند. هیچ چیزی جز شاید اعتراض همکارانتان مانع نوشتن متدی با نام «Undispose» نمیشود!
مطابق قاعدهٔ ۳، یک Container Object بهطور خودکار Child Objectهایش را Dispose میکند. نمونهٔ خوب آن یک Windows Forms Container Control مانند Form یا Panel است. Container میتواند تعداد زیادی Child Control داشته باشد، اما لازم نیست تکتک آنها را صریحاً Dispose کنید؛ بستن یا Dispose کردن Parent Control یا Form همهٔ آنها را مدیریت میکند. نمونهٔ دیگر زمانی است که یک FileStream را داخل DeflateStream Wrap میکنید. Dispose کردن DeflateStream، FileStream را نیز Dispose میکند، مگر اینکه در Constructor خلاف آن را مشخص کرده باشید.
Close و Stop
برخی Typeها افزون بر Dispose متدی با نام Close تعریف میکنند. BCL در .NET دربارهٔ معنای Close کاملاً یکدست نیست، اما تقریباً همیشه یکی از این دو حالت است:
- از نظر عملکردی دقیقاً معادل
Dispose است. - زیرمجموعهای از عملکرد
Dispose را انجام میدهد.
نمونهٔ حالت دوم IDbConnection است: Connectionی که Close شده باشد میتواند دوباره Open شود؛ Connectionی که Dispose شده باشد نمیتواند. نمونهٔ دیگر Windows Formای است که با ShowDialog فعال شده: Close آن را پنهان میکند، اما Dispose منابعش را آزاد میکند.
برخی Classها متد Stop تعریف میکنند؛ برای مثال Timer یا HttpListener. متد Stop ممکن است مانند Dispose منابع Unmanaged را آزاد کند، اما برخلاف Dispose اجازه میدهد Object دوباره Start شود.
چه زمانی Dispose کنیم؟
یک قاعدهٔ امن ـ تقریباً در همهٔ موارد ـ این است: «اگر شک دارید، Dispose کنید.» اشیایی که یک Handle منبع Unmanaged را Wrap میکنند تقریباً همیشه برای آزادکردن آن Handle به Disposal نیاز دارند. نمونهها شامل File Stream یا Network Stream، Network Socket، کنترلهای Windows Forms، قلمها، Brushها و Bitmapهای GDI+ هستند. برعکس، اگر Typeای Disposable باشد، اغلب ـ اما نه همیشه ـ بهصورت مستقیم یا غیرمستقیم به یک Unmanaged Handle اشاره میکند. دلیلش این است که Unmanaged Handleها دروازهٔ ورود به «دنیای بیرون» شامل منابع OS، Connectionهای شبکه و Lockهای Database هستند؛ یعنی مهمترین راههایی که اگر Object بهدرستی رها نشود میتواند بیرون از خودش دردسر ایجاد کند.
با این حال، سه سناریو برای Dispose نکردن وجود دارد:
- وقتی مالک Object نیستید؛ برای مثال Object مشترکی را از یک Field یا Property استاتیک میگیرید.
- وقتی متد
Dispose در Object کاری انجام میدهد که نمیخواهید.
- وقتی متد
Dispose در Object طبق طراحی ضروری نیست و Dispose کردن آن فقط پیچیدگی برنامه را بیشتر میکند.
دستهٔ اول نادر است. موارد اصلی در Namespace با نام System.Drawing دیده میشوند: اشیای GDI+ که از طریق Field یا Property استاتیک میگیرید ـ مانند Brushes.Blue ـ هرگز نباید Dispose شوند، چون همان Instance در تمام طول عمر Application استفاده میشود. در مقابل، Instanceهایی که از طریق Constructor میسازید ـ مانند new SolidBrush ـ باید Dispose شوند؛ همینطور Instanceهایی که از متدهای استاتیک مانند Font.FromHdc میگیرید.
دستهٔ دوم رایجتر است. چند نمونهٔ خوب در Namespaceهای System.IO و System.Data وجود دارد:
نمونههایی که Dispose کردن آنها همیشه مطلوب نیست| Type | عملکرد Disposal | چه زمانی Dispose نکنیم |
MemoryStream | I/O بعدی را غیرممکن میکند. | وقتی بعداً هنوز لازم است Stream را بخوانید یا بنویسید. |
StreamReader، StreamWriter | Reader/Writer را Flush میکند و Stream زیرین را میبندد. | وقتی میخواهید Stream زیرین باز بماند؛ در این حالت باید پس از پایان کار روی StreamWriter، Flush را فراخوانی کنید. |
IDbConnection | Connection پایگاهداده را آزاد میکند و Connection String را پاک میکند. | اگر لازم است دوباره آن را Open کنید، باید بهجای Dispose، Close را فراخوانی کنید. |
DbContext در EF Core | استفادهٔ بعدی را غیرممکن میکند. | وقتی ممکن است Queryهای Lazy-Evaluated متصل به همان Context داشته باشید. |
متد Dispose در MemoryStream فقط خود Object را غیرفعال میکند؛ Cleanup حیاتی انجام نمیدهد، زیرا MemoryStream هیچ Unmanaged Handle یا منبع مشابهی نگه نمیدارد.
دستهٔ سوم شامل Classهایی مانند StringReader و StringWriter است. این Typeها بیشتر بهاجبار Base Class خود Disposable هستند تا بهدلیل نیاز واقعی به Cleanup ضروری. اگر چنین Objectای را کامل داخل یک Method ایجاد و استفاده کنید، قراردادن آن در بلوک using دردسر چندانی ندارد. اما اگر عمر Object طولانیتر باشد، پیگیری لحظهای که دیگر استفاده نمیشود فقط برای اینکه Dispose شود، پیچیدگی بیفایدهای اضافه میکند. در چنین مواردی میتوانید Disposal آن Object را نادیده بگیرید.
پاککردن Fieldها هنگام Disposal
بهطور کلی لازم نیست در متد Dispose، Fieldهای Object را پاک کنید. با این حال، بهتر است از Eventهایی که Object طی عمرش به آنها Subscribe شده Unsubscribe کنید؛ نمونهای در «Managed Memory Leaks» صفحهٔ 600 آمده است. Unsubscribe کردن از چنین Eventهایی هم از دریافت Notificationهای ناخواسته جلوگیری میکند و هم مانع میشود Object ناخواسته از دید Garbage Collector زنده نگه داشته شود.
همچنین بهتر است Field یا Propertyای داشته باشید که نشان دهد Object Dispose شده است تا اگر Consumer بعداً بخواهد Memberهای آن را فراخوانی کند، بتوانید ObjectDisposedException پرتاب کنید. یک Pattern مناسب استفاده از Automatic Property با دسترسی خواندن عمومی است:
public bool IsDisposed { get; private set; }
هرچند از نظر فنی ضروری نیست، میتواند مفید باشد که Event Handlerهای خود Object را نیز در Dispose با قرار دادن مقدار null پاک کنید. این کار احتمال Fire شدن Eventها در هنگام یا بعد از Disposal را از بین میبرد.
گاهی Object رازهای ارزشمندی مانند Encryption Key نگه میدارد. در چنین مواردی منطقی است هنگام Disposal این دادهها از Fieldها پاک شوند تا وقتی حافظه بعداً به سیستمعامل برگردانده میشود، خطر کشف آنها توسط Processهای دیگر کاهش یابد. Class با نام SymmetricAlgorithm در System.Security.Cryptography دقیقاً همین کار را با فراخوانی Array.Clear روی Byte Array حامل کلید رمزنگاری انجام میدهد.
Disposal ناشناس (Anonymous Disposal)
گاهی مفید است IDisposable را بدون نوشتن یک Class جداگانه پیادهسازی کنید. فرض کنید میخواهید روی یک Class متدهایی ارائه دهید که پردازش Event را Suspend و Resume کنند:
class Foo
{
int _suspendCount;
public void SuspendEvents() => _suspendCount++;
public void ResumeEvents() => _suspendCount--;
void FireSomeEvent()
{
if (_suspendCount == 0)
... fire some event ...
}
...
}
چنین APIای استفادهٔ دستوپاگیری دارد. Consumer باید یادش بماند ResumeEvents را فراخوانی کند و برای Robust بودن باید این کار را داخل finally انجام دهد تا در صورت وقوع Exception نیز اجرا شود:
var foo = new Foo();
foo.SuspendEvents();
try
{
... do stuff ... // Because an exception could be thrown here
}
finally
{
foo.ResumeEvents(); // ...we must call this in a finally block
}
Pattern بهتر این است که ResumeEvents را حذف کنیم و SuspendEvents یک IDisposable برگرداند. در این صورت Consumer میتواند چنین بنویسد:
using (foo.SuspendEvents())
{
... do stuff ...
}
مشکل این است که این کار زحمت را روی دوش کسی میگذارد که باید SuspendEvents را پیادهسازی کند. حتی با حذف Spaceهای اضافی، کد اضافی زیر ایجاد میشود:
public IDisposable SuspendEvents()
{
_suspendCount++;
return new SuspendToken (this);
}
class SuspendToken : IDisposable
{
Foo _foo;
public SuspendToken (Foo foo) => _foo = foo;
public void Dispose()
{
if (_foo != null) _foo._suspendCount--;
_foo = null; // Prevent against consumer disposing twice
}
}
Pattern با نام Anonymous Disposal این مشکل را حل میکند. با Class قابل استفادهٔ مجدد زیر:
public class Disposable : IDisposable
{
public static Disposable Create (Action onDispose)
=> new Disposable (onDispose);
Action _onDispose;
Disposable (Action onDispose) => _onDispose = onDispose;
public void Dispose()
{
_onDispose?.Invoke(); // Execute disposal action if non-null.
_onDispose = null; // Ensure it can’t execute a second time.
}
}
میتوان متد SuspendEvents را به شکل زیر کوتاه کرد:
public IDisposable SuspendEvents()
{
_suspendCount++;
return Disposable.Create (() => _suspendCount--);
}
جمعآوری زبالهٔ خودکار (Automatic Garbage Collection)
فارغ از اینکه یک Object برای منطق Tear-down سفارشی به متد Dispose نیاز دارد یا نه، در نهایت باید حافظهای که روی Heap اشغال کرده آزاد شود. CLR این بخش را کاملاً خودکار با GC مدیریت میکند. شما هرگز حافظهٔ Managed را خودتان Deallocate نمیکنید. برای مثال:
public void Test()
{
byte[] myArray = new byte[1000];
...
}
وقتی Test اجرا میشود، Arrayای برای نگهداری 1,000 Byte روی Memory Heap Allocate میشود. متغیر myArray که روی Local Variable Stack قرار دارد به آن Array اشاره میکند. وقتی Method تمام میشود، myArray از Scope خارج میشود و دیگر چیزی به Array روی Heap اشاره نمیکند. Array یتیمشده در نتیجه واجد شرایط Reclaim شدن در Garbage Collection میشود.
Garbage Collection بلافاصله بعد از یتیمشدن Object انجام نمیشود. شبیه جمعآوری زباله در خیابان، دورهای رخ میدهد، اما برخلاف آن برنامهٔ زمانی ثابتی ندارد. CLR براساس عواملی مانند حافظهٔ در دسترس، حجم Allocation و زمان سپریشده از آخرین Collection تصمیم میگیرد چه زمانی Collect کند؛ GC خود را با Patternهای خاص دسترسی به حافظه در Application Tune میکند. بنابراین بین یتیمشدن Object و آزادشدن آن از حافظه تأخیری نامشخص وجود دارد که میتواند از Nanosecond تا چند روز باشد.
Garbage Collection و مصرف حافظه
GC تلاش میکند میان زمانی که برای Garbage Collection صرف میکند و مصرف حافظهٔ Application یا Working Set تعادل برقرار کند. در نتیجه، Application میتواند بیش از مقدار واقعاً موردنیاز حافظه مصرف کند، بهویژه وقتی Arrayهای موقت بزرگ ساخته میشوند.
میتوانید مصرف حافظهٔ یک Process را با Windows Task Manager یا Resource Monitor پایش کنید، یا بهصورت Programmatic یک Performance Counter را Query کنید:
// These types are in System.Diagnostics:
string procName = Process.GetCurrentProcess().ProcessName;
using PerformanceCounter pc = new PerformanceCounter
("Process", "Private Bytes", procName);
Console.WriteLine (pc.NextValue());
این کد Private Working Set را Query میکند که بهترین نمای کلی از مصرف حافظهٔ برنامه میدهد. بهطور مشخص، حافظهای را که CLR در داخل Deallocate کرده و در صورت نیاز Process دیگری حاضر است به OS پس بدهد، شامل نمیشود.
Rootها
Root چیزی است که یک Object را زنده نگه میدارد. اگر Object بهصورت مستقیم یا غیرمستقیم از یک Root Reference نشود، واجد شرایط Garbage Collection خواهد بود.
Root یکی از موارد زیر است:
- Local Variable یا Parameter در Method در حال اجرا، یا هر Method دیگری در Call Stack آن؛
- Static Variable؛
- Objectای در Queue که اشیای آماده برای Finalization را نگه میدارد؛ بخش بعدی را ببینید.
اجرای Code در Object حذفشده ناممکن است؛ بنابراین اگر هر احتمالی برای اجرای یک Instance Method وجود داشته باشد، Object آن باید به یکی از این شکلها Reference شده باشد.
توجه کنید گروهی از اشیا که بهصورت چرخهای به یکدیگر Reference میدهند، اگر Root Referee نداشته باشند مرده محسوب میشوند؛ شکل 12-1 را ببینید. به بیان دیگر، اشیایی که با دنبالکردن Arrowهای Reference از یک Root Object قابل دسترسی نیستند، Unreachable هستند و در نتیجه مشمول Collection میشوند.
شکل 12-1 — Rootهابرچسبهای شکل: Managed heap = هیپ مدیریتشده؛ root = ریشه؛ root (while x is in use) = ریشه تا زمانی که x در حال استفاده است؛ Unreachable objects subject to collection = اشیای غیرقابلدسترسی مشمول Collection.
Finalizerها
پیش از آزادشدن یک Object از حافظه، اگر Finalizer داشته باشد، Finalizer آن اجرا میشود. Finalizer مانند Constructor تعریف میشود، اما پیشوند آن علامت ~ است:
class Test
{
~Test()
{
// Finalizer logic...
}
}
هرچند Declaration آن شبیه Constructor است، Finalizer نمیتواند public یا static باشد، Parameter بگیرد یا Base Class را فراخوانی کند.
Finalizerها ممکن هستند چون Garbage Collection در Phaseهای مجزا کار میکند. ابتدا GC اشیای بلااستفاده و آمادهٔ حذف را شناسایی میکند. اشیایی که Finalizer ندارند فوراً حذف میشوند. اشیایی که Finalizer آنها هنوز اجرا نشده است فعلاً زنده نگه داشته و در Queue ویژهای قرار داده میشوند.
در این مرحله Garbage Collection تمام میشود و برنامهٔ شما اجرای خود را ادامه میدهد. سپس Finalizer Thread وارد عمل میشود و بهصورت موازی با برنامه اجرا میشود، Objectها را از Queue ویژه برمیدارد و Methodهای Finalization آنها را اجرا میکند. پیش از اجرای Finalizer هر Object، آن Object هنوز کاملاً زنده است؛ آن Queue نقش Root Object را دارد. پس از Dequeue شدن و اجرای Finalizer، Object یتیم میشود و در Collection بعدی برای Generation خودش حذف خواهد شد.
Finalizerها میتوانند مفید باشند، اما چند ملاحظه دارند:
- Finalizerها Allocation و Collection حافظه را کند میکنند، چون GC باید پیگیری کند کدام Finalizerها اجرا شدهاند.
- Finalizerها عمر Object و تمام اشیایی که به آنها Reference میدهد را طولانی میکنند؛ همه باید برای حذف واقعی تا چرخهٔ بعدی Garbage Collection منتظر بمانند.
- نمیتوان ترتیب فراخوانی Finalizerهای مجموعهای از اشیا را پیشبینی کرد.
- کنترل شما روی زمان فراخوانی Finalizer یک Object محدود است.
- اگر Code داخل Finalizer Block شود، اشیای دیگر نمیتوانند Finalize شوند.
- اگر Application بهشکل تمیز Unload نشود، ممکن است Finalizerها بهکلی دور زده شوند.
خلاصه اینکه Finalizerها تا حدی شبیه وکلا هستند: موقعیتهایی وجود دارد که واقعاً به آنها نیاز دارید، اما در حالت عادی بهتر است مگر در صورت ضرورت مطلق از آنها استفاده نکنید. اگر استفاده میکنید باید صددرصد مطمئن باشید دقیقاً چه کاری برای شما انجام میدهند.
راهنمای پیادهسازی Finalizer:
- Finalizer باید سریع اجرا شود.
- هرگز داخل Finalizer Block نکنید؛ «Blocking» در صفحهٔ 634 را ببینید.
- به اشیای Finalizable دیگر Reference ندهید.
- Exception پرتاب نکنید.
فراخوانی Dispose از Finalizer
یک Pattern رایج این است که Finalizer، Dispose را فراخوانی کند. وقتی Cleanup فوری نیست و تسریع آن با Dispose بیشتر Optimization است تا ضرورت، این کار منطقی است.
Pattern استاندارد پیادهسازی چنین است:
class Test : IDisposable
{
public void Dispose() // NOT virtual
{
Dispose (true);
GC.SuppressFinalize (this); // Prevent finalizer from running.
}
protected virtual void Dispose (bool disposing)
{
if (disposing)
{
// Call Dispose() on other objects owned by this instance.
// You can reference other finalizable objects here.
// ...
}
// Release unmanaged resources owned by (just) this object.
// ...
}
~Test() => Dispose (false);
}
Dispose Overload شده تا Flag بولی disposing بگیرد. نسخهٔ بدون Parameter، Virtual نیست و فقط نسخهٔ کاملتر را با مقدار true فراخوانی میکند.
نسخهٔ کاملتر منطق واقعی Disposal را دارد و protected virtual است؛ بنابراین نقطهٔ امنی به Subclass میدهد تا منطق Disposal خودش را اضافه کند.
Flag با نام disposing نشان میدهد این متد «بهدرستی» از متد Dispose فراخوانی شده است، نه در حالت Last-resort از Finalizer. ایده این است که وقتی disposing=false است، این Method در حالت کلی نباید به اشیای دیگری که Finalizer دارند Reference کند، چون ممکن است آن اشیا خودشان Finalize شده و در وضعیت غیرقابل پیشبینی باشند. این محدودیت بسیاری از کارها را حذف میکند. با این حال در حالت Last-resort هنوز میتوان برای مثال:
- Reference مستقیم به منابع OS را آزاد کرد؛ مثلاً چیزی که با P/Invoke به Win32 API گرفته شده است.
- فایل موقتی را که هنگام Construction ساخته شده حذف کرد.
برای Robust بودن، هر Codeای که ممکن است Exception پرتاب کند باید داخل try/catch قرار گیرد و بهتر است Exception Log شود. Logging نیز باید تا حد ممکن ساده و قابل اعتماد باشد.
توجه کنید در نسخهٔ بدون Parameter متد Dispose، GC.SuppressFinalize را فراخوانی میکنیم؛ این کار مانع میشود وقتی GC بعداً به Object میرسد Finalizer اجرا شود. از نظر فنی با توجه به اینکه Dispose باید فراخوانی تکراری را تحمل کند، این کار ضروری نیست؛ اما Performance را بهتر میکند چون Object و اشیای Referenced آن میتوانند در یک Cycle جمعآوری شوند.
Resurrection
فرض کنید Finalizer یک Object زنده را طوری تغییر دهد که دوباره به Object در حال مرگ Reference کند. وقتی Garbage Collection بعدی برای Generation آن Object رخ دهد، CLR میبیند Object قبلاً در حال مرگ دیگر یتیم نیست و بنابراین از Garbage Collection فرار میکند. این سناریوی پیشرفته Resurrection نام دارد.
برای مثال فرض کنید Classای میخواهیم که فایل موقت را مدیریت کند. وقتی Instance آن Garbage-Collect شد، میخواهیم Finalizer فایل موقت را حذف کند. در ظاهر ساده است:
public class TempFileRef
{
public readonly string FilePath;
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef() { File.Delete (FilePath); }
}
متأسفانه Bug دارد: File.Delete ممکن است Exception پرتاب کند؛ مثلاً بهدلیل نداشتن Permission، در حال استفاده بودن فایل یا اینکه قبلاً حذف شده است. چنین Exceptionای کل Application را از کار میاندازد و مانع اجرای Finalizerهای دیگر نیز میشود. میتوان Exception را با Catch خالی «بلعید»، اما در آن صورت هرگز نمیفهمیم مشکلی رخ داده است. فراخوانی یک API پیچیدهٔ Error Reporting هم مطلوب نیست چون Finalizer Thread را سنگین میکند و Garbage Collection اشیای دیگر را عقب میاندازد. باید Actionهای Finalization را به کارهای ساده، قابل اعتماد و سریع محدود کنیم.
گزینهٔ بهتر ثبت Failure در یک Static Collection است:
public class TempFileRef
{
static internal readonly ConcurrentQueue<TempFileRef> FailedDeletions
= new ConcurrentQueue<TempFileRef>();
public readonly string FilePath;
public Exception DeletionError { get; private set; }
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef()
{
try { File.Delete (FilePath); }
catch (Exception ex)
{
DeletionError = ex;
FailedDeletions.Enqueue (this); // Resurrection
}
}
}
Enqueue کردن Object در Static Collection با نام FailedDeletions یک Referee دیگر به آن میدهد و تضمین میکند تا زمانی که Object سرانجام Dequeue شود زنده بماند.
GC.ReRegisterForFinalize
Finalizer یک Object Resurrected برای بار دوم اجرا نمیشود، مگر اینکه GC.ReRegisterForFinalize را فراخوانی کنید.
در مثال زیر مانند مثال قبلی تلاش میکنیم فایل موقت را در Finalizer حذف کنیم. اگر حذف شکست بخورد، Object را دوباره Register میکنیم تا در Garbage Collection بعدی دوباره امتحان شود:
public class TempFileRef
{
public readonly string FilePath;
int _deleteAttempt;
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef()
{
try { File.Delete (FilePath); }
catch
{
if (_deleteAttempt++ < 3) GC.ReRegisterForFinalize (this);
}
}
}
بعد از سومین تلاش ناموفق، Finalizer بیسروصدا از حذف فایل صرفنظر میکند. میتوان این را با مثال قبلی ترکیب کرد؛ یعنی پس از شکست سوم Object را به Queue با نام FailedDeletions افزود.
GC چگونه کار میکند؟
CLR استاندارد از یک GC نسلمحور از نوع Mark-and-Compact استفاده میکند که مدیریت خودکار حافظه برای اشیای Managed Heap را انجام میدهد. GC از نوع Tracing GC محسوب میشود؛ یعنی در هر دسترسی به Object دخالت نمیکند، بلکه هر از گاهی بیدار میشود و Graph اشیای روی Managed Heap را Trace میکند تا مشخص شود چه اشیایی Garbage محسوب شده و قابل Collection هستند.
GC هنگام انجام Memory Allocation ـ از طریق Keyword با نام new ـ Garbage Collection را آغاز میکند؛ یا وقتی مقدار مشخصی حافظه Allocate شده، یا در زمانهای دیگری برای کاهش Memory Footprint Application. میتوان این Process را دستی نیز با System.GC.Collect آغاز کرد. در Garbage Collection ممکن است تمام Threadها Freeze شوند؛ در بخش بعدی بیشتر توضیح داده میشود.
GC از Root Object Referenceها شروع میکند و Object Graph را پیمایش میکند و هر Object لمسشده را Reachable علامت میزند. وقتی این Process تمام شد، تمام اشیای بدون Mark بلااستفاده در نظر گرفته و مشمول Garbage Collection میشوند.
Objectهای بلااستفادهٔ بدون Finalizer فوراً دور ریخته میشوند. Objectهای بلااستفادهٔ دارای Finalizer پس از اتمام GC برای پردازش در Finalizer Thread Enqueue میشوند. سپس این اشیا در GC بعدی مربوط به Generation خود واجد شرایط Collection میشوند، مگر اینکه Resurrect شده باشند.
اشیای «زنده» باقیمانده سپس به ابتدای Heap جابهجا و Heap Compact میشود و فضای جدید برای اشیا ایجاد میکند. Compaction دو هدف دارد: از Memory Fragmentation جلوگیری میکند و اجازه میدهد GC برای Allocation اشیای جدید Strategy بسیار سادهای داشته باشد؛ همیشه حافظه را در انتهای Heap Allocate کند. به این ترتیب نیاز به نگهداری فهرست Segmentهای آزاد حافظه که میتواند زمانبر باشد حذف میشود.
اگر پس از Garbage Collection فضای کافی برای Allocate کردن Object جدید وجود نداشته باشد و OS نیز نتواند حافظهٔ بیشتری فراهم کند، OutOfMemoryException پرتاب میشود.
تکنیکهای Optimization
GC برای کاهش زمان Garbage Collection چند تکنیک Optimization دارد.
Collection نسلمحور
مهمترین Optimization نسلمحور بودن GC است. این از این واقعیت استفاده میکند که بسیاری از اشیا سریع Allocate و Discard میشوند، اما برخی اشیا عمر طولانی دارند و لازم نیست در هر Collection Trace شوند.
بهطور پایه، GC Managed Heap را به سه Generation تقسیم میکند. اشیایی که تازه Allocate شدهاند در Gen0 هستند؛ اشیایی که یک Cycle از Collection را زنده ماندهاند به Gen1 میروند؛ تمام اشیای دیگر در Gen2 قرار دارند. Gen0 و Gen1 نسلهای Ephemeral یا کوتاهعمر نامیده میشوند.
CLR بخش Gen0 را نسبتاً کوچک نگه میدارد؛ اندازهٔ معمول آن از چند صد KB تا چند MB است. وقتی Gen0 پر میشود، GC یک Gen0 Collection راه میاندازد که نسبتاً مکرر رخ میدهد. GC Threshold مشابهی برای Gen1 نیز اعمال میکند؛ Gen1 نقش Buffer برای Gen2 را دارد، بنابراین Collectionهای Gen1 نیز نسبتاً سریع و مکرر هستند. اما Full Collectionهایی که Gen2 را هم شامل میشوند بسیار بیشتر طول میکشند و کمتر رخ میدهند. شکل 12-2 نتیجهٔ یک Full Collection را نشان میدهد.
شکل 12-2 — Generationهای Heapبرچسبهای شکل: Before GC = پیش از GC؛ After full GC = پس از Full GC؛ Live object = شیء زنده؛ Dead object = شیء مرده؛ Gen1/Gen2 pointer = اشارهگر نسل؛ Space for new object allocations = فضا برای Allocation اشیای جدید.
برای یک برآورد بسیار تقریبی، Gen0 Collection ممکن است کمتر از یک Millisecond طول بکشد؛ در Application عادی آنقدر کم است که محسوس نیست. اما Full Collection در برنامهای با Object Graphهای بزرگ ممکن است تا حدود 100 ms طول بکشد. این اعداد به عوامل زیادی وابستهاند و میتوانند بسیار متفاوت باشند، بهویژه در Gen2 که برخلاف Gen0 و Gen1 اندازهٔ آن Bound ندارد.
نتیجه این است که اشیای کوتاهعمر از نظر استفاده از GC بسیار کارآمدند. StringBuilderهایی که Method زیر میسازد تقریباً قطعاً در Gen0 سریع Collect میشوند:
string Foo()
{
var sb1 = new StringBuilder ("test");
sb1.Append ("...");
var sb2 = new StringBuilder ("test");
sb2.Append (sb1.ToString());
return sb2.ToString();
}
Large Object Heap
GC برای اشیایی بزرگتر از یک Threshold مشخص ـ در حال حاضر 85,000 Byte ـ Heap جداگانهای به نام Large Object Heap یا LOH استفاده میکند. این کار هزینهٔ Compact کردن Objectهای بزرگ را حذف و از Gen0 Collectionهای بیشازحد جلوگیری میکند. بدون LOH، Allocate کردن مجموعهای از Objectهای 16 MB ممکن بود بعد از هر Allocation یک Gen0 Collection ایجاد کند.
بهطور پیشفرض LOH Compact نمیشود، چون جابهجایی Blockهای بزرگ حافظه در Garbage Collection هزینهٔ بسیار زیادی دارد. این دو پیامد دارد:
- Allocation میتواند کندتر شود، چون GC همیشه نمیتواند فقط در انتهای Heap Object قرار دهد؛ باید وسط Heap نیز دنبال Gap بگردد و برای این کار Linked List از Blockهای آزاد حافظه نگه دارد.1
- LOH دچار Fragmentation میشود. یعنی آزادشدن Object میتواند حفرهای در LOH ایجاد کند که بعداً پرکردنش دشوار باشد. مثلاً حفرهٔ باقیمانده از Object با اندازهٔ 86,000 Byte فقط با Objectای بین 85,000 و 86,000 Byte پر میشود، مگر اینکه با حفرهٔ دیگری مجاور باشد.
اگر انتظار مشکل Fragmentation دارید، میتوانید به GC دستور دهید در Collection بعدی LOH را Compact کند:
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
راهحل دیگر، اگر برنامه مرتب Arrayهای بزرگ Allocate میکند، استفاده از API با نام Array Pooling در .NET است؛ «Array Pooling» صفحهٔ 599 را ببینید.
LOH همچنین Nongenerational است: تمام Objectهای آن Gen2 در نظر گرفته میشوند.
Workstation Collection در برابر Server Collection
.NET دو Mode برای Garbage Collection دارد: Workstation و Server. Workstation پیشفرض است. برای تغییر به Server، مورد زیر را به فایل .csproj Application اضافه کنید:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
پس از Build پروژه، این Setting در فایل .runtimeconfig.json Application نوشته و توسط CLR خوانده میشود:
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
...
وقتی Server Collection فعال است، CLR برای هر Core یک Heap و GC جداگانه Allocate میکند. Collection سریعتر میشود، اما Memory و CPU بیشتری مصرف میشود، چون هر Core Thread خودش را لازم دارد. اگر Machine تعداد زیادی Process دیگر با Server Collection فعال اجرا کند، میتواند باعث CPU Oversubscription شود؛ این موضوع روی Workstationها بهخصوص زیانآور است چون کل OS را کند و بیپاسخ جلوه میدهد.
Server Collection فقط روی Systemهای Multicore در دسترس است؛ روی Device یا Virtual Machine تکهستهای Setting نادیده گرفته میشود.
Background Collection
در هر دو Mode با نام Workstation و Server، CLR بهطور پیشفرض Background Collection را فعال میکند. میتوانید با افزودن مورد زیر به .csproj آن را غیرفعال کنید:
<PropertyGroup>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
هنگام Build، این Setting در .runtimeconfig.json نوشته میشود:
"runtimeOptions": {
"configProperties": {
"System.GC.Concurrent": false,
...
GC باید برای دورههایی در طول Collection، Execution Threadهای شما را Freeze یا Block کند. Background Collection این دورههای Latency را کم میکند و Application Responsiveتر میشود، با هزینهٔ مصرف کمی بیشتر CPU و Memory. بنابراین با غیرفعالکردن Background Collection:
- مصرف CPU و Memory کمی کاهش مییابد.
- Pause یا Latency هنگام Garbage Collection بیشتر میشود.
Background Collection اجازه میدهد Code Application همزمان با Gen2 Collection اجرا شود. Gen0 و Gen1 آنقدر سریع در نظر گرفته میشوند که از این Parallelism سودی نمیبرند.
Background Collection نسخهٔ بهبودیافتهٔ چیزی است که قبلاً Concurrent Collection نامیده میشد: محدودیتی را برطرف میکند که در آن اگر هنگام اجرای Gen2 Collection بخش Gen0 پر میشد، Concurrent Collection دیگر Concurrent باقی نمیماند. این به Applicationهایی که پیوسته حافظه Allocate میکنند کمک میکند Responsiveتر باشند.
GC Notificationها
اگر Background Collection را غیرفعال کنید، میتوانید از GC بخواهید کمی قبل از رخدادن Full Collectionِ Blocking به شما Notification بدهد. این قابلیت برای Server Farm طراحی شده است: درست قبل از Collection، Requestها را به Server دیگری هدایت میکنید، Collection را فوراً آغاز و تا پایان آن صبر میکنید و سپس Requestها را دوباره به همان Server برمیگردانید.
برای شروع Notification، GC.RegisterForFullGCNotification را فراخوانی کنید. سپس Thread دیگری راهاندازی کنید ـ فصل 14 ـ که ابتدا GC.WaitForFullGCApproach را فراخوانی کند. وقتی این Method یک GCNotificationStatus برگرداند که نشان میدهد Collection نزدیک است، میتوانید Requestها را به Serverهای دیگر هدایت و Manual Collection را Force کنید. سپس GC.WaitForFullGCComplete را فراخوانی میکنید؛ وقتی بازگشت، Collection کامل شده و دوباره میتوانید Request بپذیرید. این Cycle تکرار میشود.
Force کردن Garbage Collection
در هر زمان میتوانید با GC.Collect Garbage Collection را دستی Force کنید. GC.Collect بدون Argument یک Full Collection ایجاد میکند. اگر مقدار Integer بدهید، فقط Generationها تا آن مقدار Collect میشوند؛ بنابراین GC.Collect(0) فقط Gen0 Collection سریع انجام میدهد.
در حالت کلی بهترین Performance زمانی است که اجازه دهید GC خودش زمان Collection را انتخاب کند. Force کردن Collection میتواند با Promote کردن غیرضروری Objectهای Gen0 به Gen1 و Gen1 به Gen2 به Performance آسیب بزند. همچنین Self-tuning GC را مختل میکند؛ قابلیتی که Threshold هر Generation را هنگام اجرای Application بهصورت Dynamic تنظیم میکند تا Performance بیشینه شود.
استثناهایی وجود دارد. رایجترین مورد زمانی است که Application برای مدتی Sleep میرود. نمونهٔ خوب Windows Serviceای است که فعالیت روزانهای مانند بررسی Updateها انجام میدهد. ممکن است System.Timers.Timer هر 24 ساعت این فعالیت را شروع کند. پس از اتمام Activity تا 24 ساعت Code دیگری اجرا نمیشود؛ در نتیجه Memory Allocation نداریم و GC فرصتی برای فعالشدن پیدا نمیکند. هر مقدار Memory که Service برای انجام Activity مصرف کرده تا 24 ساعت بعد همچنان مصرف میشود، حتی با Object Graph خالی. راهحل فراخوانی GC.Collect بلافاصله پس از اتمام Activity روزانه است.
برای اطمینان از Collection اشیایی که بهعلت Finalizerها Collectionشان بهتأخیر افتاده، WaitForPendingFinalizers را نیز فراخوانی و دوباره Collect کنید:
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
اغلب این کار در Loop انجام میشود، چون اجرای Finalizerها ممکن است اشیای بیشتری را آزاد کند که خودشان Finalizer دارند.
مورد دیگر برای فراخوانی GC.Collect، تست Classای است که Finalizer دارد.
تنظیم Garbage Collection در Runtime
Property استاتیک GCSettings.LatencyMode تعیین میکند GC چگونه بین Latency و Efficiency کلی تعادل برقرار کند. تغییر مقدار پیشفرض Interactive به LowLatency یا SustainedLowLatency به CLR میگوید Collectionهای سریعتر ولی مکررتری را ترجیح دهد. این برای Applicationی مفید است که باید خیلی سریع به Eventهای Real-time پاسخ دهد.
تغییر Mode به Batch Throughput را به حداکثر میرساند، به قیمت Responsiveness بالقوه ضعیف؛ برای Batch Processing مفید است.
SustainedLowLatency در صورتی که Background Collection را در .runtimeconfig.json غیرفعال کنید پشتیبانی نمیشود.
همچنین میتوانید با GC.TryStartNoGCRegion موقتاً Garbage Collection را Suspend و با GC.EndNoGCRegion دوباره Resume کنید.
Memory Pressure
Runtime براساس عوامل مختلف، از جمله Total Memory Load روی Machine، زمان شروع Collection را تعیین میکند. اگر برنامه Unmanaged Memory ـ فصل 24 ـ Allocate کند، Runtime برداشت بیشازحد خوشبینانهای از مصرف حافظه خواهد داشت چون CLR فقط Managed Memory را میشناسد.
میتوانید با GC.AddMemoryPressure به CLR بگویید فرض کند مقدار مشخصی Unmanaged Memory Allocate شده است. هنگام آزادکردن آن حافظه با GC.RemoveMemoryPressure این اثر را خنثی کنید.
Array Pooling
اگر Application مرتب Array میسازد، با Array Pooling میتوانید بخش بزرگی از Overhead مربوط به Garbage Collection را حذف کنید. Array Pooling در .NET Core 3 معرفی شد و با «اجارهکردن» Array کار میکند؛ بعداً Array را برای استفادهٔ مجدد به Pool برمیگردانید.
برای Allocate کردن Array، Method با نام Rent را روی ArrayPool در Namespace با نام System.Buffers فراخوانی و Size موردنیاز را مشخص کنید:
int[] pooledArray = ArrayPool<int>.Shared.Rent (100); // 100 bytes
این کد Arrayای با حداقل 100 Byte از Global Shared Array Pool Allocate میکند. Pool Manager ممکن است Array بزرگتری از درخواست شما بدهد؛ معمولاً اندازهها را بر اساس توانهای 2 Allocate میکند.
پس از پایان کار با Array، Return را فراخوانی کنید؛ Array به Pool برمیگردد و میتواند دوباره Rent شود:
ArrayPool<int>.Shared.Return (pooledArray);
میتوانید یک Boolean اختیاری بدهید تا Pool Manager پیش از برگرداندن Array آن را Clear کند.
بهجای Shared Array Pool میتوانید Pool سفارشی بسازید و از آن Rent کنید. این خطر خرابکردن APIهای دیگر را کم میکند، اما مصرف کلی Memory را افزایش میدهد چون فرصت Reuse کمتر میشود:
var myPool = ArrayPool<int>.Create();
int[] array = myPool.Rent (100);
...
نشت حافظهٔ Managed
در زبانهای Unmanaged مانند C++ باید بهخاطر داشته باشید وقتی Object دیگر لازم نیست حافظه را دستی Deallocate کنید؛ وگرنه Memory Leak رخ میدهد. در دنیای Managed، این نوع خطا بهدلیل Automatic Garbage Collection در CLR ناممکن است.
با این حال Applicationهای بزرگ و پیچیدهٔ .NET میتوانند شکل خفیفتری از همان Syndrome با همان نتیجهٔ نهایی نشان دهند: Application در طول عمرش Memory بیشتری و بیشتری مصرف میکند تا سرانجام مجبور به Restart میشود. خبر خوب این است که Managed Memory Leak معمولاً آسانتر Diagnose و Prevent میشود.
Managed Memory Leak وقتی رخ میدهد که اشیای بلااستفاده بهدلیل Referenceهای بلااستفاده یا فراموششده زنده بمانند. Event Handlerها Candidate رایجی هستند؛ آنها Reference به Target Object نگه میدارند، مگر اینکه Target یک Static Method باشد. مثال:
class Host
{
public event EventHandler Click;
}
class Client
{
Host _host;
public Client (Host host)
{
_host = host;
_host.Click += HostClicked;
}
void HostClicked (object sender, EventArgs e) { ... }
}
Class تست زیر Methodای دارد که 1,000 Client میسازد:
class Test
{
static Host _host = new Host();
public static void CreateClients()
{
Client[] clients = Enumerable.Range (0, 1000)
.Select (i => new Client (_host))
.ToArray();
// Do something with clients ...
}
}
ممکن است انتظار داشته باشید بعد از پایان CreateClients، 1,000 Object از نوع Client واجد شرایط Collection شوند. اما هر Client یک Referee دیگر دارد: Object با نام _host که Event با نام Click آن اکنون به هر Client Instance Reference میدهد. اگر Click Event Fire نشود یا Method با نام HostClicked کاری نکند که جلب توجه کند، این مشکل میتواند پنهان بماند.
یک راهحل این است که Client، IDisposable را پیادهسازی کند و در Dispose Event Handler را جدا کند:
public void Dispose() { _host.Click -= HostClicked; }
Consumerها سپس وقتی کارشان با Clientها تمام شد Instanceها را Dispose میکنند:
Array.ForEach (clients, c => c.Dispose());
Timerها
Timerهای فراموششده نیز میتوانند Memory Leak ایجاد کنند؛ Timerها در فصل 21 بررسی میشوند. دو سناریوی متفاوت، بسته به نوع Timer، وجود دارد. ابتدا Timer در Namespace با نام System.Timers را ببینیم. در مثال زیر Class با نام Foo پس از Instantiate شدن هر ثانیه Method با نام tmr_Elapsed را فراخوانی میکند:
using System.Timers;
class Foo
{
Timer _timer;
Foo()
{
_timer = new System.Timers.Timer { Interval = 1000 };
_timer.Elapsed += tmr_Elapsed;
_timer.Start();
}
void tmr_Elapsed (object sender, ElapsedEventArgs e) { ... }
}
متأسفانه Instanceهای Foo هرگز Garbage-Collect نمیشوند. مشکل این است که Runtime خودش Reference به Timerهای فعال نگه میدارد تا Event با نام Elapsed را Fire کند؛ بنابراین:
- Runtime،
_timer را زنده نگه میدارد. _timer از طریق Event Handler با نام tmr_Elapsed، Instance با نام Foo را زنده نگه میدارد.
وقتی متوجه میشوید Timer، IDisposable را پیادهسازی میکند، راهحل روشن است. Dispose کردن Timer آن را Stop میکند و تضمین میکند Runtime دیگر به Object Reference ندارد:
class Foo : IDisposable
{
...
public void Dispose() { _timer.Dispose(); }
}
Timerهای WPF و Windows Forms از این نظر همان رفتار توضیحدادهشده را دارند.
اما Timer در Namespace با نام System.Threading خاص است. .NET به Timerهای Threading فعال Reference نگه نمیدارد؛ بهجای آن مستقیماً Callback Delegateها را Reference میکند. بنابراین اگر فراموش کنید Threading Timer را Dispose کنید، Finalizer میتواند اجرا و Timer را خودکار Stop و Dispose کند:
static void Main()
{
var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000);
GC.Collect();
System.Threading.Thread.Sleep (10000); // Wait 10 seconds
}
static void TimerTick (object notUsed) { Console.WriteLine ("tick"); }
اگر این مثال در Release Mode ـ Debugging غیرفعال و Optimization فعال ـ Compile شود، Timer پیش از آنکه حتی یک بار فرصت Fire شدن داشته باشد Collect و Finalize میشود. دوباره میتوان با Dispose کردن Timer در پایان کار مشکل را حل کرد:
using (var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000))
{
GC.Collect();
System.Threading.Thread.Sleep (10000); // Wait 10 seconds
}
فراخوانی ضمنی tmr.Dispose در پایان بلوک using تضمین میکند Variable با نام tmr «استفادهشده» محسوب شود و تا پایان Block از دید GC مرده تلقی نشود. جالب اینکه همین فراخوانی Dispose در واقع Object را مدت بیشتری زنده نگه میدارد.
تشخیص Memory Leak
سادهترین راه جلوگیری از Managed Memory Leak این است که همزمان با توسعهٔ Application، مصرف Memory را بهصورت Proactive پایش کنید. میتوانید مصرف Memory فعلی اشیای برنامه را به شکل صفحهٔ بعد بگیرید.
Argument با مقدار true به GC میگوید ابتدا Collection انجام دهد:
long memoryUsed = GC.GetTotalMemory (true);
اگر Test-driven Development انجام میدهید، میتوانید Unit Test بنویسید که Assert کند حافظه طبق انتظار Reclaim شده است. اگر Assertion شکست بخورد، فقط لازم است تغییرات اخیر را بررسی کنید.
اگر از قبل Application بزرگی با Managed Memory Leak دارید، Tool با نام windbg.exe میتواند در یافتن آن کمک کند. Toolهای گرافیکی دوستانهتر نیز وجود دارند، مانند Microsoft CLR Profiler، SciTech Memory Profiler و Red Gate ANTS Memory Profiler.
CLR همچنین Event Counterهای زیادی برای کمک به Resource Monitoring ارائه میکند.
Weak Referenceها
گاهی مفید است Referenceای به Object داشته باشید که از دید GC در زنده نگهداشتن Object «نامرئی» باشد. این Weak Reference نام دارد و با Class با نام System.WeakReference پیادهسازی میشود.
برای استفاده، WeakReference را با Target Object بسازید:
var sb = new StringBuilder ("this is a test");
var weak = new WeakReference (sb);
Console.WriteLine (weak.Target); // This is a test
اگر Target فقط توسط یک یا چند Weak Reference Reference شده باشد، GC آن Target را واجد شرایط Collection میداند. پس از Collection، Property با نام Target در WeakReference مقدار null خواهد داشت:
var weak = GetWeakRef();
GC.Collect();
Console.WriteLine (weak.Target); // (nothing)
WeakReference GetWeakRef () =>
new WeakReference (new StringBuilder ("weak"));
برای جلوگیری از Collect شدن Target بین بررسی null بودن و استفاده از آن، Target را در Local Variable قرار دهید:
var sb = (StringBuilder) weak.Target;
if (sb != null) { /* Do something with sb */ }
پس از Assignment به Local Variable، Target یک Strong Root دارد و تا زمانی که آن Variable در حال استفاده است نمیتواند Collect شود.
Class زیر با Weak Referenceها همهٔ Widgetهای Instantiateشده را Track میکند، بدون آنکه مانع Collection آن Objectها شود:
class Widget
{
static List<WeakReference> _allWidgets = new List<WeakReference>();
public readonly string Name;
public Widget (string name)
{
Name = name;
_allWidgets.Add (new WeakReference (this));
}
public static void ListAllWidgets()
{
foreach (WeakReference weak in _allWidgets)
{
Widget w = (Widget)weak.Target;
if (w != null) Console.WriteLine (w.Name);
}
}
}
تنها ملاحظه این است که Static List در طول زمان رشد میکند و Weak Referenceهایی با Target برابر null جمع میشوند. بنابراین باید Strategyای برای Cleanup پیادهسازی کنید.
Weak Reference و Caching
یکی از کاربردهای WeakReference، Cache کردن Object Graphهای بزرگ است. این اجازه میدهد Data پرمصرف از نظر Memory برای مدت کوتاهی Cache شود، بدون اینکه مصرف Memory بیشازحد بالا برود:
_weakCache = new WeakReference (...); // _weakCache is a field
...
var cache = _weakCache.Target;
if (cache == null) { /* Re-create cache & assign it to _weakCache */ }
در عمل این Strategy میتواند فقط تا حدی مؤثر باشد چون کنترل کمی روی زمان Fire شدن GC و Generation انتخابی آن دارید. بهخصوص اگر Cache در Gen0 بماند ممکن است در چند Microsecond Collect شود؛ و به یاد داشته باشید GC فقط زمانی که Memory کم است Collect نمیکند، بلکه در شرایط عادی نیز مرتب اجرا میشود. پس حداقل باید Cache دوسطحی داشته باشید: ابتدا Strong Reference نگه دارید و با گذشت زمان آنها را به Weak Reference تبدیل کنید.
Weak Reference و Eventها
قبلاً دیدیم Eventها چگونه میتوانند Managed Memory Leak ایجاد کنند. سادهترین راه این است که در چنین شرایطی Subscribe نکنید یا یک Dispose برای Unsubscribe کردن بسازید. Weak Reference راهحل دیگری ارائه میدهد.
Delegateای را تصور کنید که فقط Weak Reference به Targetهایش نگه میدارد. چنین Delegateای Target را زنده نگه نمیدارد، مگر اینکه Target Referee مستقل داشته باشد. البته این مانع نمیشود Delegate در فاصلهٔ زمانی بین واجد شرایط Collection شدن Target و رسیدن GC به آن، Target بدون Reference را فراخوانی کند. برای مؤثر بودن چنین راهحلی Code شما باید در برابر این Scenario Robust باشد.
با فرض این Robustness، میتوانید Class مربوط به Weak Delegate را چنین پیادهسازی کنید:
public class WeakDelegate<TDelegate> where TDelegate : Delegate
{
class MethodTarget
{
public readonly WeakReference Reference;
public readonly MethodInfo Method;
public MethodTarget (Delegate d)
{
// d.Target will be null for static method targets:
if (d.Target != null) Reference = new WeakReference (d.Target);
Method = d.Method;
}
}
List<MethodTarget> _targets = new List<MethodTarget>();
public void Combine (TDelegate target)
{
if (target == null) return;
foreach (Delegate d in (target as Delegate).GetInvocationList())
_targets.Add (new MethodTarget (d));
}
public void Remove (TDelegate target)
{
if (target == null) return;
foreach (Delegate d in (target as Delegate).GetInvocationList())
{
MethodTarget mt = _targets.Find (w =>
Equals (d.Target, w.Reference?.Target) &&
Equals (d.Method.MethodHandle, w.Method.MethodHandle));
if (mt != null) _targets.Remove (mt);
}
}
public TDelegate Target
{
get
{
Delegate combinedTarget = null;
foreach (MethodTarget mt in _targets.ToArray())
{
WeakReference wr = mt.Reference;
// Static target || alive instance target
if (wr == null || wr.Target != null)
{
var newDelegate = Delegate.CreateDelegate (
typeof(TDelegate), wr?.Target, mt.Method);
combinedTarget = Delegate.Combine (combinedTarget, newDelegate);
}
else
_targets.Remove (mt);
}
return combinedTarget as TDelegate;
}
set
{
_targets.Clear();
Combine (value);
}
}
}
در Methodهای Combine و Remove، تبدیل Reference از target به Delegate را با Operator با نام as انجام میدهیم، نه Cast Operator معمولی. دلیل این است که C# Cast Operator را با این نوع Parameter مجاز نمیداند، چون ممکن است بین Custom Conversion و Reference Conversion ابهام ایجاد شود.
سپس GetInvocationList را فراخوانی میکنیم چون این Methodها ممکن است با Multicast Delegate ـ Delegate دارای بیش از یک Method Recipient ـ فراخوانی شوند.
در Property با نام Target، یک Multicast Delegate میسازیم که تمام Delegateهای Referenced با Weak Referenceهایی که Target زنده دارند را ترکیب میکند و Referenceهای باقیمانده و مرده را از List حذف میکند تا _targets بینهایت رشد نکند. میتوان Class را با انجام همین Cleanup در Method با نام Combine بهتر کرد؛ بهبود دیگر افزودن Lock برای Thread Safety است، بخش «Locking and Thread Safety» در صفحهٔ 638 را ببینید. همچنین Delegateهایی را مجاز میکنیم که اصلاً Weak Reference ندارند؛ اینها Delegateهایی هستند که Targetشان Static Method است.
مثال زیر نحوهٔ استفاده از این Delegate برای پیادهسازی Event را نشان میدهد:
public class Foo
{
WeakDelegate<EventHandler> _click = new WeakDelegate<EventHandler>();
public event EventHandler Click
{
add { _click.Combine (value); } remove { _click.Remove (value); }
}
protected virtual void OnClick (EventArgs e)
=> _click.Target?.Invoke (this, e);
}