عنوان:

‫مدیریت حافظه و جمع‌آوری زباله (Garbage Collection) - قسمت بیست و یکم


نویسنده: امیر مکارچی
تاریخ: ۱۴۰۳/۱۲/۰۹ ۲۳:۰۰
آدرس: www.dntips.ir
مفاهیم زبان‌محور در مدیریت حافظه و Finalisation

Finalisation به برنامه‌نویس این امکان را می‌دهد تا کدی را تعریف کند که زمانی اجرا شود که GC متوجه شود دیگر هیچ مرجعی به یک شیء وجود ندارد. مثلاً:
مشکل: فرض کنید یک برنامه فایل‌هایی را باز می‌کند. این فایل‌ها به منابع خارجی مثل سیستم‌عامل وابسته هستند. اگر برنامه‌نویس فراموش کند که فایل را ببندد، ممکن است این منابع آزاد نشوند و به مشکل بخوریم.
راه‌حل: با استفاده از Finalisation می‌توانیم مطمئن شویم وقتی GC شیء مربوط به فایل را پاک می‌کند، فایل هم به درستی بسته میشود.

چالش‌های Finalisation:
زمان اجرای Finalisers:
  • Finalisers می‌توانند هنگام جمع‌آوری حافظه (GC) اجرا شوند یا بعد از آن.
  • اگر هنگام GC اجرا شوند، ممکن است به قفل‌ها (Locks) یا داده‌های اشتراکی دسترسی پیدا کنند و باعث مشکلاتی مثل Deadlock شوند.
  • بهترین حالت این است که بعد از GC و در یک نخ جداگانه اجرا شوند.
مشکلات هم‌زمانی (Concurrency):
  • در برنامه‌های چندنخی، Finaliserها ممکن است با نخ‌های دیگر تداخل داشته باشند.
دسترسی به شیء:
  • Finaliser معمولاً به داده‌های شیء دسترسی دارد، اما این باعث می‌شود GC مجبور شود آن شیء و اشیای مرتبط را تا پایان اجرای Finaliser نگه دارد.
مشکل Resurrecting Objects:
  • Finaliser می‌تواند شیء را دوباره قابل دسترسی کند که این کار مدیریت حافظه را پیچیده‌تر می‌کند.
خطاها در Finaliser:
  • اگه Finaliser دچار خطا شود یا استثنایی (Exception) رخ بدهد، باید مدیریت شود تا کل برنامه مختل نشود.

روش‌هایی برای جلوگیری از مشکلات
اجرای Finaliserها در نخ جداگانه:
  • این کار باعث می‌شود تداخل با نخ‌های اصلی برنامه کاهش پیدا کند.
استفاده از قفل‌ها با دقت:
  • باید مطمئن شویم که Finaliserها در حالت‌هایی که نیاز به قفل دارند اجرا نمی‌شوند.
استفاده از روش‌های جایگزین مثل Weak References:
  • در زبان‌هایی مثل Java یا C Sharp می‌شود از Weak References برای مدیریت دقیق‌تر اشیاء استفاده کرد.
استفاده از GC.KeepAlive یا Reference.reachabilityFence:
  • این توابع باعث می‌شوند که شیء تا پایان کار برنامه‌، زنده بماند و GC آن را حذف نکند.

دات نت از Finaliserها برای منابع غیرمدیریتی (Unmanaged Resources) مثل فایل‌ها استفاده می‌کند. از توابعی مثل Dispose برای مدیریت بهتر Finalisation استفاده می‌کند.

Finalisation می‌تواند مفید باشد، اما اگر اشتباه استفاده شود، ممکن است بیشتر از فایده، مشکل ایجاد کند.
جایگزین بهتر:
استفاده از الگوهایی مثل RAII (Resource Acquisition Is Initialization) در C++ یا Dispose Pattern در دات نت. این روش‌ها به برنامه‌نویس اجازه می‌دهند منابع را به‌صورت دستی و قابل پیش‌بینی مدیریت کند.

