عنوان:

‫بهینه‌سازی حافظه و عملکرد Garbage Collector در برنامه‌های ASP.NET Core


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۳/۱۴ ۰۹:۵۲
آدرس: www.dntips.ir
مدیریت حافظه و عملکرد بهینه آن، به‌ویژه در برنامه‌های تحت وب پرترافیک مانند آنچه با ASP.NET Core توسعه داده می‌شود، یکی از چالش‌های همیشگی توسعه‌دهندگان است. نشت حافظه (Memory Leak)، مصرف بی‌رویه منابع و وقفه‌های طولانی ناشی از فرآیند بازپس‌گیری حافظه توسط Garbage Collector (GC) می‌تواند منجر به کاهش چشمگیر پاسخ‌دهی، عدم پایداری و در نهایت از کار افتادن سرویس شود. این مقاله، با الهام از یک تجربه عملی در بهینه‌سازی یک سرور REST API مبتنی بر ASP.NET Core، به بررسی راهکارها و تنظیمات کلیدی برای بهبود مدیریت حافظه و عملکرد GC در اکوسیستم دات‌نت می‌پردازد.

درک مشکل: فشار حافظه و رفتار پیش‌فرض GC
سناریوی رایج، به‌خصوص در محیط‌های توسعه یا محیط‌های عملیاتی با محدودیت منابع (مانند کانتینرهای Kubernetes با حافظه محدود)، مواجهه با خطاهای کمبود حافظه (OutOfMemoryException) است. در تجربه مورد بررسی، یک سرور ASP.NET Core که برای یک بازی استفاده می‌شد، در یک محیط Kubernetes با محدودیت حافظه ۵۱۲ مگابایت، هنگام تست بار (Load Testing) با ابزار k6 به سرعت دچار مشکل و از کار می‌افتاد.
تحلیل اولیه با ابزار پروفایلینگ حافظه مانند dotMemory نشان داد که فرآیند GC بسیار دیر فعال می‌شد (حدود ۲ گیگابایت مصرف حافظه کلی، شامل حافظه مدیریت شده و بومی) و مرحله جمع‌آوری آن ۳ تا ۵ ثانیه به طول می‌انجامید. این زمان توقف برای یک API بسیار نامطلوب است و باعث عدم پاسخ‌دهی سرویس می‌شود. اگرچه مشکلات کدنویسی مانند عدم استفاده از Stream ها برای سریال‌سازی JSON و در نتیجه تولید حجم زیادی از زباله‌های حافظه (Garbage) نیز در این امر دخیل بودند، اما انتظار می‌رفت GC در فواصل زمانی کوتاه‌تری فعال شود.

کلید بهینه‌سازی: پیکربندی صحیح Garbage Collector
چارچوب دات‌نت (.NET) انعطاف‌پذیری قابل توجهی در پیکربندی GC ارائه می‌دهد. دو تنظیم حیاتی که تأثیر بسزایی در بهبود وضعیت داشتند، فعال‌سازی حالت Server GC و حالت Background/Concurrent GC بودند.

Server GC در مقابل Workstation GC
دات‌نت دو نوع اصلی GC را ارائه می‌دهد: Workstation GC و Server GC. به طور پیش‌فرض، برنامه‌های ASP.NET Core ممکن است تحت شرایطی از Workstation GC استفاده کنند، مگر اینکه صراحتاً نوع سرور آن فعال شده باشد.
  • Workstation GC: برای برنامه‌های کلاینت و دسکتاپ طراحی شده است که در آن کاهش وقفه‌ها (Latency) و پاسخ‌دهی سریع رابط کاربری در اولویت قرار دارد. این حالت معمولاً از یک هیپ (Heap) و یک نخ (Thread) برای عملیات GC استفاده می‌کند.
  • Server GC: برای برنامه‌های سروری با توان عملیاتی بالا (Throughput) بهینه‌سازی شده است. این حالت از چندین هیپ و چندین نخ اختصاصی برای GC استفاده می‌کند که به موازات هسته‌های CPU کار می‌کنند. این امر منجر به جمع‌آوری سریع‌تر و کارآمدتر زباله‌ها در سیستم‌های چند هسته‌ای می‌شود، اگرچه ممکن است در لحظه شروع، منابع بیشتری مصرف کند.

فعال‌سازی Server GC باعث می‌شود GC به مراتب بیشتر و در فواصل زمانی کوتاه‌تری اجرا شود. این اجراهای مکرر، با وجود اینکه ممکن است به نظر بار اضافی بیایند، در عمل منجر به کاهش حجم حافظه مصرفی و زمان بسیار کوتاه‌تر برای هر بار جمع‌آوری می‌شوند، زیرا در هر چرخه، حجم کمتری از زباله‌ها برای پردازش وجود دارد. در تجربه مذکور، پس از فعال‌سازی Server GC، هر بار جمع‌آوری به کمتر از ۵ میلی‌ثانیه کاهش یافت.
برای فعال‌سازی Server GC، می‌توانید فایل پروژه (.csproj) خود را به صورت زیر ویرایش کنید:
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
یا از طریق فایل runtimeconfig.json:
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true
    }
  }
}

Background/Concurrent GC
حالت جمع‌آوری همزمان یا پس‌زمینه (Background/Concurrent GC) به GC اجازه می‌دهد تا بیشتر عملیات خود را همزمان با اجرای نخ‌های برنامه انجام دهد. این ویژگی به طور قابل توجهی وقفه‌های محسوس برنامه را کاهش می‌دهد، زیرا برنامه برای مدت طولانی متوقف نمی‌شود. Server GC به طور پیش‌فرض در حالت همزمان عمل می‌کند، اما اطمینان از فعال بودن این ویژگی (یا فعال کردن آن برای Workstation GC در صورت نیاز) حیاتی است.
برای اطمینان از فعال بودن Concurrent GC (معمولاً همراه با Server GC فعال است، اما برای Workstation GC می‌توان جداگانه تنظیم کرد):
<PropertyGroup>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
یا از طریق فایل runtimeconfig.json:
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Concurrent": true // Mostly relevant for Workstation GC
    }
  }
}
توجه داشته باشید که System.GC.Server معمولاً به طور ضمنی بهترین حالت همزمانی را برای سناریوهای سروری فراهم می‌کند.

کنترل دقیق‌تر حافظه: تنظیم سقف سخت برای هیپ (Heap Hard Limit)
یکی دیگر از تنظیمات قدرتمند، به‌ویژه در محیط‌های با منابع محدود مانند کانتینرها، قابلیت تنظیم سقف سخت برای هیپ (Heap Hard Limit) است. این تنظیم به GC اطلاع می‌دهد که تا چه میزان می‌تواند حافظه مصرف کند. این ویژگی به خصوص در نسخه‌های جدیدتر دات‌نت (.NET 5 و بالاتر) بهبود یافته و به GC اجازه می‌دهد تا از محدودیت‌های اعمال شده توسط cgroups در لینوکس (که توسط Kubernetes برای مدیریت منابع استفاده می‌شود) آگاه باشد.
می‌توانید این محدودیت را به صورت درصدی از کل حافظه فیزیکی در دسترس پروسه (GCHardLimitPercent) یا به عنوان یک مقدار مطلق (GCHardLimit) تنظیم کنید. در تجربه گزارش شده، پس از پروفایلینگ، مقدار اولیه ۶۰٪ برای GCHardLimitPercent در نظر گرفته شد، اما تاکید شد که این مقدار نیازمند بررسی و تنظیم دقیق‌تر بر اساس پروفایلینگ بیشتر است.
این تنظیم را می‌توان از طریق متغیرهای محیطی (Environment Variables) یا فایل runtimeconfig.json اعمال کرد. برای مثال، برای تنظیم سقف سخت به ۶۰٪ از حافظه در دسترس کانتینر:
از طریق متغیر محیطی (مثلاً در فایل deployment.yaml کوبرنتیز):
DOTNET_GCHardLimitPercent=60
یا در runtimeconfig.json:
{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.HeapHardLimitPercent": 60
    }
  }
}
این تنظیم به GC کمک می‌کند تا پیش از رسیدن به محدودیت‌های سخت کانتینر و اجبار به خاتمه توسط سیستم‌عامل، به طور پیشگیرانه اقدام به بازپس‌گیری حافظه کند.

تأثیر بهینه‌سازی‌های کد و منابع محیطی
در کنار تنظیمات GC، نباید از اهمیت بهینه‌سازی‌های سطح کد غافل شد. همانطور که اشاره شد، استفاده از string برای سریال‌سازی JSON به جای Stream، منجر به تولید حجم زیادی از اشیاء بزرگ (Large Object Heap - LOH) و فشار بر GC می‌شود. استفاده از APIهای مبتنی بر Stream در کتابخانه‌هایی مانند System.Text.Json (مثلاً JsonSerializer.SerializeAsync(stream, ...) ) می‌تواند به طور چشمگیری تخصیص حافظه و تولید زباله را کاهش دهد. بررسی و استفاده از الگوهایی مانند Pooling اشیاء و استفاده از Span و Memory نیز در کاهش فشار حافظه مؤثر است.
در سناریوی مورد بحث، محدودیت حافظه کانتینر در Kubernetes نیز از ۵۱۲ مگابایت به ۲ گیگابایت افزایش یافت. اگرچه این افزایش به دلیل مشکل موقتی در یک کش درون حافظه‌ای (In-memory Cache) بود که مانع از مقیاس‌پذیری افقی سرورها می‌شد، اما با اعمال تنظیمات GC، مصرف حافظه تحت بار هرگز از ۳۸۰ مگابایت تجاوز نکرد. این نشان می‌دهد که حتی با افزایش منابع، تنظیمات صحیح GC همچنان برای استفاده بهینه از حافظه و حفظ عملکرد بالا ضروری است و فضای زیادی تا رسیدن به سقف ۶۰٪ از ۲ گیگابایت (یعنی ۱.۲ گیگابایت) وجود داشت. مهم‌تر از آن، زمان پاسخ P99 (صدک ۹۹ام) به طور قابل توجهی بهبود یافت، زیرا وقفه‌های طولانی GC دیگر وجود نداشت.

