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

مهام Runnable و Callable

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

مهام Runnable و Callable

يفصل إطار Executor بين ما يجب فعله وبين كيفية وتوقيت تنفيذه. يُعبَّر عن "الماذا" عبر كائن مهمة — إما Runnable أو Callable. فهم أيّهما يناسب حالتك، ولماذا، هو أساس كتابة كود متزامن صحيح وقابل للصيانة.

Runnable: أطلق وانسَ

تتواجد java.lang.Runnable منذ الإصدار الأول من Java. عقدها بسيط للغاية: تابع وحيد run() لا يأخذ أي معاملات، يُعيد void، ولا يستطيع رمي استثناء محدود (checked exception).

import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class RunnableDemo { public static void main(String[] args) throws InterruptedException { ExecutorService pool = Executors.newFixedThreadPool(3); Runnable task = () -> { String name = Thread.currentThread().getName(); System.out.println(name + " processing record"); }; for (int i = 0; i < 6; i++) { pool.execute(task); // تسليم المهمة؛ يُعيد void } pool.shutdown(); // لن تُقبَل مهام جديدة } }

execute(Runnable) هو تابع التسليم المقابل في Executor. يضع المهمة في الطابور ويعود فورًا — لا تحصل على أي مقبض، ولا طريقة لمعرفة وقت الانتهاء، ولا إمكانية لاسترداد نتيجة أو التقاط استثناء محدود يُرمى بالداخل.

متى تستخدم Runnable: اختره للعمل الجانبي الذي لا تحتاج فيه للنتيجة فعلًا — الكتابة في سجل، إرسال إشعار، تحديث مدخل في ذاكرة التخزين المؤقت. إن وجدت نفسك تضع نتيجة في متغير مشترك من داخل run() لـ"تُعيدها"، فانتقل إلى Callable.

Callable: مهام تُعيد نتيجة

قُدِّمت java.util.concurrent.Callable<V> في Java 5 جنبًا إلى جنب مع إطار Executor. إنها واجهة وظيفية (functional interface) عامة بتابع وحيد: V call() throws Exception. الفارقان الجوهريان عن Runnable هما إعادة قيمة من نوع محدد وإمكانية رمي أي استثناء محدود.

import java.util.concurrent.*; public class CallableDemo { static int heavyComputation(int input) { // محاكاة عمل على المعالج return input * input; } public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(4); Callable<Integer> task = () -> heavyComputation(42); Future<Integer> future = pool.submit(task); // غير مانع System.out.println("Task submitted, doing other work..."); int result = future.get(); // يمنع حتى الانتهاء System.out.println("Result: " + result); // 1764 pool.shutdown(); } }

يُدرج submit(Callable) المهمة في الطابور ويُعيد فورًا Future<V> — وعدًا بالنتيجة المقبلة. يمنع استدعاء future.get() الخيط الاستدعائي حتى تصبح النتيجة جاهزة. إذا رمت call() استثناءً فإن get() يُغلّفه في ExecutionException؛ استخرج السبب الأصلي عبر getCause().

تسليم Runnable عبر submit() مقابل execute()

يمكنك أيضًا تمرير Runnable إلى submit() — يُغلّفه التجمع ويُعيد Future<?> تُعيد get() منه null عند الاكتمال. يفيد هذا عندما تريد الانتظار حتى تنتهي مهمة جانبية دون الحاجة لنتيجة.

Future<?> f = pool.submit(() -> System.out.println("done")); f.get(); // يمنع حتى تنتهي الـ runnable؛ يُعيد null
افضّل submit() على execute() حتى مع Runnables في معظم كود الإنتاج. مع execute()، يُعدم أي استثناء غير محدود (unchecked) داخل المهمة بهدوء مخيف (يُغلق خيط العامل والتجمع يُعيد توليده، لكن تتبع المكدس يختفي ما لم تُنصّب UncaughtExceptionHandler). مع submit() يُخزَّن الاستثناء داخل Future ويُعاد رمؤه عند استدعاء get()، مما يُتيح لك التعامل معه.

تسليم عدة مهام Callable دفعة واحدة

يوفر ExecutorService طريقتين مريحتين للتسليم الجماعي:

  • invokeAll(Collection<Callable<T>>) — يُسلّم جميع المهام ويمنع حتى تكتمل كل واحدة (أو تنتهي المهلة الاختيارية). يُعيد قائمة من Futures جميعها في حالة اكتمال.
  • invokeAny(Collection<Callable<T>>) — يُسلّم جميع المهام لكنه يُعيد نتيجة أولى مهمة تنجح ويُلغي الباقي. مفيد للحسابات الزائدة أو السباق بين مصادر بيانات متعددة.
import java.util.*; import java.util.concurrent.*; public class InvokeAllDemo { public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newCachedThreadPool(); List<Callable<String>> tasks = List.of( () -> "result-A", () -> "result-B", () -> { Thread.sleep(50); return "result-C"; } ); List<Future<String>> futures = pool.invokeAll(tasks); for (Future<String> f : futures) { System.out.println(f.get()); // جميعها منتهية؛ لا منع هنا } pool.shutdown(); } }

انتشار الاستثناءات: فارق جوهري

هنا يُفاجأ كثير من المطورين في الإنتاج. مع Runnable المُسلَّم عبر execute()، ينتشر أي استثناء غير محدود إلى UncaughtExceptionHandler الخاص بالخيط — افتراضيًا يطبع تتبع المكدس فحسب ويُعوَّض الخيط. لا يُدرك الكود الاستدعائي بأي مشكلة.

مع Callable (أو Runnable مُسلَّم عبر submit())، يُخزَّن الاستثناء داخل Future. لا شيء يُطبع، لا شيء ينتشر. يظهر الاستثناء فقط عند استدعاء future.get():

Callable<String> risky = () -> { if (Math.random() < 0.5) throw new RuntimeException("flaky!"); return "ok"; }; Future<String> f = pool.submit(risky); try { String value = f.get(); } catch (ExecutionException ex) { Throwable cause = ex.getCause(); // الـ RuntimeException الأصلي System.err.println("Task failed: " + cause.getMessage()); }
لا تتجاهل Future أبدًا. إذا استدعيت submit() وأهملت الـ Future المُعادة دون استدعاء get() عليها يومًا ما، فإن أي استثناء داخل المهمة يُفقد بصمت تام. هذه واحدة من أكثر الأخطاء شيوعًا في كود Java المتزامن.

Callable مع مهلة زمنية

المنع اللانهائي على get() نادرًا ما يكون آمنًا في الإنتاج. الشكل المُحمَّل الزائد get(long timeout, TimeUnit unit) يرمي TimeoutException إن لم تنته المهمة في الوقت المحدد، مُتيحًا لك إلغاءها:

Future<String> f = pool.submit(() -> { Thread.sleep(5_000); // يحاكي إدخال/إخراج بطيء return "slow result"; }); try { String value = f.get(1, TimeUnit.SECONDS); } catch (TimeoutException ex) { f.cancel(true); // مقاطعة الخيط الجاري System.err.println("Task timed out and was cancelled"); }

الاختيار بين Runnable و Callable: دليل القرار

  • تحتاج قيمة مُعادة؟ ← Callable
  • تحتاج نشر استثناء محدود بنظافة؟ ← Callable
  • تحتاج معرفة متى تنتهي مهمة جانبية؟ ← Runnable عبر submit()
  • أطلق وانسَ تمامًا بلا معالجة للأخطاء؟ ← Runnable عبر execute() (فقط إن كنت لا تكترث فعلًا بالإخفاقات)
  • تُركّب خطوط معالجة غير متزامنة؟ ← CompletableFuture (مغطى في الدرس الخامس)

الخلاصة

Runnable هي مهمة بلا نتيجة ولا استثناء محدود. Callable<V> تضيف قيمة مُعادة من نوع محدد وشفافية كاملة للاستثناءات. سلّم المهام عبر execute() للتطاير والنسيان الحقيقي، أو عبر submit() للحصول على Future — وهو أمر بالغ الأهمية حين تحتاج نتائج أو انتظار الاكتمال أو معالجة موثوقة للأخطاء. استخدم invokeAll لتوزيع دفعة من Callables وجمع جميع النتائج، أو invokeAny للتسابق بينها. احرص دائمًا على معالجة الـ Future التي تحصل عليها من submit()؛ تجاهلها يبتلع الاستثناءات بصمت.