مقدمه

در درس قبل، با مبانی طول عمر شیء و نقش زباله‌روب (Garbage Collector - GC) در مدیریت خودکار حافظه آشنا شدیم. دیدیم که GC به طور خودکار اشیاء غیرقابل دسترس را از حافظه‌ی Heap پاک می‌کند. این مدیریت خودکار یکی از بزرگ‌ترین مزایای .NET است، اما داشتن درک عمیق‌تری از نحوه‌ی کار GC به ما کمک می‌کند تا برنامه‌هایی با عملکرد بالاتر و مصرف حافظه‌ی بهینه‌تر بنویسیم. در این درس، به بررسی الگوریتم زباله‌روبی نسل‌بندی شده (Generational Garbage Collection) و نحوه‌ی تعامل با GC از طریق کلاس System.GC خواهیم پرداخت.

مشکل زباله‌روبی ساده

اگر GC در هر بار اجرا مجبور بود تمام اشیاء موجود در Heap را بررسی کند تا اشیاء غیرقابل دسترس را پیدا کند، این فرآیند بسیار کند و هزینه‌بر می‌شد. در برنامه‌های بزرگ، میلیون‌ها شیء ممکن است در حافظه وجود داشته باشد و بررسی تک‌تک آن‌ها باعث ایجاد وقفه‌های طولانی در اجرای برنامه می‌شد. مهندسان .NET با مشاهده و تحقیق دریافتند که بیشتر اشیاء طول عمر بسیار کوتاهی دارند. این مشاهده که به آن "فرضیه‌ی نسل‌بندی" (Generational Hypothesis) گفته می‌شود، پایه‌ی اصلی بهینه‌سازی GC در .NET است: بیشتر اشیاء جوان می‌میرند.

زباله‌روبی نسل‌بندی شده (Generational GC)

برای بهره‌برداری از فرضیه‌ی نسل‌بندی، GC حافظه‌ی Heap را به سه بخش یا نسل (Generation) تقسیم می‌کند:

  • نسل 0 (Generation 0): این نسل میزبان اشیاء جدید و جوان است. هر شیء جدیدی که با کلمه‌ی کلیدی new ایجاد می‌شود، در نسل 0 قرار می‌گیرد. زباله‌روبی در این نسل بسیار سریع و مکرر انجام می‌شود. از آنجایی که بیشتر اشیاء عمر کوتاهی دارند، انتظار می‌رود که در همان اولین زباله‌روبی نسل 0 از بین بروند.
  • نسل 1 (Generation 1): اشیائی که از یک زباله‌روبی در نسل 0 جان سالم به در می‌برند (یعنی هنوز قابل دسترس هستند)، به نسل 1 "ارتقاء" (promote) پیدا می‌کنند. این نسل به عنوان یک حائل بین اشیاء کوتاه‌عمر و بلندعمر عمل می‌کند. زباله‌روبی در این نسل کمتر از نسل 0 اتفاق می‌افتد.
  • نسل 2 (Generation 2): اشیائی که از زباله‌روبی نسل 1 نیز جان سالم به در می‌برند، به نسل 2 ارتقاء می‌یابند. این نسل خانه‌ی اشیاء بلندعمر است؛ اشیائی که انتظار می‌رود برای مدت طولانی یا حتی برای تمام طول عمر برنامه زنده بمانند (مانند اشیاء استاتیک یا سرویس‌های Singleton). زباله‌روبی در نسل 2 (که به آن full collection هم گفته می‌شود) کمترین تکرار را دارد، زیرا هزینه‌برترین نوع زباله‌روبی است.

این رویکرد نسل‌بندی بسیار کارآمد است، زیرا به GC اجازه می‌دهد تا در بیشتر مواقع، تنها با اجرای یک زباله‌روبی سریع بر روی نسل 0، بخش بزرگی از حافظه را آزاد کند، بدون اینکه نیازی به بررسی میلیون‌ها شیء بلندعمر در نسل‌های 1 و 2 داشته باشد.

کار با زباله‌روب از طریق کلاس System.GC

هرچند GC به صورت خودکار کار می‌کند، C# یک کلاس استاتیک به نام System.GC فراهم کرده که به ما اجازه می‌دهد تا حدی با زباله‌روب تعامل داشته باشیم.

هشدار مهم: به عنوان یک قانون کلی، شما نباید به صورت دستی GC را فراخوانی کنید. الگوریتم‌های GC بسیار هوشمند و بهینه هستند و معمولاً بهتر از ما می‌دانند چه زمانی باید اجرا شوند. فراخوانی دستی GC در بیشتر موارد عملکرد برنامه را بدتر می‌کند. از این ابزارها فقط برای اهداف تشخیصی و در سناریوهای بسیار خاص استفاده کنید.

فراخوانی دستی زباله‌روب با GC.Collect()

متد GC.Collect() به GC دستور می‌دهد تا یک زباله‌روبی را در اسرع وقت انجام دهد. این متد می‌تواند برای اهداف آزمایشی یا درک بهتر نحوه‌ی کار حافظه مفید باشد.

Copy Icon Program.cs
Console.WriteLine($"Total memory before creating objects: {GC.GetTotalMemory(false):N0} bytes");

// Create a lot of objects to consume memory
for (int i = 0; i < 100000; i++)
{
    var obj = new object();
}

Console.WriteLine($"Total memory after creating objects: {GC.GetTotalMemory(false):N0} bytes");

// Force a garbage collection
GC.Collect();

Console.WriteLine($"Total memory after collection: {GC.GetTotalMemory(true):N0} bytes");

در این مثال، متد GC.GetTotalMemory(false) مقدار حافظه‌ی تخصیص داده شده را به صورت تخمینی و سریع برمی‌گرداند. ارسال مقدار true به این متد، آن را مجبور می‌کند تا قبل از گزارش، منتظر یک زباله‌روبی کامل بماند تا عدد دقیق‌تری ارائه دهد.

جلوگیری از اجرای Finalizer با GC.SuppressFinalize

این متد به الگوی IDisposable مرتبط است. همانطور که در درس‌های قبل اشاره شد، وقتی شما به صورت دستی با فراخوانی متد Dispose منابع یک شیء را آزاد می‌کنید، دیگر نیازی نیست که GC تخریب‌گر (Finalizer) آن شیء را نیز فراخوانی کند. متد GC.SuppressFinalize(this) به GC اطلاع می‌دهد که "این شیء قبلاً پاکسازی شده و نیازی به قرار گرفتن در صف تخریب ندارد." این کار یک بهینه‌سازی مهم است، زیرا پردازش اشیاء دارای تخریب‌گر برای GC هزینه‌بر است.

Copy Icon Program.cs
public class MyResource : IDisposable
{
    public void Dispose()
    {
        // 1. Clean up unmanaged resources here...
        Console.WriteLine("Resources cleaned up.");

        // 2. Tell the GC it doesn't need to call the finalizer.
        GC.SuppressFinalize(this);
    }

    // A finalizer (destructor) as a backup. Avoid if possible.
    ~MyResource()
    {
       Console.WriteLine("Finalizer called.");
    }
}

در این الگو، اگر Dispose به درستی (مثلاً از طریق بلوک using) فراخوانی شود، SuppressFinalize از اجرای غیرضروری تخریب‌گر جلوگیری می‌کند. تخریب‌گر تنها به عنوان یک مکانیزم پشتیبان برای مواقعی که برنامه‌نویس فراخوانی Dispose را فراموش کرده عمل خواهد کرد. در درس بعد این الگو را به تفصیل بررسی خواهیم کرد.