Levelwise
فارسی
پایه‌های C# و .NET

حافظه و Garbage Collector

سیستم GC فقط اشیایی را پاک می‌کند که دیگر هیچ چیز به آن‌ها اشاره نمی‌کند. اشیای تازه در نسل ۰ هستند و بیشترشان زود می‌میرند. نشت حافظه در .NET یعنی یک مرجع فراموش‌شده که شیء را زنده نگه داشته است.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۵ دقیقهمثال سبد خرید فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: حافظه‌ای که فقط بالا می‌رود

سرویس سبد خرید فروشگاه ما در Kubernetes اجرا می‌شود. در داشبورد مانیتورینگ این را می‌بینیم:

  • حافظه سرویس در چند روز آهسته و مدام بالا می‌رود.
  • آخر کار، pod با وضعیت OOMKilled ری‌استارت می‌شود.
  • بعد از ری‌استارت، همین چرخه دوباره شروع می‌شود.

یک همکار می‌گوید: «.NET که GC دارد. نشت حافظه ممکن نیست. فقط limit حافظه را بیشتر کنیم.»

این حرف غلط است. برای اینکه بفهمیم چرا، باید بدانیم حافظه در .NET چطور کار می‌کند.

Stack و Heap

برنامه .NET دو جای اصلی برای داده دارد:

Stackبا تمام شدن متد، خودکار پاک می‌شودAddToCart()int quantity = 3Cart cartHandleRequest()Guid userIdHeapمشترک بین همه، جمع‌آوری خودکارCartdecimal TotalList LinesList<CartLine>یک شیء دیگرزباله: کسی اشاره نمی‌کندمقدار عددی داخل یک شیء، همراه همان شیء ذخیره می‌شود
متغیر cart روی Stack فقط یک مرجع است. خود شیء Cart روی Heap است.

Stack

  • هر thread یک Stack جدا دارد.
  • متغیرهای محلی متد اینجا هستند.
  • وقتی متد تمام شود، جایش خودکار آزاد می‌شود. GC لازم نیست.
  • خیلی سریع است، ولی کوچک است.

Heap

  • بین همه thread ها مشترک است.
  • هر شیئی که با new از یک class ساخته شود، اینجاست.
  • شیء بعد از تمام شدن متد هم می‌تواند زنده بماند.
  • سیستم GC آن را پاک می‌کند.

نوع مقداری و نوع ارجاعی

  1. نوع ارجاعی (class، record، string، آرایه). متغیر فقط یک آدرس به شیء روی Heap است. اگر متغیر را به متغیر دیگری بدهی، هر دو به همان یک شیء اشاره می‌کنند.
  2. نوع مقداری (int، decimal، Guid، struct). متغیر خود مقدار است. اگر آن را به متغیر دیگری بدهی، یک کپی ساخته می‌شود.
یک باور نیمه‌درست: «نوع مقداری همیشه روی Stack است» درست نیست. اگر یک decimal فیلد یک class باشد، همراه همان شیء روی Heap است. جای داده را این تعیین می‌کند که کجا تعریف شده است، نه فقط نوعش.
public readonly record struct Money(decimal Amount, string Currency); // value type

public sealed class Cart // reference type, lives on the heap
{
    private readonly List<CartLine> _lines = [];
    public Money Total { get; private set; } // stored inside the Cart object
}

void AddToCart(Cart cart, int quantity) // quantity is a local on the stack
{
    var copy = cart;  // copies the reference: same Cart object
}

GC چطور زباله را پیدا می‌کند؟

سیستم GC از ریشه‌ها (GC Roots) شروع می‌کند. ریشه‌ها چیزهایی هستند که برنامه حتماً به آن‌ها دسترسی دارد:

  • متغیرهای محلی متدهایی که الان در حال اجرا هستند.
  • فیلدهای static.
  • چیزهایی که از این‌ها به آن‌ها می‌رسیم، مثل سرویس‌های Singleton داخل container.

بعد، مثل دنبال کردن یک نقشه، همه مرجع‌ها را دنبال می‌کند. هر شیئی که به آن برسد زنده است. بقیه زباله هستند و جایشان آزاد می‌شود.

ریشه‌هامتغیر محلیمتدی که در حال اجراستstaticفیلد، تا آخر عمر برنامهSingletonسرویس، تا آخر عمر برنامهCartCartLineDictionaryCart (old)PriceChangedCartWatcherOrderstringزبالهاز هیچ ریشه‌ای راهی نیستزنده، ولی دیگر لازم نیستاین همان نشت حافظه است
سیستم GC فقط می‌پرسد «آیا راهی از یک ریشه به این شیء هست؟». نمی‌پرسد «آیا برنامه هنوز به این شیء نیاز دارد؟».

همین تفاوت کوچک، دلیل نشت حافظه است. اگر یک شیء دیگر به کار نمی‌آید، ولی هنوز از یک ریشه به آن راه هست، GC آن را پاک نمی‌کند.

نسل‌ها: چرا GC سریع است؟

اگر GC هر بار کل Heap را بگردد، خیلی کند می‌شود. پس .NET از یک واقعیت ساده استفاده می‌کند: بیشتر اشیا خیلی جوان می‌میرند. شیء‌های داخل یک درخواست بعد از چند میلی‌ثانیه زباله هستند.

برای همین Heap سه نسل دارد:

  1. نسل ۰ (Gen0). هر شیء تازه اینجا ساخته می‌شود. کوچک است و زود پر می‌شود. GC آن سریع است.
  2. نسل ۱ (Gen1). اشیایی که یک GC را زنده ماندند. یک لایه بین جوان و پیر.
  3. نسل ۲ (Gen2). اشیای بلندعمر، مثل کش و سرویس‌های Singleton. GC این نسل یعنی GC کامل. کار زیادی دارد و گران است.

یک بخش جدا هم داریم: Large Object Heap یا LOH. اشیای ۸۵ هزار بایت و بزرگ‌تر (معمولاً آرایه‌های بزرگ) مستقیم اینجا می‌روند. این بخش فقط همراه GC کامل جمع می‌شود.

مثال زنده: نسل‌های GC

هر درخواست چند شیء کوتاه‌عمر می‌سازد. بعضی وقت‌ها هم یک شیء بلندعمر به کش اضافه می‌شود. مربع پر یعنی شیء زنده است. مربع خالی یعنی زباله.

Gen0شیء تازه، GC سریع و زیاد
Gen1یک بار زنده مانده
Gen2بلندعمر، GC کامل و گران
LOHاشیای بزرگ، فقط با GC کامل
    درس این مثال: هر چه زباله بیشتری بسازی، GC نسل ۰ بیشتر اجرا می‌شود. هر چه اشیای بیشتری بی‌دلیل زنده بمانند، GC کامل بیشتر اجرا می‌شود. GC کامل همان مکث‌های طولانی است که کاربر حس می‌کند.

    نشت حافظه در .NET

    حالا برگردیم به سرویس سبد خرید. این کد را پیدا می‌کنیم:

    public sealed class PriceFeed // registered as Singleton
    {
        public event EventHandler<PriceChanged>? PriceChanged;
    }
    
    public sealed class CartPriceWatcher // registered as Scoped
    {
        public CartPriceWatcher(PriceFeed feed)
        {
            feed.PriceChanged += OnPriceChanged; // never removed
        }
    
        private void OnPriceChanged(object? sender, PriceChanged e) { /* ... */ }
    }

    چرا این نشت است؟ قدم به قدم:

    1. هر event یک مرجع به همه مشترک‌هایش نگه می‌دارد. پس PriceFeed به هر CartPriceWatcher اشاره می‌کند.
    2. سرویس PriceFeed از نوع Singleton است. یعنی از یک ریشه به آن راه هست، تا آخر عمر برنامه.
    3. پس هر CartPriceWatcher که در هر درخواست ساخته می‌شود، هم برای همیشه زنده می‌ماند.
    4. بعد از میلیون‌ها درخواست، میلیون‌ها watcher در نسل ۲ داریم. GC کامل هم آن‌ها را پاک نمی‌کند.

    راه حل: وقتی کار تمام شد، از event جدا شو. container سرویس Scoped را در پایان درخواست dispose می‌کند:

    public sealed class CartPriceWatcher : IDisposable
    {
        private readonly PriceFeed _feed;
    
        public CartPriceWatcher(PriceFeed feed)
        {
            _feed = feed;
            _feed.PriceChanged += OnPriceChanged;
        }
    
        private void OnPriceChanged(object? sender, PriceChanged e) { /* ... */ }
    
        public void Dispose() => _feed.PriceChanged -= OnPriceChanged;
    }

    علت‌های رایج دیگر

    • کش static که فقط بزرگ می‌شود. یک Dictionary استاتیک که به آن اضافه می‌کنیم ولی هیچ وقت پاکش نمی‌کنیم. برای کش، از IMemoryCache با زمان انقضا و محدودیت اندازه (SizeLimit) استفاده کن.
    • یک DbContext که برای همیشه زنده است. هر entity که می‌خواند، در Change Tracker آن می‌ماند.
    • تایمری که dispose نمی‌شود. تایمر به callback خودش اشاره می‌کند و آن را زنده نگه می‌دارد.
    • منابع unmanaged. فایل، connection و stream را با using ببند. GC فقط حافظه .NET را می‌شناسد.

    پیدا کردن نشت، قدم به قدم

    حدس نزن. اندازه بگیر:

    1. ببین کدام بالا می‌رود. با ابزار dotnet-counters اندازه GC Heap را نگاه کن. اگر GC Heap ثابت است ولی حافظه کل process بالا می‌رود، احتمالاً نشت در حافظه native است.
    2. دو snapshot بگیر. با ابزار dotnet-gcdump، با فاصله مثلاً یک ساعت.
    3. دو snapshot را مقایسه کن. سؤال این است: تعداد کدام نوع شیء مدام زیاد می‌شود؟ مثلاً دو میلیون CartPriceWatcher.
    4. ریشه را پیدا کن. با ابزار dotnet-dump و دستور gcroot، زنجیره مرجع‌ها از ریشه تا آن شیء را ببین. اینجا جواب، event داخل PriceFeed است.
    5. کد را اصلاح کن و دوباره اندازه بگیر.
    dotnet-counters monitor --process-id 1234
    dotnet-gcdump collect --process-id 1234
    حافظه بالا همیشه نشت نیست. سیستم GC حافظه آزادشده را همیشه فوراً به سیستم‌عامل پس نمی‌دهد. آن را برای بعد نگه می‌دارد. معیار درست این است: حافظه را بعد از هر GC کامل نگاه کن. اگر هر بار به یک سطح ثابت برمی‌گردد، نشت نیست. اگر این سطح هر بار بالاتر می‌رود، نشت داریم.

    Workstation GC و Server GC

    Workstation GC Server GC
    برای چه برنامه‌ای؟ برنامه دسکتاپ، سرویس کوچک سرور با چند هسته CPU
    تعداد Heap یکی چند Heap، برای کار موازی
    مصرف حافظه کمتر معمولاً بیشتر
    پیش‌فرض در ASP.NET Core خیر بله

    تنظیم GC را آخر بررسی کن. اول کد را درست کن: زباله کمتر بساز و مرجع‌های فراموش‌شده را پیدا کن.

    قانون‌های مهم

    1. سیستم GC حافظه را مدیریت می‌کند، نه منابع را. فایل، connection و socket را با using ببند.
    2. هر کش باید سقف داشته باشد. سقف تعداد، سقف اندازه یا زمان انقضا.
    3. هر اشتراک event روی یک شیء بلندعمر، باید یک جدا شدن هم داشته باشد.
    4. متد Collect در کلاس GC را صدا نزن. تقریباً همیشه کار را بدتر می‌کند. GC خودش بهتر می‌داند کی اجرا شود.
    5. در کد پرفشار، زباله کمتر بساز. هر allocation کوچک، GC نسل ۰ را نزدیک‌تر می‌کند.
    6. اول اندازه بگیر، بعد اصلاح کن. حدس زدن درباره حافظه معمولاً غلط است.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    «GC داریم، پس نشت نداریم» نشت دیده نمی‌شود تا pod بمیرد. مقایسه snapshot و پیدا کردن ریشه.
    بیشتر کردن limit حافظه فقط OOM را عقب می‌اندازد. پیدا کردن علت.
    اشتراک event روی Singleton بدون جدا شدن هر شیء مشترک برای همیشه زنده می‌ماند. جدا شدن در متد Dispose.
    کش static بدون سقف حافظه بی‌پایان بالا می‌رود. کش با انقضا و SizeLimit.
    صدا زدن متد Collect مکث بیشتر، بدون سود واقعی. گذاشتن کار به خود GC.
    ساختن آرایه بزرگ در هر درخواست فشار روی LOH و GC کامل بیشتر. استفاده دوباره از بافر با ArrayPool.

    خلاصه در شش خط

    1. متغیرهای محلی روی Stack هستند. اشیای class روی Heap هستند.
    2. سیستم GC از ریشه‌ها شروع می‌کند. هر شیئی که به آن نرسد، زباله است.
    3. اشیای تازه در نسل ۰ هستند. بیشترشان زود می‌میرند و GC آن‌ها ارزان است.
    4. یک GC کامل (نسل ۲) گران است. اشیای بزرگ در LOH فقط با GC کامل پاک می‌شوند.
    5. نشت حافظه یعنی یک مرجع فراموش‌شده از یک ریشه، مثل event روی Singleton یا کش static.
    6. برای پیدا کردن نشت: dotnet-counters، دو snapshot، مقایسه، و gcroot.