كيف أنقذ معيار واحد لغة C++ من فوضى تعدد الخيوط
تخيل: أنت تكتب برنامجًا متعدد الخيوط بلغة C++، وهو يعمل بشكل ممتاز على حاسوبك المحمول. تقوم بتحميل الكود على الخادم - فيتعطل. أو الأسوأ من ذلك، يعطي نتائج غير صحيحة، ولكن فقط مرة واحدة في الشهر. هل هذا مألوف؟ حتى عام 2011، كان هذا هو القاعدة. لم يكن لدى C++ نموذج ذاكرة موحد - مجموعة من القواعد تصف كيف ترى الخيوط المختلفة تغييرات البيانات. كل مترجم ومعالج كان يفعل ما يريد. النتيجة - فوضى، سباقات البيانات ولعنات موجهة لتعدد الخيوط.
لقد غيّر C++11 كل شيء. لقد قدم نموذج ذاكرة موحدًا. يبدو جافًا؟ لكن لا. هذا النموذج هو الأساس الحقيقي لتعدد خيوط آمن وفعال. في هذه المقالة، سننظر تحت الغطاء: كيف يعمل نموذج الذاكرة، وما هي العمليات الذرية وحواجز الذاكرة، وكيف يؤثر ذلك على كودك. إذا كنت تكتب بلغة C++ وتريد أن تكون برامجك قابلة للتنبؤ، - تابع القراءة.
المشكلة: عالم بدون نموذج ذاكرة
قبل C++11، كان تعدد الخيوط في C++ يشبه الغرب المتوحش. كان لديك خيوط (عبر pthreads، Windows API أو Boost)، لكن معيار اللغة لم يقل شيئًا عن كيفية تفاعلها عبر الذاكرة. يمكن للمترجم إعادة ترتيب التعليمات لتحسين الأداء، ويمكن للمعالج تغيير ترتيب الكتابات في ذاكرة التخزين المؤقت. في الكود أحادي الخيط، هذا آمن: النتيجة لا تتغير. لكن في الكود متعدد الخيوط - كارثة.
لنأخذ مثالًا بسيطًا:
// علم الجاهزية والبياناتint data = 0;bool ready = false;
// الخيط 1 (المنتج)void producer() { data = 42; // (1) ready = true; // (2)}
// الخيط 2 (المستهلك)void consumer() { while (!ready); // انتظار العلم std::cout << data; // نتوقع 42}بديهيًا يبدو: إذا كان ready == true، فإن data يساوي بالفعل 42. لكن بدون نموذج الذاكرة، يمكن للمترجم تبديل (1) و (2)! أو يمكن للمعالج جعل كتابة ready مرئية لنواة أخرى قبل كتابة data. نتيجة لذلك، سيرى المستهلك ready == true، لكنه سيقرأ data == 0. خطأ لا يمكن اكتشافه بواسطة مصحح الأخطاء.
كان المبرمجون يحاربون هذا يدويًا: بإدراج حواجز الذاكرة (asm volatile)، واستخدام المتغيرات المتطايرة (التي لا تحل المشكلة)، والدعاء. أخيرًا، أعطى C++11 قواعد واضحة.
ما هو نموذج الذاكرة C++11؟
نموذج الذاكرة هو قواعد رسمية تحدد تحت أي ظروف يضمن خيط واحد رؤية التغييرات التي أجراها خيط آخر. يجيب على الأسئلة:
- متى تصبح الكتابة في متغير مرئية لخيط آخر؟
- بأي ترتيب يمكن ملاحظة العمليات من خيوط مختلفة؟
- ما هو سباق البيانات (data race) وكيفية تجنبه؟
الابتكار الرئيسي في C++11 هو العمليات الذرية (std::atomic<T>) وستة نماذج لترتيب الذاكرة (memory_order). إنها تمنح المبرمج التحكم في كيفية إعادة ترتيب المترجم والمعالج للعمليات.
الآن سنحلل كيف يعمل هذا تحت الغطاء.
الذرية والترتيب: ركيزتا النموذج
العمليات الذرية
العملية الذرية هي عملية يتم تنفيذها بالكامل أو لا يتم تنفيذها على الإطلاق. بالنسبة للخيوط الأخرى، فهي غير قابلة للتجزئة. على سبيل المثال، قراءة أو كتابة int على x86 عادة ما تكون ذرية (إذا كانت محاذية)، لكن ++count ليست كذلك (هي read-modify-write). يوفر C++11 std::atomic<T> الذي يضمن الذرية للأنواع الأساسية.
#include <atomic>
std::atomic<int> counter(0);
// زيادة ذريةvoid increment() { counter.fetch_add(1); // مكافئ لـ counter++ لكن بشكل ذري}لكن الذرية هي نصف المعادلة فقط. النصف الثاني هو التر