الاحتقان والحيوية
الاحتقان والحيوية
يوقف استخدام الكلمة المفتاحية synchronized بشكل صحيح حالات السباق، لكنّه يفتح فئة جديدة من المخاطر: إخفاقات الحيوية. إخفاق الحيوية يعني أن الخيوط توقف عن التقدّم — لا بسبب خطأ في البيانات، بل لأن الخيوط نفسها عالقة تنتظر بعضها. الأشكال الكلاسيكية الثلاثة هي الاحتقان (Deadlock)، والحيوية الزائفة (Livelock)، والمجاعة (Starvation).
الاحتقان (Deadlock)
يحدث الاحتقان عندما تحتجز خيوط متعددة كل واحدة منها قفلًا تحتاجه الأخرى، فتظل كل خيط تنتظر إلى الأبد ولا تتقدّم أي منها. النمط الكلاسيكي هو مشكلة ترتيب الأقفال:
الخيط T1 يمسك lockA وينتظر lockB. الخيط T2 يمسك lockB وينتظر lockA. لا يمكن لأيٍّ منهما الاستمرار.
كيفية تجنّب الاحتقان
١. ترتيب الأقفال بشكل متسق. الحل الأكثر موثوقية: تضمن أن كل خيط يأخذ الأقفال دائمًا بنفس الترتيب العالمي. إذا أخذ كل الكود القفل A قبل B، فلا يمكن أن تتكوّن انتظارات دائرية.
٢. استخدام محاولات القفل المحدودة زمنيًا. تتيح لك ReentrantLock.tryLock(timeout, unit) التراجع وإعادة المحاولة بدلًا من الانتظار للأبد. هذا يحوّل الاحتقان إلى موقف قابل للتعافي:
٣. تجنّب الاحتجاز بقفل أثناء استدعاء كود خارجي مجهول. خطأ شائع هو استدعاء دالة رد نداء مقدَّمة من المستخدم أو دالة خارجية أثناء حيازة القفل. إذا استحوذ ذلك الكود الخارجي على قفل آخر، يصبح لديك ترتيب لم تتحكم فيه.
jstack <pid> أو ThreadMXBean.findDeadlockedThreads()) عن "Found one Java-level deadlock" مع كامل مكدس كل خيط عالق. في بيئة الإنتاج، التقاط تفريغ الخيوط هو أول خطوة تشخيصية عندما يتوقف سيرفس عن الاستجابة.
الحيوية الزائفة (Livelock)
في الحيوية الزائفة، الخيوط ليست محجوبة — بل تعمل بنشاط — لكن كل خيط يتفاعل مع تغيير حالة الآخر باستمرار، فلا يتحقق أي تقدّم. تصوّر شخصين في ممر يتحرك كل منهما في نفس الاتجاه كي يفسح للآخر المجال، مرارًا وتكرارًا.
الحل: أضف عدم التماثل أو العشوائية. أعطِ خيطًا واحدًا أولوية أعلى، أو اجعل فترة التراجع عشوائية (تراجع أسّي)، حتى لا يتفاعل الخيطان بطريقة متطابقة في نفس اللحظة.
المجاعة (Starvation)
المجاعة تعني أن خيطًا أو أكثر يُحرَم باستمرار من الوصول إلى مورد لأن خيوطًا أخرى تحتكره. الأسباب الشائعة:
- خيط ذو أولوية عالية يعمل باستمرار فلا تحصل الخيوط منخفضة الأولوية على وقت المعالج.
- يُطلق قفل ويُعاد الاستحواذ عليه فورًا من نفس الخيط قبل أن يتمكن الخيط المنتظر من أخذه (اقتحام القفل).
- خيط كاتب واحد يحتجز قفل القراءة-والكتابة باستمرار، مما يُجوّع القرّاء (أو العكس).
ReentrantLock العادل يستخدم طابور FIFO: الخيط الذي انتظر أطول يأخذ القفل أولًا. هذا يُلغي المجاعة لكنه يخفض الإنتاجية قليلًا لأن JVM لم تعد قادرة على السماح للخيط الحالي (المُجدوَل فعلًا على نواة المعالج) بإعادة الاستحواذ دون تبديل السياق.
java.util.concurrent.
تشخيص مشاكل الحيوية في الإنتاج
- تفريغ الخيوط:
kill -3 <pid>على Linux أوjstack <pid>يطبع جميع حالات الخيوط. الخيوط المحجوبة تُظهر القفل الذي تنتظره والخيط الذي يملكه. - JConsole / VisualVM / JMC: أدوات رسومية تُبرز الخيوط المحتقنة بصريًا.
ThreadMXBean: استدعManagementFactory.getThreadMXBean().findDeadlockedThreads()برمجيًا لاكتشاف الاحتقان من خيط مراقبة أو نقطة فحص الصحة.
synchronized أكثر من مستوى واحد، تراجع خطوة وفكّر فيما إذا كان بإمكان بُنية متزامنة أعلى مستوى (مثل ConcurrentHashMap أو BlockingQueue أو خوارزمية خالية من الأقفال) أن تُلغي التداخل كليًا.
الخلاصة
الاحتقان انتظار دائري بين خيوط تحتجز أقفال بعضها — الحل هو ترتيب الأقفال بشكل متسق أو استخدام tryLock مع مهلة. الحيوية الزائفة نشاط مستمر بدون تقدّم — الحل هو العشوائية أو عدم التماثل. المجاعة هي خسارة دائمة لسباق القفل — الحل هو قفل عادل أو إعادة تصميم نمط الوصول. الثلاثة جميعها إخفاقات في الحيوية: قد يكون الكود منطقيًا صحيحًا ومع ذلك يتوقف البرنامج عن تحقيق تقدّم مفيد. التعرّف على أي إخفاق يواجهك هو الخطوة الأولى نحو حله.