المستوى: متوسط — الكلام ده مبني على فرضية إنك بتكتب بايثون (CPython تحديداً) وعندك إلمام بالكلاسات والمتغيرات.
لو عندك خدمة بايثون شغّالة على مدار اليوم وبتاكل ذاكرة كل ساعة لحد ما توقع بـ OOM، المشكلة في الغالب مش في الكود اللي بتشوفه قدامك. المشكلة في كائنات بتشاور على بعض ومحدش بيحررها. هنا هتعرف بالظبط ليه ده بيحصل، تقيس التسريب بنفسك بالأرقام، وتصلحه في سطر واحد.
جامع المهملات في بايثون: ليه ذاكرتك بتتسرّب رغم الـ GC
المشكلة باختصار
في لغات زي C لازم تنادي free() بإيدك لكل حاجة حجزتها. بايثون بيوفرلك ده: بيحرر الذاكرة لوحده. بس "لوحده" دي ليها تفاصيل، ولما متفهمهاش بتدفع التمن ذاكرة بتتسرّب في الإنتاج بدون أي رسالة خطأ.
الآلية الأساسية: العدّ المرجعي (Reference Counting)
تخيّل مكتبة فيها كتاب، وجنب كل كتاب ورقة مكتوب فيها كام شخص ماسك الكتاب دلوقتي. كل ما حد ياخد نسخة الرقم بيزيد واحد، وكل ما حد يرجّعها بيقل واحد. أول ما يوصل صفر، أمين المكتبة بيرمي الكتاب فوراً. ده بالظبط اللي بايثون بيعمله مع كل كائن.
علمياً: كل كائن في CPython جواه عدّاد اسمه reference count. كل ما متغيّر يشاور عليه يزيد، وكل ما تعمل del أو المتغير يخرج من النطاق يقل. أول ما يوصل صفر، الذاكرة بتترجع في نفس اللحظة. شوف بنفسك:
import sys
x = []
print(sys.getrefcount(x)) # 2 (واحد لـ x، وواحد مؤقت لوسيط الدالة)
y = x
print(sys.getrefcount(x)) # 3
del y
print(sys.getrefcount(x)) # 2
الرقم بيطلع 2 مش 1 لأن استدعاء getrefcount نفسه بيعمل مرجع مؤقت. ركز على الفرق بين السطور. الخلاصة: العدّ المرجعي حتمي وفوري، وده ميزة كبيرة. لكن عنده ثغرة واحدة قاتلة.
المشكلة الحقيقية: الدورة المرجعية (Reference Cycle)
الطريقة دي بتفشل في حالة واحدة: لما كائنين يشاوروا على بعض. تخيّل صديقين كل واحد ماسك إيد التاني ومصمم ميسيبش غير لما التاني يسيب الأول. النتيجة؟ محدش بيسيب أبداً. العدّاد بتاع كل واحد بيفضل 1 للأبد، حتى لو مفيش حد تاني في البرنامج بيعرفهم.
ده بيحصل بشكل طبيعي: عقدة في شجرة بتمسك أبوها وأبوها بيمسكها، أو كائن فيه callback بيشاور على نفسه. العدّ المرجعي لوحده عمره ما هيحرر الدورة دي. عشان كده عند بايثون طبقة تانية: جامع الدورات (cyclic garbage collector) في موديول gc، اللي بيمشي من الجذور وبيرمي أي حاجة مش قادر يوصلها. خلينا نقيس:
import gc, tracemalloc
class Node:
def __init__(self):
self.partner = None
self.payload = bytearray(10_000) # 10 كيلوبايت لكل عقدة
def make_cycle():
a, b = Node(), Node()
a.partner = b
b.partner = a # دورة: a بيمسك b و b بيمسك a
gc.disable()
tracemalloc.start()
for _ in range(20_000):
make_cycle()
current, _ = tracemalloc.get_traced_memory()
print(f"قبل التجميع: {current/1024/1024:6.1f} MB")
gc.collect()
current, _ = tracemalloc.get_traced_memory()
print(f"بعد gc.collect(): {current/1024/1024:6.1f} MB")
المخرجات على CPython 3.12 بتقرب من:
قبل التجميع: 390.6 MB
بعد gc.collect(): 0.4 MB
ركز في الرقم. الـ 40 ألف عقدة خرجوا من النطاق بعد كل دورة، يعني المفروض يتحرروا فوراً. بس لأنهم متشابكين، العدّ المرجعي فضل شايفهم 1، فاحتجزوا حوالي 390 ميجا. أول ما شغّلنا gc.collect() رجعوا كلهم. ده شكل التسريب اللي بيوقّع خدمتك بـ OOM.
الـ trade-off: ليه فيه طبقتين؟
طب ما دام جامع الدورات بيمسك كل حاجة، ليه بايثون مش بيعتمد عليه لوحده؟ هنا الـ trade-off:
- العدّ المرجعي: بتكسب تحرير فوري وحتمي للذاكرة. بتخسر تكلفة بسيطة على كل إسناد، وعجز كامل عن الدورات.
- جامع الدورات: بيمسك الدورات. بتخسر إنه بيشتغل في نوبات بتستهلك CPU، ومش حتمي في توقيته.
عشان يقلل التكلفة، بايثون بيستخدم تجميع الأجيال: بيقسم الكائنات 3 أجيال، وبيفحص الجيل الصغير أكتر بكتير من الكبير، على فرضية إن أغلب الكائنات بتموت بدري. العتبات الافتراضية (700, 10, 10) وتشوفها بـ gc.get_threshold().
سيناريو واقعي وبرقم
فريق Instagram لقى إن جامع الدورات بياخد CPU من غير فايدة في الـ web workers بتاعتهم لأنهم بيعيدوا تشغيل الـ process دورياً. عطّلوه وكسبوا حوالي 10% سعة معالجة على نفس العتاد. الدرس مش "عطّل الـ GC"، الدرس إن الجامع له تكلفة حقيقية تقدر تقيسها.
متى لا تستخدم هذه الطريقة (تعطيل الـ GC)
- ينفع في عملية قصيرة العمر، لأن النظام هيرجّع الذاكرة عند الخروج بأي حال.
- كارثة في خدمة طويلة العمر بتعمل دورات كتير، لأن التسريب (390 ميجا) هيتراكم لحد الـ OOM.
ملاحظة: قبل بايثون 3.4 الكائنات اللي فيها __del__ وداخلة في دورة مكانتش بتتحرر وبتروح في gc.garbage. PEP 442 حسّن ده، لكن لسه __del__ بيعقّد التجميع فقلل استخدامه. وكل ده على CPython؛ PyPy و Jython بيستخدموا جامع مهملات tracing مختلف.
الخطوة التالية
افتح خدمتك دلوقتي، وقبل وبعد أي دورة عمل اطبع gc.get_count() و len(gc.garbage). لو الرقم بيكبر باطّراد فعندك دورات. دوّر على أي كائن فيه مرجع لنفسه أو لأبوه أو __del__ مخصص. أكّد بـ gc.collect() وشوف الذاكرة رجعت ولا لأ.
المصادر
- توثيق بايثون — موديول gc: docs.python.org/3/library/gc.html
- توثيق بايثون — sys.getrefcount: docs.python.org/3/library/sys.html
- دليل مطوّري CPython — جامع المهملات: devguide.python.org/internals/garbage-collector
- PEP 442: peps.python.org/pep-0442
- Instagram Engineering — Dismissing Python Garbage Collection: instagram-engineering.com