هذا المقال يتطلّب مستوى: متوسط.
لو خدمتك بتتجمّد لجزء من الثانية كل شوية والـ CPU تقريبًا فاضي، السبب غالبًا مش كودك — ده جامع المهملات بيشتغل. في نهاية المقال هتعرف بالظبط إمتى بيشتغل، ليه بيوقف برنامجك، وإزاي تقلّل الوجع ده برقم.
الـ Garbage Collection: إزاي البرنامج بيفضّي الذاكرة لوحده
المشكلة باختصار
كل كائن بتعمله في برنامجك بياخد مساحة في الذاكرة. لو محدش فضّاها بعد ما تخلص منها، الذاكرة بتزيد لحد ما البرنامج يقع بـ Out Of Memory. في لغات زي C لازم تفضّيها بإيدك، وده مصدر أخطر الأخطاء. في بايثون وجافا وجافاسكريبت وGo، فيه حارس شغّال في الخلفية اسمه جامع المهملات (Garbage Collector) بيفضّيها بدالك. المشكلة إن الحارس ده نفسه بياخد وقت، وساعات بيوقف برنامجك كله عشان يكنس.
المفهوم بمثال أولًا
تخيّل فندق فيه غرف. كل نزيل يحجز غرفة (كائن في الذاكرة). لما النزيل يمشي، الغرفة تفضل مقفولة ومحجوزة على ورق حتى لو فاضية. لو محدش بيشيل الحجوزات القديمة، هتوصل لحظة تقول فيها "مفيش غرف" رغم إن نصهم فاضي.
جامع المهملات هو عامل الاستقبال اللي بيلف كل فترة، يبص مين لسه ماسك مفتاح غرفة فعلًا، وأي غرفة محدش ماسك مفتاحها يعتبرها فاضية ويرجّعها للمتاح. عشان يعدّ بدقّة، لازم يوقف حركة الفندق لحظة — وده بالظبط الـ "stop-the-world" اللي بتحسّه كتجمّد بسيط.
نفس الكلام علميًا
فيه طريقتين أساسيتين، وأغلب اللغات بتخلط بينهم:
- عدّ المراجع (Reference Counting): كل كائن معاه عدّاد بيزيد لما حاجة تشاور عليه، وينقص لما تسيبه. أول ما العدّاد يوصل صفر، الكائن يتفضّى فورًا. ده أساس بايثون (CPython).
- التتبّع (Tracing / Mark-and-Sweep): الجامع يبدأ من الجذور (المتغيّرات الحيّة)، يمشي على كل كائن يقدر يوصله ويعلّمه "حيّ"، وأي كائن مش متعلّم يكنسه. ده أساس جافا وGo وجافاسكريبت.
عدّ المراجع بسيط وفوري، لكن عنده ثغرة واحدة قاتلة: المرجع الدائري. كائنان يشاور كل واحد على التاني، فالعدّاد لأي منهم عمره ما يوصل صفر، حتى لو مفيش حد تاني ماسكهم. عشان كده بايثون بيضيف فوق عدّ المراجع جامعًا متتبّعًا يمسك الحلقات دي.
مثال تنفيذي: شوف التسريب بنفسك
الكود ده بيصنع مرجعًا دائريًا، بيوقف الجامع المتتبّع، وبيوريك إن الذاكرة مش بترجع إلا لما تشغّله يدويًا:
import gc
class Node:
def __init__(self):
self.ref = None
gc.disable() # نوقف الجامع المتتبّع عشان نشوف التسريب بوضوح
def make_cycle():
a = Node()
b = Node()
a.ref = b # a بيشاور على b
b.ref = a # b بيشاور على a -> حلقة
# a و b هيخرجوا من النطاق هنا، لكن عدّاد المراجع بتاعهم = 1 لكل واحد
for _ in range(100_000):
make_cycle()
print("كائنات لسه عايشة قبل الكنس:", len(gc.get_objects()))
freed = gc.collect() # نشغّل الجامع المتتبّع يدويًا
print("كائنات كنسها الجامع:", freed)
هتلاقي عشرات الآلاف من الكائنات فاضلة عايشة رغم إن محدش محتاجها، ولمّا تنادي gc.collect() بترجع كلها. الدرس: عدّ المراجع لوحده مش بيمسك الحلقات، والمتتبّع بيكمّل الشغل لكن بتكلفة توقّف.
فرضية الأجيال: ليه الجامع بقى سريع
الملاحظة اللي بنى عليها معظم الجامعات الحديثة اسمها "فرضية الأجيال": أغلب الكائنات بتموت بدري جدًا. عشان كده الذاكرة بتتقسم لأجيال — جيل صغير (young) بيتكنس كتير وبسرعة، وجيل كبير (old) بيتكنس نادرًا. بايثون عنده 3 أجيال، وجافا في G1 بتقسّم الـ heap لمناطق وتركّز على اللي فيها أكتر مهملات. النتيجة: الجامع بيفحص جزء صغير من الذاكرة في معظم الدورات بدل ما يمسح كل حاجة.
الـ trade-off: إنتاجية مقابل زمن استجابة
مفيش جامع مثالي، فيه بس اختيار. الافتراض هنا إن عندك خدمة حسّاسة لزمن الاستجابة (API مثلًا):
- بتكسب: أمان ذاكرة تلقائي، وصفر أخطاء use-after-free و double-free اللي بتاكل أسابيع تصحيح في C++.
- بتخسر: توقّفات غير متوقّعة. جامع Go بيستهدف توقّف أقل من ميلي ثانية (sub-millisecond) لكن بتكلفة CPU إضافية، بينما جامعات الإنتاجية العالية في جافا (زي Parallel GC) ممكن توقفك مئات المللي ثانية عشان تخلّص أسرع إجمالًا.
القاعدة العملية: لو بتقيس P99 latency وبتلاقي قفزات دورية متزامنة مع دورات الجامع، إنت في مقايضة زمن الاستجابة، والحل عادةً تقليل معدّل توليد القمامة (allocation rate) مش تغيير الجامع.
متى لا تشغّل بالك بده
لو بتكتب سكربت بيشتغل ثواني وبيقفل، أو خدمة صغيرة تحت مليون كائن، سيب الجامع الافتراضي وخلاص — تعديل إعداداته غالبًا هيأذيك أكتر ما ينفع. متعطّلش الجامع في الإنتاج زي ما عملنا في المثال إلا لو عندك سبب مقاس. الضبط اليدوي للـ GC يبدأ يبقى له معنى فقط لما تكون خدمة طويلة العمر، بتولّد كائنات بمعدّل عالي، وقِست إن التوقّفات بتكسر هدف زمن الاستجابة بتاعك.
الخطوة التالية
افتح خدمتك اللي بتشتغل طويل، وشغّل مراقبة لتوقّفات الجامع: في بايثون فعّل gc.callbacks أو استخدم gc.get_stats()، وفي جافا شغّل -Xlog:gc. قيس مدة أطول توقّف ومعدّل التوليد. لو التوقّف عدّى هدفك (مثلًا 50 مللي ثانية)، ابدأ بتقليل الكائنات المؤقتة قبل ما تلمس أي إعداد.
المصادر
- توثيق بايثون الرسمي لوحدة جمع المهملات: docs.python.org/3/library/gc.html
- شرح CPython لعدّ المراجع ودورة الجمع: devguide.python.org/internals/garbage-collector
- مدوّنة Go الرسمية عن جامع المهملات وأهداف التوقّف: go.dev/blog/ismmkeynote
- توثيق Oracle لجامع G1 في جافا: docs.oracle.com — G1 Garbage Collector