عنوان:

‫آماده شدن برای GC جدید در دات نت 10


نویسنده: امیر مکارچی
تاریخ: ۱۴۰۴/۰۷/۲۵ ۰۰:۲۰
آدرس: www.dntips.ir
اکثر قابلیت‌های جدید GC چه یک نسخه با ویژگی‌های خاص جدید از GC باشد، چه مکانیزمی که امکان انجام کاری جدید را برای GC فراهم کند، و چه بهینه‌سازی در مکانیزم‌های موجود معمولاً بدون نیاز به اقدام خاصی از سوی کاربر و به‌محض ارتقا به نسخه‌ی جدید فعال می‌شوند. دلیلش هم ساده است: این ویژگی‌ها برای بهبود عملکرد در دامنه‌ی وسیعی از سناریوها طراحی شده‌اند.
البته هر تغییری در عملکرد همیشه با خطر بروز پسرفت (regression) همراه است مخصوصاً وقتی میلیون‌ها نفر از چارچوب استفاده می‌کنند، تقریباً حتمی است که تغییری باعث پسرفت در برخی سناریوها شود. این پسرفت‌ها در microbenchmark‌ها (بنچمارک‌های بسیار کوچک و دقیق) معمولاً شدیدتر به چشم می‌آیند، چون حتی تغییرات کوچک هم می‌تواند باعث نوسانات بزرگ در نتایج شود.
نمونه‌ی اخیر این مسئله تغییر در نحوه‌ی مدیریت free region‌ها برای نسل‌های UOH (یعنی LOH + POH) همچنین سیاست trimming بود. پیش از اینکه جلوتر بریم لازمه که در مورد سناریوی free region‌ها کمی بیشتر توضیح بدهم

free region‌
در GC سه نسل اصلی داریم:
  • Gen0 / Gen1 : برای آبجکت‌های کوتاه‌عمر
  • Gen2 : برای آبجکت‌های بلندعمر
  • UOH (Unpinned Object Heaps) شامل LOH (Large Object Heap) و POH (Pinned Object Heap) برای آبجکت‌های خاص که نمی‌خواهیم جابه‌جا شوند یا خیلی بزرگ‌اند
LOH برای آبجکت‌های بزرگ‌تر از 85,000 بایت است.
POH برای آبجکت‌هایی است که به‌دلیل pinned بودن (مثلاً آرایه‌ای که به native code داده‌ایم) نمی‌توانند در جمع‌آوری‌ها (GC compaction) جابه‌جا شوند.

region-based heap در نسل‌های جدید
در گذشته (مثلاً .NET Framework)، LOH فقط یک بخش بزرگ پیوسته بود.
وقتی GC چیزی آزاد می‌کرد، این بخش تکه‌تکه (fragmented) می‌شد و فضای آزادش به‌درستی قابل استفاده نبود.
اما در نسخه‌های جدید .NET (از .NET 5 به بعد) LOH و POH به‌صورت region-based مدیریت می‌شن:
Heap به چند region (معمولاً چند مگابایت) تقسیم می‌شود. هر region یا در حال استفاده است یا آزاد (free region). وقتی یک region به‌طور کامل خالی شود (هیچ آبجکتی در آن زنده نماند)، GC آن را در یک لیست از free regions قرار می‌دهد. وقتی GC می‌خواهد در LOH یا POH آبجکت جدیدی تخصیص دهد:
ابتدا در بین free regionها جستجو می‌کند تا ببیند آیا فضای آزاد کافی هست یا نه.(در واقع از free list خودش استفاده می‌کند.) اگر free region آزاد مناسبی پیدا شد: آن region به وضعیت “in use” تغییر می‌کند. از ابتدای آن شروع به تخصیص آبجکت‌های جدید می‌کند. اگر چنین regionی وجود نداشت: GC از OS حافظه‌ی جدید درخواست می‌کند و یک region جدید به heap اضافه می‌کند. وقتی یک GC کامل (مثلاً Gen2 GC) انجام شود، GC بررسی می‌کند کدام regionها کاملاً خالی هستند. این regionها به free list برگردانده می‌شوند تا بعداً دوباره استفاده شوند.
در LOH و POH هیچ فشرده‌سازی (compaction) انجام نمی‌شود چون جابه‌جا کردن این آبجکت‌ها پرهزینه یا غیرممکن است (به‌ویژه برای pinned objects). پس مدیریت free regionها تنها روش GC برای جلوگیری از هدررفت حافظه در این بخش‌هاست.
به بیان ساده‌تر: در UOH، GC به‌جای جابه‌جا کردن آبجکت‌ها، با بازیافت کامل regionهای خالی، fragmentation را کاهش می‌دهد.

