La concurrencia en la plataforma Java con Project Loom

Escrito por picodotdev el , actualizado el .
java planeta-codigo
Enlace permanente Comentarios

Desde la publicación de Java 8 junto con el nuevo calendario de publicación las mejoras en la plataforma Java y en el lenguaje han sido constantes y significativas. Las mejoras continúan en cada nueva versión y hay muchas otras en preparación para ser publicadas cuando estén listas. Una de ellas muy prometedoras es una nueva implementación de los threads mucho más ligera que han existido desde la primera versión. Estos harán innecesarios en la mayoría de los casos los más complicados modelos programación asíncrona, la programación reactiva, la programación mediante callbacks y las construcciones async/await.

Java

Los threads han existido en Java desde la primera versión siendo uno de sus componentes esenciales. Los threads representan una unidad de trabajo concurrente como una abstracción de los recursos computacionales disponibles y que ocultan la complejidad de gestionar esos recursos.

Ya se usen de forma directa o dentro de un framework como JAX-RS la concurrencia en Java significan threads. En realidad, la plataforma Java entera, desde la máquina virtual al lenguaje y librerías incluidos depuradores y profilers está construida alrededor de los threads como componente esencial de ejecutar un programa.

En general la plataforma Java se basa en:

  • Las APIs son síncronas y describen operaciones E/S de inicio y espera a sus resultados como una secuencia ordenada de sentencias por threads que se bloquean.
  • Las operaciones de memoria con efectos colaterales son ordenadas secuencialmente por las acciones del thread.
  • Las excepciones proporcionan información útil indicando la operación fallida en el contexto del thread actual.
  • Los depuradores siguen el orden de ejecución aunque se realice procesado de E/S.

Los problemas de los threads y sus alternativas

En la implementación de Linux los threads no se diferencian de los procesos. Los threads son costosos de crear y pesados aún empleando pools de threads por lo que el sistema operativo solo es capaz de mantener unos pocos miles activos. Esto afecta especialmente en las aplicaciones Java en el lado de servidor ya que para procesar cada petición se le asigna un thread de modo que el número de peticiones simultáneas se ve limitado por el número de threads que soporta el sistema operativo. En aplicaciones con un número elevado de usuarios y peticiones la escalabilidad se ve limitada.

Por este motivo ha surgido la programación asíncrona, la programación reactiva, la programación mediante callbacks y las construcciones async/await y frameworks basándose en estos principios como Vert.x o Spring Reactive o librerías como RxJava. El resultado es una proliferación de APIs asíncronas desde NIO en el JDK a los servlets asíncronos a las librerías denominadas reactivas para no bloquear los threads. Sin embargo, estas formas de programación tienen un costo mayor que el tradicional y simple modelo secuencial. Son más difíciles de programar, más difíciles de mantener e implican cambios importantes en el modelo de programación. Por otro lado es más difícil depurarlos ya que no se mantiene en una única pila de llamadas toda la tarea.

Estos estilos de programación no han sido inventados porque sean más fáciles de entender, son más difíciles también de depurar y de hacer profile. Son muy intrusivos y hacen la integración con el código síncrono virtualmente imposible simplemente porque la implementación de los threads es simplemente inadecuada en Java tanto en carga del sistema como rendimiento. La programación asíncrona es contraria al modelo original diseñado en la programación de la plataforma Java en varios aspectos con un alto coste de mantenibilidad y observabilidad. Pero lo hacen por una buena razón, para conseguir la escalabilidad y el rendimiento haciendo buen uso de los costosos recursos hardware.

La nueva implementación de los threads

El proyecto Loom persigue crear unos threads que eliminen los costes de los hilos tradicionales del sistema operativo. Serán mucho más ligeros, con ellos Java será capaz de mantener varios órdenes de magnitud superior, millones de threads en vez de solo unos pocos miles. Estos threads virtuales o fibras de la plataforma Java son también simplemente threads pero que crearlos y bloquearlos es mucho más barato. Son gestionados por el entorno de ejecución de Java y no son una representación uno a uno de un envoltorio de los threads del sistema operativo, en vez de eso están implementados en el espacio de usuario del JDK.

Los hilos de los sistemas operativos son pesados porque deben soportar todos los lenguajes y tipo de cargas de forma genérica. Un thread requiere la habilidad de suspender y reactivar su ejecución de la computación. Esto requiere preservar su estado, lo que incluye su puntero de instrucciones así como todo los datos locales de computación que son almacenados en la pila. Dado que el sistema operativo no conoce cómo implementa el lenguaje su pila debe reservar una suficientemente grande.

Loom añade la habilidad de controlar la ejecución, suspensión y reactivación manteniendo su estado no como un recurso del sistema operativo sino como un objeto Java conocido por la máquina virtual bajo el control directo del entorno de ejecución. El conocimiento de las estructuras internas del lenguaje hace que mantener su estado sea más pequeño en comparación con el que mantiene el sistema operativo. Cuando un thread invoca una operación bloqueante se traspasa el control a otro thread con un coste mucho menor que el realizado por el sistema operativo.

El proyecto Loom modificará muchas de las clases de forma interna para implementar los threads con los thread virtuales. Las librerías y aplicaciones que hagan uso de estas clases se beneficiarán de estas mejoras sin necesidad de realizar ninguna modificación.

Estos párrafos son varios extractos del magnífico artículo State of Loom.

Programmers are forced to choose between modeling a unit of domain concurrency directly as a thread and wasting considerable throughput that their hardware can support, or using other ways to implement concurrency on a very fine-grained level but relinquishing the strengths of the Java platform. Both choices have a considerable financial cost, either in hardware or in development and maintenance effort.

We can do better.

Project Loom intends to eliminate the frustrating tradeoff between efficiently running concurrent programs and efficiently writing, maintaining and observing them. It leans into the strengths of the platform rather than fight them, and also into the strengths of the efficient components of asynchronous programming. It lets you write programs in a familiar style, using familiar APIs, and in harmony with the platform and its tools — but also with the hardware — to reach a balance of write-time and runtime costs that, we hope, will be widely appealing. It does so without changing the language, and with only minor changes to the core library APIs. A simple, synchronous web server will be able to handle many more requests without requiring more hardware.

Whereas the OS can support up to a few thousand active threads, the Java runtime can support millions of virtual threads. Every unit of concurrency in the application domain can be represented by its own thread, making programming concurrent applications easier. Forget about thread-pools, just spawn a new thread, one per task. You’ve already spawned a new virtual thread to handle an incoming HTTP request, but now, in the course of handling the request, you want to simultaneously query a database and issue outgoing requests to three other services? No problem — spawn more threads. You need to wait for something to happen without wasting precious resources? Forget about callbacks or reactive stream chaining — just block. Write straightforward, boring code. All the benefits threads give us — control flow, exception context, debugging flow, profiling organization — are preserved by virtual threads; only the runtime cost in footprint and performance is gone. There is no loss in flexibility compared to asynchronous programming because, as we’ll see, we have not ceded fine-grained control over scheduling.

However, the existence of threads that are so lightweight compared to the threads we’re used to does require some mental adjustment. First, we no longer need to avoid blocking, because blocking a (virtual) thread is not costly. We can use all the familiar synchronous APIs without paying a high price in throughput. Second, creating these threads is cheap. Every task, within reason, can have its own thread entirely to itself; there is never a need to pool them. If we don’t pool them, how do we limit concurrent access to some service? Instead of breaking the task down and running the service-call subtask in a separate, constrained pool, we just let the entire task run start-to-finish, in its own thread, and use a semaphore in the service-call code to limit concurrency — this is how it should be done.

Using virtual threads well does not require learning new concepts so much as it demands we unlearn old habits developed over the years to cope with the high cost of threads and that we’ve come to automatically associate with threads merely because we’ve only had the one implementation.

La API de threads

La forma de programación con los nuevos threads es muy parecida a la tradicional que ha existido siempre. Se parece tanto a los threads de siempre que incluso ni siquiera cambia la clase que los representa, que sigue siendo Thread, las diferencias de implementación son internas a la clase y en la JVM. En estos ejemplos se ejecutan tareas de dos formas diferentes y en la tercera se envían tareas para su ejecución y posteriormente se espera a obtener el resultado.

1
2
Thread thread = Thread.startVirtualThread(() -> System.out.println("Hello"));
thread.join();
threads-api-1.java
1
2
3
4
5
6
Thread thread1 = Thread.builder().virtual().task(() -> System.out.println("Hello")).build();
Thread thread2 = Thread.builder()
                      .virtual()
                      .name("bob")
                      .task(() -> System.out.println("I'm Bob!"))
                      .start();
threads-api-2.java
1
2
3
4
5
ThreadFactory tf = Thread.builder().virtual().factory();
ExecutorService e = Executors.newUnboundedExecutor(tf);
Future<Result> f = e.submit(() -> { ... return result; }); // spawns a new virtual thread
...
Result y = f.get(); // joins the virtual thread
threads-api-3.java

Conclusión

Esta nueva implementación de los threads es una mejora significativa sobre la implementación original basada en el sistema operativo. Una vez esté disponible en una versión del JDK muchas aplicaciones se beneficiarán de forma transparente de sus mejoras simplemente por usar un JDK más reciente. Como es principio en la plataforma Java estos cambios están implementados de forma que sean compatibles hacia atrás para que no haya que realizar ningún cambio o muy pocos en las aplicaciones o librerías para beneficiarse de ellos.

El modelo secuencial de los threads más simple que la programación reactiva, asíncrona, callbacks o las construcciones async/await tiene ventajas en la creación del software en su mantenibilidad, legibilidad y es beneficioso desde el punto de vista económico.

Loom es un nuevo ejemplo de que Java no adopta las nuevas tendencias de forma inmediata sino que espera a ver como se desarrollan, y después de evaluar todas las posibilidades opta por una que en este caso es mejor que la programación reactiva o asíncrona que otros lenguajes para permitirlas han tenido que realizar modificaciones comprometiendo la compatibilidad en el futuro del código fuente o desaconsejando el uso de funcionalidades para eliminarlas en el futuro. Esto mismo lo mencionaba en 10 razones para seguir usando Java.

Este artículo es simplemente un resumen de otros dos magníficos artículos State of Loom que explica todo esto en mayor profundidad. Muy recomendables su lectura junto a otros relacionados con Loom.

Y otros artículos sobre Loom.