سلسلة ++c للمحترفين مدخل إلى إدارة الموارد (Resources) وتخصيصها في Cpp


محمد الميداوي

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

تقنية RAII: تخصيص الموارد يكافئ التهيئة

تقنية RAII وتدعى اكتساب أو تخصيص الموارد هي تهيئة (Resource Acquisition Is Initialization) هو مصطلح شائع في إدارة الموارد، ويستخدم المؤشّرات الذكية (smart pointer) لإدارة الموارد في حالة الذاكرة الديناميكية، وتُمنح الموارد المكتسبة ملكية مؤشر ذكي أو مدير موارد مكافئ مباشرة عند استخدام أسلوب RAII. ولا يمكن الوصول إلى المورد إلا من خلال ذلك المدير، مما يمكِّن المدير من تتبّع مختلف العمليات الجارية على المورد.

على سبيل المثال، تحرِّر الكائنات من النوع ‎std::auto_ptr‎ مواردها تلقائيًا عندما تخرج عن النطاق، أو عندما تُحذف بطريقة أخرى.

#include <memory>
#include <iostream>
using namespace std;
int main() {
    {
        auto_ptr ap(new int(5)); 
        cout << *ap << endl; // تطبع 5
    } 
}

في المثال السابق، كان المورد هو الذاكرة الديناميكية، ودُمِّر auto-ptr ثم حُرِّر مورده تلقائيًا.

الإصدار ≥ C++‎ 11

كانت المشكلة الرئيسية في كائنات std::auto_ptr أنّها لا يمكن أن تُنسخ دون نقل الملكية:

#include <memory>
#include <iostream>
using namespace std;

int main() {
    auto_ptr ap1(new int(5));
    cout << *ap1 << endl; // تطبع 5
    auto_ptr ap2(ap1); 
    cout << *ap2 << endl; // تطبع 5
    cout << ap1 == nullptr << endl;
}

