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

العمليات والخيوط والتزامن

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

العمليات والخيوط والتزامن

لقد أتقنتَ بالفعل لبنات 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)

تحدث شرط السباق حين تعتمد صحة البرنامج على التوقيت النسبي لتنفيذ الخيوط. تأمّل عدّادًا بسيطًا:

// تحذير: هذا الكود مكسور عمدًا public class UnsafeCounter { private int count = 0; public void increment() { count++; // يبدو ذريًا، لكنه ليس كذلك } public int get() { return count; } }

count++ يُصرَّف إلى ثلاث تعليمات بايت كود: قراءة count، إضافة 1، كتابة النتيجة. إذا نفّذ خيطان هذه الخطوات الثلاث بترتيب متشابك فقد يقرآن نفس القيمة القديمة ويكتبان نفس النتيجة المزادة — مما يفقد زيادة واحدة فعليًا. شغّل هذا مع 1,000 خيط يستدعي كل منها increment() مرة واحدة وستحصل على نتيجة نهائية أقل من 1,000 في الغالب.

٢ — ظهور الذاكرة (Memory Visibility)

لدى وحدات المعالجة الحديثة مستويات متعددة من الذاكرة المخبأة. كتابة الخيط A لمتغير قد تظل في ذاكرته المخبأة لوقت غير محدد قبل دفعها إلى الذاكرة الرئيسية. الخيط B الذي يعمل على نواة مختلفة بذاكرة مخبأة خاصة به قد لا يرى التحديث أبدًا. هذا تحسين في العتاد يكسر التفكير السطحي في المتغيرات المشتركة.

// تحذير: قد يحلّق إلى الأبد على بعض البنى public class VisibilityProblem { private boolean stop = false; // لا ضمان للظهور public void requestStop() { stop = true; } public void run() { while (!stop) { // الخيط B قد لا يرى stop = true أبدًا // دوران انتظار } System.out.println("Stopped."); } }

يُحدد نموذج ذاكرة 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 متزامن صالح — خيطان يطبعان على الإخراج القياسي في آنٍ واحد. يُظهر أن الخيوط يمكنها فعلًا التشابك منتجةً إخراجًا غير حتمي:

public class TwoThreads { public static void main(String[] args) throws InterruptedException { Thread a = new Thread(() -> { for (int i = 0; i < 5; i++) { System.out.println("Thread A: " + i); } }); Thread b = new Thread(() -> { for (int i = 0; i < 5; i++) { System.out.println("Thread B: " + i); } }); a.start(); // يُجدوِل الخيط A مع نظام التشغيل b.start(); // يُجدوِل الخيط B مع نظام التشغيل a.join(); // الخيط الرئيسي ينتظر انتهاء A b.join(); // الخيط الرئيسي ينتظر انتهاء B System.out.println("Both threads done."); } }

شغّل هذا عدة مرات وستلاحظ على الأرجح تشابكات مختلفة لأسطر "Thread A" و"Thread B". لا يوجد خطأ — هذا التزامن في العمل. مُجدوِل نظام التشغيل يقرر أي خيط يعمل متى، ولا سيطرة لك على ذلك القرار. مهمتك كمبرمج متزامن هي كتابة كود صحيح بغض النظر عن أي تشابك يحدث فعليًا.

الطريق أمامنا

يغطي هذا الفصل الأدوات الكاملة: إنشاء الخيوط، فهم دورة حياتها، مزامنة الوصول بـ synchronized وvolatile، استخدام المتغيرات الذرية، التنسيق بـ wait/notify، تشخيص الأقفال الميتة، وبناء عدّاد آمن للخيوط كمشروع ختامي. بنهاية هذا الفصل ستتمكن من التفكير بثقة في الحالة المشتركة واستخدام أدوات التزامن في Java بشكل صحيح.