Concurrent Utilities

Runnable & Callable Tasks

15 min Lesson 2 of 13

Runnable & Callable Tasks

The Executor framework separates what to do from how and when to do it. The "what" is expressed as a task object — either a Runnable or a Callable. Understanding which to use, and why, is the foundation of writing correct, maintainable concurrent code.

Runnable: fire and forget

java.lang.Runnable has existed since Java 1.0. Its contract is minimal: a single run() method that takes no arguments, returns void, and cannot throw a 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); // submit; returns void } pool.shutdown(); // no new tasks accepted // pool.awaitTermination(10, TimeUnit.SECONDS); } }

execute(Runnable) is the corresponding submission method on Executor. It queues the task and returns immediately — you get no handle back, no way to know when it finished, and no way to retrieve a result or catch a checked exception thrown inside.

When to use Runnable: choose it for side-effectful work where you genuinely do not need the outcome — writing to a log, sending a notification, updating a cache entry. If you find yourself setting a shared variable from inside run() to "return" a result, switch to Callable.

Callable: tasks that return a result

java.util.concurrent.Callable<V> was introduced in Java 5 alongside the Executor framework. It is a generic functional interface with one method: V call() throws Exception. The two key differences from Runnable are that it returns a typed value and is allowed to throw any checked exception.

import java.util.concurrent.*; public class CallableDemo { static int heavyComputation(int input) { // simulate CPU work 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); // non-blocking System.out.println("Task submitted, doing other work..."); int result = future.get(); // blocks until done System.out.println("Result: " + result); // 1764 pool.shutdown(); } }

submit(Callable) enqueues the task and immediately returns a Future<V> — a promise of the eventual result. Calling future.get() blocks the calling thread until the result is ready. If the call() method threw an exception, get() wraps it in an ExecutionException; unwrap it with getCause().

Submitting Runnable to submit() vs execute()

You can also pass a Runnable to submit() — the pool wraps it and returns a Future<?> whose get() returns null on completion. This is useful when you want to wait for a fire-and-forget task to finish without needing a result.

Future<?> f = pool.submit(() -> System.out.println("done")); f.get(); // blocks until the runnable has run; returns null
Prefer submit() over execute() even for Runnables in most production code. With execute(), any unchecked exception thrown inside the task silently kills the worker thread (the pool replaces it, but the stack trace is swallowed unless you install an UncaughtExceptionHandler). With submit(), the exception is captured in the Future and re-thrown when you call get(), giving you a chance to handle it.

Submitting multiple Callable tasks at once

ExecutorService offers two convenience methods for bulk submission:

  • invokeAll(Collection<Callable<T>>) — submits all tasks and blocks until every one completes (or the optional timeout expires). Returns a list of Futures, all in a done state.
  • invokeAny(Collection<Callable<T>>) — submits all tasks but returns the result of the first one to succeed, cancelling the rest. Useful for redundant computation or racing multiple data sources.
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()); // all done; no blocking here } pool.shutdown(); } }

Exception propagation: a critical difference

This is where many developers get surprised in production. With a Runnable submitted via execute(), an unchecked exception propagates to the thread's UncaughtExceptionHandler — by default it just prints the stack trace and the thread is replaced. The calling code has no indication anything went wrong.

With a Callable (or a Runnable submitted via submit()), the exception is stored inside the Future. Nothing is printed. Nothing propagates. The exception only surfaces when you call 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(); // the original RuntimeException System.err.println("Task failed: " + cause.getMessage()); }
Never ignore the Future. If you call submit() and throw away the returned Future without ever calling get(), any exception inside the task is silently lost. This is one of the most common bugs in concurrent Java code.

Callable with a timeout

Blocking on get() indefinitely is rarely safe in production. The overloaded form get(long timeout, TimeUnit unit) throws TimeoutException if the task has not finished in time, leaving you the option to cancel it:

Future<String> f = pool.submit(() -> { Thread.sleep(5_000); // simulates slow I/O return "slow result"; }); try { String value = f.get(1, TimeUnit.SECONDS); } catch (TimeoutException ex) { f.cancel(true); // interrupt the running thread System.err.println("Task timed out and was cancelled"); }

Choosing between Runnable and Callable: a decision guide

  • Need a return value? → Callable
  • Need to propagate a checked exception cleanly? → Callable
  • Need to know when a side-effectful task finishes? → Runnable via submit()
  • Purely fire-and-forget with no error handling? → Runnable via execute() (but only if you truly do not care about failures)
  • Composing async pipelines? → CompletableFuture (covered in lesson 5)

Summary

Runnable is the zero-result, no-checked-exception task. Callable<V> adds a typed return value and full exception transparency. Submit tasks via execute() for true fire-and-forget, or via submit() to receive a Future — critical whenever you need results, need to wait for completion, or need reliable error handling. Use invokeAll to fan out a batch of Callables and collect all results, or invokeAny to race them. Always handle the Future you receive from submit(); discarding it silently swallows exceptions.