اذهب إلى المحتوى

مقدمة إلى الخيوط Threads في جافا


رضوى العربي

يمكن للحواسيب إنجاز عدة مهامٍ مختلفة بنفس الوقت؛ فإذا كان الحاسوب مُكوَّنًا من وحدة معالجةٍ واحدة، فإنه لا يستطيع حرفيًا إنجاز شيئين بنفس الوقت؛ ولكن ما يزال بإمكانه تحويل انتباهه بين عدة مهامٍ باستمرار. تتكوَّن معظم الحواسيب العصرية من أكثر من مجرد وحدة معالجةٍ واحدة، وبإمكانها تنفيذ عدة مهامٍ بنفس الوقت حرفيًا، وغالبًا ما تكون زيادة قدرة المعالجة للحواسيب ناتجةً عن إضافة عددٍ أكبر من المعالجات إليها بدلًا من زيادة سرعة كل معالجٍ على حدى.

حتى تتمكّن البرامج من تحقيق الاستفادة القصوى من الحواسيب متعددة المعالجات، لا بُدّ أن تكون مُبرمَجَةً على التوازي parallel programming؛ بما يعني كتابة البرنامج بهيئة مجموعةٍ من المهام المُمكِن تنفيذها بنفس الوقت. ما تزال تقنيات البرمجة على التوازي مفيدة حتى بالحواسيب أحادية المعالج، حيث يُساعد تقسيم المشكلات إلى مجموعة من المهام على معالجة المشكلة بصورةٍ مُبسّطة.

يُطلَق اسم خيط thread بلغة جافا على كل مهمة، ويشير ذلك الاسم إلى "خيط التحكُّم" أو "خيط التنفيذ" الذي يعني متتالية التعليمات المُنفَّذة واحدةً تلو الأخرى؛ حيث يمتد الخيط عبر الزمن ويربط كل تعليمةٍ بما يليها من تعليمات. توجد خيوط تحكمٍ كثيرة بالبرامج مُتعدّدة الخيوط، وتَعمَل جميعًا على التوازي، لتُشكِّل "نسيج" البرنامج. يتكوَّن كل برنامج عمومًا من خيطٍ واحدٍ على الأقل؛ فعندما نُشغِّل برنامجًا معينًا بآلة جافا الافتراضية Java virtual machine، فإنها تُنشِئ خيطًا مسؤولًا عن تنفيذ البرنامج الرئيسي الذي يستطيع أن يُنشِئ بدوره خيوطًا أخرى قد تستمر حتى بعد انتهاء الخيط الرئيسي.

بالنسبة لبرامج واجهة المُستخدِم الرسومية GUI، يكون هنالك خيطٌ إضافي مسؤولٌ عن معالجة الأحداث events ورسم مُكوِّنات الواجهة على الشاشة؛ وفي حالة اِستخدَام مكتبة جافا إف إكس JavaFX، يكون ذلك الخيط هو خيط التطبيق، ويكون مسؤولًا عن إنجاز كل الأمور المتعلّقة بمعالجة الأحداث ورسم المكونات على الشاشة.

تُعدّ البرمجة المتوازية أصعب نوعًا ما من البرمجة أحادية الخيط؛ فعند وجود عدة خيوطٍ تَعمَل معًا لحل مشكلةٍ معينة، قد تَنشَأ أنواعٌ جديدة من الأخطاء، ولذلك تُعدّ التقنيات المُستخدَمة لكتابة البرامج كتابة صحيحة ومتينة أكثر أهميةً بالبرمجة المتوازية منها بالبرمجة العادية. تُوفِّر جافا لحسن الحظ واجهة برمجة تطبيقات للخيوط threads API تُسِّهل كثيرًا من استخدام الخيوط على نحوٍ معقول، كما أنها تحتوي على الكثير من الأصناف القياسية التي تُغلِّف الأجزاء الأكثر تعقيدًا وتخفيها بالكامل. يُمكِننا إنجاز أمورٍ كثيرة باستخدام الخيوط دون أن نتعلم أي شيءٍ عن تقنياتها منخفضة المستوى.

إنشاء الخيوط وتشغيلها

يُمثَّل الخيط بلغة جافا بواسطة كائنٍ ينتمي إلى الصنف java.lang.Thread، أو إلى أيٍّ من أصنافه الفرعية؛ حيث يُنشَأ هذا الكائن بغرض تنفيذ تابعٍ method -يُمثِّل المهمة المُفترَض للخيط تنفيذها- لمرةٍ واحدةٍ فقط، أي يُنفَّذ ذلك التابع داخل خيط التحكم الخاص به، والذي يُمكِنه أن يَعمَل بالتوازي مع خيوطٍ أخرى. يتوقف الخيط عن العمل بعد الانتهاء من تنفيذ التابع طبيعيًا أو نتيجةً لحدوث استثناءٍ exception لم يُلتقَط، وعندها لا تتوفَّر أي طريقةٍ لإعادة تشغيله أو حتى لاستخدام الكائن المُمثِّل لذلك الخيط لإنشاء واحدٍ جديد.

تتوفَّر طريقتان لبرمجة خيط؛ حيث تتمثّل الأولى بإنشاء صنفٍ فرعي من الصنف Thread يحتوي على تعريفٍ للتابع public void run()‎، ويكون هذا التابع مسؤولًا عن تعريف المُهمة التي سيُنفِّذها الخيط؛ فهو يَعمَل بمجرد بدء تشغيل الخيط. تُعرِّف الشيفرة التالية على سبيل المثال صنفًا بسيطًا لخيطٍ لا يَفعَل أكثر من مجرد طباعة رسالةٍ إلى الخرج القياسي standard output:

public class NamedThread extends Thread {
   private String name;  // اسم الخيط
   public NamedThread(String name) {  // يضبُط الباني اسم الخيط
      this.name = name;
   }
   public void run() {  // تُرسِل رسالة إلى الخرج القياسي
      System.out.println("Greetings from thread '" + name + "'!");
   }
}

يجب أن نُنشِئ كائنًا ينتمي إلى الصنف NamedThread لنتمكَّن من اِستخدامه. ألقِ نظرةً على ما يلي، على سبيل المثال:

NamedThread greetings = new NamedThread("Fred");

لا يؤدي إنشاء ذلك الكائن إلى بدء تشغيل الخيط، أو تنفيذ تابعه run()‎ تلقائيًا، وإنما يجب استدعاء التابع start()‎ المُعرَّف بالكائن. يُمكِننا مثلًا كتابة التعليمة التالية:

greetings.start();

يُنشِئ التابع start()‎ خيط تحكمٍ جديد مسؤولٍ عن تنفيذ التابع run()‎ المُعرَّف بالكائن، حيث يعمل هذا الخيط الجديد على التوازي إلى جانب الخيط الذي استدعينا به التابع start()‎، وكذلك إلى جانب أي خيوطٍ أخرى موجودةٍ مسبقًا. ينتهي التابع start()‎ من العمل ويُعيد قيمته بمجرد تشغيله للخيط الجديد دون أن ينتظر انتهاء الخيط من العمل؛ وهذا يَعنِي أن شيفرة التابع run()‎ المُعرَّف بكائن الخيط تُنفَّذ بنفس الوقت الذي تُنفَّذ خلاله التعليمات التالية لتعليمة استدعاء التابع start()‎. ألقِ نظرةً على الشيفرة التالية:

NamedThread greetings = new NamedThread("Fred");
greetings.start();
System.out.println("Thread has been started");

يوجد بعد تنفيذ التعليمة greetings.start()‎ خيطان؛ حيث يَطبَع الأول جملة "Thread has been started"؛ بينما يريد الآخر طباعة جملة "!`Greetings from thread 'Fred". قد يختلف ترتيب طباعة الجملتين كلما شغَّلت البرنامج، حيث يَعمَل الخيطان بنفس الوقت، ويحاول كلٌ منهما الوصول إلى الخرج القياسي لطباعة الرسالة الخاصة به. وبالتالي، سيطبع الخيط الذي يتمكَّن من الوصول إلى الخرج القياسي أولًا، رسالته أولًا.

يختلف ذلك عن البرامج العادية أحادية الخيط، حيث تُنفَّذ التعليمات بترتيبٍ مُحدَّد ومتوقَّع من البداية إلى النهاية؛ بينما هناك دائمًا عدم تحديد indeterminacy في البرامج متعددة الخيوط، فلا يكون الترتيب معروفًا أو محددًا، ولذلك لا يُمكِننا التأكُّد أبدًا من الترتيب الذي ستُنفَّذ على أساسه التعليمات، وهذا يَجعَل البرمجة المتوازية parallel programming صعبةً نوعًا ما.

اقتباس

ملاحظة: يختلف استدعاء التابع greetings.start()‎ تمامًا عن استدعاء التابع greetings.run()‎؛ حيث يؤدي استدعاء greetings.run()‎ إلى تنفيذ run()‎ بنفس الخيط بدلًا من إنشاء خيطٍ جديد، أي تُنفَّذ شيفرته بالكامل قبل انتقال الحاسوب إلى التعليمة التالية لتعليمة استدعاء greetings.run()‎؛ أي لا يكون هناك توازٍ أو عدم تحديد.

001Threads_Vs_Subroutines.png

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

كنا قد ذكرنا أن هناك طريقتين لبرمجة خيط؛ حيث كانت الطريقة الأولى بتعريف صنفٍ فرعي من الصنف Thread. ننتقل الآن إلى الطريقة الثانية، وهي بتعريف صنفٍ يُنفِّذ الواجهة java.lang.Runnable؛ حيث تُعرِّف تلك الواجهة interface تابعًا وحيدًا، هو public void run()‎. يُمكِننا إنشاء خيطٍ من النوع Thread مهمته هي تنفيذ التابع run()‎ المُعرَّف بالواجهة، بمجرد حصولنا على كائنٍ منفِّذ لتلك الواجهة.

يحتوي الصنف Thread على بانٍ constructor يَستقبِل كائنًا منفِّذًا للواجهة Runnable على أنه معاملٌ parameter. عندما نُمرِّر ذلك الكائن للباني، يَستدعِي تابع الخيط run()‎ التابع run()‎ المُعرَّف بالواجهة Runnable؛ وعندما نَستدعِي تابع الخيط start()‎، فإنه يُنِشئ خيط تحكمٍ جديد يكون مسؤولًا عن تنفيذ التابع run()‎ المُعرَّف بالواجهة Runnable. يُمكِننا مثلًا تعريف الصنف التالي بدلًا من إنشاء الصنف NamedThread:

public class NamedRunnable implements Runnable {
   private String name;  // الاسم
   public NamedRunnable(String name) {  // يَضبُط الباني اسم الكائن
      this.name = name;
   }
   public void run() {  // يُرسِل رسالةً إلى الخرج القياسي
      System.out.println("Greetings from runnable '" + name +"'!");
   }
}

سنُنشِئ الآن كائنًا ينتمي إلى الصنف NamedRunnable المُعرَّف بالأعلى، ونَستخدِمه لإنشاء كائن من النوع Thread على النحو التالي:

NamedRunnable greetings = new NamedRunnable("Fred");
Thread greetingsThread = new Thread(greetings);
greetingsThread.start();

تتميِّز تلك الطريقة عن الأولى في إمكانية أي كائنٍ من تنفيذ الواجهة Runnable وتعريف التابع run()‎، والذي يُمكِن تنفيذه بعد ذلك بخيطٍ منفصل. بالإضافة إلى ذلك، يستطيع التابع run()‎ الوصول إلى أي شيءٍ مُعرَّفٍ بالصنف بما في ذلك توابعه ومتغيراته الخاصة private. في المقابل، لا تُعدّ تلك الطريقة كائنية التوجه object-oriented تمامًا؛ فهي تخالف المبدأ الذي ينصّ على ضرورة أن يكون لكل كائنٍ مسؤوليةً وحيدةً محددةً بوضوح. لذلك، يكون من الأفضل أن نُعرِّف الخيط باستخدام صنف متداخل nested فرعي من الصنف Thread، بدلًا من إنشاء كائنٍ عشوائي من النوع Runnable فقط لنَستخدِمه مثل خيط. انظر مقال الأصناف المتداخلة Nested Classes في جافا.

أخيرًا، لاحِظ أن الواجهة Runnable هي واجهة نوع دالة functional interface، أي يُمكِن تمريرها مثل تعبير لامدا lambda expression. يَعنِي ذلك أن بإمكان باني الصنف Thread استقبال تعبير لامدا على أنه معامل. ألقِ نظرةً على المثال التالي:

Thread greetingsFromFred = new Thread( 
    () -> System.out.println("Greetings from Fred!")
);
greetingsFromFred.start();

سنفحص الآن المثال التوضيحي ThreadTest1.java، لنفهم طريقة تنفيذ الخيوط المتعددة على التوازي؛ حيث سيُنشِئ هذا البرنامج عدة خيوط، بحيث ينفِّذ كل خيطٍ منها نفس المهمة تمامًا. ستكون المهمة هي عدُّ الأعداد الصحيحة الأوليّة الأقل من 5000000. ليس هناك غرضٌ محددٌ من اختيار تلك المهمة بالتحديد، فكل ما يَهُمّ هنا هو أن تستغرق المهمة وقتًا طويلًا بعض الشيء. لاحِظ أيضًا أن هذا البرنامج غير واقعي، فمن الحماقة إنشاء عدة خيوط لتنفيذ الأمر نفسه. لاحِظ أيضًا عدم عمل التابع المسؤول عن العدّ بكفاءة عالية. لن يستغرق البرنامج أكثر من عدّة ثوانٍ على أي حاسوبٍ عصري. تُعرِّف الشيفرة التالية صنفًا متداخلًا ساكنًا static nested class لتمثيل الخيوط المسؤولة عن تنفيذ المهمة:

// 1
private static class CountPrimesThread extends Thread {
   int id;  // مُعرِّف هوية لهذا الخيط 
   public CountPrimesThread(int id) {
      this.id = id;
   }
   public void run() {
      long startTime = System.currentTimeMillis();
      int count = countPrimes(2,5000000); // عدّ الأعداد الأولية
      long elapsedTime = System.currentTimeMillis() - startTime;
      System.out.println("Thread " + id + " counted " + 
            count + " primes in " + (elapsedTime/1000.0) + " seconds.");
   }
}

[1] عند تشغيل خيطٍ ينتمي إلى هذا الصنف، فإنه يَعُدّ عدد الأعداد الأولية الواقعة بنطاقٍ يتراوح من 2 إلى 5000000. سيَطبَع النتيجة إلى الخرج القياسي، مع رقم مُعرِّف الهوية الخاص به، وكذلك الزمن المُنقضِي منذ لحظة بدء المعالجة وحتى نهايتها.

