أساسيّات التزامن

حالات السباق والحالة المشتركة

15 دقيقة الدرس 4 من 13

حالات السباق والحالة المشتركة

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

السبب الجذري دائمًا واحد: خيطان أو أكثر يقرأان ويكتبان متغيّرًا مشتركًا قابلًا للتعديل دون تنسيق.

عدّاد بسيط يسلك سلوكًا خاطئًا

لنبدأ بأبسط مثال: خيطان يزيد كل منهما عدّادًا مشتركًا مئة ألف مرة.

public class RaceDemo { static int counter = 0; public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 100_000; i++) { counter++; // تبدو عملية واحدة — لكنها ليست كذلك } }; Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Final counter: " + counter); // المتوقّع: 200,000 — الفعلي: أقل من ذلك بصورة غير حتمية } }

شغّل هذا الكود عدة مرات وستحصل على قيم كـ 134,827 أو 189,003 — لن تحصل على 200,000 بشكل موثوق. السبب أن counter++ ليست عملية ذرية.

مشكلة التحقق-ثم-التصرف

على مستوى bytecode، تتفكّك counter++ إلى ثلاث عمليات مستقلة:

  1. القراءة — تحميل القيمة الحالية لـ counter من الذاكرة الرئيسية إلى سجلّ المعالج.
  2. الزيادة — إضافة 1 داخل السجلّ.
  3. الكتابة — تخزين النتيجة مجددًا في الذاكرة الرئيسية.

يمكن لجدولة الخيوط أن تعلّق خيطًا بين أي خطوتين من هذه الخطوات. إليك تشابكًا ملموسًا يُضيّع تحديثًا:

// قيمة counter تبدأ من 42 Thread 1: READ → يحصل على 42 Thread 2: READ → يحصل على 42 // T2 يقاطع T1 قبل أن يكتب T1 Thread 1: WRITE → يخزّن 43 Thread 2: WRITE → يخزّن 43 // يتجاوز نتيجة T1 — ضاع تحديث واحد

هذا ما يُسمّى التحديث الضائع. اعتقد كلا الخيطين أنهما يعملان مع القيمة 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 تُعدّد التشابكات بصورة منهجية.
  • مراجعة الكود — كل حقل يصل إليه أكثر من خيط دون تزامن هو مشتبه به.

نمط كلاسيكي آخر: التهيئة الكسولة

التهيئة الكسولة بدون تزامن هي حالة سباق نموذجية:

public class Registry { private static Registry instance; // حقل مشترك قابل للتعديل public static Registry getInstance() { if (instance == null) { // Thread 1 وThread 2 يريانها null instance = new Registry(); // كلاهما ينشئ نسخة } return instance; // يعيدان كائنات مختلفة } }

يمكن لخيطين تجاوز اختبار null في الوقت ذاته. يُنشئ كل منهما Registry خاصًا به، وتُكتب نسختان، فتُلغي الثانية الأولى — غير أن أي كود احتفظ بمرجع النسخة الأولى يحمل الآن كائنًا قديمًا مُهجورًا. في حالات التهيئة الأكثر تعقيدًا قد يكون الكائن في حالة إنشاء جزئية مرئية لخيط دون الآخر.

لا تستخدم تهيئة كسولة غير متزامنة في الكود متعدد الخيوط إطلاقًا. النمط أعلاه آمن في البرامج أحادية الخيط فحسب. الحل الصحيح هو synchronized، أو volatile مع نمط القفل المزدوج الفحص، أو صنف الحامل عند الطلب. هذه المواضيع تغطيها دروس لاحقة.

تحديد الحالة المشتركة القابلة للتعديل

ليست كل الحالات المشتركة تُسبّب سباقات. الأسئلة الجوهرية هي:

  • هل هي قابلة للتعديل؟ الحقل المُعلَّم بـ final والمكتوب مرة واحدة في المُنشئ ولا يتغيّر آمنٌ للمشاركة بين الخيوط.
  • هل يمكن الوصول إليها من خيوط متعددة؟ المتغيّر المحلي على المكدّس لا يُشارَك أبدًا — لكل خيط إطار مكدّس خاص به.
  • هل ثمة كاتب واحد على الأقل؟ إن كانت كل الخيوط تقرأ فحسب فلا توجد سباقة (وإن كانت قواعد الرؤية تسري على النشر الأوّلي).

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

ما ليس تزامنًا

من المغري الاعتقاد بأن تسريع العمليات يُزيل السباقات — لكنه لا يفعل. حالة السباق مشكلة بنيوية في الكود لا مشكلة أداء. العلاجات الوحيدة هي:

  1. إزالة قابلية التعديل — استخدم كائنات غير قابلة للتغيير؛ شاركها بحرية.
  2. إزالة المشاركة — أعطِ كل خيط نسخته الخاصة عبر ThreadLocal أو بالتصميم.
  3. تنسيق الوصول — استخدم أوليات التزامن (synchronized، volatile، فئات java.util.concurrent).

الدروس التالية تغطّي كل علاج من هذه العلاجات. الفهم الأساسي الآن هو أن حالة السباق غياب علاقة happens-before بين كتابة على خيط وقراءة على خيط آخر.

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

الخلاصة

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