Runnable & Callable Tasks
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.
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.
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.
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.
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 ofFutures, 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.
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():
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:
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? →
Runnableviasubmit() - Purely fire-and-forget with no error handling? →
Runnableviaexecute()(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.