سيطلب البرنامج main()‎ المُعرَّف فيما يلي من المُستخدِم إدخال عدد الخيوط المطلوب تشغيلها، ثم سيُنشِئ تلك الخيوط ويُشغِّلها:

public static void main(String[] args) {
   int numberOfThreads = 0;
   while (numberOfThreads < 1 || numberOfThreads > 25) {
      System.out.print("How many threads do you want to use  (1 to 25) ?  ");
      numberOfThreads = TextIO.getlnInt();
      if (numberOfThreads < 1 || numberOfThreads > 25)
         System.out.println("Please enter a number between 1 and 25 !");
   }
   System.out.println("\nCreating " + numberOfThreads 
                                           + " prime-counting threads...");
   CountPrimesThread[] worker = new CountPrimesThread[numberOfThreads];
   for (int i = 0; i < numberOfThreads; i++)
      worker[i] = new CountPrimesThread( i );
   for (int i = 0; i < numberOfThreads; i++)
      worker[i].start();
   System.out.println("Threads have been created and started.");
}

ربما من الأفضل أن تُصرِّف compile البرنامج وتُشغِّله.

عند تشغيل البرنامج باستخدام خيطٍ واحدٍ على حاسوبٍ قديمٍ نوعًا ما، يستغرق الحاسوب حوالي "6.251 ثانية" لإجراء العملية؛ وعند تشغيله باستخدام ثمانية خيوط، يكون الخرج على النحو التالي:

Creating 8 prime-counting threads...
Threads have been created and started.
Thread 4 counted 348513 primes in 12.264 seconds.
Thread 2 counted 348513 primes in 12.569 seconds.
Thread 3 counted 348513 primes in 12.567 seconds.
Thread 0 counted 348513 primes in 12.569 seconds.
Thread 7 counted 348513 primes in 12.562 seconds.
Thread 5 counted 348513 primes in 12.565 seconds.
Thread 1 counted 348513 primes in 12.569 seconds.
Thread 6 counted 348513 primes in 12.563 seconds.

يَطبَع الحاسوب السطر الثاني تلقائيًا بعد السطر الأول، ويكون البرنامج main()‎ في تلك اللحظة قد انتهى، بينما تستمر الثمانية خيوط الأخرى بالعمل. بعد فترةٍ تَصِل إلى "12.5 ثانية"، تكتمل جميع الخيوط الثمانية بنفس الوقت تقريبًا. لا يكون ترتيب انتهاء الخيوط من العمل هو نفسه ترتيب بدء تشغيلها، فالترتيب غير حتمي؛ أي إذا شغَّلنا البرنامج مرةً أخرى، فلربما سيختلف ذلك الترتيب.

نظرًا لاحتواء الحاسوب على أربعة معالجات، استغرقت الثمانية خيوط عند تشغيلها عليه ضعف الزمن الذي استغرقه خيطٌ واحدٌ تقريبًا؛ فعند تشغيل ثمانية خيوط على أربعة معالجات (أي نصف معالج لكل خيط)، كان كل خيطٍ منها نَشطًا فعليًا لمدةٍ تصل إلى نصف ذلك الزمن فقط، ولهذا استغرقت ضعف الوقت لإنهاء نفس المهمة. بالمثل، إذا احتوى الحاسوب على معالجٍ واحدٍ فقط، فستستغرق الخيوط الثمان زمنًا يَصِل إلى ثمانية أضعاف الزمن الذي يستغرقه الخيط الواحد؛ وإذا احتوى الحاسوب على ثمانية معالجات أو أكثر، فلربما لن تستغرق الخيوط الثمان زمنًا أكبر مما يستغرقه الخيط الواحد. ومع ذلك، قد يكون التزايد الفعلي في السرعة أصغر قليلًا مما أشرنا إليه هنا نتيجةً لبعض التعقيدات، ويكون التزايد الفعلي مُحددًا في الحواسيب متعددة المعالجات. والآن حان دورك، ماذا يحدث عندما تُشغِّل البرنامج على حاسوبك الشخصي؟ كم عدد المعالجات الموجودة بحاسوبك؟

عندما يكون هناك خيوطٌ أكثر من عدد المعالجات المتاحة، يُقسِّم الحاسوب قدرته المعالجية على الخيوط المُشغَّلة بالتبديل بينها بسرعة. يعني ذلك تشغيل كل معالجٍ خيطًا واحدًا لفترة، ثم الانتقال إلى خيطٍ آخر لتشغيله لفترة، ثم ينتقل لغيره، وهكذا. يُطلق على تلك التنقلات اسم تبديلات السياق context switches، والتي تحدث بمعدلٍ يصل إلى 100 مرة أو أكثر بالثانية الواحدة. يستطيع الحاسوب بتلك الطريقة إحراز بعض التقدم بجميع المهمات المطلوبة، ويظن المُستخدِم أنها تُنفَّذ جميعًا بنفس الوقت. ولهذا السبب، انتهت جميع الخيوط التي كان مطلوبًا منها نفس حجم العمل بنفس الوقت تقريبًا في المثال التوضيحي السابق. خلاصة القول أنه ولأي فترةٍ زمنيةٍ أكبر من جزءٍ من الثانية، فسيُقسَّم زمن الحاسوب بالتساوي تقريبًا على جميع الخيوط.

العمليات على الخيوط

ستجد غالبية واجهة برمجة تطبيقات جافا للخيوط مُعرَّفةً بالصنف Thread، ومع ذلك سنبدأ بتابعٍ متعلقٍ بالخيوط ومعرَّفٍ بالصنف Runtime؛ حيث يَسمَح ذلك الصنف لبرامج جافا بالوصول إلى بعض المعلومات عن البيئة التي يعمل عليها البرنامج. عند إجراء برمجةٍ على التوازي بغرض توزيع العمل على أكثر من معالجٍ واحد، فلربما يكون من المهم أن نعرف عدد المعالجات المتاحة أولًا، فقد تُنشِئ خيطًا واحدًا لكل معالجٍ مثلًا. تستطيع معرفة عدد المعالجات باستدعاء الدالة التالية:

Runtime.getRuntime().availableProcessors()

تُعيد تلك الدالة قيمةً من النوع int تُمثِّل عدد المعالجات المتاحة بآلة جافا الافتراضية Java Virtual Machine. قد تكون تلك القيمة في بعض الحالات أقل من عدد المعالجات الفعلية المتاحة بالحاسوب.

يحتوي أي كائنٍ من النوع Thread على الكثير من التوابع المفيدة المتعلِّقة بالعمل مع الخيوط؛ حيث يُعدّ التابع start()‎ الذي نُوقِش بالأعلى واحدًا من أهم تلك التوابع.

بمجرد بدء الخيط، فإنه يَظَل مُشغَّلًا إلى حين انتهاء تابعه run()‎ من العمل. من المفيد في بعض الأحيان أن يعرف خيط معين فيما إذا كان خيطٌ آخر قد انتهى أم لا؛ فإذا كان thrd كائنًا من النوع Thread، ستفحص الدالة thrd.isAlive()‎ فيما إذا thrd قد انتهى أم لا. يُعدّ الخيط "نشطًا alive" منذ لحظة تشغيله إلى لحظة انتهائه، ويُعدّ "ميتًا dead" بعد انتهائه. تُستخدَم نفس تلك الاستعارة عندما نُشير إلى "إيقاف" أو "إلغاء" الخيط. تذكَّر أنه من غير الممكن إعادة تشغيل أي خيطٍ بعد انتهائه.

