أدوات التزامن المتقدّمة

أنواع مجمّعات الخيوط

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

أنواع مجمّعات الخيوط

إنشاء Thread مستقل لكل وحدة عمل أمرٌ مكلف — فإنشاء الخيط يستلزم استدعاءات نواة النظام، وتخصيص الحزمة البرمجية (stack)، وحسابات الجدولة. تحل مجمّعات الخيوط (Thread Pools) هذه المشكلة بإبقاء مجموعة من الخيوط المُنشأة مسبقًا حيّة وإعادة استخدامها عبر مهام كثيرة. تمنحك فئة المصنع java.util.concurrent.Executors أربعة أنماط مجرَّبة من المجمّعات، كل منها مضبوط لنمط حمل عمل مختلف.

نظرة عامة على مصنع Executors

تعيد الطرق الأربع دومًا ExecutorService أو ScheduledExecutorService، فتتعامل دائمًا مع الواجهة نفسها بصرف النظر عن نوع المجمّع. الفارق كامن في عدد الخيوط واستراتيجية الطابور وكيفية التعامل مع ذروات الحمل.

مجمّع الخيوط الثابت

تُنشئ Executors.newFixedThreadPool(n) عددًا ثابتًا من n خيطًا وتبقيها حيّة للأبد (حتى تغلق المجمّع). تنتظر المهام المُرسَلة في طابور LinkedBlockingQueue غير محدود الحجم عندما تكون جميع الخيوط مشغولة.

