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

إطار عمل Executor

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

إطار عمل Executor

قبل الإصدار الخامس من Java، كان تشغيل شيء ما بالتزامن يعني إنشاء Thread يدويًا وتشغيله والأمل في النتائج. لهذا النهج مشكلتان جوهريتان: إنشاء الخيوط مكلف (يستهلك كل خيط نحو 1 ميغابايت من ذاكرة المكدس ومئات من الميكروثانية من وقت نظام التشغيل)، ولا توجد آلية مدمجة لإعادة الاستخدام أو الإبلاغ عن الأخطاء أو إدارة دورة الحياة. يُحل إطار عمل Executor — الذي أُدرج في java.util.concurrent في Java 5 — كل هذه المشكلات بفصل الماذا (وحدة العمل) عن الكيف (استراتيجية الخيوط التي تُنفّذه).

التجريد الجوهري: Executor

يرتكز الإطار بأكمله على واجهة واحدة:

public interface Executor { void execute(Runnable command); }

هذه الطريقة الواحدة هي العقد كاملًا. أي كائن يقبل Runnable ويُشغّله في نهاية المطاف هو Executor. أبسط تطبيق ممكن يُشغّل المهمة مباشرةً على خيط الاستدعاء:

Executor directExecutor = Runnable::run; directExecutor.execute(() -> System.out.println("Runs on caller thread"));

تطبيق أكثر فائدة يُدرج المهمة في قائمة انتظار على خيط جديد:

Executor threadPerTask = task -> new Thread(task).start(); threadPerTask.execute(() -> System.out.println("Runs on a new thread"));

كلاهما تطبيقان صالحان لـ Executor. لا يتغير كود الاستدعاء — تتغير الاستراتيجية فقط. هذه هي قوة التجريد.

ExecutorService: إدارة دورة الحياة

يُوسّع ExecutorService واجهة Executor بإضافتين مهمتين: القدرة على إرسال مهام تُعيد نتائج، والقدرة على إيقاف تشغيل المُنفّذ بشكل منظم.

public interface ExecutorService extends Executor { <T> Future<T> submit(Callable<T> task); Future<?> submit(Runnable task); void shutdown(); // لا مهام جديدة؛ أكمل العمل المُصطفّ List<Runnable> shutdownNow(); // اقطع المهام الجارية؛ أعِد غير المبدوءة boolean isTerminated(); boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException; }
أوقف دائمًا ExecutorService. خيوطه غير خادمة (non-daemon) افتراضيًا، مما يعني أن JVM لن يخرج إذا نسيت استدعاء shutdown(). إن ExecutorService المُهملة هي سبب شائع لتوقف التطبيقات بدلًا من إنهائها نظيفًا.

مجمّعات الخيوط: لماذا توجد؟

مجمّع الخيوط هو ExecutorService يحتفظ بمجموعة ثابتة أو ديناميكية من خيوط العمل. عند إرسال مهمة، يُسلّمها المجمّع إلى خيط خامل (أو يُدرجها في القائمة إذا كانت جميع الخيوط مشغولة). عند انتهاء المهمة، يعود الخيط إلى المجمّع وينتظر المهمة التالية — دون تفكيك أو إعادة إنشاء.

الفوائد ملموسة:

  • تقليل الكمون — الخيوط مُهيَّأة مسبقًا؛ إرسال مهمة يكاد لا يُكلّف شيئًا.
  • التحكم في الموارد — تختار عدد الخيوط التي يمكن أن تعمل في وقت واحد، ما يمنع الانفجار الخيطي تحت الحِمل.
  • إعادة الاستخدام — تتعامل كائنات الخيوط ذاتها مع آلاف المهام طوال عمرها.

إنشاء مجمّعات الخيوط باستخدام Executors

تُوفّر فئة المصنع Executors أكثر أنواع المجمّعات شيوعًا. إليك الأكثر استخدامًا:

import java.util.concurrent.*; // 1. مجمّع ذو حجم ثابت — N خيط بالضبط، تُصطفّ المهام حين تكون جميعها مشغولة ExecutorService fixed = Executors.newFixedThreadPool(4); // 2. مجمّع مخبّأ — ينشئ خيوطًا عند الطلب، يُعيد استخدام الخاملة، // يُنهي الخيوط الخاملة لمدة 60 ثانية ExecutorService cached = Executors.newCachedThreadPool(); // 3. مُنفّذ أحادي الخيط — خيط واحد، تُشغَّل المهام بترتيب الإرسال ExecutorService single = Executors.newSingleThreadExecutor(); // 4. مُنفّذ مجدوَل — تشغيل المهام بعد تأخير أو وفق جدول ثابت ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);

مثال متكامل من البداية للنهاية: إرسال عشر مهام إلى مجمّع ثابت، ثم إيقاف تشغيله وانتظار الاكتمال.

import java.util.concurrent.*; public class FixedPoolDemo { public static void main(String[] args) throws InterruptedException { ExecutorService pool = Executors.newFixedThreadPool(3); for (int i = 1; i <= 10; i++) { int taskId = i; pool.execute(() -> { System.out.printf("Task %d running on %s%n", taskId, Thread.currentThread().getName()); }); } pool.shutdown(); // أوقف قبول مهام جديدة pool.awaitTermination(10, TimeUnit.SECONDS); // انتظر المهام الحالية System.out.println("All tasks done."); } }

شغّل هذا وسترى ثلاثة أسماء خيوط تتناوب على عشر مهام — دليل على إعادة استخدام الخيوط.

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

  • العمل المُقيَّد بالمعالج (CPU-bound) — استخدم newFixedThreadPool(Runtime.getRuntime().availableProcessors()). خيوط أكثر من الأنوية تُضيف فقط تكاليف تبديل السياق دون زيادة الإنتاجية.
  • العمل المُقيَّد بالإدخال/الإخراج (IO-bound) — تقضي الخيوط معظم وقتها في الانتظار (شبكة، قرص). يُتيح المجمّع الأكبر للمعالج البقاء مشغولًا بينما تنتظر خيوط أخرى. منذ Java 21 يمكنك أيضًا استخدام الخيوط الافتراضية وهي أرخص بكثير للإدخال/الإخراج الحاجز.
  • العمل غير المحدود مع حركة مرور متفجّرةnewCachedThreadPool() مريح لكنه خطير تحت الحِمل المستدام: إذا وصلت المهام أسرع مما تُكتمل، ينشئ المجمّع خيوطًا غير محدودة وينفد الذاكرة. فضّل ThreadPoolExecutor مخصصًا بطابور انتظار محدود في الإنتاج.
  • ضمان التسلسل — يمنحك newSingleThreadExecutor() قناة تنفيذ متسلسلة مرتّبة دون أي مزامنة صريحة.
فضّل ThreadPoolExecutor الصريح في الإنتاج. طرق المصنع مناسبة للتعلم والأدوات البسيطة، لكن في خدمة حقيقية تريد تسمية خيوطك (لتسهيل التصحيح)، وتحديد عمق قائمة الانتظار، وتعريف سياسة الرفض. يكشف ThreadPoolExecutor كل هذه الخيارات. ستستكشفه بالتفصيل في الدرس القادم حول أنواع المجمّعات.

نمط الإيقاف الصحيح

يتبع الإيقاف القوي النمط ثنائي المرحلة الموصى به في توثيق Java:

static void shutdownAndAwait(ExecutorService pool) { pool.shutdown(); // المرحلة 1: ارفض المهام الجديدة، دع المصطفّة تنتهي try { if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { pool.shutdownNow(); // المرحلة 2: اقطع المهام الجارية if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { System.err.println("Pool did not terminate"); } } } catch (InterruptedException ex) { pool.shutdownNow(); Thread.currentThread().interrupt(); // استعِد حالة الانقطاع } }
لا تستدعِ shutdownNow() مباشرةً إلا إذا كنت بحاجة لإلغاء العمل الجاري. تُرسل shutdownNow() إشارة انقطاع لجميع الخيوط الجارية، وهذا يعمل فقط إذا كانت تلك الخيوط تتحقق فعلًا من حالة الانقطاع (مثل الحجب على إدخال/إخراج أو استدعاء Thread.sleep()). المهام التي تتجاهل الانقطاعات ستستمر في التشغيل بغض النظر.

الخلاصة

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