التمييز بين انتظار GC وتسريب الذاكرة في .NET ── الإجراء العمليّ لمراقبة الذاكرة المتزايدة ومقارنتها وإثباتها

· آخر تحديث: · · .NET, CSharp, GC, MemoryLeak, Diagnostics, dotnet-counters, dotnet-dump, التشغيل, الاستفادة من الأصول القائمة

1. ما ينبغي فهمه أوّلاً

عند تشغيل تطبيقات .NET، تحدث أحياناً حالات يتزايد فيها استهلاك الذاكرة تدريجيّاً.

عند النظر إلى مدير المهامّ (Task Manager) أو top، تجد ذاكرة العمليّة تتزايد. استهلاك ذاكرة الحاوية (container) يتزايد أيضاً. في المراقبة، يميل رسم Working Set أو RSS البيانيّ إلى الارتفاع.

عند رؤية هذه الحالة، يميل المرء فوراً إلى التفكير: «أليس هذا تسريب ذاكرة؟». لكن في .NET، تزايد ذاكرة العمليّة وحدوث تسريب ذاكرة ليسا الشيء نفسه.

يمتلك .NET جمع القمامة (garbage collection). فور أن يصبح الكائن غير مطلوب، لا تُعاد الذاكرة فوراً إلى نظام التشغيل. يعمل GC بناءً على حالة التخصيص، وعتبات الكومة، وضغط الذاكرة، والأجيال (generations)، وحالة حِمل العمل.

لذلك، تحدث حالات مثل التالية.

  • كائن لم يعد مطلوباً، لكن لم يُجمَع بعد بواسطة GC
  • اكتمل الـ GC، لكن Working Set الخاصّ بالعمليّة لا ينخفض فوراً
  • تزداد الذاكرة مرّة واحدة فقط بسبب أوّل وصول، أو JIT، أو الذاكرة المؤقّتة، أو مجمّع الاتّصالات، ثمّ تستقرّ بعد ذلك
  • الكومة المُدارة (managed heap) مستقرّة، لكنّ الذاكرة الأصليّة (native) أو الخيوط (threads) أو المقابس (sockets) أو مكتبات معالجة الصور هي التي تتزايد
  • كائن كان يُفترَض أن يصبح غير مطلوب، لكنّه فعلاً ما زال يُشار إليه من مكان ما

يتناول هذا المقال كيفيّة التمييز عن الحالة الأخيرة، «التسريب الحقيقيّ». وما ينبغي النظر إليه ليس مجرّد استهلاك الذاكرة، بل النقاط الثلاث التالية.

  1. هل تتزايد الذاكرة التي تبقى حيّة بعد الـ GC؟
  2. ما هو النوع (type) المتزايد؟
  3. من الذي يشير إلى ذلك الكائن؟

إنّ التحقيق في تسريب الذاكرة في .NET هو عمل لا يتوقّف عند «الذاكرة تتزايد»، بل يصل إلى «كائنات هذا النوع تتزايد، وما زالت مُشاراً إليها من هذا الجذر (root)».

علماً بأنّ الشيفرات الواردة في هذا المقال منشورة على GitHub كمجموعة عيّنات كاملة قابلة للبناء والتشغيل (مكتبة لأنماط التسريب النمطيّة، وعرض توضيحيّ لمراقبة الفرق بين انتظار GC والبقاء حيّاً، واختبارات وحدة تتحقّق من الاحتفاظ والتحرير عبر WeakReference).

dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)

2. أوّلاً، توحيد معنى «تسريب الذاكرة»

تسريب الذاكرة في .NET لا يقتصر على شكل «نسيان تحرير الذاكرة المخصَّصة» كما في C أو C++.

في الشيفرة المُدارة (managed code)، يجمع GC الكائنات. ويُحدَّد ما إذا كان بإمكان GC جمع كائن أم لا بناءً على «هل ما زالت هناك إشارة (reference) يمكن الوصول من خلالها إلى ذلك الكائن؟».

بعبارة أخرى، تسريب الذاكرة النمطيّ في .NET هو كالتالي.

حالة يكون فيها الكائن غير مطلوب من الناحية التشغيليّة بعد الآن، لكنّه ما زال يُشار إليه من حقل static، أو ذاكرة مؤقّتة، أو حدث، أو Timer، أو مجموعة (collection)، أو عمر حقن (DI)، أو سياق غير متزامن (async context)، ما يجعله يبدو من منظور GC وكأنّه ما زال قيد الاستخدام.

الـ GC ذكيّ، لكنّه لا يعرف ما إذا كان الكائن غير مطلوب من الناحية التشغيليّة. إن كان مُشاراً إليه، فإنّه يُقرِّر أنّه حيّ.

لذلك، من الأسهل فهم الأمر في .NET بوصفه «احتفاظاً غير مقصود» بدلاً من «تسريب».

في المقابل، الحالات التالية لا يمكن وصفها فوراً بأنّها تسريب ذاكرة.

الحالة سبب عدم اعتبارها بالضرورة تسريباً
تزايد Working Set / RSS هي الذاكرة التي خصّصها نظام التشغيل للعمليّة، ولا تتطابق بالضرورة مع كمّيّة الكائنات الحيّة في الكومة المُدارة
تزايد Total Allocated هي الكمّيّة التراكميّة المخصَّصة منذ بدء التشغيل، فتتزايد أساساً طالما يعمل التطبيق
تزايد GC Heap لحظيّاً قد يكون مجرّد بقاء كائنات غير مُجمَّعة حتّى الـ GC التالي
التزايد فور بدء التشغيل يحدث غالباً بسبب JIT، وتحميل الأنواع، والذاكرة المؤقّتة الأوليّة، ومجمّع الاتّصالات، وتوسيع القوالب
كِبَر حجم LOH قد يكون بسبب إعادة استخدام مصفوفات أو buffers كبيرة، أو التجزّؤ (fragmentation)، أو تأثير استراتيجيّة التجميع (pool)
عدم انخفاض الذاكرة حتّى بعد الجمع بواسطة GC، لا تُعيد العمليّة الذاكرة إلى نظام التشغيل فوراً بالضرورة

على العكس، كلّما اجتمعت الحالات التالية معاً، قويت الشبهة في وجود تسريب ذاكرة.

نتيجة الملاحظة المعنى
تتزايد الكومة بعد الـ GC مع كلّ تكرار لنفس العمليّة الكائنات التي تبقى حيّة تتزايد
يستمرّ حجم Gen 2 أو LOH في التزايد كائنات طويلة العمر، أو كائنات كبيرة، ما زالت باقية
يتزايد Count / Size لنفس النوع عبر عدّة تفريغات (dumps) يمكن تحديد النوع المتزايد
تظهر عبر gcroot إشارة من static، أو حدث، أو ذاكرة مؤقّتة، أو خدمة طويلة العمر يمكن تفسير سبب عدم قدرة GC على الجمع
حتّى بعد إيقاف الحمل، لا تعود الذاكرة بعد وقت كافٍ أو بعد GC اختباريّ يُرجَّح أنّها ليست مجرّد تخصيص مؤقّت

3. تحديد «أيّ ذاكرة» ننظر إليها

أوّل ما يسبّب الالتباس في تحقيق الذاكرة هو اختلاط مؤشّرات ذاكرة متعدّدة. فحتّى لو كانت جميعها تُسمّى «ذاكرة»، يختلف معناها.

المؤشّر ما يُقاس كيفيّة القراءة
Working Set / RSS صفحات العمليّة الموجودة فعلاً في الذاكرة الفيزيائيّة ذاكرة من منظور نظام التشغيل. ليست كومة GC نفسها
Private Bytes / Commit الذاكرة المُلتزَم بها (committed) الخاصّة بالعمليّة تشمل الذاكرة الأصليّة، والمكدّس (stack)، وشيفرة JIT، وقطاعات GC وغيرها
GC Heap Size كمّيّة الكائنات على الكومة المُدارة مدخل لرؤية الذاكرة الخاضعة لـ GC في .NET
Total Allocated الكمّيّة التراكميّة المخصَّصة منذ بدء التشغيل تتزايد أساساً. لا تُستخدَم وحدها للحكم على وجود تسريب
Gen 0 / Gen 1 / Gen 2 الكومة حسب الجيل ما يبقى في Gen 2 يكون طويل العمر
LOH كومة تحوي الكائنات الكبيرة التي يبلغ حجمها 85,000 بايت فأكثر تميل إلى التزايد بسبب المصفوفات والسلاسل النصّيّة والـ buffers الكبيرة
POH كومة مخصَّصة للكائنات المثبَّتة (pinned) دليل لرؤية تأثير التكامل مع الشيفرة الأصليّة والتثبيت
Finalization Queue الكائنات المنتظِرة لعمليّة الإنهاء (finalization) دليل على عدم استدعاء Dispose أو ازدحام الـ finalizer

لا حاجة للنظر بتفصيل إلى كلّ شيء من البداية. أوّلاً، نُفكِّك الأمر إلى الأسئلة التالية.

ذاكرة العمليّة تتزايد
  ↓
هل تتزايد الكومة المُدارة أيضاً؟
  ↓
هل تتزايد الكمّيّة التي تبقى حيّة بعد الـ GC؟
  ↓
أيّ نوع (type) يتزايد؟
  ↓
من الذي يشير إليه؟

الالتزام بهذا الترتيب يقلّل احتمال الخلط بين «تزايد الذاكرة الظاهريّ» و«التسريب الحقيقيّ».

4. مخطّط اتّخاذ القرار

في العمل الفعليّ، يسهل التقدّم بالفصل وفق التدفّق التالي.

1. تحديد شرط إعادة الإنتاج
   - في أيّ API أو شاشة أو مهمّة (job) أو دفعة (batch) تتزايد الذاكرة
   - كم مرّة تنفيذ تسبّب التزايد
   - ماذا يحدث عند إيقاف الحمل

2. النظر إلى الاتّجاه عبر dotnet-counters
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - عدد مرّات GC

3. المقارنة بفارق زمنيّ
   - فور بدء التشغيل
   - بعد الإحماء (warm-up)
   - أثناء الحمل
   - بعد إيقاف الحمل
   - بعد تكرار نفس العمليّة N مرّة

4. أخذ تفريغَين (dumps) أو أكثر
   - before
   - after
   - وإن أمكن، بعد إيقاف الحمل أيضاً

5. البحث عن النوع المتزايد
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. التحقّق من مصدر الإشارة
   - gcroot
   - gchandles
   - finalizequeue

7. إصدار الحكم
   - انتظار GC
   - تزايد طبيعيّ في الذاكرة المؤقّتة (cache)
   - تسريب ذاكرة مُدارة
   - مشكلة في الذاكرة الأصليّة (native)
   - تجزّؤ LOH أو تخصيص مؤقّت بحجم كبير

المهمّ هو عدم الحكم بناءً على رقم واحد. بما أنّ تسريب الذاكرة هو «اتّجاه مستمرّ في التزايد»، فالمقارنة تكون زمنيّاً تحت نفس الظروف، لا بقيمة واحدة.

5. الأدوات المستخدَمة

في هذا المقال، سنستخدم بشكل رئيسيّ الأدوات التالية.

الأداة مكان الاستخدام
dotnet-counters لرؤية اتّجاه GC وWorking Set للعمليّة قيد التشغيل
dotnet-gcdump لأخذ إحصائيّات خفيفة عن الكائنات المُدارة الحيّة
dotnet-dump للنظر بالتفصيل إلى الكومة، وتتبّع مصدر الإشارة عبر dumpheap وgcroot
Visual Studio Memory Usage يُستخدَم للمقارنة عبر واجهة رسوميّة (GUI) في Windows
PerfView يُستخدَم للنظر بعمق إلى GC / الكومة / التتبّع (trace) في Windows
dotnet-trace يُستخدَم لتتبّع أحداث التخصيص أو GC زمنيّاً

أوّلاً، نُثبِّت أدوات سطر الأوامر (CLI).

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace

إن كانت مُثبَّتة بالفعل، نُحدِّثها.

dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace

نبحث عن العمليّة المُراد التحقيق فيها.

dotnet-counters ps

في الأمثلة اللاحقة، سنكتب مُعرِّف العمليّة الهدف كـ <PID>.

في Linux أو macOS أو بيئات الحاويات، يجب أن تعمل أداة التشخيص والعمليّة الهدف بنفس المستخدم. كما تتأثّر بعض البيئات بـ TMPDIR، ومنفذ التشخيص، وفضاء أسماء PID (PID namespace) الخاصّ بالحاوية.

عند التنفيذ في بيئة الإنتاج، لا تأخذ تفريغاً (dump) مباشرةً، بل تأكّد أوّلاً من الحمل والتأثير في بيئة اختبار.

6. أوّلاً، رؤية الاتّجاه عبر dotnet-counters

أوّل ما ننظر إليه ليس تفريغاً مفصَّلاً، بل الاتّجاه.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime

يختلف المُخرَج قليلاً باختلاف إصدار .NET. في .NET 9 فما بعد، قد يُعرَض باسم Meter الخاصّ بـ System.Runtime، وفي .NET 8 فما قبل، قد يُعرَض باسم EventCounter التقليديّ.

البنود الرئيسيّة التي ننظر إليها هي التالية.

البند ما يُراد رؤيته
dotnet.process.memory.working_set الذاكرة المقيمة للعمليّة من منظور نظام التشغيل
dotnet.gc.last_collection.heap.size حجم الكومة حسب الجيل بعد آخر GC
dotnet.gc.last_collection.memory.committed_size كمّيّة الذاكرة الملتزَم بها من قِبل GC
dotnet.gc.heap.total_allocated الكمّيّة التراكميّة المخصَّصة منذ بدء التشغيل
dotnet.gc.collections عدد مرّات GC حسب الجيل
dotnet.gc.pause.time التراكم الزمنيّ لتوقّف GC

يجوز أيضاً تضييق النطاق والمراقبة.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]

إن أردتَ المراجعة لاحقاً، احفظ في CSV.

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output counters.csv \
  --counters System.Runtime

ما نريد رؤيته في هذه المرحلة هو الفروق التالية.

6.1 تزايد Total Allocated فقط

dotnet.gc.heap.total_allocated قيمة تراكميّة. فمعالجة التطبيق لكلّ طلب تُخصِّص كائنات، وحتّى لو أصبح الكائن المخصَّص غير مطلوب فوراً وجُمِع بواسطة GC، تستمرّ الكمّيّة التراكميّة المخصَّصة في التزايد.

لذلك، مجرّد تزايد Total Allocated لا يعني تسريب ذاكرة. ما ينبغي النظر إليه هو ما إذا كان ما خُصِّص لا يزال باقياً بعد ذلك.

Total Allocated: يتزايد
GC Heap Size:    يستقرّ مع بعض التذبذب صعوداً ونزولاً
Gen 2 / LOH:     لا يستمرّ في التزايد

في هذه الحالة، الأمر ليس تسريباً، بل تطبيقاً كثير التخصيص.

الإجراء المناسب ليس إصلاح تسريب، بل تقليل التخصيص، وإعادة استخدام الـ buffers، ومراجعة الإفراط في استخدام LINQ، وتقليل إنشاء السلاسل النصّيّة، ومراجعة معالجة التسلسل (serialization).

6.2 تزايد Working Set مع استقرار GC Heap

قد يتزايد Working Set أو RSS بينما يبقى GC Heap مستقرّاً. في هذه الحالة، لا يعني الأمر بالضرورة تسريباً في الكائنات المُدارة.

العوامل المحتملة هي كالتالي.

  • شيفرة تمّت معالجتها بواسطة JIT
  • التجميعات (assemblies) المحمَّلة
  • مكدّسات الخيوط (thread stacks)
  • ذاكرة المكتبات الأصليّة (native libraries)
  • ذاكرة غير مُدارة كتلك المخصَّصة عبر Marshal.AllocHGlobal
  • buffers على الجانب الأصليّ لمعالجة الصور، والضغط، والتشفير، وبرامج تشغيل قواعد البيانات
  • buffers داخليّة للمقابس (sockets)، ومعالجات الملفّات، وSSL، وHTTP/2، وgRPC
  • مجرّد عدم استرجاع نظام التشغيل للصفحات الفيزيائيّة من العمليّة فوراً

في هذه الحالة، مهما نظرتَ إلى dumpheap، فقد لا تجد الفاعل الرئيسيّ.

معيار الحكم هو كالتالي.

Working Set / RSS: يتزايد
GC Heap Size:      مستقرّ
Gen 2 / LOH:       مستقرّ

في هذه الحالة، لا نشكّ في تسريب الكومة المُدارة في .NET، بل في الذاكرة الأصليّة، والمقابض (handles)، وعدد الخيوط، والمقابس، والمكتبات الخارجيّة.

لا يُكتفى بـ dotnet-counters فقط، بل يُنظَر أيضاً إلى أدوات نظام التشغيل، ومقاييس الحاوية، وعدد المقابض، وعدد الخيوط، والكومة الأصليّة، ومقاييس المكتبات الخارجيّة.

6.3 تزايد GC Heap مع عودته بعد إيقاف الحمل

من الطبيعيّ أن يتزايد GC Heap أثناء الحمل.

الطلبات كثيرة. الكائنات المؤقّتة كثيرة. معالجة JSON كبير الحجم. إنشاء قوائم أو مصفوفات مؤقّتة.

في مثل هذه الحالات، تتزايد الكومة حتّى الـ GC التالي. وعند إيقاف الحمل، قد يعمل GC وتعود الكومة إلى حجمها.

أثناء الحمل:       يتزايد GC Heap
بعد إيقاف الحمل:   ينخفض GC Heap، أو يعود إلى قيمة ثابتة
بعد التكرار:       خطّ الأساس (baseline) لا يستمرّ في التزايد

في هذه الحالة، يمكن الحكم بأنّ الأمر «مجرّد عدم جمعه بعد بواسطة GC» أو «تخصيص مؤقّت كثير».

لكن، إن كان التخصيص المؤقّت أثناء الحمل كثيراً جدّاً، يتزايد عدد مرّات GC وزمن التوقّف فتصبح مشكلة أداء. حتّى لو لم يكن تسريباً، يبقى هدفاً لتحسين الأداء.

6.4 استمرار تزايد Gen 2 / LOH بعد GC

النمط الذي يستحقّ الانتباه هو هذا.

تكرار نفس العمليّة
  ↓
يتزايد Gen 2
  ↓
يتزايد LOH
  ↓
لا يعود حتّى بعد إيقاف الحمل
  ↓
يزداد أكثر في القياس التالي أيضاً

Gen 2 هو الجيل الذي تدخل فيه الكائنات طويلة العمر. LOH هي كومة تميل إلى استقبال المصفوفات والسلاسل النصّيّة الكبيرة.

إن استمرّ هذان في التزايد، نشكّ في تسريب، أو ذاكرة مؤقّتة غير محدودة، أو الاحتفاظ بـ buffer ضخم، أو عدم إلغاء الاشتراك في حدث، أو مجموعة static، أو احتفاظ من قِبل خدمة طويلة العمر.

في هذه المرحلة، ننتقل إلى الخطوة التالية.

7. طريقة التفكير للتحقّق من كون الأمر «انتظار GC»

للتحقّق من كون الأمر «لم يُجمَع بعد فقط»، ننظر إلى الحالة بعد إتاحة فرصة كافية لـ GC.

لكن، لا يجوز إدراج GC.Collect() بسهولة في شيفرة الإنتاج.

يفرض GC.Collect() تنفيذ GC. وخصوصاً GC الحاجب (blocking) لكلّ الأجيال، فإنّه يُحدِث زمن توقّف للتطبيق. في التشغيل الاعتياديّ، الأساس هو ترك الأمر لـ GC.

مع ذلك، في التحقيق، يُنظَر أحياناً في بيئة اختبار مُدارة إلى «هل يبقى الكائن حتّى بعد GC قسريّ؟».

في تطبيق console اختباريّ أو بيئة إعادة إنتاج، يمكن التحقّق من الحالة بعد GC كامل بشيفرة مثل التالية.

static void ForceFullGcForDiagnosticsOnly()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

النقطة المهمّة هي عدم استخدام هذا كحلّ. إنّه لأغراض التحقيق فقط.

ما نريد التحقّق منه هو هذا التدفّق.

قبل العمليّة
  ↓
تكرار العمليّة N مرّة
  ↓
إيقاف الحمل
  ↓
الانتظار وقتاً كافياً، أو استحثاث GC كامل في بيئة اختبار
  ↓
هل تعود الكومة بعد الـ GC إلى قيمة قريبة من ما قبل العمليّة؟

إن عادت، فيُرجَّح أنّ الأمر انتظار GC أو تخصيص مؤقّت. وإن لم تعُد، وارتفع خطّ الأساس مع كلّ تكرار لنفس العمليّة، فإنّ شيئاً ما لا يزال حيّاً. نبحث عن ذلك «الشيء» عبر التفريغ (dump).

8. مقارنة خفيفة عبر dotnet-gcdump

لأوّل مقارنة، تُعَدّ dotnet-gcdump مفيدة.

تُستخدَم dotnet-gcdump للحصول على GC dump من عمليّة .NET قيد التشغيل، ورؤية إحصائيّات كلّ نوع على الكومة.

dotnet-gcdump collect --process-id <PID> --output before.gcdump

بعد فرض الحمل، نأخذ تفريغاً مرّة أخرى.

dotnet-gcdump collect --process-id <PID> --output after.gcdump

يمكن أيضاً رؤية تقرير بسيط عبر CLI.

dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump  > after-heap.txt

ما ننظر إليه هو Count وSize لكلّ نوع.

على سبيل المثال، إن تزايدت أنواع مثل التالية بشكل كبير في after، تصبح موضع تحقيق.

Size (Bytes)   Count       Type
============   =====       ====
180,000,000    2,000,000   System.String
120,000,000    1,000,000   MyApp.Models.Customer
 90,000,000       25,000   System.Byte[]

المهمّ ليس «النوع الكبير»، بل «النوع المتزايد».

يظهر System.String وSystem.Byte[] في مقدّمة القائمة في كثير من التطبيقات. مجرّد الظهور في المقدّمة لا يعني بالضرورة أنّه الفاعل.

زوايا المقارنة هي كالتالي.

