عنوان:

‫تخصیص حافظه در دات‌نت


نویسنده: امیر مکارچی
تاریخ: ۱۴۰۳/۱۲/۱۵ ۰۹:۵۵
آدرس: www.dntips.ir
چرا تخصیص حافظه در دات‌نت سریع‌تر از malloc در C++/C است؟

در زبان‌های C و ++C، هنگامی که malloc فراخوانی می‌شود، سیستم‌عامل باید یک فضای خالی در حافظه‌ی Heap پیدا کند. این کار از طریق ساختارهایی مانند لیست‌های آزاد (Free Lists) انجام می‌شود. این فرآیند می‌تواند باعث کاهش سرعت تخصیص حافظه شود، به‌ویژه زمانی که تکه‌تکه شدن حافظه (Fragmentation) رخ دهد (نقشه راه مدیریت حافظه و جمع‌آوری زباله). اما در دات‌نت، تخصیص حافظه معمولاً تنها یک عملیات اضافه کردن روی اشاره‌گر (Pointer Increment) است که بسیار سریع‌تر انجام می‌شود. نیازی به جستجو در لیست‌های آزاد برای تخصیص‌های معمولی ندارد، بنابراین مشکل تکه‌تکه شدن حافظه کمتر است. پردازنده می‌تواند داده‌هایی را که هم‌زمان ایجاد شده‌اند در مجاورت یکدیگر ذخیره کند که کارایی حافظه نهان (Cache) را افزایش می‌دهد.
مثال:
در یک فروشگاه، محصولات جدید همواره در آخرین قفسه قرار داده می‌شوند. اگر این قفسه پر شود، تنها یک قفسه جدید اضافه می‌شود. اما در روش malloc، لازم است که کل فروشگاه برای یافتن یک فضای خالی جستجو شود که فرایند کندتری است.

چرا وجود Finalizer باعث کاهش کارایی Garbage Collector می‌شود؟
هنگامیکه یک شیء دارای Finalizer باشد (~MyObject)، GC نمی‌تواند بلافاصله حافظه آن را آزاد کند، زیرا:
  • شیء ابتدا به صف Finalization Queue اضافه می‌شود.
  • باید منتظر بماند تا Finalizer اجرا شود.
  • پس از اجرای Finalizer، در چرخه بعدی GC بررسی شده و حذف می‌شود.
مثال:
در یک هتل، اتاق‌های معمولی را می‌توان به‌سرعت تمیز و برای مهمان بعدی آماده کرد. اما برخی از اتاق‌ها نیاز به نظافت ویژه دارند (مانند اشیائی که Finalizer دارند). این اتاق‌ها باید توسط یک تیم جداگانه تمیز شوند که زمان بیشتری می‌گیرد.

تفاوت بین شیء دارای Finalizer و بدون Finalizer
using System.Diagnostics;

class NoFinalizer
{
	public int Value;
}

class WithFinalizer
{
	public int Value;
    
	~WithFinalizer()  
	{
    	//...
	}
}

class Program
{
	static void Main()
	{
    	Stopwatch stopwatch = new Stopwatch();

    	stopwatch.Start();
    	for (int i = 0; i < 1000000; i++)
    	{
        	NoFinalizer obj = new NoFinalizer();
    	}
    	GC.Collect();
    	GC.WaitForPendingFinalizers();
    	stopwatch.Stop();
    	Console.WriteLine($"Without Finalizer: {stopwatch.ElapsedMilliseconds} ms");

    	stopwatch.Restart();
    	for (int i = 0; i < 1000000; i++)
    	{
        	WithFinalizer obj = new WithFinalizer();
    	}
    	GC.Collect();
    	GC.WaitForPendingFinalizers(); 
    	stopwatch.Stop();
    	Console.WriteLine($"With Finalizer: {stopwatch.ElapsedMilliseconds} ms");
	}
}

در این کد، دو کلاس داریم:
NoFinalizer که هیچ Finalizer (متد ~ClassName) ندارد.
WithFinalizer که دارای Finalizer است.
در کلاس NoFinalizer، GC می‌تواند حافظه این اشیاء را مستقیماً و بدون تأخیر آزاد کند.
در کلاس WithFinalizer، فرآیند جمع‌آوری حافظه پیچیده‌تر می‌شود:
  • اشیاء ابتدا به صف Finalization Queue اضافه می‌شوند.
  • GC نمی‌تواند بلافاصله این اشیاء را حذف کند.
  • در چرخه بعدی GC، Finalizer اجرا شده و پس از آن شیء از حافظه آزاد می‌شود.
اجرای برنامه نشان می‌دهد که آزادسازی اشیای دارای Finalizer به‌طور قابل توجهی کندتر از اشیای بدون Finalizer است.

چرا Finalizer باعث کاهش عملکرد می‌شود؟
اجرای Finalizer توسط یک Thread جداگانه انجام می‌شود
  • Finalizer در یک Thread مخصوص اجرا می‌شود و مستقیماً تحت کنترل GC نیست.
GC نمی‌تواند حافظه اشیای دارای Finalizer را بلافاصله آزاد کند
  • ابتدا باید Finalizer اجرا شود و سپس شیء در یک چرخه بعدی حذف شود.
فراخوانی GC.WaitForPendingFinalizers() باعث کندی برنامه می‌شود
  • این متد GC را مجبور می‌کند که تا اتمام تمامی Finalizerها صبر کند که ممکن است باعث کاهش کارایی برنامه شود.

راهکار بهینه برای جلوگیری از کندی Finalizer
به جای Finalizer، از IDisposable و using استفاده شود
اگر یک شیء نیاز به عملیات پاک‌سازی (مانند بستن فایل یا ارتباطات شبکه‌ای) دارد، به جای Finalizer، الگوی IDisposable استفاده شود.
class BetterClass : IDisposable
{
	public void Dispose()
	{
    	// عملیات پاکسازی
	}
}
در این روش، کاربر به‌طور دستی متد Dispose را فراخوانی کرده و GC سریع‌تر حافظه را آزاد می‌کند.

چه مواقعی واقعاً به Finalizer نیاز داریم؟
استفاده از Finalizer در دات‌نت تنها در شرایط خاصی ضروری است. معمولاً توصیه می‌شود که تا حد امکان از IDisposable به‌جای Finalizer استفاده شود. اما در مواردی که Garbage Collector (GC) قادر به مدیریت خودکار حافظه نیست، استفاده از Finalizer اجتناب‌ناپذیر خواهد بود.

موارد ضروری برای استفاده از Finalizer
۱. آزادسازی منابع "غیرمدیریتی" (Unmanaged Resources)
GC تنها حافظه مدیریت‌شده (Managed Memory) را آزاد می‌کند، اما هیچ کنترلی بر منابع غیرمدیریتی ندارد. در چنین مواردی، Finalizer می‌تواند به‌عنوان آخرین خط دفاعی برای آزادسازی منابع غیرمدیریتی استفاده شود.
نمونه‌هایی از منابع غیرمدیریتی:
  • هندل‌های فایل (File Handles)
  • اتصالات شبکه‌ای (Network Sockets)
  • منابع گرافیکی (مانند GDI+ و DirectX)
  • حافظه اختصاص داده‌شده با malloc در C/C++
class FileHandler
{
	private IntPtr fileHandle; 
    
	public FileHandler(string filePath)
	{
    	fileHandle = OpenFile(filePath);
	}

	~FileHandler()
	{
    	CloseFile(fileHandle);
	}
}

۲. در صورتی که Dispose() فراموش شود (Fallback Mechanism)
اگر کلاس IDisposable را پیاده‌سازی کرده باشد اما کاربر Dispose را فراخوانی نکند، Finalizer به‌عنوان مکانیسم پشتیبان برای آزادسازی منابع غیرمدیریتی عمل می‌کند.
class MyResource : IDisposable
{
	private IntPtr unmanagedResource;

	public MyResource()
	{
    	unmanagedResource = AllocateUnmanagedResource();
	}

	public void Dispose()
	{
    	FreeUnmanagedResource(unmanagedResource);
    	GC.SuppressFinalize(this);
	}

	~MyResource()
	{
    	FreeUnmanagedResource(unmanagedResource);
	}
}
چرا GC.SuppressFinalize(this)؟
این متد باعث می‌شود که GC دیگر نیازی به اجرای Finalizer نداشته باشد و به سرعت شیء را از حافظه حذف کند.
در صورتی که Dispose به‌درستی صدا زده شود، Finalizer اجرا نمی‌شود و باعث بهبود عملکرد می‌شود.

۳. تعامل با کدهای Native (مثل PInvoke و COM)
اگر از PInvoke (DllImport) یا کتابخانه‌های COM استفاده شود، ممکن است نیاز به مدیریت دستی منابع Native باشد. در این موارد، Finalizer می‌تواند منابع اختصاص داده‌شده توسط کد Native را آزاد کند.
class NativeResource
{
	private IntPtr handle;

	public NativeResource()
	{
    	handle = GetNativeHandle();
	}

	~NativeResource()
	{
    	ReleaseNativeHandle(handle);
	}
}

۴. برخی از کلاس‌های خاص در دات‌نت (مانند SafeHandle)
مایکروسافت در بسیاری از APIها برای مدیریت منابع غیرمدیریتی از SafeHandle استفاده می‌کند. این کلاس به‌طور پیش‌فرض دارای Finalizer داخلی است و نیازی به پیاده‌سازی دستی Finalizer در کلاس‌های سفارشی وجود ندارد.

چه مواقعی نباید از Finalizer استفاده شود؟
۱. برای اشیای معمولی که تنها از حافظه Managed استفاده می‌کنند
به‌عنوان مثال، کلاس‌هایی مانند List<int>، string و <Dictionary<TKey, TValue نیازی به Finalizer ندارند، زیرا GC آن‌ها را به‌طور کامل مدیریت می‌کند.
۲. در کلاس‌هایی که IDisposable بدون منابع غیرمدیریتی استفاده شده است
اگر Dispose تنها برای پاک‌سازی داده‌های مدیریت‌شده استفاده شود، نیازی به Finalizer نخواهد بود.
۳. در کلاس‌هایی که GC به‌طور کامل قادر به مدیریت حافظه آن‌ها است
برای اکثر کلاس‌های معمولی، GC خود به‌صورت بهینه حافظه را آزاد می‌کند و اضافه کردن Finalizer فقط باعث کاهش عملکرد می‌شود.

چرا برخی از برنامه‌های دات‌نت همچنان دچار مشکلات حافظه می‌شوند؟
با وجود مدیریت خودکار حافظه توسط GC، مشکلاتی مانند Memory Leak همچنان ممکن است رخ دهند. برخی از دلایل عبارت‌اند از:
وجود اشیای بدون استفاده اما دارای Reference فعال
  • اگر یک شیء دیگر مورد نیاز نباشد اما هنوز یک اشاره‌گر به آن وجود داشته باشد، GC نمی‌تواند آن را آزاد کند.
  • نمونه: یک لیست که پر از داده‌های بی‌استفاده است، اما همچنان به آن دسترسی وجود دارد.

مشکل Fragmentation در Large Object Heap (LOH)
  • اشیای بزرگ (بیش از 85 کیلوبایت) در LOH ذخیره می‌شوند.
  • GC این بخش را Compact نمی‌کند، که می‌تواند باعث تکه‌تکه شدن حافظه شود.

استفاده بیش از حد از Pinned Objects
  • اگر شیءها را Pin کنیم (مثلاً با GCHandle.Alloc(obj, GCHandleType.Pinned))، GC نمی‌تواند آن‌ها را جابجا کند، که در نهایت Fragmentation را افزایش می‌دهد.
مثال:
در یک پارکینگ، خودروها به‌طور اتوماتیک در فضاهای خالی پارک می‌شوند (مشابه عملکرد GC). اما اگر برخی خودروها در مکان‌های تصادفی ثابت بمانند (Pinned Objects)، فضای کلی پارکینگ کاهش می‌یابد، حتی اگر جای خالی وجود داشته باشد.

آیا مسیر سریع (Fast Path) همیشه کار می‌کند؟
خیر! مسیر سریع تنها زمانی کارایی مطلوب دارد که:
فضای کافی در Allocation Buffer موجود باشد.
شیء کوچک باشد و Finalizer نداشته باشد.
مثال:
در یک کافه، باریستا تعدادی فنجان آماده برای سرو قهوه دارد (مشابه Allocation Buffer). اما اگر تعداد مشتریان زیاد شود و تمام فنجان‌ها پر شوند، او مجبور است فنجان‌های جدید بیاورد (مشابه Slow Path) که باعث کاهش سرعت سرویس‌دهی می‌شود.

آیا همیشه Heap دات‌نت از Native Heap بهتر است؟
خیر، در برخی سناریوها Native Heap کارایی بهتری دارد، از جمله:
  • نیاز به کنترل دقیق روی تخصیص و آزادسازی حافظه.
  • برنامه‌هایی که حجم بسیار بالایی از داده‌ها را مدیریت می‌کنند و عملکرد GC ممکن است تأثیر منفی داشته باشد.
مثال:
در یک کارخانه، اگر تمام فرایندها به‌صورت خودکار و تحت مدیریت یک سیستم مرکزی انجام شوند (مشابه GC در دات‌نت)، بازدهی کلی بالاتر خواهد بود. اما در برخی از مراحل، کنترل دستی دقیق‌تری نیاز است (مشابه Native Heap)، که در این موارد اتوماسیون کامل همیشه بهترین گزینه نیست.


برای بهینه‌سازی عملکرد یک برنامه دات‌نت، لازم است که مکانیزم GC را درک کنید، نه این‌که با آن مقابله نمایید.