Guía para Virtual Threads – Hilos livianos en Java

Advertisements

Desde el principio, – atrás a 1997 -, Java proporcionó una manera fácil de trabajar con aplicaciones de multi-thread. Sí, estamos hablando de la clase Thread y la interfaz Runnable. Con las nuevas versiones de Java, el JDK incluía más formas de simplificar el trabajo con concurrencia en Java. Por ejemplo: el Executor Service, ForkJoinPool, Future, CompletableFuture (que son muy similares a las promesas en Java Script), el parallel Stream, el paquete java.concurrent.* con colecciones, utilidades, bloqueos y más.

Todas esas características hacen de Java un ecosistema rico para trabajar con aplicaciones multi-hilos, sin embargo, se han limitado a los subprocesos del sistema operativo. En aplicaciones grandes con cientos de procesos simultáneos, es posible que no sea lo suficientemente eficiente y que no se escale fácilmente, lo que requiere agregar más CPU para proporcionar más subprocesos disponibles. Esto se debe principalmente al modelo de simultaneidad de estado compartido que se usa de forma predeterminada.

Es por eso que Project Loom comenzó en 2017, y es una iniciativa para proporcionar hilos livianos que no están vinculados a los Procesos del sistema operativo, pero que son administrados por JVM. Muchos cambios y propuestas desde el primer borrador, pero parece estar listo en Java 21.

Project Loom y Threads Virtuales

Como se mencionó anteriormente, el proyecto Loom comenzó como una iniciativa interna para proporcionar threads livianos en Java, pasando por diferentes borradores y propuestas sobre cómo hacerlo, conceptos como Fibra y Canales renombrados al concepto actual de Threads Virtuales. Project Loom estaba incubando algunas de esas funciones en versiones anteriores de Java, agregadas en Java 19 en modo de vista previa disponible para que los desarrolladores proporcionen comentarios, la etapa de vista previa terminó y ahora las funciones están disponibles como parte de Java 21.

Los threads virtuales son un nuevo tipo de threads agregados a Java, que no están vinculados a los procesos del sistema operativo como los “Threads de plataforma”. Son livianos porque es posible tener miles e incluso millones de threads virtuales con un mínimo de recursos, ya que son administrados por la JVM, el límite es la memoria en la JVM.

Si ha trabajado con Kotlin o Go Lang, notará que los threads virtuales son muy similares a las rutinas de Go o las corrutinas de Kotlin. Algunos desarrolladores se mudan a esos lenguajes debido a esas características, parece que Java ahora proporciona lo mismo en un enfoque compatible con versiones anteriores. Para los amantes de Java, esta es una razón más para continuar usando Java.

Thread.startVirtualThread(() -> {
    System.out.println("Hello, Virtual Thread!");
});
go func() {
    println("Hello, Goroutines!")
}()
runBlocking {
    launch {
        println("Hello, Kotlin coroutines!")
    }
}


Como usar threads virtuales

La claseThread proporciona algunos métodos para construir con ellos instancias de un VirtualThread. Por ejemplo: startVirtualThread, create el virtual thread y lo inicia, todo en una misma operación.

var task = () -> System.out.println("Hello World!");
// Creating and starting the thread in the same operation
Thread.startVirtualThread(task);

También usando el método ofVirtual() que devuelve un Builder para crear paso a paso el hilo deseado.

//Creating the Virtual thread, and then Running the start method. 
var thread = Thread.ofVirtual().start(task);
var thread2 = Thread.ofVirtual().name("My Thread").start(task); 

//If need to create the thread but not start it yet
var unstartedThread = Thread.ofVirtual().unstarted();
//... some other logic... 
unstartedThread.started(); //Now starts the thread

Además de eso, Builder puede devolver una instancia de fábrica desde la lata, podemos construir VirtualThreads a pedido.

// Virtual thread created with a factory
ThreadFactory factory = Thread.ofVirtual().factory();
Thread virtualThreadFromAFactory = factory.newThread(task);
virtualThreadFromAFactory.start();

Executor Service y Virtual Threads

Un pool de threads es una estrategia válida para reutilizar los costosos threads de plataforma para múltiples tareas, pero con los threads virtuales, que son livianos y económicos, no es necesario crear un grupo de ellos. ExecutorService es una API que proporciona algunos grupos de subprocesos en Java y se usa ampliamente en muchas aplicaciones. Por lo tanto, desperdiciar recursos agrupando objetos muy baratos, generalmente con una vida útil corta, no tiene sentido, por eso no se recomienda agrupar threads virtuales.