before → after كيفيّة القراءة
Count متقارب تقريباً يُرجَّح أنّ ذلك النوع ليس الفاعل الرئيسيّ
يتزايد كلّ من Count وSize يصبح مرشَّحاً
تتزايد أنواع MyApp.* يسهل الشكّ في احتفاظ على مستوى منطق العمل
يتزايد System.Byte[] يُشتبَه في الـ buffer، والتسلسل، والصور، والضغط، وHTTP، وقاعدة البيانات
يتزايد System.String يُشتبَه في الذاكرة المؤقّتة، والسجلّات، وJSON، ومفاتيح القاموس، والسلاسل النصّيّة المكرَّرة
تتزايد Task وTimer وCancellationTokenSource يُشتبَه في المعالجة غير المتزامنة، وTimer، وعدم إلغاء الإلغاء

dotnet-gcdump سهلة الاستخدام كمدخل للمقارنة، لكنّها تستحثّ Gen 2 GC عند الأخذ. في البيئات ذات الكومة الكبيرة أو الحسّاسة لزمن الاستجابة (latency)، انتبه لزمن التوقّف واستهلاك الذاكرة الإضافيّ.

في Windows، يمكن فتح .gcdump عبر Visual Studio أو PerfView ومقارنته. في البيئات غير Windows، الأسلوب العمليّ هو رؤية إحصائيّات النوع عبر report في CLI، ثمّ الانتقال إلى dotnet-dump للتعمّق في مصدر الإشارة.

9. رؤية الكومة ومصدر الإشارة عبر dotnet-dump

بعد أن يتّضح «النوع المتزايد»، ننظر بعدها إلى «لماذا لا يُجمَع».

لذلك، نأخذ تفريغاً عبر dotnet-dump ونحلّله بأوامر SOS.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-1.dmp

بعد فترة، نأخذ تفريغاً مرّة أخرى.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-2.dmp

أخذ التفريغ عمليّة ثقيلة. خصوصاً تفريغات Full / Heap فحجمها كبير، وتُحمِّل العمليّة أو الحاوية عبئاً إضافيّاً. عند أخذها في الإنتاج، انتبه للتوقيت الزمنيّ، ومساحة القرص، وحدود ذاكرة الحاوية، واحتمال تضمّن معلومات شخصيّة أو سرّيّة.

نحلّل التفريغ الذي حصلنا عليه.

dotnet-dump analyze myapp-2.dmp

أوّلاً، ننظر إلى إحصائيّات الكومة كاملةً.

> dumpheap -stat

المُخرَج هو عدد العناصر والحجم لكلّ نوع.

MT               Count       TotalSize Class Name
00007f...        120000      3840000   MyApp.Models.Order
00007f...        250000      8000000   System.String
00007f...         10000     40000000   System.Byte[]

نضيّق النطاق إلى نوع معيّن.

> dumpheap -stat -type MyApp.Models.Order

أو نضيّق النطاق حسب MethodTable معيّن.

> dumpheap -mt <MT>

بعد معرفة عنوان النسخة (instance)، نبحث عن مصدر الإشارة.

> gcroot <OBJECT_ADDRESS>

هذه هي أهمّ نقطة. نتحقّق عبر gcroot من سبب بقاء ذلك الكائن حيّاً.

لنفترض أنّنا رأينا مسار إشارة مثل التالي.

static MyApp.CustomerCache._items
  -> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
  -> MyApp.Models.Customer
  -> System.String

في هذه الحالة، سبب عدم جمع GC واضح: لأنّ Customer مُشار إليه من ذاكرة مؤقّتة static، فيبدو من منظور GC وكأنّه ما زال قيد الاستخدام.

هنا فقط يمكن اتّخاذ القرارات التالية.

  • هل تلك الذاكرة المؤقّتة ضروريّة حقّاً؟
  • هل لها سقف؟
  • هل لها مدّة صلاحيّة؟
  • هل التصميم يجعل المفاتيح تتزايد باستمرار؟
  • هل تتزايد إلى ما لا نهاية باستخدام المستأجر (tenant) أو المستخدم أو التاريخ أو مُعرِّف الطلب كمفتاح؟

المهمّ في تحقيق تسريب الذاكرة هو عدم التوقّف عند dumpheap -stat. dumpheap -stat يخبرنا بـ«ما الذي كثُر»، وgcroot يخبرنا بـ«لماذا بقي». والذي يقود إلى الإصلاح هو الثاني.

10. جدول مرجعيّ سريع للتمييز

نُنظّم الأنماط الشائعة في العمل الفعليّ.

الملاحظة الاحتمال ما يُنظَر إليه لاحقاً
يتزايد Total Allocated فقط تخصيص اعتياديّ، أو إفراط في التخصيص Allocation Rate، وعدد مرّات GC، وCPU، وdotnet-trace
يتزايد Working Set مع استقرار GC Heap الذاكرة الأصليّة، وJIT، والمكدّس، والاحتفاظ من جهة نظام التشغيل عدد الخيوط، وعدد المقابض، وأدوات native، والمكتبات الخارجيّة
يتزايد GC Heap أثناء الحمل فقط ويعود بعد التوقّف انتظار GC، تخصيص مؤقّت Gen 2 / LOH بعد إيقاف الحمل، وعدد مرّات GC
يستمرّ تزايد Gen 2 بعد GC احتفاظ بكائن طويل العمر dumpheap -stat، وgcroot
يستمرّ تزايد LOH مصفوفات كبيرة، وbuffers، وتجزّؤ، وسلاسل نصّيّة ضخمة System.Byte[]، وSystem.Char[]، وLOH، ومنطقة Free
كِبَر System.String ذاكرة مؤقّتة للسلاسل النصّيّة، وJSON، والسجلّات، ومفاتيح القاموس البحث عن نوع خاصّ يحتفظ بالسلاسل النصّيّة
كِبَر System.Byte[] buffer، وتسلسل، وصور، وضغط، واتّصال النوع المالك، وعدم إعادة ArrayPool، والتكامل مع native
يتزايد Task معالجة غير متزامنة لا تكتمل، طابور انتظار انتظار async، والإلغاء، والقناة (channel)، والطابور
يتزايد Timer عدم التخلّص من Timer Dispose، وإلغاء التسجيل، والخدمة طويلة العمر
يتزايد CancellationTokenSource عدم التخلّص من CTS، وكثرة الرموز المرتبطة (linked tokens) Dispose، وفكّ الارتباط، ومكان إنشاء المهلة الزمنيّة
يبقى EventHandler أو delegate عدم إلغاء الاشتراك في الحدث فرق العمر بين publisher وsubscriber
يتزايد Finalization Queue عدم استدعاء Dispose، وازدحام الـ finalizer finalizequeue، وخيط الـ finalizer
كثرة Pinned handle buffer مثبَّت، وتكامل مع native gchandles، وPOH، ومكان التثبيت

11. الأشكال الشائعة للتسريب

11.1 مجموعة (collection) من نوع static

هذا هو الشكل الأوضح.

public static class CustomerStore
{
    private static readonly List<Customer> Customers = new();

    public static void Add(Customer customer)
    {
        Customers.Add(customer);
    }
}

في هذه الشيفرة، يبقى Customer الذي أُضيف إلى Customers طالما بقيت العمليّة حيّة. حتّى لو كانت النيّة حفظاً مؤقّتاً، فلن يُجمَع بواسطة GC طالما ظلّ مُشاراً إليه من static.

يختلف اتّجاه الإصلاح حسب الاستخدام.

  • وضع سقف
  • وضع مدّة صلاحيّة
  • استخدام آليّة ذاكرة مؤقّتة مثل MemoryCache
  • الحذف الصريح
  • التخلّي عن static والانتقال إلى خدمة ذات عمر مناسب
  • إن كان الهدف الحفظ الدائم، الانتقال إلى قاعدة بيانات أو تخزين خارجيّ

