اذهب إلى المحتوى

إدارة الذاكرة في dot NET


رضوى العربي

تُخزَّن الكائنات المُنشئة باستخدام العَامِل new بقسم الكَوْمَة المُدار في الذاكرة (managed heap). يُوفِّر اطار عمل ‎.Net كَانِس المُهملات (Garbage Collector)، والذي يُدير الذاكرة ويُنْهِي (finalize) الكائنات المهملة دون أيّ تَدَخُّل صريح من المُبرمج.

تَستعرِض بعض الأمثلة التالية طريقة عمل كَانِس المُهملات وسُلوكه بشئ من التفصيل، ويُوضِح بعضها الآخر كيفية إعداد الأنواع بطريقة تَسمَح لكَانِس المُهملات بإدارتها بشكل سليم.

مفهوم الكائنات الحية والكائنات الميتة

كقاعدة عامة، يُعدّ الكائن حيًّا (live object) إذا ما زال بالإِمكان إعادة اِستخدَامه، ويُعدّ مَيتًا (dead object) إذا أصبح ذلك غير ممكن. ما يُحدِّد إِمكانية الاستخدَام مِن عدمه هو وجود مُتغيّر أو حَقْل واحد على الأقل يَحمِل مَرجِعًا (reference) إلى مكان الكائن بالذاكرة. في حالة خروج كل مُتغيّرات مَراجِع كائن ما -هذا إن وُجدت أساسًا في مرحلة ما أثناء التَّنفيذ- من النطاق (scope)، يُعدّ الكائن مَيتًا ويُنْهَى (finalize) عند إجراء عملية كَنْس.

مثال 1

في الشيفرة التالية، نُعرِّف النوع FinalizableObject، والذي يَحوِي فقط باني النوع (constructor) ومُنْهِيه (finalizer):

public class FinalizableObject
{
    public FinalizableObject()
    {
        Console.WriteLine("Instance initialized");
    }
    ~FinalizableObject()
    {
        Console.WriteLine("Instance finalized");
    }
}

نُنشئ كائنًا من هذا النوع:

new FinalizableObject(); 

مما يُنتِج الخْرج التالي بالرغم مِن أننا لم نَستخدِم الكائن بَعْد:

<namespace>.FinalizableObject initialized

لن يُنْهَى الكائن حتى يَنتهي عَمَل البرنامج، والذي يؤدي في الواقع إلى إنهاء جميع الكائنات وتحرير مساحتها من قسم الكَوْمَة بالذاكرة. ومع ذلك يُمكنك استدعاء التابع Collect لإجبار كَانِس المُهملات (Garbage Collector) على إجراء عملية كَنْس خلال لحظة معينة، كالتالي:

new FinalizableObject(); 
GC.Collect();

مما يُنتِج الخْرج التالي:

<namespace>.FinalizableObject initialized
<namespace>.FinalizableObject finalized

أُنْهيت جميع الكائنات الميتة غير المُستخدَمة (dead objects)، وحُرِّرت مساحتها من قسم الكَوْمَة بالذاكرة بمجرد اِستدعاء كَانِس المُهملات.

مثال 2

بِفَرْض أن كلًا من النوعين FinalizableObject1 و FinalizableObject2 مُشتقَّين من النوع FinalizableObject المُعرَّف مُسبقًا ويَرِثا نفس سُلوك طباعة رسائل التهيئة والإنهاء، نُنشِئ الكائنات التالية:

var obj1 = new FinalizableObject1(); 
var obj2 = new FinalizableObject2(); 

obj1 = null; // (1)

GC.Collect(); // (2)

يكون الخْرج كالتالي:

<namespace>.FinalizableObject1 initialized
<namespace>.FinalizableObject2 initialized
<namespace>.FinalizableObject1 finalized