ابزارهای تشخیص و پروفایلینگ
برای شناسایی گلوگاه‌های حافظه و رفتار GC، استفاده از ابزارهای مناسب ضروری است:
  • dotMemory: یک پروفایلر قدرتمند حافظه از شرکت JetBrains که به تحلیل دقیق مصرف حافظه، شناسایی نشت حافظه و درک رفتار GC کمک می‌کند.
  • k6: یک ابزار مدرن و کارآمد برای تست بار که در شناسایی مشکلات عملکردی تحت فشار بسیار مفید است.
  • Visual Studio Diagnostic Tools: ابزارهای پروفایلینگ داخلی ویژوال استودیو نیز قابلیت‌های خوبی برای تحلیل حافظه و CPU ارائه می‌دهند.
  • ابزارهای خط فرمان دات‌نت: ابزارهایی مانند dotnet-counters (برای مشاهده معیارهای عملکردی زنده شامل اطلاعات GC)، dotnet-trace (برای جمع‌آوری ردپاهای اجرایی) و dotnet-gcdump (برای جمع‌آوری dumpهای GC) برای تحلیل‌های عمیق‌تر بسیار کاربردی هستند.


نتیجه‌گیری
بهینه‌سازی حافظه و عملکرد Garbage Collector یک فرآیند مداوم و چندوجهی در توسعه برنامه‌های ASP.NET Core است. با درک صحیح رفتار GC، استفاده از تنظیمات مناسب مانند Server GC و Heap Hard Limit، به‌کارگیری شیوه‌های صحیح کدنویسی برای کاهش تولید زباله، و استفاده از ابزارهای پروفایلینگ، می‌توان به طور چشمگیری پایداری، پاسخ‌دهی و کارایی برنامه‌های کاربردی را افزایش داد. تجربه عملی نشان می‌دهد که حتی با وجود مشکلات زمینه‌ای در کد، تنظیمات صحیح GC می‌تواند تأثیر مثبت شگرفی بر عملکرد کلی سیستم، به‌ویژه در محیط‌های کانتینری با منابع محدود، داشته باشد و از وقفه‌های طولانی که تجربه کاربری را مختل می‌کند، جلوگیری نماید.


مشاهده مطلب اصلی

نظرات

  • هادی مزارعی در ۱۴۰۴/۰۳/۱۴ ۱۶:۳۱
    یک تجربه جدید از منِ تازه کار.

    1. نیاز داشتم حدود 300،000 ردیف رو پردازش کنم (البته فرآیندی روتین در برنامه نبود و هر چند روز یکبار باید انجام می‌شد)
    2. ابتدا هر ردیف که بصورت stream دریافت میشد را به یک نوع (type) تبدیل کردم (DTO)
    3. مدل‌های ایجاد شده را در یک لیست از همان نوع اضافه کردم
    4. در حلقه اصلی، بررسی می‌شد اگر تعداد آیتم‌های لیست برابر 1000 شد، لیست را برای پردازش بعدی به تابع مورد نظر ارسال کنه
    5. بعد از ارسال لیست برای انجام مراحل بعدی، مجدد لیست را new میکردم تا برای دسته (batch) بعدی آماده بشه
    6. بعد از پایان عملیات، حدود 1GB حافظه اشغال شده بود افتضاح بود.
    7. حالا قبل از new کردن لیست، ابتدا لیست موجود را clear و پس از اون GC رو هم صدا زدم. ولی این بار بعد از پایان عملیات فقط 500MB حافظه اشغال شده بود که باز هم افتضاح بود
    8. در گام آخر لیست رو nullable تعریف کردم و بعد از اینکه 1000 آیتم به لیست اضافه می‌شد، ابتدا لیست را برابر null قرار دادم تا در حلقه‌های بعدی مجددا فرآیند افزودن آیتم به لیست انجام شود و...
    9. این بار در پایان کار حدود 10MB حافظه اشغال شده بود
    10. در همان حالت Runtime، چندین بار تمام 300،000 آیتم رو پردازش کردم ولی دامنه اشغال حافظه همان 8 تا 10 مگابایت باقی ماند.

    سوال: چرا null اینقدر در کاهش مصرف حافظه کمک کرده؟ آیا میشه در فرآیندهای پردازشی سنگین که تعداد متغیرهای زیادی تعریف و مقدار دهی میشن، همه آنها را بصورت nullable تعریف کرد و آنچه که در انتهای پردازش در حافظه باقی می‌ماند را به GC بسپاریم؟
    • وحید نصیری در ۱۴۰۴/۰۳/۱۴ ۱۸:۱۱
      اشیاء موجود در حافظه تا زمانی که هیچ مرجعی (reference) به آن‌ها وجود نداشته باشد، توسط GC جمع‌آوری نمی‌شوند. وقتی شما لیست را با new کردن دوباره بازنویسی می‌کردید، لیست قبلی همچنان در حافظه باقی می‌ماند، چون ممکن بود همچنان مرجعی به آن وجود داشته باشد (مثلاً در جایی از برنامه یا در stack/heap). این باعث می‌شد که حافظه به‌مرور انباشته شود، به‌خصوص در پردازش‌های سنگین با تعداد زیادی شیء. وقتی لیست را با متد Clear خالی می‌کردید، فقط اشیاء داخل لیست (عناصر لیست) از لیست حذف می‌شدند، اما خود شیء لیست (ساختار داده‌ای آن) و برخی متادیتاها (مانند ظرفیت داخلی آرایه) همچنان در حافظه باقی می‌ماندند. همچنین، اگر عناصر لیست خودشان اشیاء پیچیده‌ای (مانند DTOها) باشند، ممکن است مرجع‌هایی به آن‌ها در جای دیگری از برنامه وجود داشته باشد، که مانع از جمع‌آوری کامل توسط GC می‌شود. به همین دلیل، حتی با فراخوانی دستی GC، حافظه آزادسازی کامل نشد و همچنان 500 مگابایت اشغال شده بود. وقتی لیست را null می‌کنید، مرجع به شیء لیست کاملاً قطع می‌شود. این کار به GC اجازه می‌دهد که نه‌تنها خود شیء لیست، بلکه تمام اشیاء داخل آن (مانند DTOها) را که دیگر مرجعی به آن‌ها وجود ندارد، جمع‌آوری کند. این باعث شد که حافظه به‌صورت مؤثرتری آزاد شود و در نهایت فقط 8 تا 10 مگابایت اشغال شود. دلیل این موفقیت این است که null کردن مرجع، به GC سیگنال واضحی می‌دهد که این شیء دیگر مورد نیاز نیست و می‌تواند به‌طور کامل از حافظه حذف شود. GC در زبان‌های مدرن مانند #C و Java به‌گونه‌ای طراحی شده است که به‌صورت خودکار و بهینه حافظه را مدیریت کند. اگر شما به‌صورت دستی مرجع‌ها را null کنید، ممکن است در برخی موارد به GC فشار غیرضروری وارد کنید یا رفتار پیش‌بینی‌ناپذیری ایجاد کنید. به‌عنوان مثال، اگر متغیری هنوز در scope باشد، null کردن آن ممکن است تأثیر محدودی داشته باشد، چون GC همچنان ممکن است آن را نگه دارد تا scope به پایان برسد.

      روش بهتر کار با تعداد آیتم بالا، عدم ساخت لیست‌های سنگین و استفاده از yield return است:
      private static IEnumerable<DataRowDto> GetDataStreamFromDatabase()
      {
          using (var connection = new SqlConnection("connection_string"))
          {
              connection.Open();
              using (var command = new SqlCommand("SELECT Id, Data FROM YourTable", connection))
              using (var reader = command.ExecuteReader())
              {
                  while (reader.Read())
                  {
                      yield return new DataRowDto
                      {
                          Id = reader.GetInt32(0),
                          Data = reader.GetString(1)
                      };
                  }
              }
          }
      }
      در اینجا به‌جای ذخیره‌ی تمام نتایج در یک لیست، از IEnumerable و yield return برای استریم کردن استفاده می‌کنیم. متد GetDataStreamFromDatabase از yield return استفاده می‌کند تا داده‌ها را به‌صورت تنبل (lazy) تولید کند. این یعنی داده‌ها فقط زمانی که نیاز است (در زمان iteration) تولید می‌شوند، نه اینکه همه‌ی ردیف‌ها یکجا در حافظه بارگذاری شوند.