المهمّ ليس القول إنّ «static سيّئ»، بل فهم أنّ كلّ ما يُوضَع في static يصبح طويل العمر، واستخدامه مع فهم هذه الخاصّيّة.

11.2 ذاكرة مؤقّتة غير محدودة

الذاكرة المؤقّتة تستهلك الذاكرة عمداً، فإن كان التزايد وفق التصميم فليس تسريباً. لكنّ الذاكرة المؤقّتة التي لا سقف لها ولا مدّة صلاحيّة تصبح تسريب ذاكرة فعليّاً.

public sealed class ReportCache
{
    private readonly Dictionary<string, Report> _cache = new();

    public Report GetOrCreate(string userId, DateTime date)
    {
        var key = $"{userId}:{date:O}";

        if (_cache.TryGetValue(key, out var report))
        {
            return report;
        }

        report = BuildReport(userId, date);
        _cache[key] = report;
        return report;
    }
}

في هذا المثال، إن استمرّ تركيب userId وdate في التزايد، تستمرّ الذاكرة المؤقّتة أيضاً في التزايد.

الخطر بشكل خاصّ هو حالة تضمين قيم مثل هذه في المفتاح.

  • مُعرِّف الطلب
  • الوقت الحاليّ
  • GUID
  • مُعرِّف الجلسة
  • سلسلة نصّيّة مستخدَمة من إدخال المستخدم دون تسوية (normalization)
  • تحويل شرط SQL أو البحث إلى سلسلة نصّيّة كما هو

يُحدَّد للذاكرة المؤقّتة الشروط التالية مسبقاً.

الشرط مثال
العدد الأقصى حتّى 10,000 عنصر
الحجم الأقصى حتّى 256 ميغابايت
مدّة الصلاحيّة 30 دقيقة من آخر وصول
المدّة المطلقة 6 ساعات من الإنشاء
شرط الحذف حذف المستأجر، حذف المستخدم، تغيير الإعدادات
بند المراقبة العدد، الحجم التقديريّ، معدّل الإصابة (hit rate)، عدد مرّات الإخلاء (eviction)

ليس «يجوز أن تتزايد لأنّها ذاكرة مؤقّتة»، بل يلزم تحديد «إلى أيّ حدّ يجوز لها أن تتزايد».

11.3 عدم إلغاء الاشتراك في حدث

يتحوّل الحدث (event) إلى تسريب عندما يستمرّ publisher طويل العمر في الإشارة إلى subscriber قصير العمر.

public sealed class OrderViewModel
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

إن كان OrderService من نوع singleton، وكان OrderViewModel يُنشأ لكلّ شاشة، فإنّ حدث OrderService يستمرّ في الإشارة إلى OrderViewModel. حتّى بعد إغلاق الشاشة، إن لم يُلغَ الاشتراك، يبقى ViewModel.

مثال على الإصلاح.

public sealed class OrderViewModel : IDisposable
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    public void Dispose()
    {
        _service.OrderChanged -= OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

قد تظهر في gcroot كإشارة عبر delegate أو event handler.

يظهر هذا النمط كثيراً في WPF، وWinForms، والخدمات طويلة العمر، ووسيط الرسائل (message broker)، ومجمِّع الأحداث (event aggregator).

11.4 عدم التخلّص من Timer

أشياء مثل System.Threading.Timer وPeriodicTimer، واشتراكات Reactive Extensions، تبقى أيضاً إن لم تُتخلَّص منها.

public sealed class PollingWorker
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private void Poll()
    {
        // polling
    }
}

إن كان القصد من PollingWorker أن يكون كائناً مؤقّتاً، يلزم تصميم يتخلّص من Timer.

public sealed class PollingWorker : IDisposable
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    public void Dispose()
    {
        _timer.Dispose();
    }

    private void Poll()
    {
        // polling
    }
}

يحمل Timer delegate الخاصّاً برد النداء (callback)، وقد تمتدّ منه إشارة إلى الكائن الهدف.

11.5 عدم تحرير IDisposable

عدم تحرير IDisposable لا يظهر بالضرورة كتسريب في الكومة المُدارة.

قد يظهر كمشكلة موارد مثل الملفّات، والمقابس، واتّصالات قاعدة البيانات، والمقابض الأصليّة، والـ buffers.

public async Task<string> ReadAsync(string path)
{
    var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

في هذا المثال، بما أنّ StreamReader يُغلِق stream، فغالباً لا تكون مشكلة كبيرة، لكن في الشيفرة التي تكون فيها الملكيّة غامضة يحدث التسريب.

الأساس هو توضيح الملكيّة عبر using / await using.

public async Task<string> ReadAsync(string path)
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

يظهر عدم استدعاء Dispose بأعراض مثل التالية.

  • يتزايد عدد المقابض
  • تتزايد المقابس
  • لا تُغلَق الملفّات
  • تتزايد الذاكرة الأصليّة
  • يتزايد Finalization Queue
  • تتزايد ذاكرة العمليّة رغم استقرار GC Heap

في هذه الحالة، لا يكفي dumpheap وحده. يُنظَر أيضاً إلى مقابض نظام التشغيل والمقابس، وحالة المكتبات الخارجيّة.

11.6 الاحتفاظ بـ AsyncLocal أو بالسياق

AsyncLocal<T> مفيد، لكن إن كان ما يُوضَع فيه كبيراً، فقد يبقى طويلاً.

القيم الصغيرة مثل مُعرِّف الارتباط (correlation ID) في السجلّات نادراً ما تكون مشكلة. لكن وضع أشياء مثل معلومات المستخدم، أو جسم الطلب (request body)، أو DTO كبير، أو سياق قاعدة البيانات، يؤدّي إلى احتفاظ غير مقصود.

public static class RequestContext
{
    public static readonly AsyncLocal<RequestInfo?> Current = new();
}

بما أنّ AsyncLocal يسير مع التدفّق غير المتزامن، فقد يكون اكتشافه أصعب من حقل static بسيط.

فكِّر في تصميم يجعل ما يُوضَع فيه صغيراً وواضحاً، ويُعيده إلى null عندما لا يعود مطلوباً.

11.7 الخلط في عمر (lifetime) الحقن (DI)

في DI الخاصّ بـ ASP.NET Core وما شابه، تختلف أعمار singleton وscoped وtransient.

إن احتفظ singleton طويل العمر ببيانات خاصّة بكلّ طلب، فقد يبقى الكائن حتّى بعد انتهاء الطلب.

public sealed class AuditBuffer
{
    private readonly List<RequestAudit> _items = new();

    public void Add(RequestAudit item)
    {
        _items.Add(item);
    }
}

إن كان هذا singleton، فإنّ عمر _items يساوي عمر التطبيق.

إن كان التخزين المؤقّت (buffering) جزءاً من التصميم، يلزم سقف وإرسال وحذف وضغط عكسيّ (back-pressure). إن كان الأمر مجرّد «قد يُراجَع لاحقاً»، ينبغي إخراجه إلى سجلّ أو تخزين خارجيّ.

12. LOH عرضة للفهم الخاطئ بشكل خاصّ

LOH اختصار لـ Large Object Heap. في .NET، تُوضَع الكائنات الكبيرة في كومة منفصلة عن الكائنات الصغيرة الاعتياديّة. المثال النمطيّ هو مصفوفة كبيرة.

var buffer = new byte[1024 * 1024 * 10]; // 10MB

المشاكل الشائعة في LOH هي هذه الثلاث.

  1. إنشاء كائن كبير بشكل متكرّر
  2. الاحتفاظ بكائن كبير لفترة طويلة
  3. التجزّؤ الناتج عن إنشاء وإتلاف كائنات كبيرة

مجرّد تزايد LOH لا يعني تسريباً فوراً. فإن كان التصميم يعيد استخدام buffer كبير، فقد يستقرّ بعد التزايد إلى حجم معيّن، وحتّى لو جمعه GC، لا ينخفض Working Set فوراً بالضرورة.

لكن، ينبغي الشكّ في الحالات التالية.

  • يتزايد System.Byte[] مع كلّ عمليّة
  • يتزايد System.Char[] أو String ضخم
  • لا يعود بعد معالجة الصور، أو PDF، أو Excel، أو ZIP، أو التشفير، أو الضغط
  • عدم إعادة المصفوفة المُستأجرة عبر ArrayPool<T>.Rent
  • تحميل استجابة كبيرة بالكامل في الذاكرة
  • الإفراط في استخدام MemoryStream.ToArray()

عند استخدام ArrayPool<T>، أعِد المصفوفة دائماً.

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);

try
{
    // use buffer
}
finally
{
    pool.Return(buffer);
}

لكن، مجرّد الإعادة إلى المجمّع (pool) لا يعني انخفاض ذاكرة العمليّة فوراً بالضرورة. فقد يحتفظ المجمّع بالذاكرة لإعادة الاستخدام.

هنا أيضاً، ما ينبغي النظر إليه هو «هل تستمرّ في التزايد؟» و«هل لها سقف؟» و«هل يُعاد استخدامها؟».

13. كيفيّة قراءة gcroot

يعرض gcroot من أين يُشار إلى كائن معيّن.

نُلخِّص الجذور (roots) النمطيّة في جدول.

الجذر (root) المعنى
static field مُشار إليه من حقل static تابع لنوع
local variable / stack مُشار إليه من مكدّس خيط قيد التنفيذ
GC handle مُشار إليه من GCHandle، أو تثبيت (pin)، أو delegate، أو تكامل (interop)
finalization queue محتفَظ به لانتظار الإنهاء (finalization)
thread / async state machine تحتفظ به معالجة غير متزامنة قيد التنفيذ أو الانتظار

النقطة التي ينبغي النظر إليها كثيراً في التحقيق هي فرق العمر.

كائن طويل العمر
  -> كائن كان يُفترَض أن يكون قصير العمر

إن ظهر هذا الشكل، فهو مرشَّح للتسريب.

على سبيل المثال، التالي مثير للريبة.

SingletonService
  -> List<RequestContext>
  -> RequestContext
  -> LargeDto

يعيش SingletonService طوال عمر التطبيق كلّه. إن كان يتراكم بداخله RequestContext خاصّ بكلّ طلب، يلزم إعادة النظر في التصميم.

في المقابل، جذور مثل هذه طبيعيّة حسب التوقيت.

Thread stack
  -> Controller action local variable
  -> RequestDto

أثناء معالجة الطلب، من الطبيعيّ أن يبقى متغيّر محلّيّ.

لذلك، توقيت أخذ التفريغ مهمّ.

ليس أثناء الحمل فقط، بل أخذ تفريغ أيضاً بعد إيقاف الحمل، وبعد أن يصبح الطابور فارغاً، وبعد فترة خمول (idle) معيّنة، يجعل الحكم أسهل.

14. «انخفضت بعد GC القسريّ» لا يعني أنّ المشكلة حُلّت

أثناء التحقيق، استدعيتَ GC.Collect() فانخفضت الذاكرة. في هذه الحالة، من الخطر التفكير: «إذن يكفي استدعاء GC.Collect() بشكل دوريّ».

لا يزيل GC القسريّ السبب الجذريّ. إنّه فقط يجمع في تلك اللحظة الكائنات التي لم تُجمَع بعد.

إن كانت المشكلة معدّل تخصيص مرتفعاً، فإنّ GC القسريّ يزيد زمن التوقّف ويُسيء إلى الأداء. وإن كان تسريباً حقيقيّاً، فلن يُجمَع الكائن الذي ما زالت له إشارة حتّى مع GC قسريّ.

ما ينبغي النظر إليه في التحقيق هو الفرق التالي.

بعد GC القسريّ الحكم
ينخفض كثيراً ويستقرّ خطّ الأساس بعدها انتظار GC، أو تخصيص مؤقّت هو السبب الرئيسيّ
ينخفض قليلاً لكن ترتفع القاعدة مع كلّ تكرار جزء منه باقٍ حيّاً. مرشَّح للتسريب
لا ينخفض تقريباً ما زال مُشاراً إليه، أو أنّ السبب الرئيسيّ خارج كومة GC
ينخفض GC Heap لكن لا ينخفض Working Set احتمال احتفاظ من جهة نظام التشغيل / قطاعات GC / الجانب الأصليّ

قبل تنفيذ GC.Collect() بشكل دوريّ في الإنتاج، حدِّد دائماً «ما الذي يتزايد» أوّلاً.

15. إجراء التحقيق المستخدَم في العمل الفعليّ

من هنا فصاعداً، نُلخِّص الإجراء كخطوات للتحقيق الفعليّ.

15.1 تثبيت سيناريو إعادة الإنتاج

أوّلاً، نُثبِّت ظروف التحقيق.

الهدف:            /api/report/export
العمليّة:          تنفيذ 100 مرّة تحت نفس الظروف
فاصل القياس:      5 ثوانٍ
مدّة المراقبة:     إحماء 5 دقائق + حمل 10 دقائق + خمول 5 دقائق
البيئة:            staging / بناء Release / إعدادات مكافئة للإنتاج

في تحقيق الذاكرة، لا يمكن الحكم إن كنتَ تنظر أثناء تنفيذ عمليّات مختلفة في كلّ مرّة. ثبِّت «ما الذي فعلتَه فتزايدت الذاكرة».

15.2 أخذ خطّ الأساس (baseline)

اجعل خطّ الأساس بعد الإحماء، لا فور بدء التشغيل.

السبب أنّ فور بدء التشغيل يحدث تزايد لمرّة واحدة كالتالي.

  • JIT
  • بناء حاوية DI
  • قراءة الإعدادات
  • أوّل اتّصال بقاعدة البيانات
  • أوّل اتّصال TLS / HTTP
  • توليد بيانات وصفيّة (metadata) لمُسلسِل JSON
  • تهيئة Razor / القوالب
  • تهيئة أداة تسجيل السجلّات والمقاييس

الترتيب كالتالي.

1. تشغيل التطبيق
2. استدعاء فحص الصحّة (health check) أو API تمثيليّ عدّة مرّات
3. الانتظار نحو 1 إلى 5 دقائق
4. أخذ counters وdump كخطّ أساس

15.3 أخذ counters أثناء الحمل

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output report-export-counters.csv \
  --counters System.Runtime

بالتوازي، نُنفِّذ عمليّة إعادة الإنتاج. ما نريد رؤيته هو شكل الرسم البيانيّ.

الشكل القريب من الطبيعيّ:
  يتزايد أثناء الحمل
  يعلو وينخفض مع GC
  يعود بعد إيقاف الحمل
  لا يستمرّ خطّ الأساس في التزايد

الشكل المثير للريبة:
  يتزايد بما يتناسب مع عدد مرّات العمليّة
  ترتفع قاعدة Gen 2 / LOH
  لا يعود حتّى بعد إيقاف الحمل
  ترتفع القاعدة أكثر مع الحمل التالي

15.4 أخذ تفريغَين

نأخذها قبل الحمل وبعده.

dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# نُطبِّق الحمل
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp

إن سمح الوقت، نأخذ تفريغاً بعد إيقاف الحمل أيضاً.

# بعد إيقاف الحمل، وبعد أن يصبح الطابور فارغاً، وبعد الانتظار وقتاً كافياً
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp

عند المقارنة، لا يقتصر الأمر على before وafter، بل idle-after مهمّ أيضاً.

حتّى لو تزايدت أثناء الحمل، إن عادت بعد الخمول فقد لا تكون تسريباً.

15.5 رؤية النوع المتزايد

dotnet-dump analyze after.dmp
> dumpheap -stat

نُنظر إلى جانب before بنفس الطريقة. لا بأس بالعمل اليدويّ، لكن نُقارِن أوّلاً الأنواع في المقدّمة.

نذكر زوايا النظر.

  • هل تتزايد أنواع فضاء الأسماء (namespace) الخاصّ بالشركة؟
  • هل يكمن نوع خاصّ بالشركة خلف System.String؟
  • من الذي يملك System.Byte[]؟
  • هل تتزايد List أو Dictionary<TKey,TValue>؟
  • هل تتزايد Task أو آلة حالة async؟
  • هل تتزايد Timer أو CancellationTokenSource؟

15.6 رؤية مصدر الإشارة

نأخذ عنوان الكائن المرشَّح، ونُنفِّذ عليه gcroot.

> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>

من نتيجة gcroot، نبحث عن الأصل (parent) المُحتفِظ به.

MyApp.Services.ReportCache
  -> Dictionary<string, ReportResult>
  -> ReportResult

عند هذه النقطة، تتّضح أهداف مراجعة الشيفرة.

  • هل ReportCache من نوع singleton؟
  • هل له سقف؟
  • هل يُحذَف؟
  • هل يستمرّ المفتاح في التزايد؟
  • هل ReportResult كبير جدّاً؟
  • هل ينبغي نقله إلى قاعدة بيانات أو ملفّ بدل الذاكرة المؤقّتة؟

16. حالات استخدام dotnet-trace

يناسب dotnet-dump رؤية «ما الذي بقي كنتيجة» عبر لقطة (snapshot) لحظيّة. في المقابل، إن أردتَ رؤية «متى وأين يحدث التخصيص بكمّيّة كبيرة»، استخدم dotnet-trace.

على سبيل المثال، نتتبّع مع تضمين الأحداث المتعلّقة بـ GC.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:01:00 \
  --clrevents gc+gchandle \
  --clreventlevel informational \
  --output gc-trace.nettrace

إن أردتَ رؤية أخذ عيّنات التخصيص (allocation sampling) أيضاً، فإنّ كمّيّة الأحداث تزداد، لذا ابدأ بفترة قصيرة في بيئة اختبار.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:00:30 \
  --clrevents gc+gcsampledobjectallocationhigh \
  --clreventlevel informational \
  --output allocation-trace.nettrace

التتبّع (trace) مفيد من زاوية مختلفة عن التفريغ (dump).

ما نريد رؤيته الأداة المناسبة
ما الذي بقي dump / gcdump
من الذي يشير إليه dump + gcroot
متى تمّ التخصيص بكمّيّة كبيرة trace
متى حدث GC counters / trace
هل زمن التوقّف مشكلة counters / trace

في تحقيق التسريب، من الفعّال النظر أوّلاً إلى «ما تبقّى» عبر dump، ثمّ إن لزم الأمر النظر إلى «مكان الإنشاء» عبر trace.

17. إخراج مقاييس للتحقّق على مستوى الشيفرة

ينبغي إجراء التشخيص الكامل بأدوات خارجيّة، لكن إدراج سجلّ تشخيص بسيط في جانب التطبيق مفيد.

على سبيل المثال، طريقة إخراج معلومات GC عبر نقطة نهاية (endpoint) للمدير أو سجلّ دوريّ.

public static class GcDiagnostics
{
    public static object Snapshot()
    {
        var info = GC.GetGCMemoryInfo();

        return new
        {
            TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
            HeapSizeBytes = info.HeapSizeBytes,
            FragmentedBytes = info.FragmentedBytes,
            MemoryLoadBytes = info.MemoryLoadBytes,
            HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
            Gen0Collections = GC.CollectionCount(0),
            Gen1Collections = GC.CollectionCount(1),
            Gen2Collections = GC.CollectionCount(2)
        };
    }
}

لا يمكن الحكم على وجود تسريب بهذه المعلومات وحدها. لكنّها تُسهِّل الأحكام التالية عند حدوث عطل.

  • هل يرتفع Gen 2 فجأةً؟
  • هل يتزايد HeapSize؟
  • هل تتزايد FragmentedBytes؟
  • هل الفرق بين TotalMemory وذاكرة العمليّة كبير؟
  • هل تغيّر الاتّجاه بعد النشر؟

عند إدراجها في سجلّ التطبيق، احذر الإفراط في الإخراج. فإجراء تشخيص ثقيل بتواتر عالٍ يصبح هو نفسه عبئاً.

18. معيار الجزم بوجود «تسريب ذاكرة»

في نهاية التحقيق، اجعله قابلاً للشرح بالشكل التالي.

الظاهرة:
  عند تنفيذ /api/report/export مئة مرّة، يبقى GC Heap متزايداً بمقدار 300 ميغابايت دون أن يعود حتّى بعد إيقاف الحمل.

الملاحظة:
  حجم الكومة (heap size) لـ Gen 2 تزايد عبر dotnet-counters بما يتناسب مع عدد مرّات العمليّة.
  تزايد GC Heap أيضاً، وليس Working Set فقط.

المقارنة:
  عند مقارنة before.dmp وafter.dmp، تزايد MyApp.Models.ReportResult بمقدار 12,000 عنصر.

مصدر الإشارة:
  عبر gcroot، وُجدت إشارة من MyApp.Services.ReportCache._items.

السبب:
  ReportCache من نوع singleton، ومفتاحه معرِّف المستخدم + الوقت الحاليّ، دون حذف أو مدّة صلاحيّة أو سقف.

الإجراء:
  استُبدِل بـ MemoryCache، وضُبط سقف للحجم ومدّة صلاحيّة.
  حُوِّل عدد عناصر الذاكرة المؤقّتة إلى مقياس (metric).

بالقدرة على الشرح إلى هذا الحدّ، يصبح التقرير مترابطاً يشمل شرط إعادة الإنتاج، والقيم المُلاحَظة، والنوع المتزايد، ومصدر الإشارة، والسبب، والإجراء ── لا مجرّد «الذاكرة تتزايد».

19. نقاط يجب الانتباه إليها أثناء التحقيق

19.1 النظر عبر بناء Release

قد يختلف مظهر بناء Debug عن التشغيل الفعليّ بسبب التحسين (optimization)، وعمر المتغيّرات المحلّيّة، ومعلومات التصحيح (debug information).

في التحقيق المكافئ للإنتاج، تحقّق باستخدام بناء Release، وإعدادات قريبة من التشغيل الفعليّ، وكمّيّة بيانات قريبة منه.

19.2 عدم الحكم فور بدء التشغيل فقط

فور بدء التشغيل، تتزايد الذاكرة بسبب تهيئات متعدّدة.

خذ خطّ الأساس بعد الإحماء، وانظر هل تتزايد الذاكرة انطلاقاً منه.

19.3 عدم اتّهام النوع بناءً على تفريغ واحد

النوع الموجود في مقدّمة الكومة ليس بالضرورة هو الفاعل.

يبدو System.String وSystem.Byte[] كبيرَين في كثير من التطبيقات.

المهمّ هو هل تزايد بفارق زمنيّ، ومن الذي يملكه.

19.4 قد تحتوي التفريغات على معلومات سرّيّة

قد يحتوي تفريغ الذاكرة على الطلبات، وبيانات المصادقة، وسلاسل الاتّصال، والمعلومات الشخصيّة، وبيانات العمل.

حدِّد قواعد مكان الحفظ، والنقل خارجاً، والمشاركة، والحذف.

19.5 يشكّل أخذ التفريغ خطراً في الحاويات

إن كان حدّ ذاكرة الحاوية صارماً، فقد يحدث إنهاء OOM (OOM Kill) بسبب الذاكرة الإضافيّة أو استدعاء الصفحات (page-in) الناتج عن أخذ التفريغ.

قبل الأخذ في حاوية الإنتاج، جرِّب في بيئة staging، وتحقّق من القيمة الحدّيّة، ومساحة القرص، والصلاحيّات، وفضاء أسماء PID.

19.6 توجد تسريبات خارج GC Heap أيضاً

كونه تحقيقاً في .NET لا يعني أنّ كلّ شيء يظهر في كومة GC.

في مشاكل مثل التالية، قد تتزايد ذاكرة العمليّة حتّى مع استقرار GC Heap.

  • المكتبات الأصليّة
  • P/Invoke
  • COM
  • معالجة الصور
  • مكتبات الضغط
  • معالجة التشفير
  • برامج تشغيل قاعدة البيانات
  • المقابس
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • إفراط في عدد الخيوط

في هذه الحالة، لا يكفي dumpheap الخاصّ بـ dotnet-dump وحده. يلزم النظر إلى تشخيص نظام التشغيل، ومقاييس المكتبات الخارجيّة، والمقابض، والخيوط، والذاكرة الأصليّة.

20. التحقّق بعد الإصلاح

بعد إصلاح الموضع المشتبه بأنّه تسريب، أعِد القياس بنفس الإجراء.

قبل الإصلاح:
  بعد 100 تنفيذ، Gen 2 يزيد بمقدار +300 ميغابايت
  ReportResult يزيد بمقدار +12,000 عنصر

بعد الإصلاح:
  بعد 100 تنفيذ، يستقرّ Gen 2 ضمن +20 ميغابايت
  يعود ReportResult إلى خطّ الأساس بعد إيقاف الحمل
  يستقرّ عدد عناصر الذاكرة المؤقّتة عند سقف 1,000 عنصر

في التحقّق من الإصلاح، قارِن دائماً تحت نفس الظروف.

  • نفس كمّيّة البيانات
  • نفس عدد المرّات
  • نفس مدّة الحمل
  • نفس الإحماء
  • نفس فاصل المراقبة
  • نفس الأدوات

لا يكون تحقيق الذاكرة مقنعاً إن كانت المقارنة بين قبل الإصلاح وبعده ضعيفة.

21. الخلاصة

عندما تتزايد الذاكرة في .NET، لا تحكم فوراً بأنّه تسريب، بل افصل الأمر بالترتيب التالي.

  1. لا تحكم بناءً على Working Set / RSS فقط
  2. انظر إلى GC Heap وGen 2 وLOH وعدد مرّات GC عبر dotnet-counters
  3. قارِن بفارق زمنيّ بين أثناء الحمل وبعد إيقافه
  4. انظر إلى النوع المتزايد عبر dotnet-gcdump أو dotnet-dump
  5. انظر إلى مصدر الإشارة عبر gcroot
  6. تحقّق من static، والذاكرة المؤقّتة، والحدث، وTimer، وعمر DI، والسياق غير المتزامن
  7. إن كان GC Heap مستقرّاً، اشتبه أيضاً في الذاكرة الأصليّة أو مشاكل جهة نظام التشغيل

الفرق بين «لم يُجمَع بعد فقط» و«يوجد تسريب ذاكرة» يُحسَم في النهاية بالإشارة (reference).

إن لم يعد الكائن غير المطلوب مُشاراً إليه، يُجمَع في توقيت GC. وإن استمرّ كائن كان يُفترَض أن يكون غير مطلوب في كونه مُشاراً إليه، فلن يستطيع GC جمعه.

بعبارة أخرى، هدف التحقيق هو كالتالي.

ما الذي يتزايد؟
ما الذي يبقى بعد أيّ GC؟
من الذي يشير إليه؟
هل تلك الإشارة ضروريّة من ناحية التصميم؟

بمعرفة ذلك، يمكن ترجمة الأمر إلى نقطة إصلاح على مستوى الشيفرة، دون الانجراف وراء الرسم البيانيّ للذاكرة.

المراجع

</content>

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

هل تزايد استهلاك الذاكرة في تطبيق .NET يعني تسريب ذاكرة؟
تزايد ذاكرة العمليّة ووجود تسريب ذاكرة ليسا الشيء نفسه. يعمل GC في .NET بناءً على حالة التخصيص وعتبات الكومة (heap)، لذا قد تكون هناك حالة لم يُجمَع فيها بعد كائن لم يعد مطلوباً، أو حالة لا يستعيد فيها نظام التشغيل الذاكرة فوراً حتّى بعد الـ GC. ما ينبغي النظر إليه هو ثلاث نقاط: «هل تزداد الذاكرة التي تبقى حيّة بعد الـ GC؟» و«ما هو النوع (type) الذي يتزايد؟» و«من الذي يشير إلى ذلك الكائن؟».
ما الأدوات التي ينبغي استخدامها للتحقيق في تسريب الذاكرة في .NET؟
أوّلاً، نراقب اتّجاه Working Set وGC Heap وGen 2/LOH وعدد مرّات GC عبر dotnet-counters. ثانياً، نأخذ GC dump قبل الحمل وبعده عبر dotnet-gcdump، ونقارن Count وSize لكلّ نوع لتحديد الأنواع المتزايدة. وأخيراً، نأخذ dump للكومة عبر dotnet-dump، ونتتبّع مصدر الإشارة حتّى «لماذا لا يُجمَع» عبر dumpheap -stat وgcroot. المهمّ ليس رقماً واحداً، بل المقارنة الزمنيّة تحت نفس الظروف.
ما هي الأنماط الشائعة لتسريب الذاكرة في .NET؟
من الأمثلة النمطيّة: الإضافة المستمرّة إلى مجموعة (collection) من نوع static، والذاكرة المؤقّتة (cache) غير المحدودة بلا سقف أو مدّة صلاحيّة، وعدم إلغاء الاشتراك في حدث (event) من publisher طويل العمر، وعدم التخلّص من Timer، وعدم تحرير IDisposable، والخلط في عمر (lifetime) الحقن (DI) حيث يحتفظ singleton ببيانات خاصّة بطلب معيّن. من الأسهل فهم تسريب الذاكرة في .NET بوصفه «احتفاظاً غير مقصود» بشيء ما زال يُشار إليه رغم عدم الحاجة إليه، بدل وصفه بأنّه مجرّد «نسيان تحرير».
هل يحلّ تنفيذ GC.Collect() بشكل دوريّ مشكلة الذاكرة؟
لا يحلّها. فالـ GC القسريّ (forced GC) لا يفعل شيئاً أكثر من جمع الكائنات غير المُجمَّعة في تلك اللحظة، دون إزالة السبب الجذريّ. إن كانت المشكلة معدّل تخصيص مرتفعاً، فسيزيد GC القسريّ زمن التوقّف ويُسيء إلى الأداء، وإن كان تسريباً حقيقيّاً، فلن يُجمَع الكائن الذي ما زالت له إشارة حتّى مع GC قسريّ. يُلجَأ أحياناً في بيئة اختبار مُدارة إلى النظر في «هل يبقى الكائن بعد GC قسريّ» لأغراض التحقيق، لكن قبل إدخاله في الإنتاج كحلّ، يجب دائماً تحديد ما الذي يتزايد أوّلاً.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة