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

الاحتقان والحيوية

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

الاحتقان والحيوية

يوقف استخدام الكلمة المفتاحية synchronized بشكل صحيح حالات السباق، لكنّه يفتح فئة جديدة من المخاطر: إخفاقات الحيوية. إخفاق الحيوية يعني أن الخيوط توقف عن التقدّم — لا بسبب خطأ في البيانات، بل لأن الخيوط نفسها عالقة تنتظر بعضها. الأشكال الكلاسيكية الثلاثة هي الاحتقان (Deadlock)، والحيوية الزائفة (Livelock)، والمجاعة (Starvation).

الاحتقان (Deadlock)

يحدث الاحتقان عندما تحتجز خيوط متعددة كل واحدة منها قفلًا تحتاجه الأخرى، فتظل كل خيط تنتظر إلى الأبد ولا تتقدّم أي منها. النمط الكلاسيكي هو مشكلة ترتيب الأقفال:

public class DeadlockDemo { private final Object lockA = new Object(); private final Object lockB = new Object(); public void transfer(boolean reverse) { Object first = reverse ? lockB : lockA; Object second = reverse ? lockA : lockB; synchronized (first) { System.out.println(Thread.currentThread().getName() + " holds " + (reverse ? "B" : "A")); try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (second) { // قد تنتظر إلى الأبد System.out.println("Transfer complete"); } } } public static void main(String[] args) throws InterruptedException { DeadlockDemo demo = new DeadlockDemo(); Thread t1 = new Thread(() -> demo.transfer(false), "T1"); // تأخذ A ثم B Thread t2 = new Thread(() -> demo.transfer(true), "T2"); // تأخذ B ثم A t1.start(); t2.start(); t1.join(); t2.join(); // من المرجح جدًا أن يتوقف البرنامج إلى الأبد } }

الخيط T1 يمسك lockA وينتظر lockB. الخيط T2 يمسك lockB وينتظر lockA. لا يمكن لأيٍّ منهما الاستمرار.

كيفية تجنّب الاحتقان

١. ترتيب الأقفال بشكل متسق. الحل الأكثر موثوقية: تضمن أن كل خيط يأخذ الأقفال دائمًا بنفس الترتيب العالمي. إذا أخذ كل الكود القفل A قبل B، فلا يمكن أن تتكوّن انتظارات دائرية.

// آمن: نستخدم System.identityHashCode — ترتيب عالمي ومتسق public void safeTransfer(Object resource1, Object resource2) { Object first, second; int hash1 = System.identityHashCode(resource1); int hash2 = System.identityHashCode(resource2); if (hash1 < hash2) { first = resource1; second = resource2; } else { first = resource2; second = resource1; } synchronized (first) { synchronized (second) { // القسم الحرج } } }

٢. استخدام محاولات القفل المحدودة زمنيًا. تتيح لك ReentrantLock.tryLock(timeout, unit) التراجع وإعادة المحاولة بدلًا من الانتظار للأبد. هذا يحوّل الاحتقان إلى موقف قابل للتعافي:

import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.TimeUnit; ReentrantLock lockA = new ReentrantLock(); ReentrantLock lockB = new ReentrantLock(); boolean acquired = false; while (!acquired) { if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) { try { if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) { try { // القسم الحرج acquired = true; } finally { lockB.unlock(); } } } finally { lockA.unlock(); } } // تراجع قصير قبل إعادة المحاولة Thread.sleep(10); }

٣. تجنّب الاحتجاز بقفل أثناء استدعاء كود خارجي مجهول. خطأ شائع هو استدعاء دالة رد نداء مقدَّمة من المستخدم أو دالة خارجية أثناء حيازة القفل. إذا استحوذ ذلك الكود الخارجي على قفل آخر، يصبح لديك ترتيب لم تتحكم فيه.

اكتشاف الاحتقان في وقت التشغيل: تُبلّغ أداة تفريغ الخيوط المدمجة في Java (عبر jstack <pid> أو ThreadMXBean.findDeadlockedThreads()) عن "Found one Java-level deadlock" مع كامل مكدس كل خيط عالق. في بيئة الإنتاج، التقاط تفريغ الخيوط هو أول خطوة تشخيصية عندما يتوقف سيرفس عن الاستجابة.

الحيوية الزائفة (Livelock)

في الحيوية الزائفة، الخيوط ليست محجوبة — بل تعمل بنشاط — لكن كل خيط يتفاعل مع تغيير حالة الآخر باستمرار، فلا يتحقق أي تقدّم. تصوّر شخصين في ممر يتحرك كل منهما في نفس الاتجاه كي يفسح للآخر المجال، مرارًا وتكرارًا.

// مثال مبسّط للحيوية الزائفة — خيطان يتراجع كل منهما // كلما رأى الآخر يحاول أيضًا، فلا ينجح أيٌّ منهما. class Polite { volatile boolean active = true; void tryWork(Polite other) throws InterruptedException { while (active) { if (other.active) { System.out.println(Thread.currentThread().getName() + " steps aside"); Thread.sleep(1); // تراجع continue; // يتحقق مجددًا — الآخر لا يزال نشطًا، الحلقة تستمر إلى الأبد } // تنفيذ العمل (لا يُبلَغ به في الحالة المتماثلة) active = false; } } }

الحل: أضف عدم التماثل أو العشوائية. أعطِ خيطًا واحدًا أولوية أعلى، أو اجعل فترة التراجع عشوائية (تراجع أسّي)، حتى لا يتفاعل الخيطان بطريقة متطابقة في نفس اللحظة.

المجاعة (Starvation)

المجاعة تعني أن خيطًا أو أكثر يُحرَم باستمرار من الوصول إلى مورد لأن خيوطًا أخرى تحتكره. الأسباب الشائعة:

  • خيط ذو أولوية عالية يعمل باستمرار فلا تحصل الخيوط منخفضة الأولوية على وقت المعالج.
  • يُطلق قفل ويُعاد الاستحواذ عليه فورًا من نفس الخيط قبل أن يتمكن الخيط المنتظر من أخذه (اقتحام القفل).
  • خيط كاتب واحد يحتجز قفل القراءة-والكتابة باستمرار، مما يُجوّع القرّاء (أو العكس).
import java.util.concurrent.locks.ReentrantLock; // قفل غير عادل (الافتراضي): يمكن لـ JVM أن يسمح لنفس الخيط بالاستحواذ فورًا. ReentrantLock unfair = new ReentrantLock(); // false = غير عادل ReentrantLock fair = new ReentrantLock(true); // true = عادل (طابور FIFO)

ReentrantLock العادل يستخدم طابور FIFO: الخيط الذي انتظر أطول يأخذ القفل أولًا. هذا يُلغي المجاعة لكنه يخفض الإنتاجية قليلًا لأن JVM لم تعد قادرة على السماح للخيط الحالي (المُجدوَل فعلًا على نواة المعالج) بإعادة الاستحواذ دون تبديل السياق.

الأقفال العادلة ليست الأداة المناسبة دائمًا. استخدمها عندما تكون المجاعة قلقًا حقيقيًا (مثلًا: مهمة خلفية منخفضة الأولوية يجب أن تنفّذ في نهاية المطاف). للمسارات ذات الإنتاجية العالية، يُفضَّل استخدام الأقفال غير العادلة أو البُنى الخالية من الأقفال من java.util.concurrent.

تشخيص مشاكل الحيوية في الإنتاج

  • تفريغ الخيوط: kill -3 <pid> على Linux أو jstack <pid> يطبع جميع حالات الخيوط. الخيوط المحجوبة تُظهر القفل الذي تنتظره والخيط الذي يملكه.
  • JConsole / VisualVM / JMC: أدوات رسومية تُبرز الخيوط المحتقنة بصريًا.
  • ThreadMXBean: استدع ManagementFactory.getThreadMXBean().findDeadlockedThreads() برمجيًا لاكتشاف الاحتقان من خيط مراقبة أو نقطة فحص الصحة.
تقليص نطاق القفل هو أفضل وقاية. كلما كانت مدة حيازة القفل أقصر، كانت نافذة تكوّن الانتظارات الدائرية أضيق. إذا وجدت نفسك تُدمج كتل synchronized أكثر من مستوى واحد، تراجع خطوة وفكّر فيما إذا كان بإمكان بُنية متزامنة أعلى مستوى (مثل ConcurrentHashMap أو BlockingQueue أو خوارزمية خالية من الأقفال) أن تُلغي التداخل كليًا.

الخلاصة

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