يؤدي استدعاء التابع الساكن Thread.sleep(milliseconds)‎ إلى "سُبات sleep" الخيط المُستدعِي لفترةٍ مساويةٍ للزمن المُمرَّر بوحدة الميللي ثانية. يُعدّ الخيط النائم sleep نشطًا، ولكنه غير مُشغَّل، ويستطيع الحاسوب تنفيذ أي خيوطٍ أو برامجٍ أخرى أثناء توقُّف ذلك الخيط. يُمكِننا استخدام التابع Thread.sleep()‎ لإيقاف تنفيذ خيطٍ معين مؤقتًا. بإمكان التابع sleep()‎ التبليغ عن استثناء من النوع InterruptedException، والذي يُعدّ من الاستثناءات المُتحقَّق منها checked exception، أي لا بُدّ من معالجته؛ ويَعنِي ذلك عمليًا ضرورة استدعاء التابع sleep()‎ داخل تعليمة try..catch لالتقاط أي استثناءات محتملةٍ من النوع InterruptedException. ألقِ نظرةً على الشيفرة التالية:

try {
   Thread.sleep(lengthOfPause);
}
catch (InterruptedException e) {
}

يستطيع خيطٌ معينٌ مقاطعة Interrupt خيطٍ آخر نائم أو مُتوقِّف لأسبابٍ أخرى بهدف إيقاظه. إذا كان thrd كائنًا من النوع Thread، فسيؤدي استدعاء التابع thrd.interrupt()‎ إلى مقاطعته. يُمكِننا الاستعانة بذلك التابع إذا كان من الضروري إرسال إشارةٍ معينة من خيطٍ لآخر. عندما يلتقط أي خيطٍ استثناءًا من النوع InterruptedException، فإنه يُدرك أن خيطًا آخر قد قاطعه. بالإضافة إلى ذلك، يستطيع الخيط استدعاء التابع الساكن Thread.interrupted()‎ بأي مكانٍ خارج عبارة catch ليَفحَص فيما إذا كان قد قُوطعَ بواسطة خيطٍ آخر.

بمجرد استدعاء ذلك التابع، تنمحي حالة المقاطعة من الخيط؛ أي يستطيع الخيط قراءة حالة المقاطعة لمرةٍ واحدةٍ فقط، وهو ما قد تراه غريبًا بعض الشيء. بالنسبة للبرامج الخاصة بك، فلن يُقاطع أي خيط خيطًا آخرًا إلا إذا برمجته ليفعل ذلك بنفسك؛ لذلك لا تحتاج غالبًا لفعل أي شيء فيما هو متعلِّق بالاستثناء InterruptedException أكثر من مجرد التقاطه.

يكون من الضروري في بعض الأحيان لخيطٍ معين الانتظار إلى حين انتهاء خيطٍ آخر من العمل. يُمكِننا فعل ذلك باستخدام التابع join()‎ المُعرَّف بالصنف Thread. بفرض أن thrd كائنٌ من النوع Thread، يُمكِن لأي خيطٍ آخر استدعاء thrd.join()‎؛ مما يَعنِي أنه سيدخل في حالة سُبات sleep إلى حين انتهاء thrd. في حالة كان thrd ميتًا بالفعل عند استدعاء thrd.join()‎، لا يكون لها أي تأثير. بإمكان التابع join()‎ التبليغ عن استثناءٍ من النوع InterruptedException، والذي يجب مُعالجته كما ذكرنا بالأعلى. على سبيل المثال، تُشغِّل الشيفرة التالية عدة خيوط، وتنتظر إلى حين انتهائها جميعًا من العمل، ثم تَطبَع الزمن المُستغرَق:

CountPrimesThread[] worker = new CountPrimesThread[numberOfThreads];
long startTime = System.currentTimeMillis();
for (int i = 0; i < numberOfThreads; i++) {
   worker[i] = new CountPrimesThread();
   worker[i].start();
}
for (int i = 0; i < numberOfThreads; i++) {
   try {
      worker[i].join();  // انتظر إلى أن ينتهي إذا لم يكن قد انتهى بالفعل
   }
   catch (InterruptedException e) {
   }
}
// بالوصول إلى تلك اللحظة، تكون جميع الخيوط العاملة قد انتهت
long elapsedTime = System.currentTimeMillis() - startTime;
System.out.println("Total elapsed time: " + (elapsedTime/1000.0) + " seconds");

ربما لاحظت أن تلك الشيفرة تفترض عدم حدوث استثناءاتٍ من النوع InterruptedException. إذا كانت تلك الاستثناءات محتملةً بالبيئة المُشغَّل عليها البرنامج، ينبغي استخدام الشيفرة التالية للتأكُّد تمامًا من أن الخيط الموجود بالكائن worker[‎i] قد انتهى أم لا:

while (worker[i].isAlive()) {
   try {
      worker[i].join();
   }
   catch (InterruptedException e) { 
   }
}

تستقبل نسخةٌ أخرى من التابع join()‎ معاملًا من النوع العددي الصحيح، وهو يُمثِّل الحد الأقصى من الزمن المسموح بانتظاره بوحدة الميللي ثانية، حيث ينتظر الاستدعاء thrd.join(m)‎ إلى أن ينتهي الخيط thrd من العمل، أو إلى أن يمر m ميللي ثانية، أو إلى أن يُقاطَع الخيط المُنتظِر. يُمكِننا استخدام ذلك التابع للسماح للخيط المُنتظِر بإنجاز مهمةٍ ما أثناء انتظاره للخيط الآخر. على سبيل المثال، تُشغِّل الشيفرة التالية خيطًا اسمه thrd، ثم تَطبَع الزمن المُنقضِي كل 2 ثانية طالما كان thrd مُشغَّلًا:

System.out.print("Running the thread ");
thrd.start();
while (thrd.isAlive()) {
   try {
      thrd.join(2000);
      System.out.print(".");
   }
   catch (InterruptedException e) {
   }
}
System.out.println(" Done!");

تتميز الخيوط بخاصيتين مفيدتين في بعض الأحيان، هما: الحالة الخفية daemon status والأولوية priority؛ حيث يُمكِن للخيط أن يُضبَط ليُصبِح خيطًا خفيًا باستدعاء التابع thrd.setDaemon(true)‎ قبل بدء تشغيل الخيط. قد يُبلِّغ الاستدعاء عن استثناءٍِ من النوع SecurityException إذا لم يكن الخيط المُستدعِي قادرًا على تعديل خاصيات الخيط thrd. وفي تلك الحالة، يكون إنهاء آلة جافا الافتراضية ممكنًا بمجرد انتهاء جميع الخيوط الحيّة غير الخفية، أي لا يُعدّ وجود بعض الخيوط الحيّة الخفية كافيًا لإبقاء آلة جافا الافتراضية مشغَّلة. يُعدّ ذلك منطقيًا، فالخيوط الخفية بالنهاية موجودةٌ فقط لتوفير بعض الخدمات للخيوط غير الخفية، ونظرًا لعدم وجود أي خيوطٍ غير خفية أخرى، لا تُستدَعى تلك الخدمات التي توفِّرها الخيوط الخفية مجددًا، ولذلك يُمكِن إنهاء البرنامج أيضًا. لاحِظ أن استدعاء System.exit()‎ ينهِي آلة جافا الافتراضية JVM إجباريًأ حتى في حالة وجود بعض الخيوط المُشغَّلة غير الخفية.

