Java Callable y Future
Retorna valores de tareas con Callable y consúmelos de forma asíncrona con Future: esperar, timeout, cancelar y propagar excepciones.
Runnable permite que un hilo realice trabajo, pero no permite que ese trabajo devuelva un valor ni lance una excepción comprobada. El par que sí lo hace es Callable<V> (el productor) y Future<V> (el consumidor). Envías un Callable<V> a un ExecutorService y obtienes un Future<V>, que es tu manejador para: esperar el resultado, leer el valor, capturar la excepción de la tarea o cancelarla.
Esta es la API de más bajo nivel consciente del resultado en el conjunto de herramientas concurrentes de Java. El próximo capítulo, CompletableFuture, añade cadenas, combinadores y pipelines; pero el contrato — "un resultado asíncrono en el que puedes esperar" — es lo que Future definió primero, y sigue siendo la herramienta adecuada para el simple "ve a hacer esto y dime cuando hayas terminado."
Callable<V> — Runnable con tipo de retorno
La interfaz:
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}Las dos diferencias respecto a Runnable:
- Devuelve
V(el parámetro de tipo). - Puede lanzar cualquier
Exception— incluyendo excepciones comprobadas.
Al igual que Runnable, es una interfaz funcional — las lambdas y las referencias a métodos funcionan:
Callable<Integer> compute = () -> {
Thread.sleep(100);
return 42;
};
Callable<String> read = () -> Files.readString(Path.of("config.txt")); // can throw IOException
Callable<List<Order>> query = () -> repo.findAll(); // can throw SQLExceptionCallable es la forma correcta para cualquier trabajo de "ve a hacer esto y tráeme un valor". Runnable es la forma correcta solo cuando realmente no te importa el resultado.
Future<V> — el manejador de un resultado asíncrono
Cuando haces submit de un Callable<V>, el ejecutor devuelve un Future<V>:
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}Cinco métodos. Tres que usarás frecuentemente.
get()
Bloquea el hilo que llama hasta que la tarea se completa, y luego devuelve el resultado:
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(() -> { Thread.sleep(100); return 42; });
Integer value = f.get(); // blocks until done; returns 42get() lanza tres cosas que debes manejar:
InterruptedException— el llamador fue interrumpido mientras esperaba. Tratamiento estándar: restablecer el flag de interrupción y propagarlo.ExecutionException— la propia tarea lanzó algo. La excepción original está envuelta; accede a ella mediante.getCause().CancellationException— alguien llamó acancel()en el future.
Una forma común:
try {
Integer v = f.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // the real exception the task threw
// ... handle cause ...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// ... bail out cooperatively ...
}get(timeout, unit)
Igual que get() pero con un límite de tiempo. Lanza TimeoutException si la tarea no termina a tiempo:
try {
Integer v = f.get(500, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
f.cancel(true); // give up; ask the task to stop
throw new ServiceUnavailableException("timed out");
}Esta es la forma correcta para "estoy llamando a un backend que debería responder en N ms; si no, falla rápido." Siempre combina el catch con un cancel(true) — de lo contrario, la tarea continúa ejecutándose en segundo plano, usando un hilo del que ya no te importa el resultado.
cancel(boolean)
Pide a la tarea que se detenga:
boolean cancelled = f.cancel(true); // true = interrupt the running threadEl argumento le indica al ejecutor si debe interrumpir el hilo trabajador. Con true, el trabajador recibe una InterruptedException de cualquier llamada bloqueante (sleep, wait, I/O); con false, la cancelación no tiene efecto si la tarea ya ha comenzado — solo las tareas no iniciadas se eliminan de la cola.
cancel es cooperativa. Una tarea que no comprueba Thread.currentThread().isInterrupted() y no tiene llamadas bloqueantes seguirá ejecutándose hasta que termine. La cancelación no es un interruptor de apagado — es una solicitud que la tarea debe respetar.
Excepciones: la regla de envoltura
Todo lo que lanza el Callable se envuelve en ExecutionException cuando llamas a get. La causa es el throwable original:
Future<Integer> f = pool.submit(() -> { throw new IOException("nope"); });
try {
f.get();
} catch (ExecutionException e) {
e.getCause(); // IOException("nope")
e.getCause() instanceof IOException; // true
}Nota la asimetría: el Callable podría lanzar una excepción comprobada (el throws Exception en su firma), pero Future.get solo declara ExecutionException. El envoltorio es lo que permite que una firma transporte cualquier fallo posible.
La sobrecarga de Runnable.submit — pool.submit(Runnable) — devuelve un Future<?> cuyo get() devuelve null en caso de éxito y sigue envolviendo cualquier RuntimeException no capturada del Runnable. Es la forma estándar de descubrir que un runnable "disparar y olvidar" realmente falló.
Los límites de Future
Future es un canal unidireccional: envías, esperas, obtienes el valor. No se puede componer:
- No puedes decir "cuando esto termine, ejecuta aquello con el resultado."
- No puedes decir "cuando cualquiera de estos N termine, haz X."
- No puedes decir "combina los resultados de estos dos futures sin bloquear."
Para todo eso necesitas CompletableFuture (próximo capítulo). Future es la herramienta correcta cuando:
- Solo quieres un valor de vuelta de una sola tarea.
- Estás consumiendo una API que devuelve
Futures y no necesitas componerlos. - El contrato más simple es suficiente.
Para código moderno que realiza mucha composición asíncrona, principalmente omitirás Future y llegarás directamente a CompletableFuture — pero Future es el tipo que el servicio ejecutor sigue devolviendo de submit, por lo que verás ambos.
FutureTask — la implementación detrás de submit
La clase que impulsa submit. Puedes usarla directamente:
FutureTask<Integer> task = new FutureTask<>(() -> compute());
new Thread(task).start(); // FutureTask is a Runnable
Integer v = task.get();La mayoría del código no construye FutureTask directamente; el framework de ejecutores lo hace por ti. Pero es útil cuando necesitas un Future y un Runnable en un solo objeto — por ejemplo, para programarlo en algo distinto de un ExecutorService.
Un ejemplo práctico: enviar, agotar el tiempo, propagar
El programa a continuación envía una tarea lenta, una tarea rápida y una tarea que falla; demuestra get, get(timeout), el desempaquetado de excepciones y la cancelación.
Lo que se extrae de la ejecución:
- La sección 1 es la forma más simple: enviar un
Callable, llamar aget, recibir el valor.getbloqueó el hilo principal durante los 50 ms que tardó la tarea. Eso es todo lo que haceFutureen su forma básica — un manejador tipado y bloqueante a un resultado que llega más tarde. - La sección 2 mostró la forma del timeout. La tarea lenta habría tardado 500 ms;
get(100, MS)desistió después de 100 y lanzóTimeoutException. Elcancel(true)posterior interrumpió el hilo en ejecución para que pudiera salir pronto. Sin la cancelación, la tarea habría seguido ejecutándose durante los 400 ms restantes — usando un hilo del que ya no te importaba el resultado. - La sección 3 mostró el envoltorio de excepciones. El
CallablelanzóIOException;get()lo volvió a lanzar dentro deExecutionException.e.getCause()devolvió el original. Este es el canal universal de fallos de la API — cualquier lanzamiento comprobado o no comprobado del cuerpo llega aquí. - La sección 4 mostró la cancelación de una tarea no iniciada. Con ambos hilos del pool ocupados en
hog1yhog2, la tareaqueuedestaba en la cola de trabajo;cancel(false)la eliminó sin ejecutarla nunca. Llamar aget()en el future cancelado lanzóCancellationException— un modo de fallo diferente a "la tarea lanzó algo" (que habría sidoExecutionException). - La sección 5 mostró
invokeAny. La tarea más rápida (50 ms) ganó; las otras dos fueron canceladas por el ejecutor.invokeAnyes la herramienta adecuada para consultas redundantes — llama a múltiples fuentes, usa el primer éxito, abandona el resto. Es el bloque de construcción detrás de los patrones de solicitudes con cobertura en sistemas reales.
Qué sigue
El próximo capítulo, Java CompletableFuture, introduce la API asíncrona componible — thenApply, thenCompose, allOf, anyOf, y los docenas de combinadores que convierten Future de un manejador de un solo resultado en un pipeline reactivo completo.