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

نماذج الذاكرة في C++11 وإدارتها


محمد بغات

نماذج الذاكرة

إن حاوَلَت عدّة خيوط الوصول إلى نفس الموضع من الذاكرة، فستدخل في تسابق على البيانات (data race) إذا تسبب عملية واحدة على الأقل من العمليات المُنفّذة في تعديل البيانات -تُعرف باسم عمليات التخزين store operation-، وتتسبّب سباقات البيانات تلك في سلوك غير معرَّف. ولكي نتجنبها، امنع الخيوط من تنفيذ عمليات متضاربة (Conflicting) بشكل متزامن.

يمكن استخدام أساسيات التزامن (مثل كائنات التزامن - mutex - وما شابه) لتأمين عمليات الوصول المتضاربة، وقد قدّم الإصدار C++‎ 11 نموذج ذاكرة (Memory Model) جديد، هذا النموذج قدّم طريقتين محمولتين جديدتين لمزامنة الوصول إلى الذاكرة في بيئة متعددة الخيوط، وهما: العمليات الذرية (atomic operations) والأسوار (fences).

العمليات الذرية

أصبح من الممكن الآن القراءة من موضع معيّن من الذاكرة أو الكتابة فيها باستخدام التحميل الذري (atomic load) وعمليات التخزين الذرية (atomic store)، والتي تُغلَّف في صنف القالب ‎std::atomic<t>‎ من باب التيسير، ويغلّف هذا الصنف قيمةً من النوع ‎t‎، لكن تحميلها وتخزينها إلى الكائن يكون ذريًّا.

وهذا القالب ليس متاحًا لجميع الأنواع بل هو متعلق بالتنفيذ، لكن في العادةً يكون متاحًا لمعظم (أو جميع) الأنواع العددية الصحيحة وأنواع المؤشّرات بحيث تكون الأنواع ‎std::atomic<unsigned>‎‎ و std::atomic<std::vector<foo> *>‎ متاحة، على خلاف ‎‎std::atomic<std::pair<bool,char>‎>‎‎‎.

تتميّز العمليات الذرية بالخاصّيات التالية:

  • يمكن إجراء جميع العمليات الذرية بشكل متزامن في عدّة خيوط دون الخوف من التسبب في سلوك غير معرَّف.

  • سيرى التحميل الذري (atomic load) القيمة الأولية التي بُنِي الكائن الذري عليها، أو القيمة المكتوبة فيه عبر عملية التخزين الذرية.

  • تُرتَّب عمليات التخزين الذرية (Atomic stores) في كائن ذري معيّن بنفس الطريقة في جميع الخيوط، وإذا رأى خيطٌ قيمةَ عملية تخزين ذرية ما من قبل، فإنّ عمليات التحميل الذري اللاحقة سترى إما القيمة نفسها أو القيمة المُخزّنة بواسطة عملية التخزين الذرية اللاحقة.

  • تسمح عمليات القراءة-التعديل-الكتابة (read-modify-write) الذرية بالتحميل الذري والتخزين الذري دون حدوث أي تخزين ذرّي آخر بينهما. على سبيل المثال، يمكن للمرء أن يزيد العدّادَ ذريًّا (atomically increment) عبر عدة خيوط تلقائيًا، ولن تُفقد أيّ زيادة حتى لو كان هناك تنافر (contention) بين الخيوط.

  • تتلقى العمليات الذرية مُعاملًا اختياريًا من نوع ‎std::memory_order‎، يُعرِّف الخاصّيات الإضافية للعملية بخصوص مواضع الذاكرة الأخرى.

std::memory_order الشرح
std::memory_order_relaxed لا قيود إضافية
std::memory_order_release-std::memory_order_acquire‎ إذا رأت ‎load-acquire‎ القيمة المُخزّنة بواسطة ‎store-release‎ فإنّ التخزينات المتسلسلة (stores sequenced) قبل ‎store-release‎ ستحدث قبل التحميلات المتسلسلة (loads sequenced) بعد اكتساب التحميل (load acquire).
std::memory_order_consume مثل ‎memory_order_acquire‎ ولكن تعمل مع الأحمال غير المستقلة (dependent loads) وحسب
std::memory_order_acq_rel تجمع ‎load-acquire‎ و ‎store-release‎
std::memory_order_seq_cst تناسق تسلسلي (sequential consistency)

تسمح وسوم ترتيب الذاكرة أعلاه بثلاث نماذج من ترتيب الذاكرة:

  • الاتساق المتسلسل sequential consistency.
  • الترتيب المتراخي Relaxed Ordering.
  • ترتيب اكتساب-تحرير release-acquire واكتساب-استهلاك release-consume.

الاتساق المتسلسل (Sequential Consistency)

إذا لم يعُرِّف ترتيب الذاكرة الخاص بعملية ذرية معيّنة فسيتمّ اللجوء إلى الترتيب الافتراضي، والذي هو الاتساق المتسلسل. يمكن أيضًا اختيار هذا الوضع صراحة عبر الوسْم ‎std::memory_order_seq_cst‎.

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

الترتيب المتراخي (Relaxed Ordering)

الترتيب المتراخي للذاكرة هو نقيض ترتيب الاتساق المتسلسل، ويمكن تعريفه باستخدام الوسم std::memory_order_relaxed. لا تفرض العمليات الذرية المتراخية أيّ قيود على عمليات الذاكرة الأخرى، ولا يبقى تأثير سوى أنّ العملية بحد ذاتها ستبقى ذرية.

ترتيب التحرير-الاكتساب (Release-Acquire Ordering)

يمكن وسم عملية تخزين ذرية باستخدام ‎std::memory_order_release‎، كما يمكن وسم عملية تحميل ذري باستخدام ‎std::memory_order_acquire‎. وتُسمّى العملية الأولى تخزين-تحرير (ذري) (‎(atomic) store-release) في حين تسمّى العملية الثانية تحميل-اكتساب (ذري) (‎(atomic) load-acquire‎).

وعندما ترى عمليةُ "التحميل-الاكتساب" القيمةَ المكتوبة من قبل عملية (التخزين-التحرير) فسيحدث ما يلي: ستصبح جميع عمليات التخزين المُسلسلة التي تسبق عملية (التخزين-التحرير) مرئيّة لعمليات التحميل المُسلسلة بعد عملية (التحميل-الاكتساب).

يمكن أن تحصل عمليات (القراءة-التعديل-الكتابة) الذرية أيضًا على الوسم التراكمي ‎std::memory_order_acq_rel‎. هذا يجعل الجزء المتعلّق بالتحميل الذري من العملية عبارة عن عملية تحميل-اكتساب ذرّية، بينما يصبح الجزء المتعلق بالتخزين الذري عبارة عن عملية تخزين-تحرير ذرّية.

لا يُسمح للمُصرِّف بنقل عمليات التخزين الموجودة بعد عملية تخزين-تحرير ذرّية ما، كما لا يُسمح له أيضًا بنقل عمليات التحميل التي تسبق عملية تحميل-اكتساب ذرية (أو تحميل-استهلاك).

لاحظ أيضًا أنّه لا توجد عملية تحميل-تحرير ذرّية (atomic load-release)، ولا عملية تخزين-اكتساب ذرّية (atomic store-acquire)، وأيّ محاولة لإنشاء مثل هذه العمليات سينجم عنها عمليات متراخية (relaxed operations).

ترتيب تحرير-استهلاك (Release-Consume Ordering)

عمليات تحرير-استهلاك تشبه عمليات تحرير-اكتساب، لكن هذه المرّة يوسَم الحمل الذري باستخدام std::memory_order_consume لكي يصبح عملية تحميل-استهلاك (ذرية) - ‎(atomic) load-consume operation. هذا الوضع يشبه عملية تحرير-اكتساب، مع اختلاف وحيد هو أنّه من بين عمليات التحميل المُسلسلة الموجودة بعد عملية تحميل-استهلاك، فلن تُرتَّب إلا تلك التي تعتمد على القيمة التي حُمِّلت بواسطة عملية تحميل-استهلاك.

الأسوار (Fences)

تسمح الأسوار بترتيب عمليات الذاكرة بين الخيوط، وقد يكون السور إمّا سور تحرير (release fence)، أو سور اكتساب (acquire fence).

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

فائدة نموذج الذاكرة

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

int x, y;
bool ready = false;
void init() {
    x = 2;
    y = 3;
    ready = true;
}
void use() {
    if (ready)
        std::cout << x + y;
}

يستدعي أحد الخيطين دالة ‎init()‎، بينما يستدعي الخيط الآخر (أو معالج الإشارة) الدالةَ ‎use()‎. قد يتوقع المرء أنّ الدالّة ‎use()‎ إمّا ستطبع القيمة ‎5‎، أو لن تفعل أيّ شيء، لكن هذه الحالة قد لا تحدث كل مرة، لعدّة أسباب:

  • قد تعيد وحدة المعالجة المركزية ترتيب عمليات الكتابة التي تحدث في ‎init()‎ بحيث تبدو الشيفرة التي ستنفذ على الشكل التالي:
void init() {
    ready = true;
    x = 2;
    y = 3;
}
  • قد تعيد وحدة المعالجة المركزية ترتيب عمليّات القراءات التي تحدث في ‎use()‎ بحيث تصبح الشيفرة التي ستُنفّذ على الشكل التالي:
void use() {
    int local_x = x;
    int local_y = y;
    if (ready)
        std::cout << local_x + local_y;
}
  • قد يقرّر مصرّف C++‎ إعادة ترتيب البرنامج بطريقة مماثلة لتحسين الأداء.

لو كان البرنامج يُنفّذ في خيط واحد فلا يمكن أن يتغيّر سلوك البرنامج نتيجة لإعادة الترتيب، ذلك لأنّ الخيط لا يمكن أن يخلط بين استدعائي ‎init()‎ و ‎use()‎. أمّا في حالة الخيوط المتعددة، فقد يرى أحد الخيوط جزءًا من عمليات الكتابة التي يؤدّيها الخيط الآخر، حيث أنّ ‎use()‎ قد ترى ‎ready==true‎ وكذلك المُهملات (garbage) في ‎x‎ أو ‎y‎ أو كليهما.

يسمح نموذج الذاكرة في C++‎ للمبرمج بأن يعيد تعريف عمليات إعادة الترتيب المسموح بها أو غير المسموح بها، بحيث يمكن توقّع سلوك البرامج متعددة الخيوط. يمكن إعادة كتابة المثال أعلاه بطريقة ملائمة لتعدّد الخيوط على النحو التالي:

int x, y;
std::atomic < bool > ready {
    false
};
void init() {
    x = 2;
    y = 3;
    ready.store(true, std::memory_order_release);
}
void use() {
    if (ready.load(std::memory_order_acquire))
        std::cout << x + y;
}

في المثال أعلاه، تُجري دالة ‎init()‎ عملية تخزين-تحرير ذرية، هذا لن يخزّن قيمة ‎true‎ في ‎ready‎ وحسب، ولكن سيُخطِر أيضًا المُصرِّف بأنّه لا يمكن نقل هذه العملية قبل عمليات الكتابة المتسلسلة التي تسبقها.

كذلك تُجري الدالّة ‎use()‎ عملية تحميل-اكتساب ذرية. إذ تقرأ القيمة الحالية لـ ‎ready‎، وتمنع المُصرِّف من تقديم عمليات القراءة المتسلسلة اللاحقة قبل عملية تحميل-اكتساب الذرية.

هذه العمليات الذرية تجعل المصرّف يضع الإرشادات الضرورية المتعلقة بالجهاز لإبلاغ وحدة المعالجة المركزية بالامتناع عن تقديم عمليات إعادة الترتيب غير المرغوب فيها.

ونظرًا لأنّ عملية تخزين-تحرير الذرية تقع في نفس موقع الذاكرة الخاص بعملية تحميل-اكتساب، ينصّ نموذج الذاكرة على أنّه إذا كانت عملية تحميل-اكتساب ترى القيمة المكتوبة بواسطة عملية تخزين-تحرير، فإنّ جميع عمليات الكتابة التي تُنفّذ بواسطة دالة الخيط ‎init()‎ التي تسبق عملية التخزين-التحرير (store-release) ستكون مرئية للتحميلات التي تنفّذها دالة الخيط ‎use()‎ بعد عملية تحميل-اكتساب. أي أنّه في حال رأت الدالةُ ‎use()‎ تعليمة ‎ready==true‎، فسترى كذلك بالضرورة ‎x==2‎ و ‎y==3‎.

لاحظ أنّ المُصرِّف ووحدة المعالجة المركزية لا يزالان يستطيعان الكتابة في ‎y‎ قبل الكتابة في ‎x‎، وبالمثل يمكن أن تحدث عمليات القراءة من المتغيّرات في ‎use()‎ وفق أيّ ترتيب.

مثال على الأسوار (Fences)

يمكن أيضًا تقديم المثال أعلاه باستخدام الأسوار والعمليات الذرية المتراخية:

int x, y;
std::atomic < bool > ready {
    false
};
void init() {
    x = 2;
    y = 3;
    atomic_thread_fence(std::memory_order_release);
    ready.store(true, std::memory_order_relaxed);
}
void use() {
    if (ready.load(std::memory_order_relaxed)) {
        atomic_thread_fence(std::memory_order_acquire);
        std::cout << x + y;
    }
}

إذا رأت عملية التحميل الذرية القيمةَ المكتوبة بواسطة عملية التخزين الذري، فسيحدث التخزين قبل التحميل، وكذلك الحال مع الأسوار: يحدُث تحرير السور قبل اكتساب السور، ما يجعل عملية الكتابة في ‎x‎ و ‎y‎ التي تسبق سور التحرير مرئية للعبارة ‎std::cout‎ التي تلي اكتساب السور.

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

void block_and_use() {
    while (!ready.load(std::memory_order_relaxed))
    ;
    atomic_thread_fence(std::memory_order_acquire);
    std::cout << x + y;
}

ويستمرّ تنفيذ الدالّة ‎block_and_use()‎ إلى أن تُضبَط قيمة راية ‎ready‎ بمساعدة التحميل الذري المتراخي، ثم يُستخدَم سور اكتساب واحد لتوفير ترتيب الذاكرة المطلوب.

إدارة الذاكرة (Memory management)

التخزين الحرّ (Free Storage)

مصطلح "الكومة" (heap) هو مصطلح عام في الحوسبة يشير إلى مساحة من الذاكرة يمكن تخصيص أجزاء منها أو تحريرها بشكل مستقل عن الذاكرة التي يوفرها المكدّس (stack). ويسمي المعيار في ++C هذه المساحة بالتخزين الحر، وهو أكثر دقة من الكومة.

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

تُستخدم الكلمتان المفتاحيتان new و delete لتخصيص الذاكرة الخام (Raw memory) وتحريرها.

float *foo = nullptr; {
    *foo = new float;        // تخصيص ذاكرة لعدد عشري
    float bar;            // تخصيص المكدّس
}                // ما تزال باقية foo لكنّ bar نهاية

delete foo;        // وهذا يؤدي إلى جعل المؤشّر غير صالح ،pF حذف ذاكرة العدد العشري الموجودة عند
foo = nullptr;        // من الممارسات السيئة `nullptr` يُعد ضبط المؤشر عند القيمة.

من الممكن أيضًا تخصيص ذاكرة للمصفوفات ذات الحجم الثابت بكلمتيْ new و delete، لكن مع صيغة مختلفة قليلاً، ذلك أن تخصيص ذاكرة المصفوفات يختلف عن تخصيص الذاكرة للكائنات الأخرى، وسيؤدّي خلط الاثنتين إلى عطب في الكومة (heap corruption).

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

// تخصيص ذاكرة مؤلّفة من 256 عددًا صحيحًا
int *foo = new int[256];
// حذف المصفوفة
delete[] foo;

سيُنفَّذ المنشئ والمدمّر -كما هو حال الكائنات في المكدّس (Stack based objects)- عند استخدام new و delete بدلًا من malloc و free، لهذا فإنّ خيار new و delete خير من malloc و free. انظر المثال التالي حيث نخصص ذاكرة لنوعٍ ComplexType، ونستدعي منشئه، ثم نستدعي مدمر ()ComplexType ونحذف ذاكرة Complextype عند الموضع pC.

struct ComplexType {
    int a = 0;
    ComplexType() {
        std::cout << "Ctor" << std::endl;
   }
   ~ComplexType() {
        std::cout << "Dtor" << std::endl;
    }
};

ComplexType *foo = new ComplexType();
delete foo;

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

يوصى باستخدام المؤشّرات الذكية منذ الإصدار C++‎ 11 للإشارة إلى المِلكِيّة.

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

C++‎ 14 أضافت ‎std::make_unique‎ إلى مكتبة القوالب القياسية STL، مغيرة بذلك الإرشادات لتفضيل ‎std::make_unique‎ أو std::make_shared على استخدام new و delete.

new

قد لا ترغب في بعض الحالات في الاعتماد على التخزين الحرّ (Free Store) لتخصيص الذاكرة، وتريد تخصيص ذاكرة مخصصة باستخدام ‎new‎.

عندئذ يمكنك استخدام ‎Placement New‎، بحيث تخبر المعامل "new" بأن يخصّص الذاكرة من موضع مُخصص مسبقًا. انظر:

int a4byteInteger;
char *a4byteChar = new (&a4byteInteger) char[4];

في المثال السابق ، الذاكرة المشار إليها عبر ‎a4byteChar‎ هي 4 بايت، مخصصة "للمكدّس" عبر المتغيّر الصحيح ‎a4byteInteger‎.

وفائدة هذا النوع من تخصيص الذاكرة أنه سيكون للمبرمجين تحكّم كامل في التخصيص، فبما أن ذاكرة ‎a4byteInteger‎ في المثال أعلاه مُخصّصة في المكدّس فلن تحتاج إلى استدعاء صريح لـ a4byteChar delete. يمكن تحقيق نفس السلوك في حالة تخصيص ذاكرة ديناميكية أيضًا. مثلّا:

int *a8byteDynamicInteger = new int[2];
char *a8byteChar = new (a8byteDynamicInteger) char[8];

يشير مؤشّر الذاكرة ‎a8byteChar‎ في هذه الحالة إلى الذاكرة الديناميكية المخصصة عبر ‎a8byteDynamicInteger‎. لكن مع ذلك، سنحتاج في هذه الحالة إلى استدعاء ‎a8byteDynamicInteger‎ صراحةً لتحرير الذاكرة.

هذا مثال آخر:

#include <complex>
#include <iostream>

struct ComplexType {
    int a;
    ComplexType(): a(0) {}
    ~ComplexType() {}
};
int main() {
    char* dynArray = new char[256];

نستدعي منشئ ComplexType لتهيئة الذاكرة كـ ComplexType، نتابع …

    new((void* ) dynArray) ComplexType();
    // تنظيف الذاكرة بعد الانتهاء
    reinterpret_cast<ComplexType*>(dynArray)->~ComplexType();
    delete[] dynArray;
    // placement new يمكن أيضا استخدام ذاكرة المكدّس مع
    alignas(ComplexType) char localArray[256]; //alignas() available since C++11
    new((void* ) localArray) ComplexType();
    // لا تحتاج إلى استدعاء المدمّر إلا لذاكرة المكدّس
    reinterpret_cast<ComplexType*>(localArray)->~ComplexType();
    return 0;
}

المكدّس

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

int main() {
    int a = 0; // مُخزّنة على المكدّس
    return a;
}

وقد سُمِّي مكدّسًا لأنّ الاستدعاءات المتسلسلة للدوال ستكون لها ذاكرة مؤقتة "مُكدّسة" فوق بعضها البعض، وكل واحدة ستستخدم قسمًا صغيرًا منفصلًا من الذاكرة. في المثال التالي، ستوضع f على المكدس في النهاية بعد كل وضع كل شيء (انظر 1)، وتوضع d كذلك بعد كل شيء في نطاق ()main (انظر 2):

float bar() {
    // (1)
    float f = 2;
    return f;
}
double foo() {
    // (2)
    double d = bar();
    return d;
}
int main() {
    // foo() لا تُخزّن في المكدّس أيّ متغيرات خاصّة بالمستخدم إلى حين استدعاء
    return (int) foo();
}

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

int* pA = nullptr;
void foo() {
    int b = *pA;
    pA = &b;
}
int main() {
    int a = 5;
    pA = &a;
    foo();
    // خارج النطاق pA سلوك غير معرَّف، أصبحت القيمة التي يشير إليها
    a = *pA;
}

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

ترجمة -بتصرّف- للفصل Chapter 115: Memory management و Chapter 116: C++11 Memory Model من كتاب C++ 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.


×
×
  • أضف...