زبالهروب (GC) در Go از نوع mark-sweep است و در دو فاز کار میکند:
- فاز mark: تمام حافظه ردیابی (trace) میشود تا مشخص شود کدام اشیاء زندهاند.
- فاز sweep: حافظهی اشیاء مرده آزاد میشود تا دوباره قابل تخصیص باشد.
این دو فاز باید کاملاً از هم جدا باشند. دلیلش این است که تا همهی حافظه ردیابی نشده، نمیتوان چیزی را آزاد کرد؛ ممکن است هنوز یک اشارهگر (pointer) اسکننشده، شیئی را زنده نگه داشته باشد.
وقتی هیچ کاری برای GC باقی نمانده باشد، GC اصلاً فعال نیست. بنابراین GC مدام بین سه حالت میچرخد: sweep، خاموش (off) و mark. به این چرخه، «چرخهی GC» (GC cycle) میگویند. در این مستند فرض میکنیم چرخه با sweep شروع میشود، به حالت خاموش میرود و با mark ادامه پیدا میکند.
بخشهای بعدی کمک میکنند نسبت به هزینههای GC شهود پیدا کنید تا بتوانید پارامترهای آن را درست تنظیم کنید.
درک هزینهها
GC ذاتاً نرمافزاری پیچیده است که روی سیستمهایی پیچیدهتر ساخته شده. اگر وارد جزئیات شوید، بهراحتی گم میشوید. این بخش یک چارچوب ساده برای فکر کردن به هزینهی GC و پارامترهای تنظیم آن میدهد.
مدل هزینه
این مدل روی سه اصل ساده بنا شده است.
۱. GC فقط دو منبع را درگیر میکند: حافظهی فیزیکی و زمان CPU.
۲. هزینهی حافظهای GC از اینها تشکیل میشود:
- heap زنده (live heap)
- heap جدیدی که پیش از فاز mark تخصیص داده شده
- فضای metadata (متناسب با موارد قبلی است، اما در مقایسه با آنها کوچک)
هزینهی حافظهی GC در چرخهی N = heap زندهی چرخهی N-1 + heap جدید
💡 تعریفها:
| اصطلاح | معنا |
|---|---|
| heap زنده | حافظهای که چرخهی قبلی GC آن را زنده تشخیص داده |
| heap جدید | هر حافظهای که در چرخهی فعلی تخصیص داده شده و ممکن است تا پایان چرخه زنده بماند یا نماند |
اینکه در هر لحظه چقدر حافظه زنده است، ویژگی خود برنامه است و GC نمیتواند مستقیماً آن را کنترل کند.
۳. هزینهی CPU شامل یک بخش ثابت در هر چرخه و یک بخش حاشیهای (marginal) است که با اندازهی heap زنده بزرگ میشود.
زمان CPU مصرفی GC در چرخهی N = هزینهی ثابت CPU در هر چرخه + میانگین هزینهی CPU به ازای هر بایت × حافظهی زندهی یافتشده در چرخهی N
- هزینهی ثابت: کارهایی که در هر چرخه به تعداد ثابت انجام میشوند، مثل مقداردهی اولیهی ساختارهای دادهی چرخهی بعد. معمولاً کوچک است و فقط برای کامل بودن مدل آمده.
- هزینهی حاشیهای: بیشترین هزینهی CPU، صرف mark و اسکن میشود. میانگین این هزینه هم به پیادهسازی GC بستگی دارد و هم به رفتار برنامه.
💡 چه چیزی هزینه را بالا میبرد؟
- تعداد pointerها: هرچه بیشتر باشد، کار GC بیشتر است، چون دستکم باید همهی آنها را ببیند.
- ساختارهایی مثل linked list و tree: پیمایش موازی آنها سختتر است و میانگین هزینه به ازای هر بایت را بالا میبرد.
⚠️ این مدل، هزینهی sweep را نادیده میگیرد. هزینهی sweep متناسب با کل heap است (حتی حافظهی مرده)، اما در پیادهسازی فعلی Go آنقدر سریعتر از mark و اسکن است که در مقایسه ناچیز میشود.
نتیجهی مدل
این مدل ساده است اما هزینههای غالب GC را درست دستهبندی میکند. از آن میفهمیم:
- کل هزینهی CPU زبالهروب به تعداد چرخههای GC در یک بازهی زمانی بستگی دارد.
- در دل این مدل یک موازنهی بنیادین میان زمان و فضا (time/space trade-off) وجود دارد.
حالت پایدار (steady state)
برای دیدن این موازنه، یک سناریوی محدود اما مفید را بررسی میکنیم. از دید GC، حالت پایدار یعنی:
- نرخ تخصیص ثابت است. برنامه با سرعت ثابت (بایت بر ثانیه) حافظه تخصیص میدهد. مثلاً در یک وبسرویس: نرخ درخواست ثابت، درخواستهایی از یک نوع، و میانگین طول عمر ثابت برای هر درخواست.
- هزینههای حاشیهای GC ثابت است. آمار گراف اشیاء، مثل توزیع اندازهی اشیاء، تعداد pointerها و میانگین عمق ساختارهای داده، از چرخهای به چرخهی دیگر یکسان میماند.
مثال عددی
فرضها (همهی عددها فرضیاند):
- برنامه با نرخ 10 MiB/s حافظه تخصیص میدهد.
- GC میتواند با سرعت 100 MiB به ازای هر ثانیهی CPU اسکن کند.
- heap زنده همیشه 10 MiB است (برای سادگی).
- هزینههای ثابت GC صفر است.
حالا فرکانس GC را تغییر میدهیم:
| وضعیت | کل heap در هر چرخه | زمان اسکن در هر چرخه | سربار CPU |
|---|---|---|---|
| GC هر 1 ثانیهی CPU | 20 MiB (10 زنده + 10 جدید) | 0.1 ثانیه | 10% |
| GC هر 2 ثانیهی CPU | 30 MiB (10 زنده + 20 جدید) | 0.1 ثانیه | 5% |
یعنی با اجرای کمتکرارتر GC، سربار CPU از 10% به 5% کاهش یافته، اما 50% حافظهی بیشتر مصرف شده است.
💡 نکته: GC فقط باید heap زنده را پیمایش کند، نه کل heap را. ثابت بودن heap زنده هم به این معنا نیست که همهی حافظهی تازه مرده است. یعنی پس از اجرای GC، ترکیبی از حافظهی قدیمی و جدید میمیرد و نتیجهی نهایی در هر چرخه 10 MiB زنده است.
جمعبندی
این همان موازنهی بنیادین زمان/فضا است، و فرکانس GC در مرکز آن قرار دارد:
- GC بیشتر اجرا شود ← حافظهی کمتر، CPU بیشتر
- GC کمتر اجرا شود ← حافظهی بیشتر، CPU کمتر
در Go، زمان شروع GC مهمترین پارامتری است که کاربر روی آن کنترل دارد.
GOGC
در سطح کلی، GOGC موازنهی میان CPU مصرفی GC و حافظه را تعیین میکند.
GOGC چطور کار میکند؟
پس از هر چرخهی GC، یک «اندازهی heap هدف» (target heap size) برای چرخهی بعد تعیین میشود. هدف GC این است که چرخهی جمعآوری را پیش از رسیدن اندازهی کل heap به این هدف تمام کند.
- اندازهی کل heap: heap زنده در پایان چرخهی قبل + حافظهی heap جدیدی که از آن زمان تخصیص داده شده.
- heap هدف: طبق فرمول زیر محاسبه میشود.
حافظهی heap هدف = heap زنده + (heap زنده + GC roots) * GOGC / 100
مثال
برنامهای با این مشخصات را در نظر بگیرید:
- heap زنده: 8 MiB
- stackهای goroutine: 1 MiB
- pointerهای متغیرهای سراسری (global): 1 MiB
یعنی کار GC معادل 10 MiB است. حافظهی جدیدی که پیش از GC بعدی تخصیص داده میشود چنین است:
| مقدار GOGC | حافظهی جدید پیش از GC بعدی | ردپای کل heap |
|---|---|---|
| 50 | 5 MiB (50%) | 13 MiB |
| 100 | 10 MiB (100%) | 18 MiB |
| 200 | 20 MiB (200%) | 28 MiB |
💡 نکته (Go 1.18): GOGC فقط از Go 1.18 به بعد مجموعهی root را هم در نظر میگیرد. پیش از آن فقط heap زنده شمرده میشد. معمولاً stackهای goroutine کوچکاند، اما در برنامههایی با صدها هزار goroutine، GC تصمیمهای ضعیفی میگرفت.
قاعدهی سرانگشتی
heap هدف، فرکانس GC را کنترل میکند: هدف بزرگتر یعنی GC میتواند دیرتر فاز mark بعدی را شروع کند، و برعکس. فرمول دقیق برای تخمین مفید است، اما بهتر است GOGC را اینطور ببینید: پارامتری که یک نقطه را روی موازنهی CPU و حافظه انتخاب میکند.
⚠️ نکتهی کلیدی: دو برابر کردن GOGC، سربار حافظهی heap را دو برابر و هزینهی CPU زبالهروب را تقریباً نصف میکند، و برعکس. (توضیح کامل در پیوست مستند اصلی آمده است.)
💡 نکته: اندازهی heap هدف فقط یک «هدف» است و دلایل متعددی دارد که چرخهی GC دقیقاً همانجا تمام نشود. مثلاً یک تخصیص بزرگ میتواند بهتنهایی از هدف فراتر برود. دلایل دیگر در پیادهسازیهایی ظاهر میشوند که از مدل این راهنما فراتر میروند. جزئیات بیشتر در بخش «تأخیر» و منابع تکمیلی است.
تنظیم GOGC
GOGC را از دو راه میتوان تنظیم کرد:
- متغیر محیطی (environment variable) با نام GOGC که همهی برنامههای Go آن را میشناسند
- API با نام SetGCPercent در پکیج runtime/debug
برای خاموش کردن کامل GC (به شرط آنکه محدودیت حافظه اعمال نشود)، GOGC=off را تنظیم کنید یا SetGCPercent(-1) را فراخوانی کنید. از نظر مفهومی این معادل GOGC برابر بینهایت است، چون حافظهی جدید پیش از فعال شدن GC نامحدود میشود.
تصویرسازی تعاملی ۱: اثر GOGC
💡 در متن اصلی اینجا یک نمودار تعاملی با اسلایدر برای GOGC هست. در ادامه توضیح و نتیجهی آن آمده است.
فرضهای نمودار:
- کار غیر GC برنامه، 10 ثانیه زمان CPU میبرد.
- در ثانیهی اول یک مرحلهی مقداردهی اولیه انجام میشود (heap زنده رشد میکند) و بعد برنامه وارد حالت پایدار میشود.
- در مجموع 200 MiB حافظه تخصیص داده میشود و در هر لحظه 20 MiB آن زنده است.
- تنها کار مرتبط با GC از heap زنده میآید و (غیرواقعبینانه) برنامه حافظهی دیگری مصرف نمیکند.
- برنامه در زمان اجرای GC متوقف است، پس هزینهی CPU زبالهروب برابر زمانی است که heap جدید طی میکند تا به صفر برسد. این زمان، مجموع فاز mark چرخهی N و فاز sweep چرخهی N+1 است. (این فرض فقط برای سادهتر شدن نمودار است و شهود همچنان صادق است.)
نتیجهی نمونه با GOGC برابر 100:
| معیار | مقدار |
|---|---|
| مدت کل | 10.68 ثانیه |
| سربار CPU مربوط به GC | 6.4% |
| اوج حافظه | 40.0 MiB |
| اوج حافظهی زنده | 20.0 MiB |
برداشت از نمودار:
- GC همیشه مقداری سربار CPU و اوج حافظه ایجاد میکند.
- با افزایش GOGC، سربار CPU کم میشود اما اوج حافظه متناسب با heap زنده بالا میرود.
- با کاهش GOGC، اوج حافظه کم میشود به قیمت سربار CPU بیشتر.
- زمان CPU اضافهای که GC مصرف میکند، مدت کل برنامه را بیشتر میکند.
💡 نکته: نمودار زمان CPU را نشان میدهد، نه زمان واقعی (wall-clock). اگر برنامه روی یک CPU اجرا شود و کاملاً از آن استفاده کند، این دو برابرند. اما برنامهی واقعی معمولاً روی سیستم چندهستهای اجرا میشود و همیشه 100% از CPUها را مصرف نمیکند؛ پس تأثیر GC بر زمان واقعی کمتر خواهد بود.
💡 نکته: GC در Go حداقل اندازهی کل heap برابر 4 MiB دارد. اگر هدفِ حاصل از GOGC کمتر از این باشد، به بالا گرد میشود. نمودار هم این جزئیات را در نظر میگیرد.
تصویرسازی تعاملی ۲: بار کاری پویاتر
در این مثال واقعیتر، باز هم برنامه بدون GC به 10 ثانیهی CPU نیاز دارد، اما:
- نرخ تخصیص در حالت پایدار، در میانهی اجرا بهشدت بالا میرود.
- اندازهی heap زنده در فاز اول کمی تغییر میکند.
این مثال نشان میدهد وقتی heap زنده واقعاً در حال تغییر است حالت پایدار چه شکلی دارد، و نرخ تخصیص بالاتر چگونه به چرخههای GC پرتکرارتر میانجامد.
نتیجهی نمونه با GOGC برابر 100:
| معیار | مقدار |
|---|---|
| مدت کل | 13.39 ثانیه |
| سربار CPU مربوط به GC | 25.3% |
| اوج حافظه | 40.0 MiB |
| اوج حافظهی زنده | 20.0 MiB |
محدودیت حافظه (Memory limit)
مشکل GOGC بهتنهایی
تا Go 1.19، GOGC تنها پارامتر تنظیم رفتار GC بود. GOGC برای تعیین یک موازنه عالی است، اما این را در نظر نمیگیرد که حافظهی در دسترس محدود است.
فرض کنید heap زنده یک جهش گذرا دارد. چون GC اندازهی کل heap را متناسب با heap زنده انتخاب میکند، باید GOGC را بر اساس اوج heap زنده تنظیم کنید، حتی اگر در حالت عادی مقدار بالاتر موازنهی بهتری میداد.
تصویرسازی ۳ (جهش گذرای heap) با GOGC برابر 100:
| معیار | مقدار |
|---|---|
| مدت کل | 10.67 ثانیه |
| سربار CPU مربوط به GC | 6.3% |
| اوج حافظه | 60.0 MiB |
| اوج حافظهی زنده | 30.0 MiB |
اگر این برنامه در container با کمی بیش از 60 MiB حافظه اجرا شود:
- نمیتوان GOGC را بالاتر از 100 برد، با اینکه بقیهی چرخهها حافظهی کافی برای استفادهی بیشتر دارند.
- این اوجهای گذرا ممکن است نادر و پیشبینیناپذیر باشند و به out-of-memory گاهبهگاه و پرهزینه منجر شوند.
راهحل: GOMEMLIMIT
به همین دلیل Go 1.19 از «محدودیت حافظهی زمان اجرا» (runtime memory limit) پشتیبانی کرد. دو راه برای تنظیم آن هست:
- متغیر محیطی GOMEMLIMIT که همهی برنامههای Go آن را میشناسند
- تابع SetMemoryLimit در پکیج runtime/debug
این محدودیت، سقفی برای کل حافظهای است که runtime در Go میتواند استفاده کند. حافظهی شمردهشده، بر حسب runtime.MemStats با این عبارت تعریف میشود:
Sys - HeapReleased
یا بر حسب پکیج runtime/metrics:
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
چون GC کنترل صریحی روی حافظهی heap دارد، اندازهی کل heap را بر اساس این محدودیت و میزان حافظهای که سایر بخشهای runtime مصرف میکنند تعیین میکند.
تصویرسازی تعاملی ۴: اثر محدودیت حافظه
این نمودار همان بار کاری حالت پایدار بخش GOGC را نشان میدهد، با 10 MiB سربار اضافی از runtime و یک محدودیت حافظهی قابل تنظیم.
نتیجهی نمونه با GOGC برابر 100 و محدودیت 100.0 MiB:
| معیار | مقدار |
|---|---|
| مدت کل | 10.68 ثانیه |
| سربار CPU مربوط به GC | 6.4% |
| اوج حافظه | 50.0 MiB |
| اوج حافظهی زنده | 20.0 MiB |
| حافظهی سایر بخشها | 10.0 MiB |
وقتی محدودیت حافظه را پایینتر از اوج حافظهی تعیینشده توسط GOGC بیاورید (برای GOGC برابر 100 مقدار 42 MiB)، GC بیشتر اجرا میشود تا اوج حافظه در محدوده بماند.
بهترین هر دو دنیا
در مثال جهش گذرا، با تعیین محدودیت حافظه و بالا بردن GOGC میتوان هم محدودیت را نقض نکرد و هم بهرهوری منابع را بالا برد.
نکتههای مهم این تنظیم:
- با بعضی مقادیر، اوج مصرف حافظه دقیقاً روی همان محدودیت میایستد.
- بقیهی اجرا همچنان از قاعدهی اندازهی heap تعیینشده با GOGC پیروی میکند.
- حتی اگر GOGC روی off باشد، محدودیت حافظه رعایت میشود. این پیکربندی بیشترین بهرهوری منابع را دارد، چون کمترین فرکانس GC لازم برای حفظ محدودیت را میدهد. در این حالت heap در تمام اجرا بالا میرود تا به محدودیت برسد.
هزینهی محدودیت حافظه: thrashing
محدودیت حافظه ابزار قدرتمندی است، اما بدون هزینه نیست و اهمیت GOGC را هم از بین نمیبرد.
وضعیتی را تصور کنید که heap زنده آنقدر بزرگ شده که کل مصرف حافظه به محدودیت نزدیک است. اگر در تصویرسازی بالا GOGC را خاموش کنید و محدودیت را کمکم پایین بیاورید، میبینید که زمان اجرای برنامه بدون سقف رشد میکند، چون GC دائماً برای حفظ یک محدودیت غیرممکن در حال اجرا است.
⚠️ thrashing: وضعیتی که برنامه به دلیل چرخههای پیوستهی GC پیشرفت معقولی ندارد، thrashing نام دارد. این وضعیت خطرناک است چون عملاً برنامه را متوقف میکند. بدتر اینکه دقیقاً در همان شرایطی هم میتواند رخ بدهد که میخواستیم از آن دور بمانیم: یک جهش گذرای بهاندازهی کافی بزرگ میتواند برنامه را نامحدود متوقف کند. (در تصویرسازی جهش گذرا، محدودیت را حدود 30 MiB یا کمتر بگذارید و ببینید بدترین رفتار دقیقاً از لحظهی جهش شروع میشود.)
در بسیاری از موارد توقف نامحدود بدتر از out-of-memory است، چون out-of-memory معمولاً به شکست بسیار سریعتری میانجامد.
چرا محدودیت «نرم» است؟
به همین دلیل، محدودیت حافظه «نرم» (soft) تعریف شده است. runtime تضمین نمیکند که این محدودیت را در همهی شرایط حفظ کند و فقط قول تلاش معقول میدهد. این سهلگیری برای جلوگیری از thrashing حیاتی است، چون راه فراری به GC میدهد: بگذار مصرف حافظه از محدودیت بگذرد تا زمان زیادی صرف GC نشود.
نحوهی کار داخلی:
- GC سقفی برای زمان CPU قابل مصرف در یک بازهی زمانی تعیین میکند (با کمی hysteresis برای جهشهای گذرای بسیار کوتاه).
- این سقف فعلاً تقریباً 50% است، با بازهی 2 * GOMAXPROCS ثانیهی CPU.
- نتیجه: کار GC به تأخیر میافتد و برنامه میتواند حتی بیش از محدودیت حافظه تخصیص بدهد.
💡 شهود سقف 50%: بدترین تأثیر روی برنامهای با حافظهی کافی. اگر محدودیت حافظه بهاشتباه خیلی پایین تنظیم شده باشد، برنامه حداکثر 2 برابر کندتر میشود، چون GC نمیتواند بیش از 50% زمان CPU را بگیرد.
💡 نکته: تصویرسازیهای این صفحه، سقف CPU مصرفی GC را شبیهسازی نمیکنند.
پیشنهادهایی برای استفاده
محدودیت حافظه ابزاری قدرتمند است و runtime هم برای کاهش بدترین رفتارهای ناشی از سوءاستفاده از آن تمهیداتی دارد، اما باید با دقت از آن استفاده کرد. این بخش میگوید کجا بیشترین کاربرد را دارد و کجا ممکن است ضرر بزند.
کجا استفاده کنید ✅
✅ وقتی محیط اجرا کاملاً در کنترل شماست و برنامهی Go تنها برنامهای است که به مجموعهای از منابع دسترسی دارد (مثلاً محدودیت حافظهی container).
- مثال خوب: استقرار یک وبسرویس در container با حافظهی ثابت.
- قاعدهی سرانگشتی: 5 تا 10 درصد فضای اضافی (headroom) بگذارید تا منابع حافظهای را که runtime از آنها بیخبر است پوشش دهد.
✅ وقتی میخواهید محدودیت را بلادرنگ با شرایط تغییرکننده تطبیق دهید.
- مثال خوب: برنامهی cgo که کتابخانههای C موقتاً به حافظهی بسیار بیشتری نیاز پیدا میکنند.
کجا استفاده نکنید ⚠️
⚠️ GOGC را همراه با محدودیت حافظه روی off نگذارید، اگر ممکن است برنامهی Go بخشی از حافظهی محدودش را با برنامههای دیگری به اشتراک بگذارد و آن برنامهها عموماً از برنامهی Go جدا باشند. در عوض محدودیت حافظه را نگه دارید (برای مهار رفتارهای گذرای نامطلوب) و GOGC را روی یک مقدار کوچکتر و معقول برای حالت میانگین بگذارید.
- وسوسهی «رزرو کردن» حافظه برای برنامههای هممستأجر (co-tenant) نتیجهی قابل اتکایی نمیدهد، مگر برنامهها کاملاً همگام باشند (مثلاً برنامهی Go یک subprocess را صدا بزند و تا پایانش مسدود بماند). ناگزیر هر دو برنامه به حافظهی بیشتری نیاز پیدا میکنند.
- اگر برنامهی Go وقتی نیاز ندارد کمتر حافظه مصرف کند، در مجموع نتیجه قابل اتکاتر است.
- این توصیه برای شرایط overcommit هم صادق است، یعنی وقتی مجموع محدودیت حافظهی containerهای یک ماشین از حافظهی فیزیکی ماشین بیشتر است.
⚠️ در محیطی که کنترلی روی آن ندارید از محدودیت حافظه استفاده نکنید، بهویژه وقتی مصرف حافظهی برنامهتان متناسب با ورودیهاست.
- مثال: ابزار CLI یا برنامهی دسکتاپ. وقتی معلوم نیست چه ورودیای داده میشود یا چقدر حافظه موجود است، یک محدودیت حافظهی ثابت میتواند به crashهای گیجکننده و عملکرد ضعیف منجر شود.
- کاربر نهایی پیشرفته هم همیشه میتواند خودش محدودیت حافظه تعیین کند.
⚠️ وقتی برنامه از قبل به محدودیت حافظهی محیط نزدیک است، برای پرهیز از out-of-memory محدودیت تعیین نکنید.
این کار خطر out-of-memory را با خطر کندی شدید برنامه عوض میکند، که اغلب معاملهی خوبی نیست، حتی با تلاشهای Go برای کاهش thrashing. بهتر است یکی از این دو کار را بکنید:
- محدودیتهای حافظهی محیط را افزایش دهید (و بعد در صورت نیاز محدودیت حافظه بگذارید)
- GOGC را کاهش دهید (موازنهی بسیار تمیزتری نسبت به سازوکار مهار thrashing میدهد)
تأخیر (Latency)
تصویرسازیهای این مستند، برنامه را هنگام اجرای GC متوقف فرض کردهاند. GCهایی که اینگونه رفتار میکنند «stop-the-world» نام دارند.
GC در Go چه فرقی دارد؟
GC در Go کاملاً stop-the-world نیست و بیشتر کارش را همزمان (concurrent) با برنامه انجام میدهد. هدف اصلی، کاهش تأخیر است، یعنی مدت سرتاسری یک واحد محاسبه (مثلاً یک درخواست وب).
| معیار | تعریف | مثال |
|---|---|---|
| توان عملیاتی (throughput) | چقدر کار در واحد زمان انجام میشود | تعداد درخواست در ثانیه |
| تأخیر (latency) | یک واحد کار چقدر طول میکشد | مدت پاسخ به یک درخواست |
تا اینجا بیشتر به throughput پرداختیم. اما برای یک وبسرویس، اغلب تأخیر هر درخواست اهمیت بیشتری دارد.
در یک GC از نوع stop-the-world، فازهای mark و sweep ممکن است زمان قابلتوجهی بگیرند و در این مدت برنامه، و در وبسرویس هر درخواست در حال پردازش، نمیتواند پیشرفت کند. اما GC در Go:
- طول توقفهای سراسری را متناسب با اندازهی heap نمیکند.
- الگوریتم اصلی ردیابی را در حین اجرای فعال برنامه انجام میدهد.
- از نظر الگوریتمی، توقفها بیشتر متناسب با GOMAXPROCS هستند، اما معمولاً زمان متوقف کردن goroutineهای در حال اجرا بر آن غالب است.
💡 نکته: جمعآوری همزمان بیهزینه نیست و در عمل اغلب throughput کمتری از یک GC معادل stop-the-world دارد. اما تأخیر کمتر ذاتاً به معنای throughput کمتر نیست و عملکرد GC در Go در طول زمان هم از نظر تأخیر و هم throughput بهتر شده است.
رابطهی فرکانس GC با تأخیر
همزمان بودن GC، هیچیک از مطالب قبلی را باطل نمیکند، چون هیچکدام به این انتخاب طراحی وابسته نبودند. فرکانس GC همچنان راه اصلی موازنهی میان زمان CPU و حافظه است و برای تأخیر هم همین نقش را دارد، چون بیشتر هزینههای GC در فاز mark ایجاد میشود.
⚠️ نتیجهی کلیدی: کاهش فرکانس GC ممکن است تأخیر را هم بهتر کند. این هم برای تغییر پارامترها (مثل افزایش GOGC یا محدودیت حافظه) صدق میکند و هم برای بهینهسازیهای راهنمای بهینهسازی.
اما درک تأخیر پیچیدهتر از throughput است، چون حاصل اجرای لحظهبهلحظهی برنامه است و نه فقط جمع هزینهها. پس ارتباطش با فرکانس GC کمتر مستقیم است.
منابع احتمالی تأخیر
- توقفهای کوتاه stop-the-world هنگام گذار GC بین فازهای mark و sweep
- تأخیرهای زمانبندی (scheduling)، چون GC در فاز mark، 25% از CPU را میگیرد
- کمک goroutineهای کاربر به GC در پاسخ به نرخ تخصیص بالا
- کار اضافه برای نوشتن pointer در زمان فعال بودن فاز mark
- لزوم تعلیق goroutineهای در حال اجرا برای اسکن rootهای آنها
💡 همهی این منابع در execution traceها دیده میشوند، بهجز کار اضافهی نوشتن pointer.
فایلنالایزرها، کلینآپها و اشارهگرهای ضعیف
توهم حافظهی نامحدود
زبالهروب با حافظهی محدود، توهم حافظهی نامحدود میسازد. حافظه تخصیص داده میشود اما هرگز صریحاً آزاد نمیشود. این باعث میشود APIها و الگوریتمهای همزمان سادهتر از مدیریت دستی حافظه باشند. (بعضی زبانهای دستی از رویکردهایی مثل «اشارهگرهای هوشمند» (smart pointers) و ردیابی مالکیت در زمان کامپایل استفاده میکنند، اما اینها عمیقاً در قراردادهای طراحی API آن زبانها نفوذ کردهاند.)
فقط اشیاء زنده، یعنی آنهایی که از یک متغیر سراسری یا محاسبهای در یک goroutine قابل دسترسیاند، بر رفتار برنامه اثر دارند. شیء پس از غیرقابلدسترس («مرده») شدن، هر زمان که GC بخواهد میتواند بازیافت شود. این به طراحیهای متنوعی برای GC اجازه میدهد، مثل طراحی tracing که امروز Go استفاده میکند. مرگ شیء در سطح زبان رویداد قابل مشاهدهای نیست.
سه ویژگی که این توهم را میشکنند
کتابخانهی runtime در Go سه ویژگی دارد که این توهم را میشکنند:
| ویژگی | کاربرد |
|---|---|
| cleanup | واکنش به مرگ شیء |
| weak pointer | اشارهگر به شیء بدون زنده نگه داشتن آن |
| finalizer | واکنش به مرگ شیء، و حتی برگرداندن آن (زندهسازی) |
این ویژگیها برنامههای Go را پیچیدهتر میکنند و بار اضافه روی GC میگذارند. با این حال چون در شرایط مختلف مفیدند وجود دارند و برنامههای Go همیشه از آنها بهره میبرند.
برای جزئیات هر ویژگی، مستندات پکیج آن را ببینید: runtime.AddCleanup، weak.Pointer و runtime.SetFinalizer. در ادامه توصیههای کلی، مشکلات رایج هر ویژگی و راهنمای تست آمده است.
توصیههای کلی
۱. تست واحد (unit test) بنویسید.
زمانبندی دقیق cleanupها، weak pointerها و finalizerها سخت پیشبینی میشود و بهراحتی میتوان خود را قانع کرد همهچیز درست است، حتی پس از اجراهای متوالی. اشتباهات ظریف هم آسان رخ میدهند. تست نوشتن برای آنها سخت است، اما چون استفاده از آنها اینقدر ظریف است، تست از همیشه مهمتر است.
۲. در کد معمول Go مستقیماً از این ویژگیها استفاده نکنید.
اینها ویژگیهای سطح پایین با محدودیتها و رفتارهای ظریفاند. مثلاً تضمینی نیست cleanupها یا finalizerها هنگام خروج برنامه اجرا شوند، یا اصلاً اجرا شوند. کامنتهای طولانی در مستندات API آنها را بهعنوان هشدار ببینید. اکثر کدهای Go بهرهی مستقیمی از آنها نمیبرند، فقط غیرمستقیم.
۳. استفاده از این سازوکارها را درون یک package محصور کنید.
در صورت امکان نگذارید این سازوکارها به API عمومی package نشت کنند؛ interfaceهایی بدهید که سوءاستفاده از آنها برای کاربران سخت یا غیرممکن باشد. مثلاً بهجای اینکه از کاربر بخواهید برای حافظهی تخصیصیافته توسط C یک cleanup تنظیم کند، یک package پوششی (wrapper) بنویسید و این جزئیات را در آن پنهان کنید.
۴. دسترسی به اشیائی که finalizer، cleanup و weak pointer دارند را به package سازندهشان محدود کنید.
این نکته با مورد قبل مرتبط است، اما الگوی بسیار قدرتمندی برای کاهش خطا است. مثلاً پکیج unique در پشت صحنه از weak pointerها استفاده میکند، اما اشیائی را که بهصورت ضعیف به آنها اشاره میشود کاملاً محصور میکند. بقیهی برنامه هرگز نمیتواند این مقادیر را تغییر دهد و فقط از طریق متد Value آنها را کپی میکند. به این ترتیب توهم حافظهی نامحدود برای کاربران package حفظ میشود.
۵. منابع غیرحافظهای را در صورت امکان بهصورت قطعی (deterministic) پاکسازی کنید؛ finalizer و cleanup فقط پشتیبان باشند.
cleanupها و finalizerها برای منابع حافظهای مناسباند، مثل حافظهی تخصیصیافته از بیرون (مثلاً C) یا ارجاع به یک نگاشت mmap. حافظهای که با malloc در C تخصیص داده شده باید با free در C آزاد شود. یک finalizer که free را صدا میزند و به یک شیء پوششی برای حافظهی C وصل شده، راه معقولی برای بازپسگیری نهایی آن است.
اما منابع غیرحافظهای مثل file descriptorها معمولاً تابع محدودیتهای سیستمیاند که runtime از آنها خبر ندارد. علاوه بر این، نویسندهی package معمولاً کنترل کمی روی زمانبندی GC دارد (مثلاً GOGC را اپراتورها میتوانند به مقادیر مختلف تنظیم کنند). این دو واقعیت، cleanup و finalizer را برای تنها سازوکار آزادسازی منابع غیرحافظهای نامناسب میکنند.
اگر نویسندهی packageای هستید که یک منبع غیرحافظهای را میپوشاند:
- یک API صریح برای آزادسازی قطعی بدهید (مثلاً متد Close)، بهجای تکیه بر GC.
- cleanup و finalizer را بهعنوان مدیریتکنندهی best-effort برای اشتباه برنامهنویس به کار ببرید: یا همان منبع را پاک کنید (مثل os.File)، یا شکست در پاکسازی قطعی را به کاربر گزارش کنید.
۶. cleanup را به finalizer ترجیح دهید.
از نظر تاریخی، finalizerها برای سادهسازی رابط Go و C و پاکسازی منابع غیرحافظهای اضافه شدند. قرار بود روی اشیاء پوششی اعمال شوند که مالک حافظهی C یا منبع غیرحافظهایاند، تا پس از پایان استفادهی کد Go، منبع آزاد شود. این دلایل تا حدی توضیح میدهند چرا finalizerها دامنهی محدودی دارند:
- هر شیء فقط یک finalizer میتواند داشته باشد.
- finalizer باید فقط به اولین بایت شیء وصل شود.
این محدودیت بعضی کاربردها را تنگ کرده است. مثلاً packageای که اطلاعاتی دربارهی شیئی که به آن داده شده cache میکند، نمیتواند پس از رفتن شیء آن اطلاعات را پاک کند.
مشکل بدتر این است که finalizerها شیء را «زندهسازی» (resurrect) میکنند تا به تابع finalizer پاس داده شود (و حتی ممکن است بعد از آن هم زنده بماند). پیامدها:
- اگر شیء در یک reference cycle باشد، هرگز آزاد نمیشود.
- حافظهی شیء دستکم تا چرخهی بعدی GC قابل استفادهی مجدد نیست.
- در نتیجه finalizerها ناکارآمد و مستعد خطا هستند.
با این حال، چون finalizerها اشیاء را زنده میکنند، ترتیب اجرای بهتری نسبت به cleanupها دارند. به همین دلیل هنوز بالقوه (اما بهندرت) برای پاکسازی ساختارهایی با نیازمندیهای پیچیدهی ترتیب تخریب مفیدند.
💡 توصیه: برای تمام کاربردهای دیگر در Go 1.24 و بعد از آن، cleanup را انتخاب کنید؛ انعطافپذیرتر، کمخطاتر و کارآمدتر از finalizer است.
مشکلات رایج Cleanup
⚠️ ۱. شیء نباید از تابع cleanup قابل دسترسی باشد (مثلاً از طریق یک متغیر محلی capture شده). در این حالت شیء بازیابی نمیشود و cleanup هرگز اجرا نمیشود.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
syscall.Close(f.fd) // Mistake: We reference f, so this cleanup won't run!
}, f.fd)
⚠️ ۲. شیء نباید از آرگومان تابع cleanup هم قابل دسترسی باشد. نتیجه همان است: شیء بازیابی نمیشود و cleanup هرگز اجرا نمیشود.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
syscall.Close(f.fd)
}, f) // Mistake: We reference f, so this cleanup wouldn't ever run. This specific case also panics.
⚠️ ۳. ترتیب اجرا: ترتیب اجرای finalizerها کاملاً تعریفشده است، اما ترتیب cleanupها نه. cleanupها همچنین میتوانند همزمان با هم اجرا شوند.
⚠️ ۴. cleanupهای طولانیمدت باید یک goroutine بسازند تا مانع اجرای سایر cleanupها نشوند.
⚠️ ۵. تابع runtime.GC منتظر اجرای cleanupهای اشیاء غیرقابلدسترس نمیماند، فقط منتظر قرار گرفتن همهی آنها در صف میماند.
مشکلات رایج Weak Pointer
⚠️ ۱. nil شدن غیرمنتظره: weak pointerها ممکن است در زمانهای غیرمنتظره از متد Value مقدار nil برگردانند. همیشه فراخوانی Value را با بررسی nil محافظت کنید و یک برنامهی پشتیبان داشته باشید.
⚠️ ۲. کلید map: وقتی weak pointer کلید map است، روی دسترسپذیری مقدارهای map اثری ندارد. بنابراین اگر کلید weak pointer به شیئی اشاره کند که از مقدار map هم قابل دسترسی است، آن شیء همچنان قابلدسترس حساب میشود.
مشکلات رایج Finalizer
⚠️ ۱. شیء نباید از خودش قابل دسترسی باشد (به بیان دیگر، در reference cycle نباشد). در این حالت شیء بازیابی نمیشود و finalizer هرگز اجرا نمیشود.
f := new(myCycle)
f.self = f // Mistake: f is reachable from f, so this finalizer would never run.
runtime.SetFinalizer(f, func(f *myCycle) {
...
})
⚠️ ۲. شیء نباید از تابع finalizer قابل دسترسی باشد (مثلاً از طریق متغیر محلی capture شده). نتیجه همان است.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
syscall.Close(f.fd) // Mistake: We reference the outer f, so this cleanup won't run!
})
⚠️ ۳. زنجیرههای ارجاع: پاکسازی کامل زنجیرهای از اشیاء دارای finalizer (مثلاً در یک linked list) دستکم بهاندازهی تعداد اشیاء زنجیره، چرخهی GC لازم دارد. finalizerها را کمعمق نگه دارید!
// Mistake: reclaiming this linked list will take at least 10 GC cycles.
node := new(linkedListNode)
for range 10 {
tmp := new(linkedListNode)
tmp.next = node
node = tmp
runtime.SetFinalizer(node, func(node *linkedListNode) {
...
})
}
⚠️ ۴. مرز package: روی اشیائی که از مرز package برگردانده میشوند finalizer نگذارید. در غیر این صورت کاربران package میتوانند با runtime.SetFinalizer آن را تغییر دهند، که رفتار غیرمنتظرهای است و ممکن است کاربران به آن وابسته شوند.
⚠️ ۵. finalizerهای طولانیمدت باید یک goroutine جدید بسازند تا مانع اجرای سایر finalizerها نشوند.
⚠️ ۶. تابع runtime.GC منتظر اجرای finalizerهای اشیاء غیرقابلدسترس نمیماند، فقط منتظر قرار گرفتن همهی آنها در صف میماند.
تست مرگ شیء
تست کدی که از این ویژگیها استفاده میکند گاهی سخت است. نکتههایی برای نوشتن تستهای مقاوم:
- موازی نباشند: این تستها را همراه با تستهای دیگر موازی اجرا نکنید. هرچه قطعیت (determinism) بیشتر باشد و کنترل بهتری روی وضعیت جهان در هر لحظه داشته باشید، بهتر است.
- از runtime.GC استفاده کنید: برای ایجاد یک خط پایه (baseline) هنگام ورود به تست، برای وادار کردن weak pointerها به nil شدن، و برای قرار دادن cleanupها و finalizerها در صف اجرا.
⚠️ تابع runtime.GC منتظر اجرای cleanupها و finalizerها نمیماند و فقط آنها را در صف میگذارد.
- روی اجرا مسدود شوید: برای مقاومترین تست، راهی برای مسدود شدن روی cleanup یا finalizer تزریق کنید. مثلاً یک channel اختیاری از تست به cleanup یا finalizer بدهید و پس از پایان اجرا روی آن بنویسید.
- جایگزین: اگر این کار سخت یا غیرممکن است، روی برقرار شدن یک وضعیت مشخص پس از cleanup حلقهی انتظار (spin) بزنید. مثلاً تستهای os در حلقهای runtime.Gosched را صدا میزنند و بررسی میکنند که آیا فایل پس از غیرقابلدسترس شدن بسته شده است.
- زنجیرهی finalizer: اگر زنجیرهای از اشیاء با finalizer دارید، برای اطمینان از اجرای همهی finalizerها دستکم بهاندازهی طول عمیقترین زنجیرهای که تست میسازد، runtime.GC صدا بزنید.
- حالت race: در حالت race تست کنید تا raceهای بین cleanupهای همزمان، و بین کد cleanup و finalizer و بقیهی codebase کشف شوند.
منابع تکمیلی
اطلاعات بالا دقیق است، اما جزئیات کافی برای فهم کامل هزینهها و موازنهها در طراحی GC در Go ندارد. برای اطلاعات بیشتر:
- کتاب GC Handbook: منبع و مرجع عمومی عالی دربارهی طراحی زبالهروب.
- مستند TCMalloc: سند طراحی تخصیصدهندهی حافظهی TCMalloc برای C/C++ که تخصیصدهندهی حافظهی Go بر پایهی آن است.
- اعلان GC در Go 1.5: پست وبلاگی معرفی GC همزمان Go 1.5 با توضیح بیشتر الگوریتم.
- ارائهی Getting to Go: ارائهای عمیق دربارهی تکامل طراحی GC در Go تا سال 2018.
- مستند pacing در GC همزمان Go 1.5: سند طراحی تعیین زمان شروع فاز mark همزمان.
- مستند Smarter scavenging: بازنگری روش بازگرداندن حافظه از runtime به سیستمعامل.
- مستند Scalable page allocator: بازنگری روش مدیریت حافظهای که runtime از سیستمعامل میگیرد.
- بازطراحی pacer در GC (Go 1.18): بازنگری الگوریتم تعیین زمان شروع فاز mark همزمان.
- محدودیت نرم حافظه (Go 1.19): سند طراحی محدودیت نرم حافظه.
این محتوا کاملا رایگان توسط تیم کدلپر ترجمه شده و در اختیار شما کاربران عزیز قرار گرفته است، هر گونه کپی برداری برای مقاصد غیر رایگان و بدون ذکر منبع، مورد پیگیری قانونی قرار میگیرد.
ترجمه شده از منبع: منبع مستندات