سیاست trimming
سیاست trimming از حالت مبتنی بر بودجه (budget-based) به حالت مبتنی بر سن (age-based) تغییر کرد، چون روش دوم در کل پایدارتر است (در حالت قبلی ممکن بود خیلی سریع حافظه decommit و دوباره commit شود یا برعکس، مدت طولانی بخش‌های آزاد را نگه داریم). اما همین تغییر باعث شد بنچمارک‌هایی که قبلاً با یک بار GC.Collect حافظه را تا مقدار خیلی کمی کاهش می‌دادند، حالا نیاز به ۳ بار فراخوانی داشته باشند (چون باید صبر کرد تا free regionها در ۲ جمع‌آوری نسل ۲ «قدیمی» شوند و در سومین بار روی لیست decommit قرار بگیرند).
وقتی GC (جمع‌آوری زباله) اجرا می‌شود، فقط آبجکت‌های غیرقابل‌دسترسی را آزاد می‌کند. اما «آزاد کردن» در اینجا فقط یعنی اون فضا در heap قابل تخصیص مجدد میشود commit و در حافظه‌ی فیزیکی نگه داشته شده باقی می‌ماند. یعنی GC ممکن است آبجکت‌ها را پاک کند ولی OS هنوز آن RAM را در اختیار .NET گذاشته باشد.

Trimming (یا decommit):
GC به سیستم‌عامل : "من این بخش از حافظه رو فعلاً لازم ندارم، لطفاً آزادش کن." یعنی اون صفحات حافظه از حالت commit خارج می‌شوند (decommit) و به OS برمی‌گردند. در نسخه‌های قدیمی‌تر .NET (قبل از تغییر اخیر)، GC تصمیم می‌گرفت کی حافظه را بر اساس یک بودجه (budget) decommit کند : مثلاً اگر heap خیلی بزرگ‌تر از میانگین استفاده‌اش شد، بخش‌هایی از آن را آزاد کن. یا اگر فضای آزاد از یک حد مشخص بیشتر شد، بخشی از آن را decommit کن.
این روش ساده بود ولی پایدار نبود: Decommit بیش‌ازحد سریع اگر workload نوسان داشت (به یکبار load بالا می‌رفت یا پایین می‌اومد)، GC ممکن بود بخش زیادی از حافظه را آزاد کند، بعد بلافاصله دوباره از OS بخواهد. که این باعث commit/decommit پیاپی و کندی می‌شد. Decommit خیلی دیر گاهی هم برعکس باعث میشد heap بزرگ بماند و GC مدت زیادی حافظه‌ی بلااستفاده را نگه دارد.
نتیجه: رفتار غیرقابل‌پیش‌بینی و نوسان شدید در استفاده از حافظه (به‌ویژه در workloadهای متغیر مثل ASP.NET یا سرویس‌های containerized). در طراحی جدید (که از .NET 7 به بعد وارد شد و در .NET 9/10 پخته‌تر شده)،
GC برای trimming regionها (به‌خصوص در UOH) از سیاست age-based استفاده می‌کند: یعنی هر region باید برای مدت خاصی «آزاد» بماند تا GC مطمئن شود واقعاً نیازی به آن نیست، بعد آن را decommit می‌کند.

هر region یک state داره:
  • In-use
  • Free (خالی ولی هنوز commit شده)
  • Decommitted
وقتی یه region خالی میشود (یعنی هیچ آبجکت زنده‌ای در آن نیست): GC بلافاصله آن را decommit نمی‌کند. فقط آن را به عنوان free region علامت می‌زند و در لیست مخصوصش قرار میدهد.
بعد از هر جمع‌آوری نسل ۲ (Gen2 GC): GC بررسی می‌کند هر free region چند جمع‌آوری پشت سر هم خالی مانده. اگر برای دو جمع‌آوری کامل Gen2 متوالی خالی باقی مانده باشد (یعنی «پیر» شده)، در جمع‌آوری سوم آن را decommit می‌کند.

قبلاً چون سیاست budget-based بود:
با یک GC.Collect حافظه آزاد می‌شد و بلافاصله decommit. الان چون سیاست age-based شده: بعد از اولین GC region فقط free میشه. بعد از دومین GC می‌فهمیم هنوز خالیه (پیر شده). بعد از سومین GC تازه decommit میشه و RAM واقعاً به OS برمی‌گرده. به همین دلیل، اگر قبلاً در benchmarkها با یک GC.Collect حافظه به حداقل می‌رسید، حالا باید ۳ بار متوالی GC.Collect بزنی تا همون اثر رو ببینی.

DATAS
در نسخه‌ی .NET 9 ویژگی DATAS به‌صورت پیش‌فرض فعال شد. اما از آنجایی که .NET 9 یک نسخه‌ی LTS نیست، بسیاری از کاربران برای اولین بار هنگام ارتقا به .NET 10 با DATAS مواجه خواهند شد.
DATAS برای دو نوع سناریوی خاص هدف‌گذاری شده:
بارهای کاری bursty در محیط‌های دارای محدودیت حافظه: DATAS تلاش می‌کند زمانی که اپلیکیشن به حافظه‌ی کمتری نیاز دارد، اندازه‌ی heap را کاهش دهد و وقتی نیاز افزایش پیدا می‌کند، آن را بزرگ کند. این قابلیت به‌ویژه برای اپ‌هایی که در containerها با محدودیت حافظه اجرا می‌شوند مهم است.

بارهای کاری کوچک با Server GC: برای مثال کسی که می‌خواهد یک اپ کوچک ASP.NET Core را امتحان کند، DATAS کمک می‌کند اندازه‌ی heap با نیاز واقعی اپلیکیشن هماهنگ‌تر باشد.

تشبیه ساده: رستوران با مشتری‌های فصلی
فرض کن یه رستوران داری که بعضی روزها خیلی شلوغه (مثلاً جمعه‌ها)،ولی بعضی روزها خلوت‌تره (مثلاً دوشنبه‌ها). اگه همیشه ۲۰ تا گارسون استخدام کنی (مثل Server GC)، تو روزهای خلوت منابع زیادی بی‌دلیل هدر می‌ره. اگه فقط ۵ تا گارسون داشته باشی (مثل Workstation GC)، تو روزهای شلوغ مشتری‌ها منتظر می‌مونن. DATAS دقیقاً کاری می‌کنه که تعداد گارسون‌ها (یعنی تعداد heapها و میزان حافظه‌ی commit شده) با توجه به شلوغی لحظه‌ای رستوران به‌صورت خودکار بالا و پایین بشه.

Server GC طوری طراحی شده که برای حداکثر عملکرد روی سرورهای پرقدرت کار کنه: از اول به اندازه‌ی تعداد CPUها heap جداگانه می‌سازه (مثلاً 28 تا heap روی 28 core) وقتی heap بزرگ شد (مثلاً در دوره‌ی پر بار)، حتی اگر بعداً بار کم بشه، GC معمولاً اون حافظه رو نگه می‌داره و به OS پس نمی‌ده چون هدفش throughput بالا بوده، نه صرفه‌جویی در حافظه در نتیجه: در container یا محیط cloud (که حافظه محدود داره)، ممکنه process حتی با بار کم هم حافظه‌ی زیادی اشغال کنه. و این باعث فشار روی سیستم و OOM (Out of Memory) میشه.

DATAS (Dynamic Adaptation To Application Size) اومده تا این مشکل رو حل کنه: حافظه‌ی heap را به‌صورت پویا (دینامیک) با اندازه‌ی واقعی workload تنظیم می‌کنه. وقتی بار کاری زیاد میشه DATAS به GC اجازه میده heap رشد کنه (برای حفظ performance). وقتی بار کاری کاهش پیدا می‌کنه DATAS با تحلیل مداوم LDS (Live Data Size) تشخیص میده حافظه‌ی زیادی بلااستفاده مونده و به‌تدریج heap رو کوچک می‌کنه.

در محیط‌های container (مثل Docker یا Kubernetes): هر container معمولاً memory limit مشخصی داره (مثلاً 512MB) اگر process از اون حد بالاتر بره سیستم اون container رو kill می‌کنه (OOMKill) در حالت Server GC معمولی، heap ممکنه خیلی بزرگ بمونه حتی وقتی نیاز واقعی کم شده، و این باعث میشه container بی‌دلیل حافظه‌ی زیادی مصرف کنه.
اما با DATAS:
heap بعد از دوره‌های burst، کم‌کم trim میشه process فقط به اندازه‌ی داده‌های زنده و کمی بالاتر حافظه نگه می‌داره در نتیجه، احتمال OOMKill کمتر میشه و چند container می‌تونن با ثبات بیشتری روی یه نود اجرا بشن.
برای تیم‌هایی که می‌خواهند از حافظه آزادشده استفاده کنند، یک سناریوی رایج استفاده از محیط‌های orchestrated است. DATAS باعث می‌شود اندازه‌ی heap قابل پیش‌بینی‌تر شود و این به تعیین محدودیت‌های منطقی حافظه کمک می‌کند. مثلاً در Kubernetes می‌توانید مقدار مناسب request و limit را برای بارهای کاری peak و non-peak تنظیم کنید و از HPA بهره‌ی بهتری ببرید. بعضی تیم‌ها هم کارهای زمان‌بندی‌شده‌ای دارند که وقتی ماشین‌ها یا VMها حافظه آزاد دارند اجرا می‌شوند این کار پیچیده‌تر است و معمولاً تیم‌هایی با مهندسان متخصص عملکرد انجامش می‌دهند، اما کنترل بیشتری به آن‌ها می‌دهد.

از سوی دیگر، تیم‌هایی هم وجود دارند که ناوگانی از سرورها دارند و می‌خواهند بیشترین توان پردازشی ممکن را در ساعات شلوغی داشته باشند. این تیم‌ها کوچک‌ترین افت عملکرد را هم تحمل نمی‌کنند. DATAS برای آن‌ها طراحی نشده است، چون تقریباً همیشه باعث کاهش throughput می‌شود. (در بهینه‌سازی عملکرد، تقریباً هیچ‌وقت همه یا هیچ نیست!). تمام این موارد باعث شد فعال کردن DATAS به‌صورت پیش‌فرض تصمیمی دشوار باشد؛ چون بسیاری از تیم‌ها نه می‌خواهند از throughput کم کنند و نه از حافظه آزادشده استفاده‌ای دارند.

تفاوت عملکرد بین DATAS و Server GC سنتی
برای درک دقیق تفاوت‌ها، باید ابتدا سیاست‌های عملکردی (policy) این دو را مقایسه کنیم.

رفتار Server GC
در درجه‌ی اول، Server GC هیچ‌گاه طوری طراحی نشده بود که خود را با اندازه‌ی اپلیکیشن وفق دهد این هدف آن نبوده است. سیاست اصلی Server GC بر پایه‌ی نرخ ماندگاری داده‌ها (survival rate) در هر نسل است:
زمانی که نرخ ماندگاری بالا برود، GC تصمیم می‌گیرد که چه زمانی و کدام نسل را جمع‌آوری کند. (البته عوامل دیگری هم وجود دارند، اما نرخ ماندگاری یکی از مهم‌ترین آن‌هاست.) در Server GC، تعداد heapها برابر است با تعداد هسته‌هایی که فرآیند اجازه دارد از آن‌ها استفاده کند. به همین دلیل، اجرای یک اپلیکیشن مشابه روی ماشین‌هایی با تعداد هسته‌ی متفاوت می‌تواند منجر به اندازه‌ی heap کاملاً متفاوتی شود. به‌عنوان مثال، اجرای یک اپ روی سیستم ۱۲‌هسته‌ای در مقایسه با همان اپ روی سیستم ۲۸‌هسته‌ای می‌تواند نتایج مصرف حافظه کاملاً متفاوتی داشته باشد.

