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

wait وnotify والتنسيق بين الخيوط

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

wait وnotify والتنسيق بين الخيوط

يمنع الاستبعاد المتبادل الخيوطَ من إفساد الحالة المشتركة، لكنه لا يساعدها على التعاون. فكّر في ثنائي المنتج-المستهلك الكلاسيكي: لا يجوز للمستهلك المضي قدمًا حتى يتوفر بيانات يستهلكها، ولا يجوز للمنتج أن يفيض المخزن المؤقت المحدود. كلا الخيطين يحتاجان طريقة للإيقاف المؤقت والاستئناف بناءً على شرط معين — وهذا بالضبط ما توفره wait وnotify وnotifyAll.

نمط الكتلة المحروسة

الكتلة المحروسة هي الأسلوب الأساسي: يتحقق خيط من شرط، وإن لم يكن الشرط محققًا بعد، يوقف نفسه داخل المراقِب ريثما يُشير خيط آخر إلى أن شيئًا قد تغير. يُحرَّر الخيط النائم من القفل أثناء انتظاره، لتتمكن الخيوط الأخرى من دخول المراقِب والمضي قدمًا.

الفكرة الجوهرية — wait يُحرِّر القفل. عندما يستدعي خيط object.wait() فإنه بصورة ذرية (1) يُحرِّر مراقِب الكائن object، (2) يوقف نفسه، (3) يُعيد اكتساب المراقِب قبل العودة. هذا ما يجعل التنسيق ممكنًا: يتنحى الخيط المنتظِر جانبًا ليتمكن الخيط المُشعِر من دخول نفس الكتلة المزامَنة.

الهيكل الصحيح يستخدم دائمًا حلقة while، لا if:

synchronized (lock) { while (!conditionIsMet()) { // أعِد التحقق بعد الاستيقاظ lock.wait(); // حرِّر القفل، انم، أعِد اكتسابه عند الإيقاظ } // ... الشرط محقق الآن — تابع بأمان }
استخدم دائمًا حلقة while، لا if. مشكلتان تجعلان نسخة if خاطئة: (1) الإيقاظ الوهمي — يُسمح لـ JVM بإيقاظ خيط منتظِر حتى دون أن يستدعي أحد notify؛ (2) الإشارات الضائعة — بين لحظة إيقاظ الخيط ولحظة إعادة اكتسابه للقفل، قد يكون خيط آخر قد استهلك العنصر الذي حقق الشرط. حلقة while تحمي من كلتيهما.

notify مقابل notifyAll

تُيقظ notify() خيطًا واحدًا بالضبط ينتظر على نفس المراقِب — يختاره مجدوَل JVM وغير قابل للتنبؤ. أما notifyAll() فتُيقظ كل الخيوط المنتظِرة؛ يتسابق كل منها لإعادة اكتساب القفل، ويعود جميعها ما عدا واحدًا إلى الانتظار.

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

مثال كامل: المنتج والمستهلك

المثال أدناه يُنمذج مخزنًا مؤقتًا لعنصر واحد: ينتج المنتج عنصرًا ثم ينتظر حتى يأخذه المستهلك، والعكس بالعكس.

public class OneSlotBuffer<T> { private T item; private boolean hasItem = false; public synchronized void put(T value) throws InterruptedException { while (hasItem) { // المخزن ممتلئ — انتظر المستهلك wait(); } this.item = value; this.hasItem = true; System.out.println("Produced: " + value); notifyAll(); // أيقظ المستهلك (وأي خيط منتظِر آخر) } public synchronized T take() throws InterruptedException { while (!hasItem) { // المخزن فارغ — انتظر المنتج wait(); } T result = this.item; this.item = null; this.hasItem = false; System.out.println("Consumed: " + result); notifyAll(); // أيقظ المنتج return result; } }

لاحظ أن كلًا من put وtake مزامَنتان على this، لذا يشتركان في نفس المراقِب. عندما يستدعي المنتج wait()، يُحرِّر القفل ليتمكن المستهلك من دخول take(). بعد استدعاء المستهلك notifyAll() وخروجه، يُعيد المنتج اكتساب القفل، يجد hasItem == false، ويتابع.

إطار اختبار بسيط:

public class Demo { public static void main(String[] args) { OneSlotBuffer<Integer> buffer = new OneSlotBuffer<>(); Thread producer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { buffer.put(i); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread consumer = new Thread(() -> { try { for (int i = 0; i < 5; i++) { buffer.take(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); producer.start(); consumer.start(); } }

ثلاث قواعد لا يجوز كسرها

  1. استدعِ wait/notify/notifyAll دائمًا داخل كتلة synchronized على نفس الكائن. استدعاؤها خارجها يرمي IllegalMonitorStateException في وقت التشغيل.
  2. أعِد دائمًا التحقق من الشرط في حلقة while بعد العودة من wait. الإيقاظات الوهمية والخيوط المتنافسة تجعل نسخة if غير آمنة.
  3. استخدم notifyAll ما لم تستطع إثبات صحة notify. الإيقاظ الضائع مع notify() يترك خيطًا معلقًا للأبد — وهو أصعب نوع من الأخطاء للإعادة الإنتاجية.

التعامل الصحيح مع InterruptedException

تُعلن wait() عن throws InterruptedException. إذا استدعى خيط آخر interrupt() على خيط منتظِر، ترمي wait() هذا الاستثناء فورًا. الاستجابة الصحيحة في الغالب هي استعادة حالة المقاطعة والسماح للاستثناء بالانتشار:

try { synchronized (lock) { while (!ready) { lock.wait(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // أعِد رفع العلم // ثم إما العودة أو رمي استثناء محدد أو التنظيف }

ابتلاع الاستثناء صامتًا (كتلة catch فارغة) يُتلف الإشارة ويجعل إيقاف الخيط بشكل نظيف أمرًا مستحيلًا.

wait/notify مقابل البدائل ذات المستوى الأعلى

قدّمت حزمة java.util.concurrent في Java 5 أدوات ذات مستوى أعلى مبنية فوق نفس الآليات الأساسية:

  • BlockingQueue (LinkedBlockingQueue، ArrayBlockingQueue) — قناة منتج-مستهلك جاهزة محدودة أو غير محدودة. فضّلها على بناء مخزن مؤقت يدوي بـ wait/notify.
  • Condition (من ReentrantLock.newCondition()) — يوفر await() / signal() بنفس دلالات wait/notify لكنه يسمح بـ شروط متعددة مسماة على قفل واحد ويدعم الانتظار المحدد بوقت بشكل أنظف.
  • CountDownLatch، CyclicBarrier، Semaphore — مزامِنات مخصصة لنقاط العد التنازلي أحادية الاستخدام، ونقاط التقاء الخيوط، وتحديد المعدل.
متى تلجأ إلى wait/notify اليوم. في الكود الجديد، فضّل BlockingQueue أو Condition. يظل فهم wait/notify ضروريًا: إنه يدعم كل أداة ذات مستوى أعلى، ويظهر في قواعد الكود القديمة التي ستحتاج للصيانة، وتختبره أسئلة المقابلات بانتظام. وهو أيضًا الخيار الوحيد حين لا تستطيع إدخال تبعية على java.util.concurrent.

الخلاصة

الكتل المحروسة المبنية حول wait وnotifyAll هي أساس التنسيق بين الخيوط في Java. القواعد صارمة لكنها متسقة: امتلك دائمًا المراقِب، ضع دائمًا حلقة، فضّل دائمًا notifyAll، وأعِد دائمًا نشر InterruptedException. في الدرس التالي سنفحص حالات التوقف التام — ما هي، وكيف تشخّصها، وكيف تتجنبها من خلال التصميم.