Project Loom Java Concurrencia Ligera y Eficiente

1. Overview

En este artículo, vamos a explorar Project Loom. En esencia, el objetivo principal de Project Loom es investigar, incubar y entregar características y API de la máquina virtual de Java construidas sobre estos para facilitar la concurrencia ligera de alto rendimiento y nuevos modelos de programación en la plataforma Java.

2. Project Loom

Project Loom es un intento de la comunidad de OpenJDK de introducir una construcción de concurrencia ligera en Java. Al principio, se prevé introducir la concurrencia ligera de manera general a través de nuevas características, API y optimizaciones en todo el JDK. Los prototipos de Loom han introducido cambios en la JVM y en la biblioteca de Java. La característica principal de Project Loom son los hilos virtuales, y ya ha sido implementada.

Aunque no hay una versión programada de JDK que implemente completamente Loom todavía, podemos acceder a las versiones de acceso anticipado de Project Loom.

Antes de discutir los diversos conceptos de Loom, es importante hablar sobre el modelo actual de concurrencia en Java.

3. Modelo de Concurrencia en Java

Actualmente, Thread representa la abstracción central de la concurrencia en Java. Esta abstracción y otras API concurrentes facilitan la escritura de aplicaciones concurrentes. Específicamente, utilizamos Thread para crear hilos de plataforma que generalmente están mapeados 1:1 a hilos del núcleo del sistema operativo. El sistema operativo asigna una pila grande y otros recursos a los hilos de plataforma; sin embargo, estos recursos son limitados. Aun así, utilizamos los hilos de plataforma para ejecutar todo tipo de tareas.

A continuación se muestra un ejemplo básico de cómo crear y ejecutar un hilo en Java:

public class ExampleThread extends Thread {
    @Override
    public void run() {
        System.out.println("El hilo está en ejecución");
    }

    public static void main(String[] args) {
        ExampleThread thread = new ExampleThread();
        thread.start();
    }
}

Sin embargo, dado que Java utiliza hilos del sistema operativo para la implementación, no logra cumplir con los requisitos actuales de concurrencia. Hay dos problemas importantes, en particular:

  • Los hilos no pueden igualar la escala de la unidad de concurrencia del dominio. Por ejemplo, las aplicaciones suelen permitir millones de transacciones, usuarios o sesiones. No obstante, el número de hilos que admite el núcleo es mucho menor. Por lo tanto, un hilo para cada usuario, transacción o sesión generalmente no es factible.
  • La mayoría de las aplicaciones concurrentes requieren cierta sincronización entre hilos para cada solicitud. Debido a esto, ocurre un costoso cambio de contexto entre hilos de sistema operativo.

Una posible solución a estos problemas es el uso de API concurrentes asíncronas. Ejemplos comunes son CompletableFuture y RxJava. Siempre que tales API no bloqueen el hilo del núcleo, brindan a una aplicación una construcción de concurrencia más fina sobre los hilos de Java.

Sin embargo, tales API son más difíciles de depurar e integrar con API heredadas. Por lo tanto, existe la necesidad de una construcción de concurrencia ligera que sea independiente de los hilos del núcleo.

4. Tareas y Programadores

Cualquier implementación de un hilo, ya sea ligero o pesado, depende de dos construcciones:

  • Tarea (también conocida como continuación): una secuencia de instrucciones que puede suspenderse para alguna operación bloqueante.
  • Programador: para asignar la continuación a la CPU y volver a asignar la CPU de una continuación pausada.

Actualmente, Java depende de las implementaciones del sistema operativo tanto para la continuación como para el programador.

Suspender una continuación requiere almacenar toda la pila de llamadas. De manera similar, se debe recuperar la pila de llamadas al reanudarse. Dado que la implementación del sistema operativo de las continuaciones incluye la pila de llamadas nativa junto con la pila de llamadas de Java, esto resulta en una huella pesada.

Sin embargo, un problema más grande es el uso de un programador del sistema operativo. Dado que el programador se ejecuta en modo kernel, no hay diferenciación entre los hilos. Y trata cada solicitud de CPU de la misma manera.

Este tipo de programación no es optimal para aplicaciones Java en particular. Por ejemplo, consideremos un hilo de la aplicación que realiza alguna acción sobre las solicitudes y luego pasa los datos a otro hilo para su posterior procesamiento. Aquí, sería mejor programar ambos hilos en la misma CPU. Sin embargo, dado que el programador es ajeno al hilo que solicita la CPU, esto es imposible de garantizar.

Project Loom propone resolver esto mediante hilos en modo usuario, que dependen de la implementación de continuaciones y programadores en tiempo de ejecución de Java en lugar de la implementación del sistema operativo.

5. Hilos Virtuales