تُعدّ أولوية الخيط خاصيةً أكثر أهمية، حيث يمتلك أي خيط عمومًا أولويةً مُمثَلةً باستخدام عددٍ صحيح، ويكون تشغيل الخيوط ذات الأولوية الأكبر مُفضّلًا على حساب تشغيل الخيوط ذات الأولوية الأقل. على سبيل المثال، يُمكِن للعمليات الموجودة بالخلفية، والتي تُشغَّل عندما لا يكون هناك عملٌ ضروريٌ بخيطٍ هام آخر، أن تُشغَّل بأولوية أقل. إذا كان thrd كائنًا من النوع Thread، يُعيد التابع thrd.getPriority()‎ عددًا صحيحًا يُمثِّل أولوية الخيط thrd، بينما يَضبُط التابع thrd.setPriority(p)‎ أولوية الخيط إلى العدد الصحيح المُخصَّص p.

لا يُمكِن تخصيص أي أعدادٍ صحيحة عشوائية على أنها أولويةً لخيطٍ معين، وسيبلِّغ التابع thrd.setPriority()‎ عن استثناءِ من النوع llegalArgumentException، إذا لم تَكن الأولوية المُخصَّصة بالنطاق المسموح به للخيط. يختلف نطاق الأعداد المسموح بها لقيم أولوية خيطٍ من حاسوبٍ لآخر، وتكون مُخصَّصةً عبر الثوابت Thread.MIN_PRIORITY و Thread.MAX_PRIORITY، ومع ذلك، يُمكِن تقييد أولوية خيطٍ معينٍ لتقع ضمن قيمٍ أقل من الثابت Thread.MAX_PRIORITY. يتوفَّر أيضًا الثابت Thread.NORM_PRIORITY الذي يُمثِّل القيمة الافتراضية لأولوية خيط. يُمكِننا استخدام التعليمة التالية لضبط الخيط thrd؛ بحيث يَعمَل بقيمة أولوية أقل من القيمة الافتراضية بقليل:

thrd.setPriority( Thread.NORM_PRIORITY - 1 );

ملاحظة: قد يُبلِّغ التابع thrd.setPriority()‎ عن استثناءِ من النوع SecurityException أيضًا إذا لم يَكن مسموحًا للخيط المُستدعِي بضَبْط أولوية الخيط thrd إلى القيمة المُمرَّرة.

أخيرًا، يعيد التابع الساكن Thread.currentThread()‎ الخيط الحالي؛ أي أنه يعيد الخيط المُستدِعي لنفس ذلك التابع، وبذلك يستطيع الخيط أن يحصُل على مرجع reference لذاته، وهو ما يُمكِّنه من تعديل خاصياته. يُمكِننا مثلًا تحديد أولوية الخيط الجاري تشغيله باستدعاء Thread.currentThread().getPriority()‎.

الإقصاء التشاركي Mutual Exclusion وتعليمة التزامن synchronized

من السهل برمجة عدة خيوطٍ لتنفيذ بعض المهمات المستقلة تمامًا. تَكْمُن الصعوبة الحقيقية عندما تضطّر الخيوط للتفاعل مع بعضها بطريقةٍ أو بأخرى. تُعدّ مشاركة الموارد resources واحدةً من طرق تفاعل الخيوط مع بعضها؛ فعندما يحتاج خيطان مثلًا للوصول إلى نفس المورد، مثل متغير أو نافذةٍ على الشاشة، لا بُدّ من التأكُّد من عدم استخدامهما لنفس المورد بنفس اللحظة؛ وإلا سيكون الموقف مشابهًا لما يلي: إذا كان لدينا مجموعةٌ من الطباخين يتشاركون استخدام كوب قياسٍ واحدٍ فقط. لنتخيل أن الطباخ A قد ملئ كوب القياس بالحليب، وقبل أن يتمكَّن من تفريغه بالصحن الخاص به، أمسك الطباخ B بكوب القياس المملوء بالحليب. لذلك، لا بُدّ من توفير طريقة للطباخ A تُمكِّنه من المطالبة بأحقيته وحده للوصول إلى الكوب أثناء تنفيذه للعمليتين: إضافة الحليب إلى الكوب وتفريغ الكوب بالصحن.

ينطبق الأمر ذاته على الخيوط حتى أثناء إجرائها لعمليةٍ بسيطة مثل زيادة قيمة عدادٍ بمقدار الواحد. ألقِ نظرةً على التعليمة التالية:

count = count + 1;

في الواقع، تتكوَّن التعليمة السابقة فعليًا من ثلاث عمليات:

// اقرأ قيمة العداد
Step 1.  Get the value of count
// زِد قيمة العداد بمقدار الواحد
Step 2.  Add 1 to the value.
// خزِّن القيمة الجديدة بالعداد
Step 3.  Store the new value in count

لنفترض أنه لدينا مجموعةٌ من الخيوط تنفِّذ جميعها نفس الخطوات الثلاثة السابقة. تذكَّر أنه من الممكن تشغيل خيطين بنفس الوقت حتى في حالة وجود معالجٍ واحدٍ فقط؛ حيث يستطيع ذلك المعالج التبديل بين الخيوط الموجودة بأي لحظة. لنفترض الآن أنه وبينما كان خيطٌ معينٌ بين الخطوتين الثانية والثالثة، بدأ خيطٌ آخر بتنفيذ نفس مجموعة الخطوات. نظرًا لعدم تخزين الخيط الأول القيمة الجديدة داخل المُتغيّر count بعد، سيقرأ الخيط الآخر القيمة "القديمة" للمتغير count، وبالتالي سيزيد تلك القيمة بمقدار الواحد.

