عنوان:

‫بهینه‌سازی GC در دات‌نت 9 : تقویت مدیریت حافظه با کاهش Fragmentation و افزایش کارایی


نویسنده: امیر مکارچی
تاریخ: ۱۴۰۴/۰۲/۲۳ ۲۱:۴۰
آدرس: www.dntips.ir
در نسخه‌های قبلی دات نت، GC اندازه‌ی هیپ (Heap Size) را بر اساس الگوهای تخصیص و آزادسازی حافظه تعیین می‌کرد. این الگوها عمدتاً به تعداد و حجم تخصیص‌های جدید و داده‌های آزاد شده وابسته بودند. حجم داده‌های long-lived (داده‌هایی که به نسل 2 منتقل شده‌اند) در این الگوها به‌صورت مستقیم مورد توجه قرار نمی‌گرفت. به عبارت دیگر، GC بیشتر بر روی تخصیص‌های جدید و آزادسازی‌های جدید تمرکز می‌کرد و حجم داده‌های نسل 2 (که معمولاً داده‌های قدیمی‌تر و long-lived هستند) کمتر مورد توجه قرار می‌گرفت.

فرض کنید یک برنامه در زمان اجرا تعداد زیادی شی بزرگ ایجاد می‌کند که برای مدت طولانی زنده می‌مانند (مثلاً کش‌ها یا مجموعه داده‌های پین‌شده). این داده‌ها به نسل 2 منتقل می‌شوند و GC در هر دوره باید این داده‌ها را بررسی کند. اگر هیپ همچنان با رشد داده‌های long-lived گسترش یابد، فضای زیادی اشغال می‌شود که شاید دیگر نیازی به آن نباشد. همچنین، زمانی که حجم هیپ بزرگ شود، زمان GC نیز طولانی‌تر خواهد شد، زیرا GC باید داده‌های بیشتری را اسکن کند.

رفتار GC در نسخه‌های قبلی:
مشاهده‌ی حجم تخصیص‌های جدید: GC بررسی می‌کرد که چه میزان حافظه در نسل 0 و 1 تخصیص یافته است. اگر حجم تخصیص‌های جدید زیاد بود، GC تصمیم می‌گرفت که هیپ را گسترش دهد. اما اگر تخصیص‌های جدید کم بود، GC تصمیم می‌گرفت که هیپ را ثابت نگه دارد یا حتی کوچک‌تر کند.
نادیده گرفتن حجم long-lived: اما اگر داده‌های long-lived در نسل 2 همچنان باقی می‌ماندند، GC صرفاً به آن‌ها به‌عنوان داده‌های باقی‌مانده نگاه می‌کرد. یعنی، حتی اگر داده‌های long-lived بزرگ بودند، GC تصمیم نمی‌گرفت که اندازه‌ی هیپ را متناسب با آن‌ها تنظیم کند. در نتیجه، داده‌های نسل 2 افزایش می‌یافتند و فضای بیشتری را اشغال می‌کردند، در حالی که هیپ همچنان بر اساس الگوی اولیه (حجم تخصیص‌های جدید) تنظیم می‌شد.

مثال عملی:
فرض کنید که در یک برنامه در ابتدا ۱۰۰ مگابایت داده در نسل 0 و 1 تخصیص داده شده است. سپس ۵۰ مگابایت از این داده‌ها به نسل 2 منتقل می‌شود (یعنی داده‌های long-lived). در چرخه‌های بعدی، ۵۰ مگابایت دیگر نیز به نسل 2 منتقل می‌شود. در مجموع، اکنون ۱۰۰ مگابایت داده long-lived در نسل 2 داریم.
در نسخه‌های قبلی اگر تخصیص‌های جدید کم شود (مثلاً فقط ۱۰ مگابایت داده در نسل 0 و 1 ایجاد شود)، GC تصمیم می‌گیرد که هیپ را گسترش ندهد. چرا که الگوی تخصیص جدید، کوچک به نظر می‌رسد. اما در این میان، ۱۰۰ مگابایت داده‌ی long-lived در نسل 2 باقی مانده و فضای زیادی اشغال کرده است.

نتیجه:
هیپ به اندازه کافی برای داده‌های long-lived تنظیم نمی‌شود. این داده‌ها همچنان فضای زیادی اشغال می‌کنند و GC به‌درستی به آن‌ها واکنش نشان نمی‌دهد. همچنین، اگر تخصیص‌های جدید آغاز شود، ممکن است GC مجبور شود هیپ را بیش از حد گسترش دهد زیرا حجم واقعی داده‌های زنده (یعنی نسل 2) نادیده گرفته شده است. DATAS به جای اینکه فقط به حجم تخصیص‌های جدید توجه کند، به حجم داده‌های long-lived در نسل 2 نیز توجه می‌کند. این یعنی اگر داده‌های long-lived به میزان زیادی افزایش یابند، DATAS می‌تواند اندازه‌ی هیپ را متناسب با این داده‌ها افزایش یا کاهش دهد.