Si se desea aprovechar los beneficios de los threads virtuales pero sin refactorizar el código existente mediante ExecutorService, puede utilizar la implementación VirtualThreadPerTaskExecutor. Como su nombre lo indica, crea un Executor que crea thread virtuales por Tarea, en otras palabras, no está reutilizando o “agrupando” los hilos virtuales.

Runnable task = () -> System.out.println("Hello, world");
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.execute(task);

O agregando una instancia de ThreadFactory que devuelve VirtualThreads a cualquier otro método de Executors.

ThreadFactory factory = Thread.ofVirtual().factory();
Executors.newThreadPerTaskExecutor(factory); // Same as newVirtualThreadPerTaskExecutor
Executors.newSingleThreadExecutor(factory);
Executors.newCachedThreadPool(factory);
Executors.newFixedThreadPool(1, factory);
Executors.newScheduledThreadPool(1, factory);
Executors.newSingleThreadScheduledExecutor(factory);

Ese es el primer paso recomendado para comenzar a usar threads virtuales si usa Executor Service. Como puede ver, cambios mínimos en el código existente y compatibilidad con versiones anteriores. Luego, se puede hacer una refactorización más profunda en el código si es necesario


Muestreme los números

Se puede ver en muchos lugares (incluido este artículo) que ahora es posible tener millones de tasks al mismo tiempo con threads virtuales, con un uso mínimo de recursos. Veamos algunos números recopilados de diferentes artículos que respaldan eso.

Escenario 1

En este artículo A simple benchmark for Virtual Threads, el autor creó un código que ejecuta 1000 tareas bastante sencillas, usando threads de plataforma (los normales) y thread virtuales, cada tarea es básicamente solo una suspensión(sleep) de 100 ms. Ejecutó diferentes escenarios, por ejemplo: un solo thread para todas las tareas, un executor service con un grupo de 4 threads que ejecutan todas las tareas y también executor con un solo thread virtual por cada tarea (se recomienda el uso de threads virtuales). Estos son los números:

Advertisements
one-thread-for-all     109.435 ± 0.023   sec/operation
fixed-pool              25.363 ± 1.721   sec/operation
virt-thread-per-task     0.117 ± 0.010   sec/op

Como puede ver, tomó solo 117 ms ejecutar todas las 1000 tareas, mientras que en los otros escenarios tomó entre 25 y 100 segundos para hacerlo… ¿Por qué? porque en el grupo fijo solo 4 threads se estaban ejecutando en ese momento, haciendo algo de paralelismo, pero aún ejecutándolos en secuencia.

Escenario 2

Tal vez pueda parecer que los números no son correctos porque la tarea solo hace un sueño simple y las aplicaciones reales son más complejas que eso.. En este articulo, Java virtual threads millions within grasp, el autor crea una tarea que está haciendo una solicitud a una de las URL del ranking de los 250 mejores sitios de Alexa, la solicitud son operaciones de I/O bloqueantes, por lo tanto, buenos candidatos para usar thread virtuales.

Dos escenarios, uno con un Ejecutor de Pool Fijo con tamaño según la cantidad de procesadores, el otro con un Ejecutor con un Thread Virtual por tarea. Y los resultados, notó una ejecución entre un 25 % y un 50 % más corta con thread virtuales. Lo cual es una buena mejora en un escenario más real.

Escenario 3

¿Qué hay de tener una aplicación web que acepte solicitudes con thread virtuales? Debido al uso de un thread virtual por solicitud (por tarea), es posible aceptar más solicitudes que con un conjunto fijo de threads de plataforma. Bueno, ese fue el ejercicio probado en este artículo, Virtual thread: performance gain for microservices, donde el autor modificó una aplicación Spring Boot para admitir thread virtuales para las solicitudes.

El resultado muestra cómo con el threading tradicional, con más de 200 solicitudes al mismo tiempo (como 1000 o 2000), solo se procesaban 200 (el tope de threads). Los otros que estaban “en cola” son el tiempo de respuesta promedio aumentado en el tiempo.

Por otro lado, para los Threads Virtuales, independientemente de la cantidad de concurrencia de solicitudes, casi todos se procesaron de inmediato con una cantidad similar de transacciones por segundo. Reducir el tiempo medio de respuesta de las operaciones.

