Concurrencia Estructurada en Java
Trata subtareas concurrentes como una unidad de trabajo en Java con concurrencia estructurada (StructuredTaskScope).
La concurrencia estructurada trata a un grupo de subtareas concurrentes como una única unidad de trabajo: se lanzan juntas, terminan juntas, y si una falla o el llamador es cancelado, el resto también se cancela — sin hilos huérfanos que sobrevivan al bloque que los inició. El modelo lo proporciona java.util.concurrent.StructuredTaskScope (una API en vista previa introducida en Java 21) y se apoya en los mismos hilos virtuales tratados anteriormente en esta parte. El objetivo es simple: hacer que el código concurrente sea tan fácil de leer, depurar y razonar como un método secuencial normal.
Este capítulo explica por qué importa "estructurado", la anatomía de un ámbito de tareas, las dos políticas de cierre integradas, cómo se propagan los plazos y las cancelaciones, y un ejemplo de trabajo ejecutable. Se asume que estás familiarizado con el framework de ejecutores y Callable/Future.
¿Por qué "estructurado"?
Los grupos de hilos clásicos son no estructurados: envías (submit) una tarea a un ExecutorService compartido y obtienes de vuelta un Future cuyo tiempo de vida no tiene relación con el método que lo creó. Una tarea puede sobrevivir a su llamador, un error en una tarea es invisible para sus hermanas, y la cancelación tiene que cablearse manualmente. El resultado son hilos que escapan y un manejo de errores enredado.
La concurrencia estructurada toma prestada la disciplina del flujo de control estructurado: así como un bloque try delimita sus sentencias, un ámbito de tareas confina sus subtareas. Las subtareas bifurcadas dentro de un bloque deben completarse antes de que el bloque salga. Los tiempos de vida se anidan limpiamente, por lo que un volcado de hilos y un rastreo de pila realmente te dicen quién inició qué.
| Aspecto | No estructurado (grupo compartido de ExecutorService) | Estructurado (StructuredTaskScope) |
|---|---|---|
| Tiempo de vida de la subtarea | Independiente del llamador | Acotado por el bloque que lo encierra |
| Error en una subtarea | Oculto en un Future hasta que llamas a get | Puede cortocircuitar todo el ámbito |
| Cancelación | Manual, fácil de olvidar | Automática en caso de fallo o interrupción |
| Limpieza de recursos | A tu cargo | close() espera a cada subtarea |
La forma de un ámbito
Un ámbito es un AutoCloseable, por lo que vive en un bloque try-with-resources. Bifurcas (fork) subtareas (cada una devuelve un manejador Subtask), llamas a join() para esperarlas, luego lees cada resultado. La política ShutdownOnFailure cancela las subtareas restantes en el momento en que cualquiera de ellas lanza una excepción:
import java.util.concurrent.StructuredTaskScope;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<String> user = scope.fork(() -> fetchUser(id));
StructuredTaskScope.Subtask<Integer> order = scope.fork(() -> fetchOrderCount(id));
scope.join(); // wait for both branches
scope.throwIfFailed(); // rethrow if either branch failed
return new Profile(user.get(), order.get());
} // close() guarantees both subtasks have ended before we leaveSi fetchUser lanza una excepción, ShutdownOnFailure interrumpe el fetchOrderCount que aún se ejecuta, join() regresa y throwIfFailed() vuelve a lanzar la causa original envuelta en una ExecutionException. Nunca se escapa un hilo.
Políticas de cierre integradas
Las dos políticas suministradas cubren los patrones comunes; puedes crear subclases de StructuredTaskScope para cualquier otra cosa.
| Política | Termina cuando | Úsala para |
|---|---|---|
ShutdownOnFailure | Todas tienen éxito, o una falla | Distribución en abanico donde necesitas cada resultado (el caso común) |
ShutdownOnSuccess<T> | Primera con éxito, o todas fallan | Competir fuentes redundantes; tomar la respuesta más rápida |
ShutdownOnSuccess devuelve el ganador a través de result() y cancela a los perdedores:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> queryMirrorA());
scope.fork(() -> queryMirrorB());
scope.join();
return scope.result(); // the first one to return; the slower is cancelled
}Los plazos y la cancelación se propagan
Un ámbito puede unirse con un plazo; cuando este vence, las subtareas sin terminar son canceladas:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> slowService());
scope.joinUntil(Instant.now().plusSeconds(2)); // throws TimeoutException if late
scope.throwIfFailed();
}La cancelación es cooperativa y fluye hacia abajo: si el hilo que posee el ámbito es interrumpido, cada subtarea es interrumpida a su vez. Como cada subtarea se ejecuta en su propio hilo virtual, bifurcar miles de ellas es económico — el ámbito, no un tamaño de grupo fijo, es la unidad sobre la que razonas.
Un ejemplo de trabajo: distribución en abanico, fallo y unión de una lista
StructuredTaskScope es una función en vista previa, así que para mantener este ejemplo ejecutable en un JDK estable, modelamos la misma idea con un ejecutor de un hilo virtual por tarea: un bloque try-with-resources que delimita un grupo de subtareas y solo sale una vez que cada hilo de subtarea ha terminado. Distribuye dos llamadas en abanico de forma concurrente, luego muestra cómo un fallo cortocircuita la unidad de trabajo y cómo invokeAll une toda una lista de una vez.
Lo que se puede extraer de la ejecución:
- Ambas subtareas informaron
is virtual : true— cadasubmitse ejecutó en su propio hilo virtual, el mismo portador ligero que usaStructuredTaskScope.fork, por lo que crear un hilo por subtarea es económico. - El bloque de la ruta feliz imprimió
ran concurrently (<320ms): trueaunque las dos búsquedas duermen 120ms y 200ms: se superpusieron, por lo que el tiempo de reloj sigue la rama más lenta (~200ms), no la suma (320ms). Esa superposición es el objetivo completo de la distribución en abanico. - Al salir del bloque try-with-resources se llamó a
close(), que bloqueó hasta que cada hilo de subtarea terminó — el ámbito es la unidad de tiempo de vida, exactamente la disciplina queStructuredTaskScopeimpone por construcción. - En la sección de fallo, el programa imprimió
caught: IllegalStateException -> upstream said no: un error lanzado dentro de una subtarea aparece en el punto de unión envuelto enExecutionException, ygetCause()te devuelve la excepción original. - Después de capturar el fallo imprimió
sibling cancelled: true— cancelamos la ramagoodque aún se ejecutaba para que ningún huérfano sobreviviera al bloque, que es exactamente lo queShutdownOnFailurehace automáticamente por ti; aquí lo hicimos a mano para mostrar el mecanismo.
Temas relacionados
- Hilos virtuales — los hilos ligeros en los que se ejecuta cada subtarea.
- Hilos virtuales modernos — patrones prácticos y trampas.
- Framework de ejecutores — la línea base no estructurada que este modelo reemplaza.
CallableyFuture— los tipos de tarea y resultado utilizados en el punto de unión.CompletableFuture— componer resultados asíncronos sin bloquear uniones.