(1) لمّا أُسْنِدت القيمة الفارغة null إلى المُتغيّر obj1 (كان يَحمِل مَرجِعا إلى الكائن من النوع FinalizableObject1)، أصبح مِن غير الممكن -بطبيعة الحال- الولوج لهذا الكائن مرة أُخرى وبالتالي عُدّ ميتًا. ولذلك عندما اُستدعِي كَانِس المُهملات في وقت لاحق من تنفيذ الشيفرة (2)، أنهاه وحرَّر مساحته بقسم الكَوْمَة بالذاكرة، ويَظهَر ذلك جَلّيًا من خلال طباعة عبارة المُنْهِي (finalizer). على النقيض، ما يزال هناك مَرجِع للكائن من النوع FinalizableObject2 وبالتالي عُدّ حيًّا ولم يُنْهَ.

مثال 3

بِفَرْض أن النوع FinalizableObject يَحمِل خاصية عَلّنية (public) من نفس نوعه تُسمَّى OtherObject. ماذا سيحُدث لو كان هناك كائنين ميتين، يَحمِل كلًا منهما مَرجِعًا للآخر؟ كالتالي:

var obj1 = new FinalizableObject1();
var obj2 = new FinalizableObject2();

obj1.OtherObject = obj2;
obj2.OtherObject = obj1;

obj1 = null; // لم يعد هناك مَرجِع للكائن من النوع FinalizableObject1 
obj2 = null; // لم يعد هناك مَرجِع للكائن من النوع FinalizableObject2

// لكن كلا منهما ما يزال يحمل مرجعا للآخر
GC.Collect()

يكون الخْرج كالتالي:

<namespace>.FinalizedObject1 initialized
<namespace>.FinalizedObject2 initialized
<namespace>.FinalizedObject1 finalized
<namespace>.FinalizedObject2 finalized

على الرغم من أن كِلاَ الكائنين يَحمِل مَرجِعا إلى الكائن الآخر، يَقوُم كانس المُهملات بإنهائهما ويُحرِّر مساحتهما من قسم الكَوْمَة بالذاكرة؛ وذلك لعدم وجود أي مَرجِع لهما ضِمْن كائن حيّ.

المَراجِع الضعيفة (Weak References)

المَراجِع الضعيفة WeakReference هي -كأيّ مَرجِع- تُشير إلى مكان كائن مُعين بالذاكرة. لا يُعوِّل كَانِس المُهملات على وجود المَراجِع الضعيفة (weak references) عند تقديره لحالة الكائن من حيث كَوْنه حيًا أو ميتًا. وبالتالي، لا يَمنع وجود مَرجِع ضعيف إلى كائن معين كَانِس المُهملات من إنهاء هذا الكائن وتَحرير مساحته بالذاكرة، ومن هنا كان عَدّها ضعيفة (weak).

انظر المثال التالي:

var weak = new WeakReference<FinalizableObject>(new FinalizableObject());

GC.Collect();

يُنتِج الخْرج التالي:

<namespace>.FinalizableObject initialized
<namespace>.FinalizableObject finalized

حَذَفَ كَانِس المُهملات -عند اِسْتِدْعائه- الكائن من قسم الكَوْمَة بالذاكرة على الرغم من وجود مَرجِعًا إليه من النوع WeakReference داخل النطاق (scope). نَستنتِج من ذلك:

أولًا: مِن غيْر الآمن أن تَفترِض أن المساحة المُخصَّصة بقسم الكَوْمَة لكائن مُشار إليه بمَرجِع ضعيف لا تزال صالحة.

ثانيًا: عندما تحتاج إلى تحصيل (dereference) قيمة مَرجِع ضعيف، اِستخدِم التابع TryGetTarget أولًا والذي يُعيد قيمة منطقية تُحدِّد إذا ما حُرِّرت مساحته بالذاكرة أم لا، ثم اُكتب شيفرة لمُعالجة كِلاَ الحالتين. كالتالي:

var target = new object(); 
var weak = new WeakReference<object>(target); 

target = null; 

// فحص ما إذا كان الكائن ما زال مُتاحًا
if(weak.TryGetTarget(out target))
{
    // يُمكنك استخدام الكائن هنا
}
else
{
    // لا ينبغي استخدام الكائن هنا
}

تُوفِّر جميع إصدارات إطار عمل ‎.NET نسخة غير مُعمَّمة من النوع WeakReference والتي تَعمَل بنفس الطريقة. في المقابل، دُعِّمت النسخة المُعمَّمة منذ اصدار 4.5.

انظر المثال التالي للنسخة الغيْر مُعمَّمة:

var target = new object(); 
var weak = new WeakReference(target); 

target = null; 

if (weak.IsAlive)
{
    target = weak.Target;
    // يُمكنك استخدام الكائن هنا
}
else
{
   // لا ينبغي استخدام الكائن هنا
}

التابع Dispose والمنهيات (finalizers)

إذا أردت التأكد من تحرير الموارد (resources) التي يَستخدِمها كائن مُعين بمجرد أن يَخْرُج من النطاق ويُصبح من غير الممكن اِستخدَامه -خاصة إن كانت الموارد مُستَنزِفة للذاكرة أو غير مُدارة مثل التَعامُل مع الملفات فقد يُبلَّغ عن اعتراض عند محاولة فتح ملف للقراءة ولا يُغلَق مِقبَض الملف (file handle) كما يَنبغي-، صَرِّح عن كَوْن نوع الكائن من الواجهة IDisposable والتي تحتوي على تابع وحيد هو Dispose لا يَستقبِل أي مُعامِلات:

public interface IDisposable
{
    Dispose();
}

ثم نفِّذ تابعها Dispose داخل النوع المَذكور بحيث يكون هذا التابع مَسئولًا عن تحرير تلك الموارد.

بخلاف المُنْهِيات (finalizers) التي تُستَدعَى دائمًا عند انتهاء فترة حياة الكائن، يَقع عاتِق اِستِدعاء التابع Dispose على المُبرمِج وهو ما ليس مَضْمُونًا. يُمكن للمُبرمِج اِستِدعاء التابع Dispose صراحةً، كالمثال التالي:

private void SomeFunction()
{
    // هيئ كائن يستهلك موارد مستنزفة للذاكرة
    var disposableObject = new ClassThatImplementsIDisposable();

    // ‫استدعي التابع Dispose
    disposableObject.Dispose();

    // (1)
}

(1): يَخرُج المُتغيّر disposableObject من النطاق (scope). مع ذلك، لا يُمكننا الجزم بموعد إنهاء (finalize) الكائن لنفسه، ولذلك يَضمَن اِستِدعاء التابع Dispose تحرير موارده المُستَنزِفة للذاكرة.

ربما تَستَدعِيها كذلك صراحةً داخل كُتلة finally، كالمثال التالي:

StreamReader sr;
string textFromFile;
string filename = "SomeFile.txt";

try
{
    sr = new StreamReader(filename);
    textFromFile = sr.ReadToEnd();
}
finally
{
    if (sr != null) 
        sr.Dispose();
}

أو من خلال اِستخدَام عبارة using والتي في الواقع يُفضَّل اِستخدَامها، وسيُحوِّلها المُصرِّف آليًا إلى نفس الشيفرة الموجودة بالأعلى، كالتالي:

string textFromFile;
string filename = "SomeFile.txt";

using (StreamReader sr = new Streamreader(filename))
{
    textFromFile = sr.ReadToEnd();
}

يُعدّ التَعامُل مع الأنواع التي يَكون إطار العمل مسؤولًا عن تَنشِئة كائناتها مثال آخر. في هذه الحالة، عادة ما يُشتقّ النوع الجديد من نوع أساسي (base). مثلًا، عندما تُعْلِن عن نوع مُتَحكِم جديد (controller)، يَرِث من النوع الأساسي System.Web.Mvc.ControllerBase. فإذا كان النوع الأساسي يُنفِّذ الواجهة IDisposable، غالبًا ما يَعني ذلك أن اطار العمل سيَستَدعِي التابع Dispose بطريقة سليمة، ولكن هذا ليس مَضْمُونًا.

لا يُعدّ التابع Dispose بديلًا للمُنْهِي (finalizer)، لكن ينبغي أن تَستخدِم كليهما بحسب الغرض:

  • يُحرِّر المُنْهِي الموارد -على أيّ حال- لتَجَنُّب حُدوث أي تَسرُّب للذاكرة (memory leaks).
  • يُحرِّر التابع Dispose الموارد بمجرد إنتهاء الحاجة إليها؛ لتخفيف الضغط على الذاكرة المُخصَّصة (memory allocation) عامةً.

استخدام النوع SafeHandle لتغليف الموارد غير المُدارة

عند كتابة مُغلِّف (wrapper) لموارد غير مُدارة، اِحرص على أن يُشتقّ المُغلِّف من النوع SafeHandle بدلًا من أن تُنفِّذ الواجهة IDisposable أو تُنشِئ مُنْهِي (finalizer) بنفسك، حيث يُنفِّذ هذا النوع بالفعل الواجهة من أجلك، ويُهيِئ المُنْهِيات (finalizers) تهيئة مناسبة، كما يَضمَن تَّنفيذ شيفرة التحرير.

يجب أن يَكون النوع المُشتقّ من الصنف SafeHandle صغيرًا وبسيطًا لتقليل احتمالية تَسريب المِقبَض (handle). يتأكد النوع SafeHandle من تحرير أي موارد غيْر مُدارة حتى في حالة حدوث تَسريب لكائنات المُغلِّف.

انظر المثال التالي:

using System.Runtime.InteropServices;
class MyHandle : SafeHandle
{
    public override bool IsInvalid => handle == IntPtr.Zero;
    public MyHandle() : base(IntPtr.Zero, true)
    { }
    public MyHandle(int length) : this()
    {
        SetHandle(Marshal.AllocHGlobal(length));
    }
    protected override bool ReleaseHandle()
    {
        Marshal.FreeHGlobal(handle);
        return true;
    }
}

تنبيه: لا يَتعدَّى المثال بالأعلى كَوْنِه مجرد محاولة لاستعراض طريقة اِشتقاق SafeHandle. وعليه وجب التنبيه أنه من العبث تَخصيص جزء من الذاكرة بهذه الطريقة.

تحرير الكائنات وإنهائها بشكل سليم

يَستهدِف كلًا من التابع Dispose والمُنْهِيات (finalizer) ظرفًا مختلفًا. ولذلك، من الضروري للأنواع التي تَستخدِم موارد مُستَنزِفة للذاكرة أن تُنفِّذ كلتا الطريقتين. وبالنتيجة، نحصل على نوع يُمكنه التعامُل المُلائم مع كِلاَ المَوقِفيّن المُحتملين:

  • اِستدعاء المُنْهِي فقط.
  • اِستدعاء التابع Dispose أولًا ثم اِستدعاء المُنْهِي فيما بعد.

أحد الحلول هو كتابة شيفرة التنظيف (cleanup) بطريقة قابلة للتنفيذ أكثر من مرة بدون وجود اختلاف بالنتيجة. تَعتمِد تلك القابلية على طبيعة عملية التنظيف نفسها، على سبيل المثال:

  • لا تُحدِث محاولة إغلاق اتصال بقاعدة بيانات (connection) تم إغلاقه مُسبقّا فرقًا.

  • قد يؤدي تحديث عَداد إلى نتائج خاطئة عند اِستدعاء الشيفرة مرتين بدلًا من مرة واحدة.

