محتويات الدورة

C++ / المتقدم / الدرس 17

التزامن والمزامنة

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

وقت القراءة التقريبي: 4 دقيقةتاريخ مراجعة المحتوى
في هذه الصفحة

أهداف الدرس

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

سباق البيانات أكثر من تحديث مفقود

قد تعمل الخيوط بالتوازي وتلاحظ الذاكرة المشتركة وفق نموذج ذاكرة C++. يشمل التعارض كتابة وقراءة أخرى أو كتابة إلى الموقع نفسه. قد تنشئ الأفعال غير الذرية المتعارضة المحتمل تزامنها دون ترتيب يحدث-قبل المطلوب سباق بيانات، وهو سلوك غير معرّف. لا تنسب إلى برنامجه مجال نتائج عدّاد كأنه حساب عشوائي صالح. قد يكون ترتيب يعتمد على الجدولة معرّفًا تمامًا في برنامج مضبوط المزامنة؛ فالسلوك غير المعرّف وعدم الحتمية العادي ادعاءان مختلفان.

حماية انتقال الحالة كاملًا

يحمي القفل خاصية فقط إذا التزم كل وصول متعلق بها بنظام القفل نفسه. تكتسب std::lock_guard القفل في منشئها وتحرره في مدمّرها بما يشمل الاستثناء. يحدّث المثال الأول حقلين مرتبطين تحت قفل واحد فيحافظ على تساويهما. لا تطبع الخيوط العاملة، فيستقل الإخراج النهائي عن الجدولة. يتلف نطاق داخلي كائني jthread من C++20 قبل قراءة العدادات؛ وتنتظر مدمّراتهما الخيط إذا كان قابلًا للضم. على GCC/Linux ترجم بـ-pthread إلى جانب -std=c++20، وافحص دعم خيوط المنصة في غيرها.

cpp قفل واحد يحمي خاصية حقلين
#include <iostream>
#include <mutex>
#include <thread>
int main() {
    int items{};
    int recorded{};
    std::mutex mutex;
    auto work = [&] {
        for (int i{}; i < 1000; ++i) {
            std::lock_guard<std::mutex> lock{mutex};
            ++items;
            ++recorded;
        }
    };
    { std::jthread first{work}; std::jthread second{work}; }
    std::cout << items << " " << recorded << "\n";
}
الناتج المتوقع
2000 2000

كل زوج زيادات محمي. يكتمل ضم الخيطين قبل القراءتين النهائيتين فتنعكس ألفا عملية في كل حقل.

للذرية نطاق دقيق

تدعم std::atomic<int> عمليات لا تتجزأ على عدد واحد. تجمع fetch_add القراءة والتعديل ذريًا؛ وقد يفقد load يتبعه store زيادات منطقية مع أن كليهما ذري. يحتاج مثال العداد relaxed إلى الذرية ولا يستخدم قيمة العداد لنشر بيانات أخرى. يسبق اكتمال الخيوط وضمها القراءة النهائية. الاتساق التسلسلي هو الافتراضي ونقطة بداية مفيدة؛ ولا تختَر ترتيبًا أضعف إلا بحجة مكتوبة تحدد المزامنة غير اللازمة. ولا تعني atomic بالضرورة الخلو من الأقفال.

cpp عدّاد دون بروتوكول نشر بيانات
#include <atomic>
#include <iostream>
#include <thread>
int main() {
    std::atomic<int> count{0};
    auto work = [&] {
        for (int i{}; i < 1000; ++i) {
            count.fetch_add(1, std::memory_order_relaxed);
        }
    };
    { std::jthread first{work}; std::jthread second{work}; }
    std::cout << count.load(std::memory_order_relaxed) << "\n";
}
الناتج المتوقع
2000

تُحفظ ألفا زيادة ذرية. لا يدّعي المثال أن العمليات relaxed تنشر حالة أخرى غير ذرية.

تنسيق الشروط والإيقاف

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

إظهار النشر والتحقق

يتطلب نشر بيانات عادية بعلم ذري علاقة مزامنة مناسبة، ككتابة release تراها قراءة acquire، وعمر مورد يغطي القراءة. لا يوفر علم relaxed وحده العلاقة. ابدأ بالأقفال للحالة المركبة ما لم يبرر متطلب واضح تصميمًا دون أقفال. شغّل ThreadSanitizer منفصلة على منصة مدعومة وكرر مسارات الإغلاق؛ فلا يثبت تشغيل سليم أمان كل التداخلات. راجع ترتيب الأقفال والمراجع الملتقطة وأعمار المهام واستثناءات العمال. ينهي الاستثناء غير الملتقط الخارج من دالة خيط العملية.

الأخطاء الشائعة وإصلاحها

وصف عدّاد بلا مزامنة بأنه غير دقيق فقط.

لماذا يحدث؟
أُهمل السلوك غير المعرّف لسباق البيانات.
كيف تصلحه؟
استخدم نظام قفل واحدًا أو عملية ذرية مناسبة.
كيف تتجنب تكراره؟
صنّف كل قراءة وكتابة مشتركة قبل التشغيل.

load ثم store ذريتان تفقدان زيادات.

لماذا يحدث؟
لم يكن التحديث المركب ذريًا.
كيف تصلحه؟
استخدم fetch_add أو احمِ الانتقال كاملًا بقفل.
كيف تتجنب تكراره؟
ميّز ذرية الوصول من صحة العملية.

الإغلاق ينتظر إلى الأبد.

لماذا يحدث؟
لا يراقب العامل الإلغاء أو لا يُحرر انتظاره.
كيف تصلحه؟
عرّف ونفّذ بروتوكول توقف تعاونيًا يراعي الإيقاظ.
كيف تتجنب تكراره؟
اختبر الإلغاء أثناء الخمول والمعالجة.

اختبر فهمك

1. هل يكفي استبدال items بـatomic<int> لحفظ شرط تساويه مع recorded أمام مراقبين متزامنين؟ وضّح الحد الصحيح.

اعرض الحل المشروح

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

2. يكتب منتج payload ثم يضبط ready بكتابة relaxed، وينتظر مستهلك بقراءة relaxed ثم يقرأ payload. صف تصحيحًا دون تشغيل النسخة ذات السباق.

اعرض الحل المشروح

استخدم كتابة release إلى ready وقراءة acquire ترى النشر، دون تعديل لاحق متعارض لـpayload ومع بقاء عمرها حتى القراءة. يوفر ذلك ترتيب الكتابة السابقة المطلوب. وغالبًا يكون شرط محمي بقفل أبسط عند الانتظار أو تكرار النشر أو الإلغاء أو الحالة المركبة.

أهم ما تعلمته

  • سباق البيانات سلوك غير معرّف لا تنوع نتائج محدود.
  • احمِ الخصائص كاملة واضمم الخيوط قبل المراقبة النهائية.
  • تحتاج الذرية والنشر والإغلاق التعاوني إلى حجج منفصلة.

المراجع الرسمية