رفتار DATAS
در مقابل، هدف DATAS این است که خود را با اندازه‌ی واقعی اپلیکیشن تطبیق دهد. به همین خاطر، حتی اگر تعداد هسته‌ها بین سیستم‌ها خیلی متفاوت باشد، اندازه‌ی heap تقریباً مشابه باقی می‌ماند. به همین دلیل نمی‌توان گفت که:
«DATAS حافظه را دقیقاً به اندازه‌ی X درصد نسبت به Server GC کاهش می‌دهد.» زیرا DATAS به‌جای درصد ثابت، خود را بر اساس رفتار و اندازه‌ی اپلیکیشن تنظیم می‌کند.

آیا لازم است نگران باشم اگر از Workstation GC استفاده می‌کنم؟
پاسخ بستگی دارد به این‌که چرا از Workstation GC استفاده می‌کنی. اگر از آن استفاده می‌کنی چون بار کاری (workload) برنامه‌ات اساساً نیازی به Server GC ندارد، در این صورت هیچ دلیلی برای تغییر وجود ندارد. مثلاً ممکن است برنامه‌ات تک‌ریسمانی (single-threaded) باشد، یا حجم تخصیص حافظه آن‌قدر کم است که وجود تنها یک رشته برای انجام کار جمع‌آوری حافظه کاملاً کافی است. در چنین شرایطی، Workstation GC نه‌تنها جواب می‌دهد بلکه انتخاب درست و بهینه است.
اما اگر دلیل استفاده‌ات این است که Server GC حافظه‌ی زیادی مصرف می‌کرد و برای محدود کردن مصرف حافظه به Workstation GC پناه بردی، در این صورت ویژگی جدید DATAS می‌تواند برایت بسیار جذاب باشد. چون DATAS هم می‌تواند مصرف حافظه را محدود کند و هم با استفاده از چندین رشته‌ی GC، زمان مکث‌های جمع‌آوری حافظه را کاهش دهد. هدف DATAS این است که خودش را با اندازه‌ی برنامه (Application Size) یا همان LDS – Live Data Size تطبیق دهد. بنابراین باید راهی وجود داشته باشد که این اندازه را تشخیص دهد. از آن‌جا که GC در .NET نسلی (generational) است، ما معمولاً کل heap را جمع‌آوری نمی‌کنیم. و از آن‌جا که اغلب GCهای کامل (Full GC) ما از نوع Background GC هستند که عملیات فشرده‌سازی (compaction) انجام نمی‌دهند، می‌توان مقدار LDS را تقریباً برابر با فضای اشیائی دانست که در نسل‌های قدیمی (old generations) اشغال شده‌اند.

معرفی مفهوم BCD (Budget Computed via DATAS)
در این روش، DATAS عددی به نام BCD را محاسبه می‌کند که بر اساس اندازه‌ی برنامه تعیین می‌شود. این عدد حد بالای بودجه‌ی نسل صفر (gen0 budget) را مشخص می‌کند، که می‌توان آن را تقریباً معادل اندازه‌ی نسل صفر در نظر گرفت (به‌دلیل pinning ممکن است دقیقاً برابر نباشد).

حفظ عملکرد معقول با مفهوم TCP (Throughput Cost Percentage)
در محدوده‌ی این سقف (BCD)، می‌توان حافظه را باز هم کاهش داد تا زمانی که عملکرد کلی در سطح قابل قبولی باقی بماند. ما این عملکرد معقول را با معیاری به نام TCP تعریف می‌کنیم درصد هزینه‌ی کارایی ناشی از GC.
TCP شامل هم زمان مکث‌های GC است و هم مدت زمانی که رشته‌های تخصیص‌دهنده (allocating threads) باید منتظر بمانند. در عمل، TCP را می‌توان تقریباً با درصد زمان صرف‌شده در GC در حالت پایدار تخمین زد. هدف DATAS این است که TCP را در نزدیکی مقدار هدف نگه دارد. اگر بار کاری سبک‌تر شود، DATAS بودجه‌ی gen0 را کاهش می‌دهد. یعنی نسل صفر کوچک‌تر می‌شود و در نتیجه heap کلی نیز کوچک‌تر خواهد شد. مقدار پیش‌فرض هدف TCP برابر با ۲٪ است و می‌توان آن را از طریق تنظیم GCDTargetTCP تغییر داد.