حل آخر هو التأكد من تَّنفيذ شيفرة التنظيف (cleanup) مرة واحدة فقط بغض النظر عن السياق الخارجي، وهو ما يُمكن عادةً تحقيقه باستخدام مُتغيّر راية (flag) مُخصَّص لهذا الغرض. كالتالي:

public class DisposableFinalizable1: IDisposable
{
    private bool disposed = false;
    ~DisposableFinalizable1() { Cleanup(); }
    public void Dispose() { Cleanup(); }
    private void Cleanup()
    {
        if(!disposed)
        {
            // ضمن الشيفرة الفعلية المسئولة عن تحرير الموارد هنا

            disposed = true;
        }
    }
}

كذلك يمكن تحقيقه باِستدعاء التابع SuppressFinalize()‎ في حالة اِستدعاء التابع Dispose. يُوفِّر كانس المُهملات هذا التابع الذي يَسمَح بتخطي تَّنفيذ المُنْهِي (finalizer)، كالتالي:

public class DisposableFinalizable2 : IDisposable
{
    ~DisposableFinalizable2() { Cleanup(); }
    public void Dispose()
    {
        Cleanup();
        GC.SuppressFinalize(this);
    }
    private void Cleanup()
    {
        // ضمن الشيفرة الفعلية المسئولة عن تحرير الموارد هنا
    }
}

التخزين المؤقت Caching

يُوفِّر النوع MemoryCache بإطار عمل ‎.NET العديد من التوابع لتخزين البيانات بالذاكرة (memory).

إضافة عنصر باستخدام التابع Set

يُستخدَم التابع Set لإضافة مُدخَل إلى الذاكرة المخبئية (cache)، حيث يَستقبَل مُعامل من النوع CacheItem يَحمِل كلًا من مفتاح وقيمة المُدخَل.

private static bool SetToCache()
{
    string key = "Cache_Key";
    string value = "Cache_Value";

    // ‫‏احصل على مَرجِع لنُسخة النوع MemoryCache الافتراضية   
    var cacheContainer = MemoryCache.Default;

    var policy = new CacheItemPolicy()
    {
        AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(DEFAULT_CACHE_EXPIRATION_MINUTES)
    };

    var itemToCache = new CacheItem(key, value); 
    cacheContainer.Set(itemToCache, policy);
}

لاحظ استخدام الصنف CacheItemPolicy لضبط توقيت انتهاء صلاحية المُدخَل ومَسحُه من الذاكرة المخبئية.

جلب أو إضافة عنصر باستخدام التابع AddOrGetExisting

يَجلِب التابع AddOrGetExisting قيمة المفتاح المُمرَّر إليه من الذاكرة المخبئية (cache) في حالة وجوده. أما إذا لم يُكن موجودًا، فإنه سيَستخدِم المُفوِّض valueFetchFactory المُمرَّر إليه لجَلْب قيمة يُخزِنها بالذاكرة كقيمة للمفتاح المحدَّد ثم يُعيدها.

public static TValue GetExistingOrAdd<TValue>(string key, double minutesForExpiration, Func<TValue> valueFetchFactory)
{
    try
    {
        // سيتم تقييم التهيئة المرجأة للنوع فقط في حالة عدم وجود العنصر المطلوب بالذاكرة المخبئية 
        var newValue = new Lazy<TValue>(valueFetchFactory);

        CacheItemPolicy policy = new CacheItemPolicy()
        {
            AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(minutesForExpiration)
        };

        // يعيد العنصر من الذاكرة المخبئية إذا كان موجودًا أو يُضيفه في حالة عدم وجوده
        var cachedItem = _cacheContainer.AddOrGetExisting(key, newValue, policy) as Lazy<TValue>;

        return (cachedItem ?? newValue).Value;
    }
    catch (Exception excep)
    {
        return default(TValue);
    }
}

ترجمة -وبتصرف- للفصول Memory management و Garbage Collection و System.Runtime.Caching.MemoryCache من كتاب ‎.NET Framework Notes for Professionals


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...