W3docs

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é.

AspectoNo estructurado (grupo compartido de ExecutorService)Estructurado (StructuredTaskScope)
Tiempo de vida de la subtareaIndependiente del llamadorAcotado por el bloque que lo encierra
Error en una subtareaOculto en un Future hasta que llamas a getPuede cortocircuitar todo el ámbito
CancelaciónManual, fácil de olvidarAutomática en caso de fallo o interrupción
Limpieza de recursosA tu cargoclose() 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 leave

Si 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íticaTermina cuandoÚsala para
ShutdownOnFailureTodas tienen éxito, o una fallaDistribución en abanico donde necesitas cada resultado (el caso común)
ShutdownOnSuccess<T>Primera con éxito, o todas fallanCompetir 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.

java— editable, runs on the server

Lo que se puede extraer de la ejecución:

  • Ambas subtareas informaron is virtual : true — cada submit se ejecutó en su propio hilo virtual, el mismo portador ligero que usa StructuredTaskScope.fork, por lo que crear un hilo por subtarea es económico.
  • El bloque de la ruta feliz imprimió ran concurrently (<320ms): true aunque 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 que StructuredTaskScope impone 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 en ExecutionException, y getCause() te devuelve la excepción original.
  • Después de capturar el fallo imprimió sibling cancelled: true — cancelamos la rama good que aún se ejecutaba para que ningún huérfano sobreviviera al bloque, que es exactamente lo que ShutdownOnFailure hace automáticamente por ti; aquí lo hicimos a mano para mostrar el mecanismo.

Temas relacionados

Práctica

Práctica
Con StructuredTaskScope.ShutdownOnFailure, ¿qué ocurre con las demás subtareas bifurcadas cuando una de ellas lanza una excepción?
Con StructuredTaskScope.ShutdownOnFailure, ¿qué ocurre con las demás subtareas bifurcadas cuando una de ellas lanza una excepción?
Was this page helpful?