حالات السباق والحالة المشتركة
حالات السباق والحالة المشتركة
حالة السباق هي خطأ برمجي تعتمد فيه صحّة البرنامج على التوقيت النسبي لتنفيذ خيوط متعددة. تظهر بصورة متقطّعة، وتنجو من معظم اختبارات الوحدة، وتميل إلى الظهور في بيئة الإنتاج تحت الضغط — مما يجعلها من أصعب فئات الأخطاء تشخيصًا وإصلاحًا.
السبب الجذري دائمًا واحد: خيطان أو أكثر يقرأان ويكتبان متغيّرًا مشتركًا قابلًا للتعديل دون تنسيق.
عدّاد بسيط يسلك سلوكًا خاطئًا
لنبدأ بأبسط مثال: خيطان يزيد كل منهما عدّادًا مشتركًا مئة ألف مرة.
شغّل هذا الكود عدة مرات وستحصل على قيم كـ 134,827 أو 189,003 — لن تحصل على 200,000 بشكل موثوق. السبب أن counter++ ليست عملية ذرية.
مشكلة التحقق-ثم-التصرف
على مستوى bytecode، تتفكّك counter++ إلى ثلاث عمليات مستقلة:
- القراءة — تحميل القيمة الحالية لـ
counterمن الذاكرة الرئيسية إلى سجلّ المعالج. - الزيادة — إضافة 1 داخل السجلّ.
- الكتابة — تخزين النتيجة مجددًا في الذاكرة الرئيسية.
يمكن لجدولة الخيوط أن تعلّق خيطًا بين أي خطوتين من هذه الخطوات. إليك تشابكًا ملموسًا يُضيّع تحديثًا:
هذا ما يُسمّى التحديث الضائع. اعتقد كلا الخيطين أنهما يعملان مع القيمة 42 وكتب كلاهما 43، فالأثر الصافي هو +1 بدلًا من +2. اضرب هذا في 200,000 تكرار وسيكون الفقد ملحوظًا.
if (map.get(k) == null) map.put(k, v) هي تسلسل تحقق-ثم-تصرف. بين التحقق والتصرف يمكن لخيط آخر تغيير الخريطة. تسري القاعدة ذاتها على i++، وعلى list.size() == 0 ? list.add(x) : ...، وعلى عشرات الأنماط الأخرى التي تبدو خطوة واحدة.
لماذا يجعل نموذج ذاكرة Java الأمر أسوأ
إضافة إلى تشابك الجدولة، يسمح نموذج ذاكرة Java (JMM) لكل خيط بتخزين المتغيّرات مؤقتًا في ذاكرة مخبأة خاصة بمعالجه. قد يقرأ الخيط الأول نسخة قديمة من counter لم يُدفعها الخيط الثاني إلى الذاكرة الرئيسية بعد. هذه مشكلة رؤية — مستقلة عن مشكلة الذرية وتُضاف إليها.
إذن مع الحالة المشتركة غير المتزامنة تواجه خطرين مستقلين في آنٍ واحد:
- فشل الذرية — تتعرّض عملية مركّبة للمقاطعة في منتصفها.
- فشل الرؤية — لا يرى خيطٌ ما كتبه خيط آخر إطلاقًا.
اكتشاف حالات السباق عمليًا
يصعب إعادة إنتاج حالات السباق بشكل موثوق لأنها تعتمد على قرارات جدولة نظام التشغيل، وعدد وحدات المعالجة، ودرجة تسخين JIT، وحجم الحمل. الأساليب الشائعة للكشف عنها:
- اختبار الإجهاد — شغّل الكود بخيوط كثيرة لفترة طويلة. كلما زادت وحدات المعالجة زادت التشابكات الممكنة وزاد احتمال ظهور الخطأ.
- أدوات كشف الخيوط — أدوات مثل ThreadSanitizer في الكود الأصلي أو Java PathFinder تُعدّد التشابكات بصورة منهجية.
- مراجعة الكود — كل حقل يصل إليه أكثر من خيط دون تزامن هو مشتبه به.
نمط كلاسيكي آخر: التهيئة الكسولة
التهيئة الكسولة بدون تزامن هي حالة سباق نموذجية:
يمكن لخيطين تجاوز اختبار null في الوقت ذاته. يُنشئ كل منهما Registry خاصًا به، وتُكتب نسختان، فتُلغي الثانية الأولى — غير أن أي كود احتفظ بمرجع النسخة الأولى يحمل الآن كائنًا قديمًا مُهجورًا. في حالات التهيئة الأكثر تعقيدًا قد يكون الكائن في حالة إنشاء جزئية مرئية لخيط دون الآخر.
synchronized، أو volatile مع نمط القفل المزدوج الفحص، أو صنف الحامل عند الطلب. هذه المواضيع تغطيها دروس لاحقة.
تحديد الحالة المشتركة القابلة للتعديل
ليست كل الحالات المشتركة تُسبّب سباقات. الأسئلة الجوهرية هي:
- هل هي قابلة للتعديل؟ الحقل المُعلَّم بـ
finalوالمكتوب مرة واحدة في المُنشئ ولا يتغيّر آمنٌ للمشاركة بين الخيوط. - هل يمكن الوصول إليها من خيوط متعددة؟ المتغيّر المحلي على المكدّس لا يُشارَك أبدًا — لكل خيط إطار مكدّس خاص به.
- هل ثمة كاتب واحد على الأقل؟ إن كانت كل الخيوط تقرأ فحسب فلا توجد سباقة (وإن كانت قواعد الرؤية تسري على النشر الأوّلي).
إن كانت الإجابة على الأسئلة الثلاثة نعم، فالكود مرشّح لحالة سباق ويجب حمايته.
ما ليس تزامنًا
من المغري الاعتقاد بأن تسريع العمليات يُزيل السباقات — لكنه لا يفعل. حالة السباق مشكلة بنيوية في الكود لا مشكلة أداء. العلاجات الوحيدة هي:
- إزالة قابلية التعديل — استخدم كائنات غير قابلة للتغيير؛ شاركها بحرية.
- إزالة المشاركة — أعطِ كل خيط نسخته الخاصة عبر
ThreadLocalأو بالتصميم. - تنسيق الوصول — استخدم أوليات التزامن (
synchronized،volatile، فئاتjava.util.concurrent).
الدروس التالية تغطّي كل علاج من هذه العلاجات. الفهم الأساسي الآن هو أن حالة السباق غياب علاقة happens-before بين كتابة على خيط وقراءة على خيط آخر.
الخلاصة
تنشأ حالات السباق حين تُشارك الخيوط حالة قابلة للتعديل دون تنسيق. يُظهر مثال counter++ كيف أن عملية تبدو ذرية هي في الواقع قراءة-زيادة-كتابة، وأي تشابك في هذه الخطوات بين الخيوط يُنتج نتائج خاطئة. يُضاعف نموذج ذاكرة Java المشكلة بالسماح لكل خيط برؤية نسخة محلية قديمة من الذاكرة. السباقات غير حتمية، وحسّاسة للحمل، وكثيرًا ما تكون غير مرئية في الاختبارات. الحلول الوحيدة هي إزالة قابلية التعديل، أو إزالة المشاركة، أو إضافة تزامن صريح — وهو موضوع الدروس المتبقية في هذا البرنامج التعليمي.