OpenJDK 21 introdujo hilos virtuales, junto con una disposición para crearlos en la API existente (Thread y ThreadFactory).

5.1. ¿Cómo son diferentes los hilos virtuales?

Los hilos de plataforma y los hilos virtuales son diferentes en que los últimos son típicamente hilos en modo usuario, junto con otras diferencias:

  • Programación: Los hilos virtuales son programados por el tiempo de ejecución de Java en lugar del sistema operativo.
  • Modo usuario: Los hilos virtuales envuelven cualquier tarea en una continuación interna en modo usuario. Esto permite que la tarea se suspenda y se reanude en tiempo de ejecución de Java en lugar de en el núcleo.
  • Nomenclatura: Los hilos virtuales no requieren ni tienen un nombre de hilo por defecto; sin embargo, podemos establecer un nombre.
  • Prioridad del hilo: Los hilos virtuales tienen una prioridad de hilo fija que no podemos cambiar.
  • Hilos daemon: Los hilos virtuales son hilos daemon; por lo tanto, no impiden la secuencia de cierre.

5.2. ¿Cuáles son las ventajas/desventajas de los hilos virtuales?

Los hilos virtuales tienen sus ventajas y desventajas:

VentajasDesventajas
Los hilos virtuales son ligeros.Como hilos ligeros, no son adecuados para tareas que consumen CPU.
Los hilos virtuales pueden ser creados por el usuario.Muchos hilos virtuales comparten el mismo hilo del sistema operativo. Los hilos virtuales se bloquean en construcciones que implican métodos y declaraciones sincronizadas porque los hilos virtuales están fijos a sus hilos de plataforma subyacentes.
Podemos crear hilos virtuales fácilmente cuando los necesitamos.Los desarrolladores de Project Loom deben modificar cada API en el JDK que utiliza hilos, para que se pueda utilizar sin problemas con hilos virtuales.
Normalmente, los hilos virtuales requieren pocos recursos. Por ejemplo, una sola JVM puede soportar millones de hilos virtuales.Las variables locales de hilo requerirían mucha más memoria si cada uno de un millón de hilos virtuales tuviera su copia de variables locales de hilo.

5.3. ¿Cuándo utilizar hilos virtuales?

Podemos utilizar hilos virtuales cuando queremos ejecutar tareas que pasan la mayor parte de su tiempo bloqueadas. Usamos hilos virtuales ligeros en modo usuario en lugar de hilos de plataforma para tareas que están mayormente esperando a que las operaciones de E/S se completen.

No obstante, no debemos usar hilos virtuales para operaciones que consumen mucho tiempo en CPU.

5.4. ¿Cómo crear hilos virtuales?

Tenemos dos opciones principales para crear hilos virtuales. La clase Thread agrega un nuevo método de clase llamado ofVirtual que devuelve un constructor para crear un Thread virtual o ThreadFactory que crea hilos virtuales.

A continuación, mostramos cómo iniciar un hilo virtual para ejecutar una tarea:

Thread thread = Thread.ofVirtual().start(Runnable task);

O bien, podemos usar la forma equivalente para crear un hilo virtual y programarlo para que se ejecute:

Thread thread = Thread.startVirtualThread(Runnable task);

Además, podemos usar un ThreadFactory que crea hilos virtuales:

ThreadFactory factory = Thread.ofVirtual().factory();
Thread thread = factory.newThread(Runnable task);

Podemos usar el método isVirtual() para encontrar si un hilo es virtual:

boolean isThreadVirtual = thread.isVirtual();

Un hilo es virtual si este método devuelve true.

5.5. ¿Cómo se implementan los hilos virtuales?

Los hilos virtuales se implementan utilizando un pequeño conjunto de hilos de plataforma subyacentes llamados hilos transportadores. Operaciones, como las operaciones de E/S, pueden reprogramar un hilo transportador de un hilo virtual a otro. Sin embargo, el código que se ejecuta en un hilo virtual no es consciente del hilo de plataforma subyacente. Así, el método currentThread() devuelve el objeto Thread para el hilo virtual y no el hilo de plataforma subyacente.

Vamos a revisar algunas otras optimizaciones para la concurrencia ligera.

6. Continuaciones Delimitadas

Una continuación (o co-rutina) es una secuencia de instrucciones que se ejecuta secuencialmente y que puede rendir (yield) y ser reanudada por el llamador en un momento posterior.

Cada continuación tiene un punto de entrada y un punto de rendición. El punto de rendición es donde se suspendió. Siempre que el llamador reanude la continuación, el control regresa al último punto de rendición.

Es importante darse cuenta de que esta suspensión/reanudación ahora ocurre en el entorno de ejecución del lenguaje en lugar del sistema operativo. Por lo tanto, evita la costosa conmutación de contexto entre hilos de kernel.

Las continuaciones delimitadas se agregan para admitir hilos virtuales; por lo tanto, no necesitan exponerse como una API pública. Vamos a discutir una continuación delimitada, que es esencialmente un subprograma secuencial con un punto de entrada, utilizando un ejemplo en pseudo-código. Podemos crear una continuación en el método main() con un punto de entrada como one().

Posteriormente, podemos invocar la continuación que pasa el control al punto de entrada. La one() puede llamar a otros sub-rutinas, por ejemplo, two(). La ejecución se suspende en two(), que pasa el control fuera de la continuación y la primera invocación de la continuación en main() devuelve.

Invitemos a la continuación en main() para reanudar, que pasa el control al último punto de suspensión. Todo esto ocurre dentro del mismo contexto de ejecución:

one() {  
    ... 
    two()
    ...
}
 
two() {
    ...
    suspend //  punto de suspensión
    ... // punto de reanudación
}
 
main() {
    c = continuation(one) // crear continuación  
    c.continue() // invocar continuación 
    c.continue() // invocar continuación nuevamente para reanudar
}

Para continuaciones que usan pilas (stackful), como la que discutimos, la JVM necesita capturar, almacenar y reanudar las pilas de llamadas no como parte de los hilos del núcleo. Para añadir a la JVM la capacidad de manipular pilas de llamadas, el deshacer-y-llamar (UAI) es un objetivo de este proyecto. UAI permite deshacer la pila hasta cierto punto y luego invocar un método con argumentos dados.

7. ForkJoinPool & Soporte de Programadores Personalizados en Hilos Virtuales

Hemos discutido anteriormente las limitaciones del programador de OS en la programación de hilos relacionados en la misma CPU.

Aunque es un objetivo de Project Loom permitir programadores intercambiables con hilos virtuales, ForkJoinPool en modo asíncrono se utilizará como el programador predeterminado. OpenJDK 19 añadió varias nuevas mejoras a la clase ForkJoinPool, incluyendo setParallelism(int size) para establecer la paralelismo objetivo, controlando así la futura creación, uso y terminación de los hilos de trabajo.

ForkJoinPool opera sobre el algoritmo de robo de trabajo. Así, cada hilo mantiene un deque de tareas y ejecuta la tarea desde su cabeza. Además, un hilo inactivo no se bloquea, esperando la tarea, y la roba desde la cola de otro hilo en su lugar.

La única diferencia en modo asíncrono es que los hilos de trabajo roban la tarea de la cabeza de otro deque.

ForkJoinPool forkJoinPool = new ForkJoinPool();
forkJoinPool.submit(() -> {
    // Tarea a ejecutar
});

ForkJoinPool añade una tarea programada por otra tarea en ejecución a la cola local. Por lo tanto, ejecutándola en la misma CPU.

8. Concurrencia Estructurada

OpenJDK ha introducido una característica de vista previa para la concurrencia estructurada que está en la esfera de Project Loom. El objetivo de la concurrencia estructurada es tratar grupos de tareas relacionadas que se ejecutan en diferentes hilos como una sola unidad de trabajo, con un solo alcance. Su beneficio es que simplifica el manejo de errores y la cancelación, mejorando así la confiabilidad y la observabilidad.

Para ello, se introduce la API de vista previa java.util.concurrent.StructuredTaskScope, que divide una tarea en múltiples subtareas concurrentes. Además, la tarea principal debe esperar a que se completen las subtareas. Usando el método fork() podemos iniciar nuevos hilos para ejecutar subtareas y el método join() para esperar a que terminen los hilos. Esta API está diseñada para ser utilizada dentro de una instrucción try-with-resources, como se muestra a continuación:

Callable<String> task1 = ...
Callable<String> task2 = ...

try (var scope = new StructuredTaskScope<String>()) {
    Subtask<String> subtask1 = scope.fork(task1); //crear hilo para ejecutar la primera subtarea
    Subtask<String> subtask2 = scope.fork(task2); //crear hilo para ejecutar la segunda subtarea

    scope.join(); //esperar a que las subtareas terminen

    // procesar los resultados de las subtareas
}

Después, podemos procesar los resultados de las subtareas.

9. Conclusión

En este artículo, discutimos los problemas en el modelo actual de concurrencia en Java y los cambios propuestos por Project Loom.

Al hacerlo, discutimos cómo los hilos virtuales ligeros introducidos en OpenJDK 21 proporcionan una alternativa al uso de hilos del núcleo de Java. Esto ofrece a los programadores nuevas formas de abordar problemas de concurrencia que antes eran pesados y complicados. Con las ventajas de los hilos virtuales y las continuaciones delimitadas, Java se posiciona para facilitar la creación de aplicaciones más eficientes y manejables.