Escenario 4

Pero, ¿es cierto que es posible tener hasta 1 000 000 de hilos virtuales al mismo tiempo? La respuesta es SI.

Hay benchmarks en los que alguien simplemente comienza a ejecutar 1 000 000 de tareas, durmiendo un poco durante algunos minutos, en un caso con threads de plataforma, en el otro utilizando threads virtuales. En el caso de los threads de la plataforma, la JVM falló con OutOfMemoryError y se crearon cerca de 30 000 threads. Mientras que con los threads virtuales se crearon un millón de threads, con un uso mínimo de memoria (recursos) y la aplicación nunca se bloqueó.

Otro ejemplo es este artículo, Running 1 Million Threads in Java, el autor ejecutó un escenario similar al explicado anteriormente y monitoreó la CPU en el Sistema Operativo, para darse cuenta de que Virtual Threads creó 1 000 000 de tareas más rápido y usa menos recursos en el Sistema Operativo.

Escenario 5

En este escenario (Project Loom & Virtual Threads), el benchmark está comparando la cantidad de threads del sistema operativo necesarios para alguna lógica en Java. Por un lado, usa Java Platform Threads para ejecutar 1000 tareas y, por el otro, usa Virtual Threads para ejecutar también 1000 tareas.

Para Platform Threads, creó alrededor de 1000 Platform Threads durante la prueba, usando un 2-3% de CPU y 150 Mb de memoria. Por otro lado, con los threads virtuales, solo usaba 30 “threads del OS” (como workers), el uso de la CPU era inferior al 2% y el uso de la memoria oscilaba entre 20 Mb y 60 Mb.

Por lo tanto, definitivamente, los thread virtuales eran más livianos que los thread de plataforma en uso de memoria, y también los thread virtuales requerían menos thread del OS para funcionar.

Consideraciones

Hay consideraciones sobre el uso de threads virtuales en lugar de threads de plataforma.

  • Cuando se usan tareas extensas de CPU, es mejor usar subprocesos de plataforma. Debido a que los subprocesos virtuales aprovechan el tiempo de inactividad de la CPU (como suspensión, conexiones de E/S, etc.) para cambiar y ejecutar otros subprocesos en el mismo trabajador, pero con tareas extensas de la CPU, no hay tiempo de inactividad, la CPU siempre está ocupada.
  • Use thread virtuales, cuando la tarea se trata más de hacer alguna otra lógica, como conectar otro servicio, solicitud http, conexión de socket, mover datos entre objetos, etc.

Con respecto a cómo configurar un thread virtual (en comparación con los thread de la plataforma), estas son otras consideraciones importantes.

  • Un Thread virtual se puede programar en diferentes operadores a lo largo de su vida útil; en otras palabras, el programador no mantiene afinidad entre un Thread virtual y cualquier Thread de plataforma en particular. Significa un Thread Virtual que se ejecuta sobre el Thread de plataforma A y está inactivo durante algún tiempo, cuando continúa, puede ejecutar el Thread de plataforma B, o C, D.
  • Los thread virtuales son siempre subprocesos daemon. El método Thread.setDaemon(booleano) no puede cambiar un thread virtual para que sea un subproceso que no sea demonio.
  • Los threads viertuales tienen una prioridad fija de Thread.NORM_PRIORITY. El método Thread.setPriority(int) no tiene efecto en thread virtuales.
  • Los threads virtuales no tiene permisos cuando corren un set de SecurityManager.


Conclusión

Aunque los subprocesos virtuales son compatibles con “Platform Threads”, el desarrollador no comenzará a refactorizar su código para usarlo – Si funciona, no lo toque– , pero los nuevos desarrollos pueden aprovecharlos. Además, las bibliotecas de terceros para la programación reactiva o asíncrona pueden comenzar a usarlas bajo el capó para proporcionar una solución más escalable.

Y espero ver cómo los servidores web ligeros de Java (como Tomcat, Netty, Jetty y otros) pueden comenzar a usar subprocesos virtuales para proporcionar una solución escalable para proporcionar miles de solicitudes simultáneas. Para nosotros, los desarrolladores que usan esos servidores web, no debería haber cambios en el código, solo aprovechar las nuevas mejoras de rendimiento en ellos.

Referencias

Advertisements

Leave a Reply

Your email address will not be published. Required fields are marked *