تفسير الشيفرة السابقة:

  • (auto_ptr ap2(ap1: تنسخ ap2 من ap1 وتنتقل الملكية إلى ap2.
  • cout << ap1 == nullptr << endl: تطبع القيمة 1، ويخسر ap1 ملكيته للمورد.

وبسبب دلالات النسخ الغريبة تلك فإنّ هناك قيودًا على استخدام ‎std::auto_ptr‎، مثل أنها لا يمكن أن تُستخدم في الحاويات، وذلك لمنع حذف الذاكرة مرتين: فإذا كان لدينا كائنين من النوع ‎auto_ptrs‎ يملكان نفس المورد، سيحاول كلا الكائنين تحريره عند تدميرهما.

ومن المهم منع تحرير المورد الذي سبق تحريره من قبل، إذ سيؤدي ذلك التحرير الثاني إلى حدوث مشاكل، لكن بأي حال فإن std::shared_ptr لديه طريقة لتجنب ذلك، مع عدم نقل الملكية أثناء النسخ.

#include <memory>
#include <iostream>
using namespace std;
int main() {
    shared_ptr sp2; {
        shared_ptr sp1(new int(5));    // sp1 إعطاء الملكيّة إلى
        cout << *sp1 << endl        // تطبع 5
        sp2 = sp1;            // يملك كلاهما المورد ،sp1 من sp2 نسخ
        cout << *sp1 << endl;        // تطبع 5
        cout << *sp2 << endl;        // تطبع 5
    }                // الملكية الحصرية للمورد sp2  عن النطاق وتدميره، أصبح لـ sp1 خروج
    cout << *sp2 << endl;
}                // عن النطاق، وتحرير المورد sp2 خروج

القفل Locking

هذا مثال عن قفل سيّء:

std::mutex mtx;
void bad_lock_example()
{
    mtx.lock();
    try
    {
        foo();
        bar();
        if (baz())
        {
            mtx.unlock();    // ينبغي فتح القفل عند كل نقطة خروج
            return;
        }

        quux();
        mtx.unlock();        // يحدث فتح القفل العادي هنا
    }

    catch (...)
    {
        mtx.unlock();        // ينبغي فرض فتح القفل في حال طرح اعتراض
        throw;                // والسماح للاعتراض بالاستمرار
    }
}

تلك طريقة خاطئة لتنفيذ عمليتي القفل والفتح لكائنات المزامنة (mutex)، ولا بدّ أن يتحقّق المُبرمِجُ من أنّ جميع التدفّقات (flows) الناتجة عن إنهاء الدالّة تؤدّي إلَى استدعاء ‎unlock()‎، وذلك للتأكد أنّ فتح القفل باستخدام ‎unlock()‎ سيحرّر الكائن المزامنة الصحيح. وهذه عمليات هشة كما وضحنا أعلاه، لأنّها تتطّلب من المطوّرين متابعة النمط يدويًا. ويمكن حلّ هذه المشكلة باستخدام صنف مُصمّم خصّيصًا لتنفيذ تقنية RAII:

std::mutex mtx;
void good_lock_example()
{
    std::lock_guard<std::mutex > lk(mtx);    // المنشئ يقفل.
    // المدمِّر يفتح!
    // تضمن اللغة استدعاء المدمر.
    foo();
    bar();
    if (baz())
    {
        return;
    }

    quux();
}

‎lock_guard‎ هو قالب صنف بسيط للغاية يستدعي ‎lock()‎ ويمرّر إليه الوسيط المُمرّر إلى مُنشئه ويحتفظ بمرجع إلى ذلك الوسيط، ثمّ يستدعي ‎unlock()‎ على الوسيط في مدمّره، ممّا يعني ضمان فتح قفل mutex عند خروج ‎lock_guard‎ عن النطاق. ولا يهم سبب الخروج عن النطاق سواءً كان ذلك بسبب اعتراض أو عودة مبكّرة، ذلك أن جميع الحالات سيتمّ التعامل معها، وسيفتح القفل بشكل صحيح بغض النظر عن سير البرنامج.

ScopeSuccess

الإصدار ≥ C++‎ 17

نستطيع باستخدام ‎int std::uncaught_exceptions()‎ أن ننفذ إجراءً لم يكن لينفَّذ إلّا في حالة النجاح (عدم رفع اعتراض في النطاق). وقد كانت ‎bool std::uncaught_exception()‎ فيما سبق تسمح برصد العمليّات الجارية لفكّ المكدّس (stack unwinding). انظر:

#include <exception>
#include <iostream>

template < typename F>
    class ScopeSuccess
    {
        private:
        F f;
        int uncaughtExceptionCount = std::uncaught_exceptions();
        public:
            explicit ScopeSuccess(const F &f): f(f) {}

        ScopeSuccess(const ScopeSuccess &) = delete;
        ScopeSuccess &operator=(const ScopeSuccess &) = delete;
        // f() might throw, as it can be caught normally.
        ~ScopeSuccess() noexcept(noexcept(f()))
        {
            if (uncaughtExceptionCount == std::uncaught_exceptions())
            {
                f();
            }
        }
    };
struct Foo
{
    ~Foo()
    {
        try
        {
            ScopeSuccess logSuccess
            {
            []()
                {
                    std::cout << "Success 1\n";
                }
            };
            // نجاح النطاق
            // أثناء فكّ المكدّس Foo حتى في حال تدمير
            // 0 < std::uncaught_exceptions() 
            // std::uncaught_exception() == true
        }

        catch (...) {}

        try
        {
            ScopeSuccess logSuccess
            {
            []()
                {
                    std::cout << "Success 2\n";
                }
            };

تزيد القيمة المعادة من std::uncaught_exceptions، …

            throw std::runtime_error("Failed");  
        }

وتنقص القيمة المعادة من std::uncaught_exceptions

        catch (...)
        {
        }
    }
};
int main()
{
    try
    {
        Foo foo;
        throw std::runtime_error("Failed");    // std::uncaught_exceptions() == 1
    }

    catch (...)
    {
        // std::uncaught_exceptions() == 0
    }
}

سيكون الخرج:

Success 1

ScopeFail ‏(C++‎ 17)

الإصدار ≥ C++‎ 17

يمكننا تنفيذ إجراء معيّن لا يُنفَّذ إلّا عند الفشل (رفع اعتراض في النطاق) بفضل ‎int std::uncaught_exceptions()‎، وكانت ‎bool std::uncaught_exception()‎ تسمح سابقًا برصد إن كانت هناك عملية جارية لفك المكدّس.

#include <exception>
#include <iostream>

template < typename F>
    class ScopeFail
    {
        private:
            F f;
        int uncaughtExceptionCount = std::uncaught_exceptions();
        public:
            explicit ScopeFail(const F &f): f(f) {}

        ScopeFail(const ScopeFail &) = delete;
        ScopeFail &operator=(const ScopeFail &) = delete;

يجب ألا ترفع ()f وإلا فستستدعى std::terminate، نتابع المثال …

        ~ScopeFail()
        {
            if (uncaughtExceptionCount != std::uncaught_exceptions())
            {
                f();
            }
        }
    };
struct Foo
{
    ~Foo()
    {
        try
        {
            ScopeFail logFailure
            {
            []()
                {
                    std::cout << "Fail 1\n";
                }
            };
            // نجاح النطاق
            // أثناء فكّ المكدّس Foo حتى في حال تدمير
            // 0 < std::uncaught_exceptions() في حال
            // std::uncaught_exception() == true أو سابقا

        }

        catch (...) {}

        try
        {
            ScopeFail logFailure
            {
            []()
                {
                    std::cout << "Failure 2\n";
                }
            };

تزيد القيمة المعادة من std::uncaught_exceptions ….

            throw std::runtime_error("Failed"); 
        }

تقل القيمة المعادة من std::uncaught_exceptions ….

        catch (...)
        {

        }
    }
};
int main()
{
    try
    {
        Foo foo;
        throw std::runtime_error("Failed");    // std::uncaught_exceptions() == 1
    }

    catch (...)
    {
        // std::uncaught_exceptions() == 0
    }
}

سيكون الخرج:

Failure 2

Finally/ScopeExit

إذا لم ترد كتابة أصناف خاصّة للتعامل مع بعض الموارد، فاكتب صنفًا عامًا على النحو التالي:

template < typename Function>
    class Finally final
    {
        public: explicit Finally(Function f): f(std::move(f)) {}
    ~Finally()
        {
            f();
        }    // (1) انظر أدناه
        Finally(const Finally &) = delete;
        Finally(Finally &&) = default;
        Finally &operator=(const Finally &) = delete;
        Finally &operator=(Finally &&) = delete;
        private: Function f;
    };
// عندما يخرج الكائن المُعاد عن النطاق f تنفيذ الدالة
template < typename Function>
    auto onExit(Function && f)
    {
        return Finally<std::decay_t < Function>>
        {
            std::forward<Function> (f)
        };
    }

وهذا مثال على استخدام ذلك الصنف:

void foo(std::vector<int> &v, int i)
{
    // ...
    v[i] += 42;
    auto autoRollBackChange = onExit([ &]()
    {
        v[i] -= 42;
    });
    // ... `foo(v, i + 1)` شيفرة تكرارية
}

ملاحظة (1): يجب أخذ الملاحظات التالية حول تعريف المدمّر في الاعتبار عند التعامل مع الاعتراضات:

Finally() noexcept { f(); }: std::terminate // تُستدعى في حال رفع اعتراض
Finally() noexcept(noexcept(f())) { f(); }  // إلّا في حال رفع اعتراض أثناء فك المكدّس terminate() لا تُستدعى
Finally() noexcept { try { f(); } catch (...) { /* ignore exception (might log it) */} } )
              // لا تُستدعى std::terminate، لكن لا نستطيع معالجة الخطأ (حتى في حالة عدم فك المكدّس

كائنات المزامنة وأمان الخيوط Mutexes & Thread Safety

قد تحدث مشكلة إن حاولت عدّة خيوط الوصول إلى نفس المورد، فلنفترض مثلًا أنّ لدينا خيطًا يضيف العدد 1 إلى متغيّر. فأوّل شيء يفعله هو أنّه يقرأ المتغيّر، ثم يضيف واحدًا إليه، ثم يعيد تخزينه مرّة أخرى. لنفترض الآن أنّنا هيّأنا ذلك المتغيّر عند القيمة 1، ثم أنشأنا نُسختين من ذلك الخيط، فبعد أن ينتهي كلا الخيطين، نتوقّع أن تكون قيمة ذلك المتغيّر هي 3، لكن هناك بعض المشاكل التي قد تحدث هنا، سنفصلها في الجدول التالي:

الخيط 1 الخيط 2
المرحلة 1 قراءة 1 من المتغير
المرحلة 2 قراءة 1 من المتغير
المرحلة 3 إضافة 1 إلى 1 للحصول على 2
المرحلة 4 إضافة 1 إلى 1 للحصول على 2
المرحلة 5 تخزين 2 في المتغير
المرحلة 6 تخزين 2 في المتغير

في نهاية العملية، تُخزّن القيمة 2 في المتغيّر بدلًا من 3، ذلك أن الخيط 2 يقرأ المتغيّر قبل أن يُحدِّث الخيط 1 ذلك المتغيّر. ما الحلّ إذن؟ يكون الحل في كائنات المزامنة … .

كائن المزامنة (mutex) هو كائن لإدارة الموارد، ومُصمّم لحل هذا النوع من المشاكل. فعندما يحاول خيط ما الوصول إلى مورد، فإنّه يستحوذ على كائن المزامنة لذلك المورد (resource's mutex). ويحرّر ذلك (releases) الخيطُ كائن المزامنة بمجرّد الانتهاء من العمل على المورد.

وعند استحواذ خيط على كائن مزامنةٍ فإنّ كلّ الاستدعاءات للاستحواذ على ذلك الكائن لن تعود إلى أن يُحرَّر.

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

الإصدار ≥ C++‎ 11

المكتبة std::mutexهي تطبيق كائن المزامنة في C++‎ 11.

#include <thread>
#include <mutex>
#include <iostream>
using namespace std;

void add_1(int& i, const mutex& m) { // الدالة التي ستُنفّذ في الخيط
    m.lock();
    i += 1;
    m.unlock();
}
int main() {
    int
    var = 1;
    mutex m;
    cout << var << endl; // تطبع 1

    thread t1(add_1, var, m); // إنشاء خيط مع وسائط
    thread t2(add_1, var, m); // إنشاء خيط آخر
    t1.join(); t2.join(); // انتظار انتهاء الخيطين

    cout << var << endl; // تطبع 3
}

هذا الدرس جزء من سلسلة دروس عن C++‎.

ترجمة -بتصرّف- للفصل Chapter 83: RAII: Resource Acquisition Is Initialization والفصل Chapter 132: Resource Management من كتاب C++ Notes for Professionals





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


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



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن