حافظه و Garbage Collector
سیستم GC فقط اشیایی را پاک میکند که دیگر هیچ چیز به آنها اشاره نمیکند. اشیای تازه در نسل ۰ هستند و بیشترشان زود میمیرند. نشت حافظه در .NET یعنی یک مرجع فراموششده که شیء را زنده نگه داشته است.
نویسنده: bezzad
مشکل: حافظهای که فقط بالا میرود
سرویس سبد خرید فروشگاه ما در Kubernetes اجرا میشود. در داشبورد مانیتورینگ این را میبینیم:
- حافظه سرویس در چند روز آهسته و مدام بالا میرود.
- آخر کار، pod با وضعیت OOMKilled ریاستارت میشود.
- بعد از ریاستارت، همین چرخه دوباره شروع میشود.
یک همکار میگوید: «.NET که GC دارد. نشت حافظه ممکن نیست. فقط limit حافظه را بیشتر کنیم.»
این حرف غلط است. برای اینکه بفهمیم چرا، باید بدانیم حافظه در .NET چطور کار میکند.
Stack و Heap
برنامه .NET دو جای اصلی برای داده دارد:
Stack
- هر thread یک Stack جدا دارد.
- متغیرهای محلی متد اینجا هستند.
- وقتی متد تمام شود، جایش خودکار آزاد میشود. GC لازم نیست.
- خیلی سریع است، ولی کوچک است.
Heap
- بین همه thread ها مشترک است.
- هر شیئی که با new از یک class ساخته شود، اینجاست.
- شیء بعد از تمام شدن متد هم میتواند زنده بماند.
- سیستم GC آن را پاک میکند.
نوع مقداری و نوع ارجاعی
- نوع ارجاعی (class، record، string، آرایه). متغیر فقط یک آدرس به شیء روی Heap است. اگر متغیر را به متغیر دیگری بدهی، هر دو به همان یک شیء اشاره میکنند.
- نوع مقداری (int، decimal، Guid، struct). متغیر خود مقدار است. اگر آن را به متغیر دیگری بدهی، یک کپی ساخته میشود.
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.
بعد، مثل دنبال کردن یک نقشه، همه مرجعها را دنبال میکند. هر شیئی که به آن برسد زنده است. بقیه زباله هستند و جایشان آزاد میشود.
همین تفاوت کوچک، دلیل نشت حافظه است. اگر یک شیء دیگر به کار نمیآید، ولی هنوز از یک ریشه به آن راه هست، GC آن را پاک نمیکند.
نسلها: چرا GC سریع است؟
اگر GC هر بار کل Heap را بگردد، خیلی کند میشود. پس .NET از یک واقعیت ساده استفاده میکند: بیشتر اشیا خیلی جوان میمیرند. شیءهای داخل یک درخواست بعد از چند میلیثانیه زباله هستند.
برای همین Heap سه نسل دارد:
- نسل ۰ (Gen0). هر شیء تازه اینجا ساخته میشود. کوچک است و زود پر میشود. GC آن سریع است.
- نسل ۱ (Gen1). اشیایی که یک GC را زنده ماندند. یک لایه بین جوان و پیر.
- نسل ۲ (Gen2). اشیای بلندعمر، مثل کش و سرویسهای Singleton. GC این نسل یعنی GC کامل. کار زیادی دارد و گران است.
یک بخش جدا هم داریم: Large Object Heap یا LOH. اشیای ۸۵ هزار بایت و بزرگتر (معمولاً آرایههای بزرگ) مستقیم اینجا میروند. این بخش فقط همراه 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) { /* ... */ }
}
چرا این نشت است؟ قدم به قدم:
- هر event یک مرجع به همه مشترکهایش نگه میدارد. پس PriceFeed به هر CartPriceWatcher اشاره میکند.
- سرویس PriceFeed از نوع Singleton است. یعنی از یک ریشه به آن راه هست، تا آخر عمر برنامه.
- پس هر CartPriceWatcher که در هر درخواست ساخته میشود، هم برای همیشه زنده میماند.
- بعد از میلیونها درخواست، میلیونها 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 را میشناسد.
پیدا کردن نشت، قدم به قدم
حدس نزن. اندازه بگیر:
- ببین کدام بالا میرود. با ابزار dotnet-counters اندازه GC Heap را نگاه کن. اگر GC Heap ثابت است ولی حافظه کل process بالا میرود، احتمالاً نشت در حافظه native است.
- دو snapshot بگیر. با ابزار dotnet-gcdump، با فاصله مثلاً یک ساعت.
- دو snapshot را مقایسه کن. سؤال این است: تعداد کدام نوع شیء مدام زیاد میشود؟ مثلاً دو میلیون CartPriceWatcher.
- ریشه را پیدا کن. با ابزار dotnet-dump و دستور gcroot، زنجیره مرجعها از ریشه تا آن شیء را ببین. اینجا جواب، event داخل PriceFeed است.
- کد را اصلاح کن و دوباره اندازه بگیر.
dotnet-counters monitor --process-id 1234
dotnet-gcdump collect --process-id 1234
Workstation GC و Server GC
| Workstation GC | Server GC | |
|---|---|---|
| برای چه برنامهای؟ | برنامه دسکتاپ، سرویس کوچک | سرور با چند هسته CPU |
| تعداد Heap | یکی | چند Heap، برای کار موازی |
| مصرف حافظه | کمتر | معمولاً بیشتر |
| پیشفرض در ASP.NET Core | خیر | بله |
تنظیم GC را آخر بررسی کن. اول کد را درست کن: زباله کمتر بساز و مرجعهای فراموششده را پیدا کن.
قانونهای مهم
- سیستم GC حافظه را مدیریت میکند، نه منابع را. فایل، connection و socket را با using ببند.
- هر کش باید سقف داشته باشد. سقف تعداد، سقف اندازه یا زمان انقضا.
- هر اشتراک event روی یک شیء بلندعمر، باید یک جدا شدن هم داشته باشد.
- متد Collect در کلاس GC را صدا نزن. تقریباً همیشه کار را بدتر میکند. GC خودش بهتر میداند کی اجرا شود.
- در کد پرفشار، زباله کمتر بساز. هر allocation کوچک، GC نسل ۰ را نزدیکتر میکند.
- اول اندازه بگیر، بعد اصلاح کن. حدس زدن درباره حافظه معمولاً غلط است.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| «GC داریم، پس نشت نداریم» | نشت دیده نمیشود تا pod بمیرد. | مقایسه snapshot و پیدا کردن ریشه. |
| بیشتر کردن limit حافظه | فقط OOM را عقب میاندازد. | پیدا کردن علت. |
| اشتراک event روی Singleton بدون جدا شدن | هر شیء مشترک برای همیشه زنده میماند. | جدا شدن در متد Dispose. |
| کش static بدون سقف | حافظه بیپایان بالا میرود. | کش با انقضا و SizeLimit. |
| صدا زدن متد Collect | مکث بیشتر، بدون سود واقعی. | گذاشتن کار به خود GC. |
| ساختن آرایه بزرگ در هر درخواست | فشار روی LOH و GC کامل بیشتر. | استفاده دوباره از بافر با ArrayPool. |
خلاصه در شش خط
- متغیرهای محلی روی Stack هستند. اشیای class روی Heap هستند.
- سیستم GC از ریشهها شروع میکند. هر شیئی که به آن نرسد، زباله است.
- اشیای تازه در نسل ۰ هستند. بیشترشان زود میمیرند و GC آنها ارزان است.
- یک GC کامل (نسل ۲) گران است. اشیای بزرگ در LOH فقط با GC کامل پاک میشوند.
- نشت حافظه یعنی یک مرجع فراموششده از یک ریشه، مثل event روی Singleton یا کش static.
- برای پیدا کردن نشت: dotnet-counters، دو snapshot، مقایسه، و gcroot.