Concurrencia estructurada en JDK 27: StructuredTaskScope con ejemplos prácticos

La concurrencia estructurada (StructuredTaskScope, JEP 533) lleva desde JDK 21 en fase de vista previa, y en JDK 27 llega ya a su séptima iteración. No es una feature nueva de esta versión, pero JDK 27 es un buen momento para revisarla en profundidad con código real, porque cambia bastante cómo se escribe concurrencia en Java frente al ExecutorService de toda la vida.

El problema que resuelve

Con hilos o ExecutorService sueltos, es fácil que una tarea hija sobreviva a la tarea que la lanzó, que un error en una tarea quede silenciado mientras las demás siguen corriendo, o que cancelar «todo lo relacionado con esta petición» se convierta en un ejercicio de contabilidad manual con flags y Future.cancel() repartidos por el código.

La concurrencia estructurada parte de una idea simple: si varias tareas nacen juntas para resolver una única unidad de trabajo, deberían tratarse como una unidad también en su ciclo de vida. Si una falla, las demás se cancelan. Si el bloque termina, ninguna tarea hija sigue viva por su cuenta.

Ejemplo básico: dos llamadas en paralelo

El caso típico, pedir datos de usuario y su pedido en paralelo, y combinar ambos resultados:

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow())) {

    Subtask<Usuario> usuario = scope.fork(() -> obtenerUsuario(id));
    Subtask<Pedido> pedido = scope.fork(() -> obtenerPedido(id));

    scope.join();

    return new Respuesta(usuario.get(), pedido.get());
}

Si obtenerPedido lanza una excepción, obtenerUsuario se cancela automáticamente si todavía no ha terminado, y la excepción se propaga hacia fuera del bloque try. No hay que acordarse de cancelar nada a mano.

Distintas políticas de espera con Joiner

La interfaz Joiner es la que decide qué significa «terminar» para el grupo de tareas. allSuccessfulOrThrow() exige que todas acaben bien. Para un patrón de «primero que responda gana», está anySuccessfulResultOrThrow():

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<String>anySuccessfulResultOrThrow())) {

    scope.fork(() -> consultarReplica("eu-west"));
    scope.fork(() -> consultarReplica("eu-central"));
    scope.fork(() -> consultarReplica("us-east"));

    String resultado = scope.join();
    return resultado;
}

En cuanto una de las tres réplicas responde con éxito, las otras dos se cancelan y join() devuelve ese resultado. Útil para consultar varias fuentes redundantes sin esperar a la más lenta ni gestionar la cancelación manualmente.

Manejo de errores parciales

Cuando no se quiere abortar todo el grupo ante el primer fallo, sino recoger lo que haya funcionado, se puede implementar (o usar) un Joiner personalizado que acumule éxitos y fallos por separado:

try (var scope = StructuredTaskScope.open()) {

    List<Subtask<Producto>> tareas = idsProducto.stream()
        .map(id -> scope.fork(() -> obtenerProducto(id)))
        .toList();

    scope.join();

    List<Producto> disponibles = tareas.stream()
        .filter(t -> t.state() == Subtask.State.SUCCESS)
        .map(Subtask::get)
        .toList();

    return disponibles;
}

Aquí se lanzan tantas subtareas como productos haya que consultar, y al final se filtran solo las que terminaron en SUCCESS, ignorando las que fallaron sin que eso aborte el resto.

Scopes anidados

Como cada StructuredTaskScope es, en sí mismo, una unidad estructurada, se pueden anidar sin perder la garantía de que nada queda huérfano:

try (var scopeExterno = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Resultado>allSuccessfulOrThrow())) {

    scopeExterno.fork(() -> {
        try (var scopeInterno = StructuredTaskScope.open(
                StructuredTaskScope.Joiner.<Dato>allSuccessfulOrThrow())) {
            scopeInterno.fork(() -> consultarCache());
            scopeInterno.fork(() -> consultarBaseDatos());
            scopeInterno.join();
            return combinar(scopeInterno);
        }
    });

    scopeExterno.join();
}

Si algo falla en el scope interno, se cancela primero ese nivel, y el fallo se propaga hacia el scope externo, que a su vez cancela lo que tenga pendiente. La jerarquía de cancelación sigue exactamente la jerarquía del código.

Qué cambia frente a ExecutorService

La diferencia de fondo no es de rendimiento, es de forma: con ExecutorService, las tareas y su ciclo de vida viven separados del código que las lanza, hay que gestionar explícitamente el shutdown(), esperar con awaitTermination() y decidir a mano qué pasa si una tarea falla mientras otras siguen en marcha. Con StructuredTaskScope, el bloque try-with-resources es literalmente el ciclo de vida de las tareas: si el bloque termina, ya no queda nada corriendo por su cuenta, ni fugas de hilos ni tareas zombis.

La feature sigue en vista previa en JDK 27, así que hace falta compilar y ejecutar con --enable-preview. Para quien ya usa virtual threads (estables desde JDK 21), es el complemento natural: los virtual threads resuelven el coste de crear miles de hilos baratos, la concurrencia estructurada resuelve cómo gestionar su ciclo de vida sin perder el control.

Relacionado: Virtual threads en Java 21 y la noticia sobre el calendario de JDK 27.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP