¡Domine la nueva programación concurrente estructurada de JDK21 y mejore fácilmente la eficiencia del desarrollo!

1. Información general

Simplifique la programación concurrente introduciendo una API para programación concurrente estructurada. La concurrencia estructurada trata grupos de tareas relacionadas que se ejecutan en diferentes subprocesos como una única unidad de trabajo, lo que simplifica el manejo y la cancelación de errores, mejora la confiabilidad y mejora la observabilidad. Esta es una API de vista previa.

2 historia

La simultaneidad estructurada fue propuesta por JEP 428 y lanzada como una API de incubación en JDK 19. Fue reincubado en JDK 20 por JEP 437, con una ligera actualización de los valores de alcance (JEP 429).

Proponemos aquí simultaneidad estructurada como una API preliminar en el paquete JUC. El único cambio significativo es que el método StructuredTaskScope::fork(...) devuelve una [subtarea] en lugar de un Futuro, como se analiza a continuación.

3 goles

Promueve un estilo de programación concurrente que elimina los riesgos comunes debido a la cancelación y el cierre, como fugas de subprocesos y retrasos en la cancelación.

Mejorar la observabilidad del código concurrente.

4 no objetivo

No reemplaza ninguna de las construcciones de concurrencia en el paquete JUC, como ExecutorService y Future.

La API de simultaneidad estructurada final para la plataforma Java no está definida. Se pueden definir otras construcciones de concurrencia estructuradas mediante bibliotecas de terceros o en futuras versiones de JDK.

No se define un método (es decir, canal) para compartir flujos de datos entre subprocesos. Propondrá hacerlo en el futuro.

No reemplace el mecanismo de interrupción de subprocesos existente con un nuevo mecanismo de cancelación de subprocesos. Propondrá hacerlo en el futuro.

5 motivos

Los desarrolladores gestionan la complejidad dividiendo las tareas en subtareas. En el código normal de un solo subproceso, las subtareas se ejecutan secuencialmente. Sin embargo, si las subtareas son suficientemente independientes entre sí y existen suficientes recursos de hardware, entonces se puede hacer que la tarea general se ejecute más rápido (es decir, con menor latencia) ejecutando las subtareas simultáneamente en diferentes subprocesos. Por ejemplo, combinar los resultados de múltiples operaciones de E/S en una sola tarea se ejecutará más rápido si cada operación de E/S se ejecuta simultáneamente en su propio subproceso. Los subprocesos virtuales (JEP 444) hacen que sea rentable asignar un subproceso para cada operación de E/S, pero gestionar la cantidad potencialmente grande de subprocesos sigue siendo un desafío.

6 Concurrencia no estructurada de ExecutorService

java.util.concurrent.ExecutorServiceLa API se introdujo en Java 5, que ayuda a los desarrolladores a ejecutar subtareas de forma simultánea.

Un método como el siguiente handle()representa una tarea en una aplicación de servidor. ExecutorServiceManeja las solicitudes entrantes enviando dos subtareas a .

ExecutorServiceDevuelve inmediatamente cada subtarea Futurey las ejecuta al mismo tiempo de acuerdo con la política de programación del Ejecutor. Los métodos esperan los resultados de las subtareas bloqueando handle()el método Futureque los llamó , por lo que se dice que la tarea se ha unido a sus subtareas.get()

Response handle() throws ExecutionException, InterruptedException {
    Future<String> user = esvc.submit(() -> findUser());
    Future<Integer> order = esvc.submit(() -> fetchOrder());
    String theUser = user.get();   // 加入 findUser
    int theOrder = order.get();    // 加入 fetchOrder
    return new Response(theUser, theOrder);
}

Dado que las subtareas se ejecutan simultáneamente, cada subtarea puede tener éxito o fallar de forma independiente. En este contexto, "fracaso" significa lanzar una excepción. Normalmente, handle()una tarea como esta debería fallar si falla alguna de sus subtareas. Comprender el ciclo de vida de un subproceso cuando ocurren fallas puede resultar muy complicado:

  • Si findUser()se lanza una excepción, también se lanzará una excepción user.get()al llamar handle(), pero fetchOrder()continuará ejecutándose en su propio hilo. Se trata de una fuga de subprocesos; en el mejor de los casos, un desperdicio de recursos; en el peor, fetchOrder()el subproceso puede interferir con otras tareas.

  • Si handle()se interrumpe el hilo de ejecución, esta interrupción no se propagará a las subtareas. findUser()Ambos fetchOrder()subprocesos tienen fugas y handle()continúan ejecutándose incluso después de fallar.

  • Si findUser()a tarda mucho en ejecutarse, pero falla durante ese tiempo fetchOrder(), handle()esperará innecesariamente findUser()porque lo bloqueará user.get()en lugar de cancelarlo. Solo después findUser()de completar y user.get()regresar order.get()se lanza una excepción, lo que provoca handle()que falle.

En cada caso, el problema es que nuestros programas están estructurados lógicamente en relaciones tarea-subtarea, pero estas relaciones sólo existen en la cabeza del desarrollador. Esto no sólo aumenta la probabilidad de errores, sino que también dificulta el diagnóstico y la resolución de dichos errores. handle()Por ejemplo, las herramientas de observabilidad, como los volcados de subprocesos, muestran , findUser()y , en pilas de llamadas de subprocesos no relacionados, fetchOrder()sin ningún indicio de relaciones tarea-subtarea.

Puede intentar cancelar explícitamente otras subtareas cuando se produce un error, por ejemplo, envolviendo la tarea con try-finally en el bloque catch de la tarea fallida y llamando al método de la otra Futuretarea cancel(boolean). También necesitamos try-with-resourcesusar la declaración ExecutorService, como

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

Porque Futureno hay forma de esperar a que se cancele una tarea. Pero todo esto es difícil de hacer y tiende a hacer que la intención lógica del código sea aún más difícil de entender. El seguimiento de las relaciones entre tareas y la adición manual de los bordes de cancelación entre tareas requeridos es bastante exigente para el desarrollador.

Modo concurrente ilimitado

Esta necesidad de coordinar manualmente los ciclos de vida se debe al hecho de que ExecutorServicepermiten Futuremodos de concurrencia ilimitados. Entre todos los hilos involucrados, sin límite ni orden:

  • Un hilo puede crear unExecutorService
  • Otro hilo puede enviarle trabajo.
  • El hilo que realiza el trabajo no tiene nada que ver con el primer o segundo hilo.

Después de que un hilo envía trabajo, un hilo completamente diferente puede esperar el resultado de la ejecución. Cualquier código que tenga Futureuna referencia puede unirse a él (es decir, get()esperar su resultado llamando a ) e incluso puede Futureejecutar código en un hilo diferente al que se adquirió. De hecho, una subtarea iniciada por una tarea no tiene por qué volver a la tarea que la envió. Podría devolverse a cualquiera de las muchas tareas o a ninguna.

Debido ExecutorServicea que Futurepermiten dicho uso no estructurado, no imponen ni rastrean las relaciones entre tareas y subtareas, aunque dichas relaciones son comunes y útiles. Por lo tanto, incluso si se envían subtareas y se unen en la misma tarea, el fracaso de una subtarea no puede causar automáticamente la cancelación de otra subtarea. En el handle()método anterior, fetchOrder()el fallo del no provoca automáticamente findUser()la cancelación del . fetchOrder()El no tiene relación Futurecon findUser()el Future, ni con get()el hilo que finalmente lo une a través de su método. En lugar de exigir a los desarrolladores que gestionen manualmente esta cancelación, queremos poder automatizar este proceso de forma fiable.

La estructura de tareas debe reflejar la estructura del código.

A ExecutorServicediferencia de la composición de subprocesos libres según , la ejecución de código de subproceso único siempre impone una jerarquía de tareas y subtareas. El bloque de código del método {...}corresponde a una tarea y el método llamado dentro del bloque de código corresponde a la subtarea. El método llamado debe regresar al método que lo llamó o generar una excepción al método que lo llamó. No puede vivir fuera del método que lo llamó, ni puede devolver o lanzar excepciones a otros métodos. Por lo tanto, todas las subtareas se completan antes que las tareas, cada subtarea es una subtarea de su tarea principal y el ciclo de vida de cada subtarea en relación con otras subtareas y tareas se rige por las reglas de sintaxis de la estructura del bloque de código.

Como en la versión de un solo subproceso handle(), la relación tarea-subtarea es obvia en la estructura de sintaxis:

Response handle() throws IOException {
    String theUser = findUser();
    int theOrder = fetchOrder();
    return new Response(theUser, theOrder);
}

No findUser()iniciamos subtareas hasta que se hayan completado fetchOrder(), ya sea findUser()exitosa o fallida. Si findUser()falla, no comenzamos en absoluto fetchOrder()y handle()la tarea implícitamente falla. Es importante que una subtarea solo pueda regresar a su tarea principal: esto significa que la tarea principal puede ver implícitamente el error de una subtarea como un desencadenante para cancelar otras subtareas pendientes y luego fallar ella misma.

En el código de un solo subproceso, la jerarquía tarea-subtarea se refleja en la pila de llamadas en tiempo de ejecución. Por lo tanto, obtenemos las correspondientes relaciones padre-hijo que gobiernan la propagación de errores. Las relaciones jerárquicas son evidentes cuando se analizan subprocesos individuales: findUser()(y más tarde fetchOrder()) parecen ejecutarse handle()bajo . Esto facilita la respuesta a la pregunta "¿qué es el manejo de handle()?".

La programación concurrente es más fácil, más confiable y más observable si la relación padre-hijo entre tareas y subtareas es evidente en la estructura sintáctica del código y se refleja en el tiempo de ejecución, al igual que el código de un solo subproceso. La estructura de sintaxis definirá el ciclo de vida de las subtareas y permitirá la creación en tiempo de ejecución de una representación de una jerarquía de subprocesos similar a una pila de llamadas de un solo subproceso. Esta representación permitirá la propagación de errores, la cancelación y la observación significativa de programas concurrentes.

7 Simultaneidad estructurada

La concurrencia estructurada es un enfoque de programación concurrente que preserva la relación natural entre tareas y subtareas, lo que da como resultado un código concurrente más legible, mantenible y confiable. El término "concurrencia estructurada" fue acuñado por Martin Sústrik y popularizado por Nathaniel J. Smith. Las ideas de diseño para el manejo de errores en concurrencia estructurada se pueden aprender de conceptos en otros lenguajes de programación, como los monitores jerárquicos en Erlang.

La concurrencia estructurada surge de un principio simple:

Si una tarea se divide en subtareas simultáneas, todas esas subtareas regresan al mismo lugar: el bloque de código de la tarea.

En la concurrencia estructurada, las subtareas representan el trabajo de tareas. Las tareas esperan los resultados de las subtareas y monitorean su falla. De manera similar a las técnicas de programación estructurada en código de subproceso único, el poder de la concurrencia estructurada en subprocesos múltiples proviene de dos ideas:

  • Definir puntos de entrada y salida claros para el flujo de ejecución dentro de un bloque de código.
  • en un anidamiento estricto de los ciclos de vida de las operaciones para reflejar cómo se anidan sintácticamente en el código

Dado que los puntos de entrada y salida de un bloque de código están bien definidos, la vida útil de una subtarea concurrente está limitada al bloque de sintaxis de su tarea principal. Debido a que los ciclos de vida de las subtareas hermanas están anidados dentro de los ciclos de vida de sus tareas principales, se pueden razonar y gestionar como una unidad. Dado que el ciclo de vida de una tarea principal, a su vez, está anidado dentro del ciclo de vida de su tarea principal, el tiempo de ejecución puede implementar la jerarquía de tareas como una estructura de árbol, similar a la contraparte concurrente de una pila de llamadas de un solo subproceso. Esto permite que el código aplique políticas para subárboles de tareas, como fechas límite, y permite que las herramientas de observabilidad presenten subtareas como subordinadas de las tareas principales.

La concurrencia estructurada es una buena opción para los subprocesos virtuales, que son subprocesos ligeros implementados por el JDK. Muchos subprocesos virtuales pueden compartir el mismo subproceso del sistema operativo, lo que permite admitir una gran cantidad de subprocesos virtuales. Entre otras cosas, los subprocesos virtuales son lo suficientemente baratos como para representar cualquier comportamiento concurrente que involucre E/S, etc. Esto significa que una aplicación de servidor puede usar concurrencia estructurada para manejar decenas de miles o incluso millones de solicitudes entrantes simultáneamente: puede asignar un nuevo hilo virtual a la tarea de procesar cada solicitud y, cuando una tarea avanza enviando subtareas, cuando se ejecuta simultáneamente, puede asignar un nuevo hilo virtual para cada subtarea. Detrás de escena, las relaciones tarea-subtarea se implementan como una estructura similar a un árbol al darle a cada hilo virtual una referencia a su tarea principal única, similar a cómo un marco en una pila de llamadas se refiere a su llamador único.

En resumen, los subprocesos virtuales proporcionan una gran cantidad de subprocesos. La concurrencia estructurada los coordina de manera correcta y sólida, y permite que las herramientas de observabilidad muestren los hilos tal como los entiende el desarrollador. Tener una API para concurrencia estructurada en el JDK facilitará la creación de aplicaciones de servidor mantenibles, confiables y observables.

8 descripción

Las clases principales de la API de concurrencia estructurada se encuentran java.util.concurrenten el paquete StructuredTaskScope. Esta clase permite a los desarrolladores estructurar una tarea como un conjunto de subtareas simultáneas y coordinarlas como una unidad. Las subtareas se ejecutan en sus propios subprocesos bifurcándolas por separado y uniéndolas como una unidad, posiblemente cancelándolas como una unidad. Los resultados exitosos o las excepciones de las subtareas se agregan y manejan mediante la tarea principal. StructuredTaskScopeLimite el ciclo de vida de las subtareas a un alcance léxico claro donde ocurren todas las interacciones de una tarea con sus subtareas (bifurcación, unión, cancelación, manejo de errores y combinación de resultados).

El ejemplo antes mencionado handle(), StructuredTaskScopeescrito usando:

Response handle() throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<String> user = scope.fork(() -> findUser());
        Supplier<Integer> order = scope.fork(() -> fetchOrder());

        scope.join()             // 加入两个子任务
             .throwIfFailed();   // ... 并传播错误

        // 两个子任务都成功完成,因此组合它们的结果
        return new Response(user.get(), order.get());
    }
}

Comprender la vida útil de los subprocesos involucrados se vuelve más fácil aquí que en el ejemplo original: en todos los casos, su vida útil está restringida a un único alcance léxico, el bloque de código de la declaración try-with-resources. Además, su uso StructuredTaskScopegarantiza algunas propiedades valiosas:

  1. Manejo de errores y cortocircuitos: si findUser()alguna de las fetchOrder()subtareas falla, la otra se cancelará si aún no se ha completado. (Esto se ShutdownOnFailurerige por la estrategia de cierre implementada por; otras estrategias son posibles).
  2. Propagación de cancelación: si handle()el subproceso en ejecución join()se interrumpe antes o durante la llamada a , el subproceso cancela automáticamente ambas subtareas cuando sale del alcance.
  3. Claridad: el código anterior tiene una estructura clara: configure subtareas, espere a que se completen o se cancelen, luego decida si tendrá éxito (y manejará los resultados de las subtareas ya completadas) o fallará (las subtareas ya se han completado, por lo que no hay más para limpiar).
  4. Observabilidad: como se describe a continuación, los volcados de subprocesos muestran claramente la jerarquía de tareas, donde se ejecutan los subprocesos findUser()y fetchOrder()se muestran como subtareas del alcance.

9 Superando las limitaciones de la versión preliminar

StructuredTaskScope es una API de vista previa y está deshabilitada de forma predeterminada. Para utilizar la API StructuredTaskScope, es necesario habilitar la API de vista previa:

  1. Compile javac --release 21 --enable-preview Main.javael programa y java --enable-preview Mainejecútelo con ; o
  2. Cuando utilice el iniciador de código fuente, ejecute java --source 21 --enable-preview Main.javael programa con
  3. Cuando IDEA se esté ejecutando, verifíquelo:

10 Uso de StructuredTaskScope

10.1API

public class StructuredTaskScope<T> implements AutoCloseable {

    public <U extends T> Subtask<U> fork(Callable<? extends U> task);
    public void shutdown();

    public StructuredTaskScope<T> join() throws InterruptedException;
    public StructuredTaskScope<T> joinUntil(Instant deadline)
        throws InterruptedException, TimeoutException;
    public void close();

    protected void handleComplete(Subtask<? extends T> handle);
    protected final void ensureOwnerAndJoined();

}

10.2 Flujo de trabajo

  1. Crea un alcance. El hilo que creó el alcance es su propietario.
  2. Utilice fork(Callable)el método para bifurcar subtareas en su alcance.
  3. En cualquier momento, cualquier subtarea, o el propietario del ámbito, puede llamar al shutdown()método del ámbito para cancelar subtareas pendientes y evitar bifurcar nuevas subtareas.
  4. El propietario del ámbito une el ámbito (es decir, todas las subtareas) como una unidad. El propietario puede llamar al método del alcance join()y esperar a que se completen todas las subtareas (ya sea exitosas o no) o shutdown()se cancelen mediante . Alternativamente, puede llamar al joinUntil(java.time.Instant)método del alcance y esperar hasta la fecha límite.
  5. Después de unirse, maneje cualquier error en las subtareas y procese sus resultados.
  6. Cerrar el alcance, generalmente mediante el uso implícito de prueba con recursos. Esto cierra el alcance (si aún no se ha cerrado) y espera a que se completen las subtareas que se cancelaron pero que aún no se completaron.

Cada llamada fork(...)a inicia un nuevo hilo para ejecutar una subtarea, que es un hilo virtual por defecto. Una subtarea puede crear sus propias subtareas anidadas StructuredTaskScopepara bifurcar sus propias subtareas, creando una jerarquía. Esta jerarquía se refleja en la estructura de bloques del código, lo que limita la vida útil de las subtareas: una vez que se cierra el alcance, se garantiza que todos los subprocesos de las subtareas han terminado, sin dejar ningún subproceso atrás cuando el bloque sale.

Cualquier subtarea dentro de un ámbito, cualquier subtarea secundaria dentro de ámbitos anidados y el propietario del ámbito pueden llamar al shutdown()método del ámbito en cualquier momento para indicar que la tarea está completa, incluso si otras subtareas aún se están ejecutando. shutdown()El método interrumpe los subprocesos que aún ejecutan subtareas y hace que join()el joinUntil(Instant)método o regrese. Por lo tanto, todas las subtareas deben programarse para responder a las interrupciones. shutdown()Las nuevas subtareas bifurcadas después de la llamada estarán en UNAVAILABLEestado y no se ejecutarán. En efecto, una simulación concurrente de declaraciones shutdown()en código secuencial .break

join()Llamar o dentro de un alcance joinUntil(Instant)es obligatorio. Si el bloque de código del alcance sale antes de unirse, el alcance esperará a que finalicen todas las subtareas antes de generar una excepción.

El subproceso propietario del ámbito puede interrumpirse antes o durante la unión. Por ejemplo, podría ser una subtarea del ámbito adjunto. Si esto sucede, join()se joinUntil(Instant)lanzará una excepción, ya que no tiene sentido continuar con la ejecución. La declaración try-with-resources cerrará el alcance, cancelará todas las subtareas y esperará a que finalicen. El efecto de esto es propagar automáticamente la cancelación de una tarea a sus subtareas. Si joinUntil(Instant)la fecha límite para el método expira antes de que finalice la subtarea o se llame a shutdown(), se generará una excepción y, nuevamente, la declaración de prueba con recursos cerrará el alcance.

Cuando join()se completó exitosamente, cada subtarea se completó exitosamente, falló o se canceló porque se cerró el alcance.

Una vez unido, el propietario del alcance procesa las subtareas fallidas y procesa los resultados de las subtareas completadas con éxito; esto generalmente se hace a través de una política de cierre (ver más abajo). Los resultados de las tareas completadas con éxito se pueden Subtask.get()obtener utilizando el método. get()El método nunca se bloquea; se lanzará si por error se llama antes de unirse o cuando la subtarea no se ha completado correctamente IllegalStateException.

Cuando las subtareas de una tarea bifurcada están dentro del alcance, los enlaces se heredan ScopedValue(JEP 446). Si el propietario del alcance lee un valor de un límite ScopedValue, cada subtarea leerá el mismo valor.

Si el propietario del ámbito es en sí mismo una subtarea de un ámbito existente, es decir, creado como una subtarea bifurcada, ese ámbito se convierte en el ámbito principal del nuevo ámbito. Por lo tanto, los ámbitos y las subtareas forman una estructura de árbol.

En tiempo de ejecución, StructuredTaskScopeaplica la simultaneidad estructural y secuencial. Como tal, no implementa ExecutorServicelas interfaces o Executor, ya que las instancias de estas interfaces generalmente se usan de manera no estructurada (ver más abajo). Sin embargo, es sencillo ExecutorServicemigrar el código que se utiliza para utilizar y beneficiarse de la estructura.StructuredTaskScope

En la práctica, la mayoría de los usos StructuredTaskScopede probablemente no utilizarán StructuredTaskScopela clase directamente, sino que utilizarán una de las dos subclases descritas en la siguiente sección que implementan una estrategia de cierre. En otros casos, los usuarios pueden escribir sus propias subclases para implementar estrategias de apagado personalizadas.

11 Política de Cierre

El cortocircuito se utiliza a menudo para evitar trabajo innecesario cuando se trata de subtareas simultáneas. A veces, por ejemplo, si una de las subtareas falla, todas las subtareas se cancelan (es decir, todas las tareas se llaman al mismo tiempo), o todas las subtareas se cancelan cuando una de las subtareas tiene éxito (es decir, cualquier tarea se llama al mismo tiempo). . StructuredTaskScopeDos subclases de ShutdownOnFailurey ShutdownOnSuccessrespaldan estos patrones y brindan estrategias para cerrar el alcance cuando la primera subtarea falla o tiene éxito.

Una estrategia de cierre también proporciona un medio para centralizar el manejo de excepciones y posibles resultados exitosos. Esto se debe al espíritu de concurrencia estructurada, donde todo el ámbito se trata como una unidad.

11.1 Caso

El ejemplo anterior handle()también utiliza esta estrategia, que ejecuta un conjunto de tareas simultáneamente y falla si alguna de ellas falla:

<T> List<T> runAll(List<Callable<T>> tasks) 
        throws InterruptedException, ExecutionException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        List<? extends Supplier<T>> suppliers = tasks.stream().map(scope::fork).toList();
        scope.join()
             .throwIfFailed();  // 任何子任务失败,抛异常
        // 在这里,所有任务都已成功完成,因此组合结果
        return suppliers.stream().map(Supplier::get).toList();
    }
}

Devuelve el resultado después de que regrese la primera subtarea exitosa:

<T> T race(List<Callable<T>> tasks, Instant deadline) 
        throws InterruptedException, ExecutionException, TimeoutException {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
        for (var task : tasks) {
            scope.fork(task);
        }
        return scope.joinUntil(deadline)
                    .result();  // 如果没有任何子任务成功完成,抛出异常
    }
}

Una vez que una de las subtareas se realice correctamente, este alcance se cerrará automáticamente, cancelando las subtareas no finalizadas. Una tarea fallará si todas las subtareas fallan o si transcurre la fecha límite determinada. Este patrón es útil en aplicaciones de servidor que necesitan obtener resultados de cualquiera de un conjunto de servicios redundantes.

Si bien estas dos estrategias de cierre están integradas, los desarrolladores pueden crear estrategias personalizadas para abstraer otros patrones.

11.2 Resultados del procesamiento

Después del manejo centralizado de excepciones y la unión mediante la política de cierre (por ejemplo, a través de ShutdownOnFailure::throwIfFailed), el propietario del alcance puede usar el fork(...)objeto [Subtarea] devueltoShutdownOnSuccess::result() al llamar.

Normalmente, el propietario del alcance simplemente llamará get()al método Subtarea del método. Todos los demás métodos de subtarea normalmente solo se utilizan en la implementación de handleComplete(...)métodos de estrategia de apagado personalizados. De hecho, recomendamos que las variables que hacen referencia a fork(...)subtareas devueltas por tengan un tipo definido como Supplier<String>no Subtask<String>(a menos que uno elija usar por supuesto var). Si la estrategia de cierre en sí maneja los resultados de la subtarea (como en ShutdownOnSuccessel caso de ), entonces fork(...)se debe evitar por completo el uso del objeto Subtask devuelto por y fork(...)el método se debe tratar como un retorno void. Las subtareas deben tener sus resultados como resultado de retorno, como cualquier información que la estrategia deba procesar después de manejar la excepción central.

Si el propietario del alcance maneja las excepciones de la subtarea para producir un resultado combinado, en lugar de utilizar una estrategia de cierre, la excepción se puede devolver como el valor devuelto por la subtarea. FuturePor ejemplo, aquí hay un método que ejecuta un conjunto de tareas en paralelo y devuelve una lista de finalización con los respectivos resultados de excepción o éxito de cada tarea :

<T> List<Future<T>> executeAll(List<Callable<T>> tasks)
        throws InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
          List<? extends Supplier<Future<T>>> futures = tasks.stream()
              .map(task -> asFuture(task))
               .map(scope::fork)
               .toList();
          scope.join();
          return futures.stream().map(Supplier::get).toList();
    }
}

static <T> Callable<Future<T>> asFuture(Callable<T> task) {
   return () -> {
       try {
           return CompletableFuture.completedFuture(task.call());
       } catch (Exception ex) {
           return CompletableFuture.failedFuture(ex);
       }
   };
}

11.3 Estrategia de apagado personalizada

StructuredTaskScopeSe puede ampliar y sus handleComplete(...)métodos protegidos se pueden anular para implementar otras estrategias además de ShutdownOnSuccessy . ShutdownOnFailureLas subclases pueden, por ejemplo:

  • recopilar los resultados de las subtareas completadas con éxito e ignorar las subtareas fallidas,
  • recopilar la excepción cuando la subtarea falla, o
  • shutdown()Llame al método para que se apague y haga que el método se active cuando se produzca una determinada condición join().

Cuando se completa una subtarea, incluso después de llamarla shutdown(), se Subtaskinforma al handleComplete(...)método como:

public sealed interface Subtask<T> extends Supplier<T> {
    enum State { SUCCESS, FAILED, UNAVAILABLE }

    State state();
    Callable<? extends T> task();
    T get();
    Throwable exception();
}

El método se llamará cuando la subtarea se complete en SUCCESSestado o estado. Se puede llamar al método si la subtarea está en estado y se puede llamar al método si la subtarea está en estado . Llamar o de otro modo genera una excepción. Un estado representa uno de los siguientes casos: (1) la subtarea se bifurcó pero aún no se completó; (2) la subtarea se completó después de cerrarse, o (3) la subtarea se bifurcó después de cerrarse y, por lo tanto, aún no ha comenzado . El método nunca se llama para subtareas en el estado.FAILEDhandleComplete(...)SUCCESSget()FAILEDexception()get()exception()IllegalStateExceptionUNAVAILABLEhandleComplete(...)UNAVAILABLE

Las subclases normalmente definen métodos para que los resultados, el estado u otros resultados join()estén disponibles para el código posterior después de que regresa el método. Las subclases que recopilan resultados e ignoran las subtareas fallidas pueden definir un método que devuelva una serie de resultados. Las subclases que implementan una política de cierre cuando fallan las subtareas pueden definir un método para obtener una excepción para la primera subtarea que falló.

StructuredTaskScopeUna subclase que se extiende

Esta subclase recopila los resultados de subtareas completadas con éxito. Define results()el método que utiliza la tarea principal para recuperar resultados.

class MyScope<T> extends StructuredTaskScope<T> {

    private final Queue<T> results = new ConcurrentLinkedQueue<>();

    MyScope() { super(null, Thread.ofVirtual().factory()); }

    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS)
            results.add(subtask.get());
    }

    @Override
    public MyScope<T> join() throws InterruptedException {
        super.join();
        return this;
    }

    // 返回从成功完成的子任务获取的结果流
    public Stream<T> results() {
        super.ensureOwnerAndJoined();
        return results.stream();
    }

}

Esta política personalizada se puede utilizar así:

<T> List<T> allSuccessful(List<Callable<T>> tasks) throws InterruptedException {
    try (var scope = new MyScope<T>()) {
        for (var task : tasks) scope.fork(task);
        return scope.join()
                    .results().toList();
    }
}

ventilador en escena

Los ejemplos anteriores se centran en escenarios de distribución, que gestionan múltiples operaciones de E/S salientes simultáneas. StructuredTaskScopeTambién es útil en escenarios fan-in, que administran múltiples operaciones de E/S entrantes simultáneas. En este caso, normalmente creamos dinámicamente una cantidad desconocida de subtareas en respuesta a las solicitudes entrantes.

A continuación se muestra un ejemplo de un servidor que StructuredTaskScopebifurca subtareas para manejar conexiones entrantes:

void serve(ServerSocket serverSocket) throws IOException, InterruptedException {
    try (var scope = new StructuredTaskScope<Void>()) {
        try {
            while (true) {
                var socket = serverSocket.accept();
                scope.fork(() -> handle(socket));
            }
        } finally {
            // 如果发生错误或被中断,我们停止接受连接
            scope.shutdown();  // 关闭所有活动连接
            scope.join();
        }
    }
}

Desde el punto de vista de la concurrencia, esta situación difiere de la dirección de la solicitud, pero también en términos de duración y número de tareas, ya que las subtareas se bifurcan dinámicamente en función de eventos externos.

Todas las subtareas que manejan conexiones se crean en el ámbito, por lo que es fácil verlas en el subproceso secundario de un propietario con ámbito en un volcado de subprocesos. El propietario del alcance también es fácilmente tratado como una unidad para cerrar todo el servicio.

Observabilidad

Hemos ampliado el nuevo formato de volcado de subprocesos JSON agregado por JEP 444 para mostrar StructuredTaskScopela agrupación de subprocesos en jerarquías:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

El objeto JSON de cada ámbito contiene una serie de subprocesos que se bifurcaron en el ámbito, junto con sus seguimientos de pila. El subproceso propietario del alcance normalmente estaría join()bloqueado en el método, esperando a que se complete la subtarea; los volcados de subprocesos facilitan ver qué están haciendo los subprocesos de la subtarea al mostrar la jerarquía de árbol impuesta por la concurrencia estructurada. Un objeto JSON con ámbito también tiene una referencia a su padre para que la estructura del programa pueda reconstruirse a partir del volcado.

com.sun.management.HotSpotDiagnosticsMXBeanLa API también se puede utilizar para generar dicho volcado de subprocesos, que se puede consumir directa o indirectamente a través del MBeanServer de la plataforma y las herramientas JMX locales o remotas.

¿ Por qué fork(...)no hay retorno Future?

El método regresó mientras StructuredTaskScopela API estaba incubando . Esto lo hace más parecido al método existente , proporcionando así una sensación de familiaridad. Sin embargo, dado que se utiliza de una manera completamente diferente a la descrita (es decir, de la manera estructurada descrita anteriormente), su uso crea mucha más confusión que claridad.fork(...)Futurefork(...)ExecutorService::submitStructuredTaskScopeExecutorServiceFuture

El Futureuso familiar de implica llamar a su get()método, que se bloquea hasta que el resultado esté disponible. Pero en StructuredTaskScopeel contexto de , Futureno sólo se desaconseja su uso, sino que además es poco práctico. Structured FutureLos objetos sólo deben join()consultarse después de su devolución, momento en el que se sabe que están finalizados o cancelados, y se deben utilizar métodos que no sean familiares get(), pero recién introducidos resultNow(), que nunca bloqueen.

Algunos desarrolladores se preguntaron por qué fork(...)no devolvían un CompletableFutureobjeto más potente. fork(...)Dado que los devueltos sólo deben usarse cuando se sepa que se han completado Future, CompletableFutureno proporciona ningún beneficio, ya que sus funciones avanzadas sólo son útiles para futuros pendientes. Además, CompletableFutureestá diseñado para un paradigma de programación asincrónica, mientras que StructuredTaskScopese recomienda un paradigma de bloqueo.

En resumen, Futurey CompletableFutureestán diseñados para proporcionar grados de libertad que son perjudiciales en la concurrencia estructurada.

La simultaneidad estructurada es el tratamiento de múltiples tareas que se ejecutan en diferentes subprocesos como una única unidad de trabajo y es Futureprincipalmente útil cuando múltiples tareas se tratan como tareas separadas. Por lo tanto, un alcance solo debe bloquearse una vez esperando los resultados de sus subtareas y luego centrarse en el manejo de excepciones. Por lo tanto, en la gran mayoría de los casos, el único método que se debe llamar desde fork(...)el retorno es . Este es un cambio marcado con respecto al uso normal de y el método se comporta exactamente como lo hizo durante la incubación de API .FutureresultNow()FutureSubtask::get()Future::resultNow()

plan alternativo

ExecutorServiceInterfaz mejorada . Creamos un prototipo de una implementación de esta interfaz, que siempre impone la estructuración y restringe qué subprocesos pueden enviar tareas. Sin embargo, hemos descubierto que esto no está estructurado para la mayoría de los casos de uso en el JDK y el ecosistema. Reutilizar la misma API en conceptos completamente diferentes puede generar confusión. Por ejemplo, pasar una ExecutorServiceinstancia de estructura a un método existente que acepte este tipo casi con seguridad generará una excepción en la mayoría de los casos.

¡Este artículo está publicado por OpenWrite, una plataforma de publicaciones múltiples para blogs !

Supongo que te gusta

Origin blog.csdn.net/qq_33589510/article/details/132431158
Recomendado
Clasificación