بناءً على ذلك، يَحسِب الخيطان نفس القيمة الجديدة، وعند تنفيذهما للخطوة الثالثة، يخزِّن كلاهما تلك القيمة بالمتغير count. بعد انتهاء الخيطين من العمل، تكون قيمة المتغيرcount` قد ازدادت بمقدار 1 فقط بدلًا من 2. يُطلَق على هذا النوع من المشكلات اسم "حالة التسابق race condition"، والتي تَحدُث في حالة وجود خيطٍ معينٍ وسط عمليةٍ مكوَّنةٍ من عدة خطوات، ويُغيّر خيطٌ آخر قيمةً أو شرطًا يعتمد عليه الخيط الأول لإتمام العملية التي يُجريها؛ ويُقال أن الخيط الأول يكون في حالة "تسابق" لإكمال جميع الخطوات قبل أن يقاطعه خيط آخر.

يُمكِن أن تقع حالة التسابق أيضًا بتعليمة if الشرطية. لنفحص التعليمة التالية التي تحاول تجنُّب وقوع خطأ القسمة على صفر:

if ( A != 0 ) {
   B = C / A;
}

لنفترض أن تلك التعليمة مُشغَّلةٌ بخيطٍ معين، ولنفترض أن هنالك خيطٌ آخر أو عدة خيوطٍ أخرى تتشارك مع الخيط الأول المورد A. إذا لم نُوفِّر حمايةً ضد حالة التسابق بطريقةٍ ما، فبإمكان أيٍّ من تلك الخيوط تعديل قيمة A إلى الصفر في اللحظة الواقعة بين لحظة فحص الخيط الأول للشرط A != 0، ولحظة إجراءه لعملية القسمة؛ أي قد ينتهي به الحال بإجراء عملية القسمة على صفر على الرغم من أنه قد فحص للتو أن المتغير A لا يساوي الصفر.

لحل مشكلة حالات التسابق، لا بُدّ من توفُّر طريقة تُمكِّن الخيط من الوصول وحده دون غيره إلى موردٍ تشاركي. في الواقع، لا يُعدّ ذلك أمرًا سهل التنفيذ، ولكن تُوفِّر جافا أسلوبًا عالي المستوى وسهل الاستخدام نسبيًا لتحقيق ذلك باستخدام التوابع المتزامنة synchronized، وتعليمة synchronized؛ حيث توفِّر تلك الطرائق حمايةً للموارد التي تتشاركها عدة خيوط، وذلك من خلال السماح لخيطٍ واحدٍ فقط بالوصول إلى المورد بكل مرة.

يُوفِّر التزامن بلغة جافا ما يُعرف باسم "الإقصاء التشاركي mutual exclusion"، والذي يَعنِي ضمان تحقُّق الوصول الإقصائي لموردٍ معين إذا استخدمت جميع الخيوط التي تحتاج الوصول إلى ذلك المورد المزامنة. إذا طبقنا مبدأ المزامنة على مثال الطباخين، فإنه يَعنِي أن يترك الطباخ الأول ملاحظةً تقول "إنني استخدِم كوب القياس"، وهو ما يمنحه أحقيةً حصريّةً إقصائية في الوصول إلى الكوب، ولكن لن يتحقَّق ذلك إلا إذا اتفق جميع الطباخين على فحص الملاحظة قبل محاولة الإمساك بالكوب.

نظرًا لأنه موضوع معقدٌ نوعًا ما، سنبدأ بمثالٍ بسيط. لنفترض أننا نريد تجنُّب حالة التسابق ممكنة الحدوث عند محاولة مجموعةٍ من الخيوط زيادة قيمة عدادٍ بمقدار الواحد. بدايةً، سنُعرِّف صنفًا يُمثِّل العداد، وسنَستخدِم التوابع المتزامنة ضمن ذلك الصنف. يُمكِننا الإعلان عن أن تابعًا معينًا هو من النوع المتزامن بإضافة الكلمة المحجوزة synchronized مثل مُعدِّلٍ بتعريف ذلك التابع على النحو التالي:

public class ThreadSafeCounter {

   private int count = 0;  // قيمة العداد

   synchronized public void increment() {
      count = count + 1;
   }

   synchronized public int getValue() {
      return count;
   }

}

إذا كان tsc من النوع ThreadSafeCounter، يُمكِن لأي خيطٍ استدعاء التابع tsc.increment()‎ لزيادة قيمة العداد بمقدار 1 بطريقةٍ آمنةٍ تمامًا. نظرًا لأن التابع tsc.increment()‎ متزامن، سيكون بإمكان خيطٍ واحدٍ فقط تنفيذه بالمرة الواحدة. يَعنِي ذلك أنه وبمجرد بدء خيطٍ معين بتنفيذ ذلك التابع، فسيُنهي ذلك الخيط تنفيذ التابع بالضرورة قبل أن يُسمَح لخيطٍ آخر بالوصول إلى count. وبالتالي لا يكون هناك أي احتماليةٍ لحدوث حالة تسابق.

لاحِظ أن ما سبق مشروطٌ بحقيقة أن count مُعرَّف على أنه متغيرٌ خاص private، وبالتالي لا بُدّ أن تحدث أي محاولةٍ للوصول إليه عبر التوابع المتزامنة المُعرَّفة بالصنف؛ وإذا كان count مُعرَّفًا على أنه متغيرٌ عام، فبإمكان خيطٍ آخر اجتياز المزامنة بكتابة tsc.count++‎ مثلًا، وستتغيّر في تلك الحالة قيمة المتغير count بينما ما يزال خيطٌ آخر يُنفِّذ عملية tsc.increment()‎؛ أي لا تضمَن عملية المزامنة بحد ذاتها تحقُّق الوصول الإقصائي للموارد في العموم، وإنما تضمَن تحقُّق "الإقصاء التشاركي" بين جميع الخيوط المتزامنة فقط.

ومع ذلك، لا يمنع الصنف ThreadSafeCounter جميع حالات التسابق محتملة الحدوث عند استخدام مجموعة خيوطٍ لعداد. انظر تعليمة if التالية مثلًا:

if ( tsc.getValue() == 10 ) {
   doSomething();
}

يتطلَّب التابع doSomething()‎ أن تكون قيمة العداد مساويةً للعدد 10. قد تحدث حالة تسابق إذا زاد خيطٌ آخر قيمة العداد بين لحظتي اختبار الخيط الأول للشرط tsc.getValue() == 10 وتنفيذه للتابع doSomething()‎. يحتاج الخيط الأول إذًا إلى وصولٍ إقصائي إلى العداد أثناء تنفيذه تعليمة if بالكامل؛ بينما تمنحه المزامنة بالصنف ThreadSafeCounter وصولًا إقصائيًا أثناء تحصيله لقيمة tsc.getValue()‎ فقط. يُمكِننا حل تلك المشكلة بوضع تعليمة if داخل تعليمة synchronized على النحو التالي:

synchronized(tsc) {
   if ( tsc.getValue() == 10 )
      doSomething();
}

تستقبل تعليمة synchronized كائنًا على أنه معاملٌ -كان tsc في المثال السابق-، وتُكتَب وفقًا لقواعد الصيغة التالية:

synchronized( object ) {
   statements
}

يرتبط الإقصاء التشاركي بجافا دائمًا بكائنٍ معين، ويقال أن التزامن مبنيٌ على ذلك الكائن، حيث يُعدّ تزامن تعليمة if بالأعلى مثلًا مبنيًا على الكائن tsc؛ بينما يُعدّ تزامن توابع النسخ instance method المتزامنة، مثل تلك المُعرَّفة بالصنف ThreadSafeCounter، مبنيًا على الكائن المُتضمِّن لتابع النسخة. تتكافئ إضافة المُعدِّل synchronized إلى تعريف تابع نسخة مع كتابة متن التابع داخل تعليمة synchronized على الصيغة التالية synchronized(this) {...}‎. من الممكن أيضًا تعريف توابعٍ ساكنةٍ متزامنة، ويُعدّ تزامنها مبنيًا على الكائن الخاص الذي يُمثِّل الصنف المُتضمِّن للتابع الساكن.

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

تُنفِّذ جافا ذلك باستخدام "قفل المزامنة synchronization lock، حيث يملك كل كائنٍ قفلًا يُمكِن أن يحصُل عليه خيطٌ واحدٌ فقط خلال أي لحظة. عندما نستخدِم تعليمة synchronized أو نستدعي تابعًا متزامنًا، فلا بُدّ للخيط أن يحصل على قفل الكائن المبني عليه التزامن أولًا؛ فإذا كان القفل متاحًا، سيحصل الخيط عليه فورًا ويبدأ بتنفيذ الشيفرة المتزامنة، ثم يُحرِّره بمجرد انتهاءه من تنفيذها؛ أما إذا حاول الخيط A الحصول على قفلٍ قد حصل عليه خيطٌ آخر B، فلا بُدّ إذًا أن ينتظر الخيط A حتى يُحرِّر الخيط B القفل؛ أي يتوقَّف/ينام الخيط A، ولا يعود للعمل حتى يُصبِح القفل متاحًا.

ذكرنا بالقسم اللامتغايرات من مقال كيفية كتابة برامج صحيحة باستخدام لغة جافا أن التفكير بطريقة عمل اللا متباين invariants ستصبح أعقد كثيرًا عند استخدام الخيوط، حيث تَكُمن المشكلة في حالات التسابق race conditions. نريد للصنف ThreadSafeCounter أن يُحقِّق لا متباين الصنف الذي ينص على: "تُمثِّل قيمة count عدد مرات استدعاء increment()‎". يتحقَّق ذلك بدون مزامنة synchronization بالبرامج أحادية الخيط، ولكن تصبح المزامنة ضرورية بالبرامج متعددة الخيوط للتأكُّد من تحقُّق لا متباين الصنف.

سنعود الآن إلى مسألة عدّ الأعداد الأولية بمثابة مثالٍ بسيطٍ على الموارد التشاركية shared resources، وبدلًا من أن تُنفِّذ جميع الخيوط نفس المهمة تمامًا، سننفِّذ معالجةً على التوازي أكثر واقعية. سيَعُدّ البرنامج الأعداد الأولية ضمن نطاقٍ معين من الأعداد الصحيحة، وسيفعل ذلك بتوزيع العمل على عدة خيوط؛ أي سيتعيّن على كل خيط عدّ الأعداد الأولية الموجودة ضمن جزءٍ معين من النطاق الكلي، ثم سيضطّر لإضافة القيمة التي حسبها إلى المجموع الكلي ضمن كامل النطاق. نظرًا لأن جميع الخيوط مضطرةٌ لإضافة عددٍ إلى المجموع الكلي، فلا بُدّ أن تتشارك جميعها مُتغيرًا يُمثِّل ذلك المجموع الكلي، وليكن اسمه هو total. إذا اِستخدَمت جميع الخيوط التعليمة التالية:

total = total + count;

فهناك احتماليةٌ ولو صغيرة أن يحاول خيطان تنفيذ التعليمة ذاتها بنفس الوقت، وستكون القيمة النهائية للمتغير total غير صحيحة. لذلك لا بُدّ أن يكون الوصول إلى total متزامنًا لمنع حالة التسابق race condition. سنَستخدِم ضمن هذا البرنامج تابعًا متزامنًا يَسمَح بزيادة قيمة total بمقدارٍ معين، بحيث يَستدعِي كل خيطٍ ذلك التابع لمرةٍ واحدة. سيُمثِّل ذلك التابع الطريقة الوحيدة لتعديل قيمة total. ألقِ نظرةً على شيفرة التابع:

synchronized private static void addToTotal(int x) {
   total = total + x;
   System.out.println(total + " primes found so far.");
}

يُمكِنك الإطلاع على شيفرة البرنامج من الملف ThreadTest2.java، حيث يَعُدّ هذا البرنامج الأعداد الأولية الواقعة بنطاقٍ يتراوح من 3000001 إلى 6000000 (مجرد أعداد عشوائية). يُنشِئ البرنامج main()‎ عدة خيوطٍ يتراوح عددها من "1" إلى "5"، ويُسنِد جزءًا من المهمة لكل خيط، ثم ينتظر إلى أن تنتهي جميع الخيوط من عملها باستخدام التابع join()‎ المذكور بالأعلى، وأخيرًا يُبلّغ عن العدد الكلي للأعداد الأولية مصحوبًا بالزمن الذي استغرقه البرنامج لحساب ذلك العدد. لاحِظ أن اِستخدَام التابع join()‎ ضروري؛ فلا معنى لطباعة عدد الأعداد الأولية قبل أن تنتهي جميع الخيوط. إذا شغَّلت البرنامج على حاسوبٍ متعدّد المعالجات، فسيستغرق البرنامج وقتًا أقل عند استخدام أكثر من مجرد خيطٍ واحد.

تساعد المزامنة على منع حالات التسابق، ولكنها قد تتسبَّب بنوعٍ آخر من الأخطاء يُدعى "قفل ميت deadlock". يحدث هذا النوع من الأخطاء عندما ينتظر خيطٌ موردًا معينًا إلى الأبد دون أن يحصل عليه. إذا عدنا إلى مثال "المطبخ"، فلربما يحدث القفل الميت إذا أراد طبّاخان ساذجان قياس كوب حليب بنفس الوقت، حيث سيُمسِك الطباخ الأول بكوب القياس، بينما سيُمسِك الطباخ الآخر بالحليب. يحتاج الطباخ الأول إلى الحليب، ولكنه لا يستطيع العثور عليه لأنه بحوزة الطباخ الثاني. ومن الجهة الأخرى، يحتاج الطباخ الثاني إلى كوب القياس، ولكنه لا يستطيع العثور عليه لأنه بحوزة الطباخ الأول. وبذلك، لا يتمكَّن الطباخان من إكمال عملهما. يُعدّ ذلك بمثابة قفل ميت، وقد يحدث نفس الشيء ببرنامجٍ معين إذا كان هناك خيطين (الطباخين) مثلًا يريد كلاهما الحصول على قفلين على كائنين معينين (الحليب وكوب القياس) قبل أن يكملا عملهما. تقع الأقفال الميتة بسهولة إذا لم ننتبه بما يكفي لمنعها.

المتغيرات المتطايرة

تُعدّ المزامنة واحدةً ضمن عدة تقنيات تتحكَّم بالتواصل بين الخيوط، وسنتناول تقنياتٍ أخرى لاحقًا، أما الآن فسنُنهِي هذا القسم بمناقشة التقنيتين التاليتين: المتغيرات المتطايرة volatile variables والمتغيرات الذرية atomic variables.

تتواصل الخيوط مع بعضها عمومًا من خلال تشارك عدة متغيرات والوصول إليها من خلال استخدام توابعٍ متزامنة أو تعليمة synchronized. ومع ذلك، تُعدّ عملية المزامنة مكلفةً حاسوبيًا، ولهذا ينبغي تجنُّب اِستخدَامها بكثرة، وقد يكون من المنطقي في بعض الأحيان للخيوط الإشارة إلى المتغيرات التشاركية دون مزامنة وصولها إلى تلك المتغيرات.

من الجهة الأخرى، قد تنشأ مشكلةٌ صغيرة إذا كانت قيمة متغير تشاركي تُضبَط بخيطٍ وتُستخدَم بآخر. نظرًا للطريقة التي تُنفِّذ جافا الخيوط على أساسها، فلربما لا يرى الخيط الثاني القيمة الجديدة للمتغير على الفور؛ ويَعنِي ذلك أنه من الممكن لخيطٍ معين الاستمرار بقراءة القيمة "القديمة" لمتغيرٍ تشاركي لمدةٍ معينة بالرغم من أن قيمة ذلك المتغير قد عُدِّلت بخيطٍ آخر. يَحدث ذلك لأن جافا تسمح للخيوط بتخزين البيانات التشاركية مؤقتًا cache؛ أي يُمكِن لكل خيطٍ الاحتفاظ بنسخته المحلية من البيانات التشاركية، وبالتالي عندما يُعدِّل خيطٌ معينٌ قيمة متغيرٍ تشاركي، لا تُعدَّل النسخ المحلية بمخزِّنات الخيوط الأخرى المؤقتة على الفور، وقد تستمر تلك الخيوط بقراءة القيمة القديمة لفترةٍ قصيرةٍ على الأقل.

في المقابل، يُعدّ استخدام مُتغيّر تشاركي داخل تابعٍ متزامن أو داخل تعليمة synchronized آمنًا شرط أن تكون جميع محاولات الوصول إلى ذلك المُتغيّر متزامنةً بناءً على نفس الكائن بجميع الحالات. بعبارة أخرى، إذا حاول خيطٌ معين الوصول إلى قيمة متغيرٍ داخل شيفرةٍ متزامنة، فإنه يَضمَن أن يرى أي تغييرات تجريها الخيوط الأخرى على ذلك المتغير شرط أن تكون تلك التعديلات قد أُجريت داخل شيفرةٍ متزامنةٍ بناءً على نفس الكائن.

يُمكِننا أيضًا استخدام متغيرٍ تشاركي اِستخدَامًا آمنًا خارج شيفرةٍ متزامنة، ولكن لا بُدّ في تلك الحالة أن نُصرِّح عن كون المتغير متطايرًا volatile باستخدام كلمة volatile، حيث تُعدّ الكلمة المحجوزة volatile واحدةً من المُعدِّلات modifiers المُمكِن إضافتها إلى تعليمات التصريح عن المتغيرات العامة global variable على النحو التالي:

private volatile int count;

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

عند تطبيق المُعدِّل volatile على متغيرٍ من نوع كائني، فإن ما يُصرَّح عنه ليكون متطايرًا هو المتغير ذاته فقط لا محتويات الكائن الذي يشير إليه المتغير، ولهذا يُستخدَم غالبًا المُعدِّل volatile مع المتغيرات من الأنواع البسيطة، مثل الأنواع الأساسية primitive types، أو الأنواع الثابتة غير المتغيرة immutable types مثل String.

لنفحص الآن مثالًا على استخدام متغيرٍ متطايرٍ لإرسال إشارةٍ من خيط لآخر ليخبره بأن عليه الانتهاء terminate. يتشارك الخيطان المتغير التالي:

volatile boolean terminate = false;

يفحص التابع run()‎ الخاص بالخيط الثاني قيمة terminate باستمرار، وينتهي عندما تصبح قيمته مساويةً القيمة true. ألقِ نظرةً على الشيفرة التالية:

public void run() {
   while ( terminate == false ) {
      .
      .  // Do some work.
      .
   }
}

سيستمر هذا الخيط بالعمل إلى أن يضبط خيطٌ آخر قيمة terminate إلى true. تُعدّ الشيفرة السابقة الطريقة الوحيدة للسماح لخيطٍ بغلْق خيطٍ آخر بطريقةٍ نظيفة.

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

المتغيرات الذرية

تكْمُن مشكلة البرمجة على التوازي بتعليمةٍ مثل count = count + 1 في أنها تستغرق عدة خطوات لتنفيذ التعليمة، وتكون التعليمة قد نُفذَّت على النحو الصحيح فقط إذا اكتملت الخطوات دون حدوث أي تقاطعٍ مع خيوطٍ أخرى.

تُعدّ العمليات الذرية atomic operation بمثابة شيءٍ لا يمكن مقاطعته؛ فإما يحدث كله أو لا شيء، بمعنى أنه لا يُمكِن أن يكتمل جزئيًا. تحتوي معظم الحواسيب على عملياتٍ ذرية بمستوى لغة الآلة machine language level. قد تتوفَّر على سبيل المثال تعليمةً بلغة الآلة مسؤولةً عن زيادة قيمة موضعٍ معينٍ بالذاكرة بخطوةٍ واحدة. لا تعاني مثل تلك التعليمات من خطر حالات التسابق.

بالنسبة للبرامج، يمكن لعمليةٍ أن تكون ذرية حتى لو لم تكن كذلك حرفيًا بمستوى لغة الآلة؛ حيث تُعدّ العملية ذريةً إذا لم يكن بإمكان أي خيطٍ أن يراها مكتملةً جزئيًا. على سبيل المثال، يحتوي الصنف ThreadSafeCounter المُعرَّف بالأعلى على عملية زيادةٍ ذرية. يُمكِننا أن نفكر بالمزامنة مثل طريقةٍ للتأكيد على كون العمليات ذرية. ومع ذلك، سيكون من الأفضل لو استطعنا الحصول على عملياتٍ ذرية دون استخدام المزامنة، خاصةً وأنه من الممكن تنفيذ تلك العمليات بكفاءة عالية على مستوى العتاد.

تُوفِّر جافا حزمة java.util.concurrent.atomic، والتي تحتوي على أصنافٍ تُنفِّذ عملياتٍ ذرية على عدة أنواع متغيراتٍ بسيطة. سنفحص الصنف AtomicInteger الذي يُعرَّف بعض العمليات الذرية على قيمةٍ من النوع العددي الصحيح، بما في ذلك الجمع والزيادة والنقصان. لنفترض على سبيل المثال أننا نريد إضافة قيمٍ عدديةٍ صحيحة تنتجها مجموعةٌ من الخيوط. يُمكِننا فعل ذلك باستخدام الصنف AtomicInteger على النحو التالي:

private static AtomicInteger total = new AtomicInteger();

يُنشَأ total بقيمةٍ مبدئية تُساوي الصفر. عندما يحاول خيطٌ معينٌ إضافة قيمةٍ ما إلى total، فيُمكِنه استخدام التابع total.addAndGet(x)‎، الذي يضيف x إلى total، ويعيد قيمة total الجديدة بعد الإضافة. يُعدّ ذلك مثالًا على عمليةٍ ذرية لا يمكن مقاطعتها، أي ستكون قيمة total صحيحةً بالضرورة. يُعَد البرنامج ThreadTest3.java نسخةً أخرى من البرنامج ThreadTest2، ولكنه يَستخدِم الصنف AtomicInteger بدلًا من المزامنة لحساب حاصل مجموع بعض القيم التي تنتجها خيوطٌ متعددة.

يحتوي الصنف AtomicInteger على توابعٍ أخرى، مثل total.incrementAndGet()‎ لإضافة 1 إلى total، و total.decrementAndGet()‎ لطرح 1 منه. يضبُط التابع total.getAndSet(x)‎ قيمة total إلى x، ويعيد القيمة السابقة التي حلّت x محلها. تحدث جميع تلك العمليات على الفور؛ إما لأنها تَستخدِم تعليمات لغة آلة ذرية؛ أو لكونها تَستخدِم المزامنة داخليًا.

تحذير: لا يُعدّ اِستخدَام المتغيرات الذرية حلًا تلقائيًا لجميع حالات التسابق التي قد تشملها تلك المتغيرات. ألقِ نظرةً على الشيفرة التالية على سبيل المثال:

int currentTotal = total.addAndGet(x);
System.out.println("Current total is " + currentTotal);

ربما تكون قيمة total قد تغيرت بخيطٍ آخر بحلول وقت تنفيذ تعليمة الطباعة، وبذلك لا تكون currentTotal هي القيمة الحالية للمتغير total.

ترجمة -بتصرّف- للقسم Section 1: Introduction to Threads من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...