import java.util.concurrent.*; ExecutorService fixed = Executors.newFixedThreadPool(4); for (int i = 0; i < 20; i++) { final int taskId = i; fixed.submit(() -> { System.out.printf("Task %d on %s%n", taskId, Thread.currentThread().getName()); Thread.sleep(200); // محاكاة العمل return taskId * taskId; }); } fixed.shutdown(); // أوقف قبول المهام الجديدة fixed.awaitTermination(30, TimeUnit.SECONDS);

مع 20 مهمة ومجمّع من 4 خيوط، تبدأ الدفعة الأولى من 4 مهام فورًا؛ وتنتظر المهام الـ16 المتبقية في الطابور وتُنجَز أربعة في كل مرة.

متى تستخدم المجمّع الثابت: المهام المُكثِّفة لوحدة المعالجة (CPU-bound) حيث تريد N خيطًا تتنافس على المعالج (عادةً Runtime.getRuntime().availableProcessors() أو مضاعفات صغيرة منه). يمنع المجمّع الثابت الاستهلاك غير المحدود للموارد ويتجنّب عاصفة تبادل السياق الناجمة عن وجود خيوط قابلة للتشغيل أكثر بكثير من عدد الأنوية.
خطر الطابور غير المحدود: لا يوجد حد لسعة الطابور في المجمّع الثابت. إذا أرسل المنتجون مهامًا أسرع مما يستطيع المجمّع إنجازها، ينمو الطابور دون حد وسينفد ذاكرة الكومة في نهاية المطاف. في الإنتاج، يُفضّل بناء ThreadPoolExecutor مباشرةً مع ArrayBlockingQueue محدودة ونهج رفض (rejection policy).

مجمّع الخيوط المؤقّتة

تُنشئ Executors.newCachedThreadPool() خيوطًا جديدة عند الطلب وتُعيد استخدام الخيوط الخاملة. يُنهى الخيط الخامل لمدة 60 ثانية ويُزال من المجمّع، فيعود حجم المجمّع إلى الصفر حين يهدأ الحمل.

ExecutorService cached = Executors.newCachedThreadPool(); for (int i = 0; i < 100; i++) { final int id = i; cached.submit(() -> { System.out.println("IO task " + id + " on " + Thread.currentThread().getName()); Thread.sleep(50); // عمل I/O سريع }); } cached.shutdown(); cached.awaitTermination(30, TimeUnit.SECONDS);

قد تصل إلى 100 خيط عند إرسال 100 مهمة قبل انتهاء أي منها. هذا مقبول للمهام القصيرة المُرتكزة على الإدخال/الإخراج — فالخيوط رخيصة الإبقاء لمدة 60 ثانية وتُعاد استخدامها بقوة.

النطاق المثالي للمجمّع المؤقّت: مهام قصيرة كثيرة مُركِّزة على الإدخال/الإخراج حيث يكون عدد المهام المتزامنة محدودًا طبيعيًا بمعدل الطلبات الواردة (مثل معالجة طلبات شبكة على خادم خفيف الحمل). تجنّبه في المهام المُكثِّفة للمعالج أو حين يمكن للمهام أن ترتفع دون قيود — قد تُنشئ آلاف الخيوط وتُعطل الجهاز الافتراضي.

المنفّذ ذو الخيط الواحد

تُنشئ Executors.newSingleThreadExecutor() مجمّعًا من خيط واحد بالضبط. تُنفَّذ المهام بالتسلسل وفق ترتيب الإرسال، مما يجعله أداة تسلسل مفيدة دون الحاجة إلى أي مزامنة يدوية.

ExecutorService single = Executors.newSingleThreadExecutor(); single.submit(() -> System.out.println("Step 1 — init")); single.submit(() -> System.out.println("Step 2 — process")); single.submit(() -> System.out.println("Step 3 — cleanup")); single.shutdown(); single.awaitTermination(5, TimeUnit.SECONDS); // يطبع دائمًا: Step 1, Step 2, Step 3 — ترتيب صارم مضمون.
أهمية التغليف: على خلاف newFixedThreadPool(1)، يُغلّف منفّذ الخيط الواحد عامله في مُفوِّض. إذا مات الخيط الداخلي بسبب استثناء غير مُعالَج، يُنشأ خيط جديد بصمت لاستبداله مع الإبقاء على المنفّذ حيًا. لا يُنشئ newFixedThreadPool(1) العادي خيطًا بديلًا.

مجمّع الخيوط المجدوَلة

تعيد Executors.newScheduledThreadPool(n) ScheduledExecutorService قادرًا على تشغيل المهام بعد تأخير أو وفق جدول دوري. إنه البديل الحديث لـ java.util.Timer.

import java.util.concurrent.*; ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); // تشغيل مرة واحدة بعد تأخير ثانية واحدة scheduler.schedule( () -> System.out.println("Delayed task at " + System.currentTimeMillis()), 1, TimeUnit.SECONDS ); // تشغيل متكرر — أول تنفيذ فورًا، ثم كل 3 ثوانٍ ScheduledFuture<?> handle = scheduler.scheduleAtFixedRate( () -> System.out.println("Heartbeat: " + System.currentTimeMillis()), 0, 3, TimeUnit.SECONDS ); // إلغاء بعد 10 ثوانٍ scheduler.schedule(() -> { handle.cancel(false); // دع التشغيل الحالي ينتهي scheduler.shutdown(); }, 10, TimeUnit.SECONDS); scheduler.awaitTermination(15, TimeUnit.SECONDS);

هناك نوعان من التكرار: scheduleAtFixedRate يستهدف فترة ثابتة على ساعة الجدار (مثالي لنبضات القلب واستطلاع المقاييس)، بينما ينتظر scheduleWithFixedDelay انتهاء التشغيل السابق قبل بدء العدّ (مثالي حين تتباين مدة المهمة والتداخل خطر).

استخدم خيطين على الأقل في مجمّع الجدولة عند وجود مهام دورية متعددة. مع خيط واحد فقط، ستؤخّر مهمة طويلة كل مهمة مجدوَلة تالية في الطابور.

اختيار المجمّع المناسب

  • مُكثِّف للمعالج، تزامن محدودnewFixedThreadPool(cores)
  • مهام I/O قصيرة كثيرة، معدل متغيّرnewCachedThreadPool()
  • طابور مهام تسلسلي دون مزامنةnewSingleThreadExecutor()
  • تنفيذ مؤجَّل أو دوريnewScheduledThreadPool(n)

الإغلاق الصحيح دائمًا

المنفّذ الذي لا يُغلَق أبدًا يُبقي خيوطه حيّة ويمنع الجهاز الافتراضي من الخروج ويسرب الموارد. استخدم نمط الإغلاق على مرحلتين:

pool.shutdown(); // أوقف القبول، دع المهام الجارية تنتهي if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { pool.shutdownNow(); // ألغِ المهام المعلّقة if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { System.err.println("Pool did not terminate"); } }
لا تتجاهل الإغلاق في كود الإنتاج. حيلة خيوط الخادم (daemon) أو System.exit() المباشر قد تُخفي المنفّذين المسرَّبين أثناء التطوير، لكن في الخدمة طويلة الأمد تتراكم بمرور الوقت وتستنفد ذاكرة مكدس الخيوط.

الخلاصة

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