العمليات والخيوط والتزامن
العمليات والخيوط والتزامن
لقد أتقنتَ بالفعل لبنات Java التسلسلية الأساسية — البرمجة كائنية التوجه، والأنواع العامة (Generics)، والمجموعات، وتعبيرات Lambda، والـ Streams. كل تلك المعرفة تعيش في عالم يحدث فيه شيء واحد في كل مرة، بترتيب متوقع. التزامن يحطّم هذا الافتراض، وفهم السبب هو الخطوة الأساسية الأولى قبل كتابة أي سطر متزامن.
ما هي العملية (Process)؟
عندما يُشغّل نظام التشغيل برنامج Java فإنه يُنشئ عملية: وحدة تنفيذ معزولة تملك فضاء ذاكرة خاصًا بها (الكومة والمكدس وقسم الكود) ومقابض الملفات وموارد نظام التشغيل. لا يستطيع برنامجان Java منفصلان قراءة ذاكرة بعضهما مباشرةً. هذا العزل قيّم — فعطل إحدى العمليات لا يُفسد الأخرى — لكنه يجعل مشاركة البيانات مكلفًا.
كل نسخة JVM تُشغّلها (مثل تشغيل java MyApp من الطرفية) هي عملية واحدة. تتيح لك واجهة ProcessBuilder إطلاق عمليات فرعية من داخل Java، غير أن ذلك احتياج نادر. في معظم الأحوال تريد وحدات عمل أخف وأرخص تشترك في الذاكرة.
ما هو الخيط (Thread)؟
الـخيط هو تسلسل مستقل من التنفيذ يعيش داخل عملية ويشترك في كومة الذاكرة مع كل خيط آخر في نفس العملية. تبدأ الـ JVM دائمًا بخيط واحد على الأقل — الخيط الرئيسي، الذي ينفّذ main(). يمكنك إنشاء خيوط إضافية لتتقدم عدة أكوام استدعاء في آنٍ واحد.
كل تطبيق Java قيد التشغيل يحتوي بالفعل على عدة خيوط حتى لو لم تُنشئ واحدًا بنفسك. جامع القمامة ومُصرِّف JIT والمنهي (Finaliser) كلها تعمل على خيوط خلفية تديرها الـ JVM. يمكنك رؤيتها في أي وقت عبر تفريغ الخيوط (jstack <pid>).
التزامن مقابل التوازي
كثيرًا ما يُستخدم هذان المصطلحان بالتبادل، لكنهما يعنيان شيئين مختلفين:
- التزامن (Concurrency) يتعلق بالـهيكل: تصميم البرنامج بحيث يمكن لمهام متعددة أن تكون قيد التقدم في آنٍ واحد. على نواة CPU واحدة، ينتقل نظام التشغيل بين الخيوط — خيط واحد فقط ينفّذ في كل لحظة، لكنها تبدو جميعها تتقدم. التزامن خاصية برمجية.
- التوازي (Parallelism) يتعلق بالـتنفيذ: حسابات متعددة تعمل فعليًا في نفس اللحظة على نوى CPU متعددة. التوازي خاصية مادية.
يمكن للبرنامج المتزامن أن يعمل بالتوازي على جهاز متعدد الأنوية، لكنه ليس مضطرًا لذلك. البرنامج التسلسلي لا يستفيد من أنوية إضافية بغض النظر عن عددها. التمييز مهم للتفكير الصحيح: تفكّر في التزامن للصحة، وتقيس التوازي للأداء.
لماذا التزامن صعب؟
يُضيف التزامن ثلاث مشكلات مترابطة غير موجودة في الكود التسلسلي:
١ — شروط السباق (Race Conditions)
تحدث شرط السباق حين تعتمد صحة البرنامج على التوقيت النسبي لتنفيذ الخيوط. تأمّل عدّادًا بسيطًا:
count++ يُصرَّف إلى ثلاث تعليمات بايت كود: قراءة count، إضافة 1، كتابة النتيجة. إذا نفّذ خيطان هذه الخطوات الثلاث بترتيب متشابك فقد يقرآن نفس القيمة القديمة ويكتبان نفس النتيجة المزادة — مما يفقد زيادة واحدة فعليًا. شغّل هذا مع 1,000 خيط يستدعي كل منها increment() مرة واحدة وستحصل على نتيجة نهائية أقل من 1,000 في الغالب.
٢ — ظهور الذاكرة (Memory Visibility)
لدى وحدات المعالجة الحديثة مستويات متعددة من الذاكرة المخبأة. كتابة الخيط A لمتغير قد تظل في ذاكرته المخبأة لوقت غير محدد قبل دفعها إلى الذاكرة الرئيسية. الخيط B الذي يعمل على نواة مختلفة بذاكرة مخبأة خاصة به قد لا يرى التحديث أبدًا. هذا تحسين في العتاد يكسر التفكير السطحي في المتغيرات المشتركة.
يُحدد نموذج ذاكرة Java (JMM) بدقة متى يُضمَن أن يرى خيط ما كتابات خيط آخر. بدون آليات المزامنة الصحيحة (volatile أو synchronized أو الأقفال أو أدوات java.util.concurrent) لا يوجد أي ضمان.
٣ — الذرية (Atomicity)
العملية ذرية إذا أكملت في خطوة واحدة غير قابلة للتجزئة من منظور الخيوط الأخرى. في Java، قراءة وكتابة int 32-بت أو مرجع كائن ذريّة؛ أما قراءة أو كتابة long أو double فليست مضمونة الذرية على جميع المنصات. العمليات المركّبة مثل check-then-act (مثلًا if (map.containsKey(k)) map.get(k)) ليست ذرية أبدًا حتى لو كانت كل استدعاء منفرد ذريًا.
لماذا نستخدم التزامن إذن؟
رغم هذه المخاطر، لماذا نستخدم الخيوط أصلًا؟ لأن البديل أسوأ في كثير من الأنظمة الحقيقية:
- الاستجابة: التطبيق الذي يُجمّد خيطه الرئيسي على I/O يُجمّد الواجهة أو يتوقف عن قبول الطلبات. إسناد I/O إلى خيط خلفي يُبقي التطبيق مستجيبًا.
- الإنتاجية على العتاد متعدد الأنوية: كل خادم حديث يمتلك 8 أو 32 أو حتى 256 نواة. برنامج Java أحادي الخيط يستخدم نواة واحدة بالضبط. توزيع العمل المكثف كمعالجة البيانات أو تشفير الصور على خيوط يمكن أن يُضاعف الإنتاجية بعدد الأنوية.
- المخاوف المستقلة: خادم ويب يعالج كل طلب HTTP على خيط مخصص نموذج طبيعي — الطلبات مستقلة منطقيًا، ويستطيع الإطار إدارة مجمع الخيوط بشفافية.
مثال تشغيلي أول
إليك أبسط برنامج Java متزامن صالح — خيطان يطبعان على الإخراج القياسي في آنٍ واحد. يُظهر أن الخيوط يمكنها فعلًا التشابك منتجةً إخراجًا غير حتمي:
شغّل هذا عدة مرات وستلاحظ على الأرجح تشابكات مختلفة لأسطر "Thread A" و"Thread B". لا يوجد خطأ — هذا التزامن في العمل. مُجدوِل نظام التشغيل يقرر أي خيط يعمل متى، ولا سيطرة لك على ذلك القرار. مهمتك كمبرمج متزامن هي كتابة كود صحيح بغض النظر عن أي تشابك يحدث فعليًا.
الطريق أمامنا
يغطي هذا الفصل الأدوات الكاملة: إنشاء الخيوط، فهم دورة حياتها، مزامنة الوصول بـ synchronized وvolatile، استخدام المتغيرات الذرية، التنسيق بـ wait/notify، تشخيص الأقفال الميتة، وبناء عدّاد آمن للخيوط كمشروع ختامي. بنهاية هذا الفصل ستتمكن من التفكير بثقة في الحالة المشتركة واستخدام أدوات التزامن في Java بشكل صحيح.