به عبارت دیگر:
اگر ۱۰۰ مگابایت داده در نسل 2 باقی بماند، DATAS (Dynamic adaptation to application sizes) به این داده‌ها نیز توجه می‌کند و هیپ را طوری تنظیم می‌کند که فضای کافی برای این داده‌های long-lived فراهم شود. همچنین اگر این داده‌ها آزاد شوند، DATAS می‌تواند هیپ را مجدداً به اندازه‌ی کوچکتری تنظیم کند. هدف DATAS این است که اندازه‌ی هیپِ برنامه متناسب با حجم داده‌های long-lived برنامه باشد.

DATAS چگونه عمل می‌کند؟
مکانیزم آن به این شکل است:
پایش مداوم حجم داده‌های long-lived:
DATAS به‌صورت پیوسته حجم داده‌های long-lived را رصد می‌کند. داده‌های long-lived معمولاً در نسل 2 و LOH (Large Object Heap) قرار دارند. این داده‌ها شامل اشیائی هستند که چندین چرخه‌ی GC را زنده مانده‌اند.

تنظیم اندازه‌ی هیپ بر اساس long-lived data:
اگر حجم داده‌های long-lived کاهش یابد (مثلاً وقتی داده‌های کش آزاد می‌شوند یا یک مجموعه داده بزرگ پردازش و حذف می‌شود)، DATAS می‌تواند اندازه‌ی هیپ را به‌صورت دینامیک کاهش دهد. اگر حجم داده‌های long-lived افزایش یابد، DATAS به‌جای گسترش هیپ به اندازه‌های غیرمنطقی، بهینه‌سازی‌های درون‌هیپ را اجرا می‌کند. این کار شامل فشرده‌سازی (Compaction) و بازآرایی (Rearrangement) اشیاء است.

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

مثال عملی از عملکرد DATAS:
سناریو: فرض کنید برنامه‌ای داریم که به‌صورت موقت چندین آرایه‌ی بزرگ byte برای پردازش داده‌ها ایجاد می‌کند. این آرایه‌ها پس از پردازش آزاد می‌شوند، اما برخی از داده‌های کش‌شده به مدت طولانی باقی می‌مانند.
مرحله تخصیص:
این آرایه‌های بزرگ ابتدا به نسل 0 تخصیص داده می‌شوند. برخی از آن‌ها به نسل 1 و سپس نسل 2 منتقل می‌شوند.

مرحله GC:
GC اجرا می‌شود و برخی از آرایه‌ها آزاد می‌شوند. داده‌های long-lived که در نسل 2 باقی می‌مانند، شناسایی و برچسب‌گذاری می‌شوند.

اجرای DATAS:
DATAS متوجه می‌شود که حجم داده‌های long-lived افزایش یافته است. به‌جای گسترش هیپ به اندازه‌ی بیشتر، DATAS تلاش می‌کند:
  • داده‌های long-lived را به نواحی فشرده‌تر انتقال دهد.
  • بخش‌های آزاد شده را برای تخصیص‌های جدید آماده کند.
  • اگر میزان فضای آزاد کافی نباشد، هیپ به‌صورت کنترل‌شده گسترش می‌یابد.

نتیجه: با این کار، هیپ به اندازه‌ی منطقی‌تری تنظیم می‌شود و از رشد بی‌رویه جلوگیری می‌شود. همچنین، داده‌های long-lived در ناحیه‌ای جداگانه و فشرده قرار می‌گیرند که باعث بهبود locality و کاهش زمان اسکن GC می‌شود.

به عبارت دیگر اگر اپلیکیشن در سخت‌افزارهای مختلف یک کار مشخص را انجام دهد، اندازه‌ی هیپ تقریباً یکسان خواهد بود، و اگر بار کار سبک یا سنگین شود، هیپ متناسب تغییر می‌کند. این حالت به‌ویژه برای بارهای «نوسانی» (bursty) مفید است، جایی که پس از پیک بار پردازشی، مصرف حافظه باید کاهش یابد. فعال شدن DATAS می‌تواند سبب کاهش شدید Working Set فرایند گردد.

بارهای نوسانی (Bursty Workloads) چیست؟
بارهای نوسانی یا Bursty Workloads به سناریوهایی اشاره دارد که در آن تخصیص حافظه یا پردازش داده‌ها به‌صورت ناهمگون، متناوب و در حجم بالا رخ می‌دهد.
به‌عبارت دیگر، در این نوع بارها:
در برخی لحظات، تعداد زیادی عملیات تخصیص حافظه یا پردازش داده به‌صورت ناگهانی و پشت سر هم انجام می‌شود. سپس، برای مدتی سیستم ممکن است تقریباً بیکار یا کم‌فعالیت باشد. پس از آن، دوباره موجی از تخصیص‌ها یا پردازش‌ها رخ می‌دهد. به این چرخه‌ها که در آن بار سیستم به‌صورت پیک‌های ناگهانی و متناوب تغییر می‌کند، بارهای نوسانی گفته می‌شود.

مثال‌های واقعی از بارهای نوسانی:
پردازش درخواست‌های API در سیستم‌های توزیع‌شده:
در سیستم‌های مبتنی بر میکروسرویس‌ها، ممکن است ناگهان چندین هزار درخواست API به‌صورت همزمان وارد شود. هر درخواست ممکن است شامل تخصیص چندین شی، پردازش داده‌ها و ایجاد آبجکت‌های بزرگ باشد. این حالت باعث می‌شود که تعداد تخصیص‌ها در مدت کوتاهی به شدت افزایش یابد. سپس، زمانی که این درخواست‌ها پردازش شوند و پاسخ‌ها ارسال شوند، سیستم دوباره به حالت آرامش برمی‌گردد.

جریان‌های داده در اپلیکیشن‌های مالی:
در سیستم‌های مالی، ممکن است در زمان‌های خاصی از روز (مثل پایان ساعت کاری، بسته شدن بازار سهام یا انجام تسویه‌حساب‌ها) تعداد زیادی تراکنش به‌صورت همزمان وارد شود. این حالت باعث ایجاد بار نوسانی شدید می‌شود.

سیستم‌های چت یا پیام‌رسانی:
هنگام ارسال یک پیام گروهی یا یک اعلان به تعداد زیادی کاربر، موجی از درخواست‌های ارسال و پردازش پیام‌ها ایجاد می‌شود. سپس، پس از ارسال پیام‌ها، سیستم دوباره به وضعیت کم‌فعالیت بازمی‌گردد.

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

افزایش فشار روی Large Object Heap (LOH):
اگر داده‌های بزرگ (مانند آرایه‌های حجیم) در این پیک‌ها تخصیص یابند، LOH به سرعت پر می‌شود و GC مجبور است LOH را فشرده‌سازی یا گسترش دهد.

Fragmentation:
در جریان تخصیص‌های نوسانی، اشیاء با اندازه‌های مختلف به‌صورت ناهمگن در حافظه قرار می‌گیرند. پس از آزادسازی این اشیاء، فضاهای خالی (holes) ایجاد می‌شود که باعث Fragmentation و کاهش کارایی تخصیص‌ها می‌شود.

اختلال در عملکرد GC:
GC معمولاً بر اساس الگوهای تخصیص ثابت طراحی شده است. اما در بارهای نوسانی، این الگوها به‌شدت تغییر می‌کنند و ممکن است GC مجبور به اجرای چندین چرخه GC پشت سر هم شود.

در این حالت، GC به‌طور خودکار اندازه heap را بر اساس حجم داده‌های زنده (long-lived) برنامه تنظیم می‌کند، به جای اینکه مانند حالت Server GC قدیمی صرفاً حداکثر کارایی را در نظر بگیرد. در عمل این تغییر باعث می‌شود مصرف حافظه نسبت به میزان واقعی بار کاری تنظیم شده و از رشد بیش از حد heap جلوگیری شود
DATAS نه تنها اندازه heap را بهینه می‌کند، بلکه در مواقع لازم از GCهای کامل و فشرده‌ساز (full-compacting GC) استفاده می‌کند تا از افزایش بیش‌از‌حد تکه‌تکه‌شدگی حافظه (fragmentation) جلوگیری کند.

Server GC چیست؟
Server GC یکی از دو حالت اصلی اجرای Garbage Collector (GC) در .NET است که برای برنامه‌های سروری و چندنخی (Multithreaded) طراحی شده است. در مقابل، Workstation GC برای برنامه‌های کلاینت یا دسکتاپ و تک‌نخی (Single-threaded) بهینه شده است.

چرا Server GC؟
در برنامه‌های سروری مانند APIهای وب، سرویس‌های توزیع‌شده، یا برنامه‌های بلادرنگ (Real-time)، معمولاً تعداد زیادی درخواست همزمان پردازش می‌شود.
این برنامه‌ها معمولاً روی سرورهایی با تعداد هسته‌های پردازشی زیاد اجرا می‌شوند. نیازمند زمان پاسخ‌دهی سریع‌تر هستند. حجم زیادی از تخصیص‌های حافظه را به‌صورت همزمان انجام می‌دهند. بهینه‌سازی برای تخصیص‌های همزمان و پردازش موازی بسیار مهم است.

همچنین در .NET 9 عملیات مرتب‌سازی بلوک‌های حافظه در زمان compact کردن heap بهینه‌تر شده است: الگوریتم موازی quicksort موسوم به vxsort که پیش‌تر فقط در ویندوز فعال بود، در .NET 9 روی لینوکس نیز فعال شده است تا زمان توقف GC به دلیل مرتب‌سازی آدرس‌ها کاهش یابد

الگوریتم موازی VxSort چیست؟
VxSort یک پیاده‌سازی بهینه از الگوریتم Quicksort است که برای پردازش‌های چندنخی (Multi-threaded) و معماری‌های مدرن CPU طراحی شده است.
این الگوریتم توسط تیم .NET برای بهینه‌سازی مرتب‌سازی آرایه‌های بزرگ و پشتیبانی از پردازش برداری (SIMD) توسعه داده شده است.

چرا VxSort؟
در معماری‌های مدرن CPU، قابلیت‌هایی مانند:
  • SIMD (Single Instruction Multiple Data): پردازش همزمان چندین داده با یک دستورالعمل
  • AVX/AVX2/AVX-512: مجموعه‌ای از دستورالعمل‌های پردازش برداری
  • چند هسته‌ای بودن (Multi-core): پردازش موازی
به ما امکان می‌دهند تا الگوریتم‌های سنتی مانند Quicksort را به‌صورت همزمان (Parallel) و برداری (Vectorized) اجرا کنیم.

VxSort از این معماری‌ها بهره می‌برد تا:
  • زمان اجرای مرتب‌سازی را به شدت کاهش دهد.
  • از پهنای باند حافظه بهینه استفاده کند.
  • تأخیرهای ناشی از Cache Miss را کاهش دهد.
این بهبودها به‌ویژه در سناریوهای با بار همزمان و روی سیستم‌های چندهسته‌ای بزرگ، به کاهش تأخیرهای GC (latency) کمک می‌کنند.

بهینه‌سازی‌های جدید در رفتار GC
در .NET 9 چند بهینه‌سازی مهم در فرآیند GC رخ داده است:
مرتب‌سازی موازی (vxsort) در کامپکت کردن: .NET چند نسخه قبل الگوریتم vxsort (یک quicksort موازی) را اضافه کرد، اما این الگوریتم تا کنون فقط روی ویندوز و معماری x64 فعال بود. در .NET 9 این الگوریتم روی لینوکس نیز فعال شده است. این موضوع زمان‌های توقف GC را کاهش می‌دهد.

بهبود الگوریتم کامپکت: کامپکت کردن کلی هیپ و LOH در .NET 9 هوشمندتر و کارآمدتر شده تا تکه‌تکه شدن حافظه کاهش یابد. به همین دلیل GC تمایل بیشتری به اجرای کامپکت کامل دارد که باعث استفاده بهینه‌تر از حافظه می‌شود (مطابق گزارش‌های رسمی، در آزمایش‌ها حافظه‌ی مصرفی پایدارتر بوده و وقفه‌ها کمتر شده است). علاوه بر این، امکان فشرده‌سازی هدفمند LOH نیز فراهم است (با تنظیم GCSettings.LargeObjectHeapCompactionMode=CompactOnce و سپس فراخوانی GC) که در مواقع بحرانی مفید است.

بهبود رفتار در سرور GC: با فعال شدن DATAS به‌طور پیش‌فرض، سرور GC دیگر همیشه یک heap به ازای هر هسته نمی‌سازد؛ بلکه بسته به بار کار، تعداد heapها را تنظیم می‌کند. این کار از مصرف بیش از حد حافظه در برنامه‌های کوچک جلوگیری می‌کند و در عین حال در بارهای سنگین، برای دستیابی به توان بالا می‌تواند تعداد heapها را افزایش دهد.

بهبود قفل‌ها و همگام‌سازی: علاوه بر نکته‌ی UOH، چندین PR داخلی مرتبط با متدهای GC و فراخوانی‌های داخلی، از FCALL به QCALL تبدیل شده‌اند تا سربار تماس‌های به runtime کاهش یابد. همچنین کارهایی در جهت تخصیص تنبل (lazy) برخی ساختارهای داده‌ای انجام شده که در کوتاه‌مدت مصرف حافظه‌ی اولیه را کاهش می‌دهد