مفهوم Weak References در مدیریت حافظه
GC معمولاً اشیایی را که به آنها Strong Reference (ارجاع قوی) وجود دارد، حفظ می‌کند. اما در بعضی موارد، این رفتار ایده‌آل نیست. توضیح با یک مثال:
فرض کنید یک کامپایلر دارید که متغیرهای یک برنامه را نگه می‌دارد. برای افزایش کارایی، می‌خواهیم مطمئن شویم که همه‌ی ارجاعات به یک متغیر خاص (مثلاً xyz) به یک شیء یکسان اشاره کنند.
برای این کار، کامپایلر یک جدول ذخیره‌سازی نام متغیرها (Canonicalisation Table) می‌سازد که همه‌ی نام‌های دیده‌شده را نگه می‌دارد.
حالا مشکل چیست؟
  • اگر یک متغیر دیگر در برنامه استفاده نشود، باید از حافظه خارج شود، اما چون همچنان یک اشاره‌گر از جدول به آن وجود دارد، GC نمی‌تواند آن را پاک کند!
  • این یعنی نشتی حافظه (Memory Leak)، چون این متغیرها به مرور در حافظه جمع می‌شوند.
راه‌حل؟ استفاده از Weak References!
اگر ارجاع از جدول به متغیرها ضعیف (Weak) باشد، وقتی آخرین ارجاع قوی از بین برود، GC می‌تواند آن متغیر را آزاد کند. به عبارتی: Weak Referenceها اشیاء را نگه نمی‌دارند، بلکه فقط تا زمانی که GC آنها را پاک نکرده، به آن اشاره می‌کنند.

Weak References سه حالت دسترسی را تعریف می‌کند:
Strongly-Reachable (دسترسی قوی):
شیء از طریق یک زنجیره‌ی ارجاعات قوی در دسترس است و توسط GC جمع‌آوری نمی‌شود.
Weakly-Reachable (دسترسی ضعیف):
شیء فقط از طریق Weak References در دسترس است. GC می‌تواند آن را آزاد کند و تمام Weak References را به null تبدیل کند.
Phantomly-Reachable (دسترسی فانتوم):
شیء توسط GC علامت‌گذاری شده که باید حذف شود، اما هنوز به‌طور کامل از بین نرفته است. از این وضعیت برای کنترل دقیق Finalisation (نابودسازی اشیاء) استفاده می‌شود.

چطور یک Collector متوجه می‌شود که یک Weak Reference باید پاک شود
برای این کار، GC فرآیند جستجو (Tracing) را در دو مرحله انجام می‌دهد:
مرحله‌ی اول: پیدا کردن اشیاء قابل دسترس قوی (Strongly-Reachable)
GC از ریشه‌ها (Roots) شروع می‌کند و تمام اشیایی را که از طریق ارجاع‌های قوی به آن دسترسی هست، علامت‌گذاری می‌کند.
در این مرحله Weak References نادیده گرفته می‌شوند و در لیستی ذخیره می‌شوند.
مرحله‌ی دوم: بررسی Weak References
GC بررسی می‌کند که آیا شیء مورد نظر در مرحله‌ی قبل Strongly-Reachable بود یا خیر؟
اگر بود → Weak Reference را نگه می‌دارد.
اگر نبود → Weak Reference را به null تنظیم می‌کند و شی را آزاد می‌کند.
نتیجه:
اشیائی که فقط از طریق Weak References قابل دسترس هستند، پاک می‌شوند.
این باعث می‌شود ساختارهای داده‌ای مثل Cacheها و Canonicalisation Tableها به‌صورت خودکار پاک‌سازی شوند.

سی شارپ با استفاده از GCHandle و WeakReference برای مدیریت حافظه‌ی اشیاء استفاده می‌کند.

جمع‌آوری موازی زباله (Parallel Garbage Collection)
پردازنده‌های چند هسته‌ای دارند روزبه‌روز بیشتر می‌شوند و دیگر افزایش فرکانس پردازنده‌ها راه‌حل مناسبی برای بهبود کارایی نیست. به همین دلیل، برای افزایش کارایی باید از پردازش موازی استفاده کنیم و یکی از جاهایی که این کار خیلی مهم است، جمع‌آوری زباله (Garbage Collection یا GC) است.
تا الان فرض ما این بود که در یک سیستم یک جمع‌کننده‌ی زباله‌ی تک‌نخی (Single-Threaded GC) داریم. اما روی یک پردازنده‌ی چند هسته‌ای، این روش باعث هدر رفتن منابع پردازشی می‌شود!
بنابراین به جای اجرای GC روی یک نخ، از تمام هسته‌های پردازنده برای GC استفاده می‌کنیم تا زمان توقف برنامه (Pause Time) کاهش پیدا کند!


تعریف "Stop-the-World" در جمع‌آوری زباله
در مدل‌های اولیه GC، تمام نخ‌های اجرایی برنامه (Mutator Threads) در هنگام اجرای جمع‌آوری زباله متوقف (Stop-the-World) می‌شدند.
یعنی برنامه نمی‌تواند کار دیگری انجام بدهد تا وقتی که GC کارش را تمام کند.
شکل‌های مختلف اجرا:
مدل تک پردازنده‌ای: فقط یک پردازنده مشغول اجرای GC می‌شود.
مدل چند پردازنده‌ای سنتی: چندین نخ اجرایی برنامه داریم، ولی GC تک‌نخی است و روی یک پردازنده اجرا می‌شود.
مدل موازی: همزمان چندین پردازنده روی فرآیند جمع‌آوری زباله کار می‌کنند!

بخش‌های مختلف GC که می‌توانند موازی اجرا شوند
فاز علامت‌گذاری (Marking Phase): جایی که GC تعیین می‌کند کدام اشیاء هنوز زنده هستند.
فاز جاروب (Sweeping Phase): بازیابی حافظه‌ی اشیای غیرقابل‌دسترس.
فاز کپی (Copying Phase): انتقال اشیا به مکان جدید در حافظه.
فاز فشرده‌سازی (Compaction Phase): کاهش فضای خالی و جلوگیری از تکه‌تکه شدن حافظه.

هزینه‌های همزمان‌سازی در GC موازی
در GC تک‌پردازنده‌ای، نیازی به همزمان‌سازی (Synchronization) نداریم. اما در GC موازی، چون چند نخ GC باهم کار می‌کنند، باید همزمان‌سازی بین نخ‌ها انجام شود.
روش‌های همزمان‌سازی که استفاده می‌شود:
استفاده از Lockها (اما Lock می‌تواند باعث کاهش کارایی شود!)
عملیات اتمیک (Atomic Operations) مثل Compare-and-Swap
ساختارهای داده‌ای کمکی (Auxiliary Data Structures) برای مدیریت بهینه‌ی همزمانی

کارهایی که به خوبی موازی می‌شوند:
جاروب (Sweeping) – چون هر پردازنده می‌تواند مستقل از بقیه روی بخش خودش کار کند.
فشرده‌سازی (Compaction) – می‌توانیم حافظه را به بخش‌های جداگانه تقسیم کنیم و هر نخ GC روی یک بخش کار کند.

کارهایی که به سختی موازی می‌شوند:
فاز علامت‌گذاری (Marking Phase) – در این مرحله GC باید حافظه را پیمایش کند (مثلاً درخت‌ها، لیست‌ها و گراف‌های حافظه). اگر داده‌ی پیمایش‌شده فقط یک لیست خطی باشد، نمی‌توانیم همزمان‌سازی خوبی انجام بدهیم. اما اگر ساختارهای پیچیده‌تری را مثل درخت‌ها یا گراف‌های متصل داشته باشیم، بهتر می‌توانیم پردازنده‌ها را درگیر کنیم!