Virtual Threads en Quarkus

0
99
Hilos de lana en una cesta de mimbre. Foto de Surene Palvie

Aprenderemos sobre Virtual Threads en Quarkus, cómo se instrumentalizan, sus ventajas e inconvenientes, y lo guiaremos a través de un ejemplo de código para ilustrar mejor qué son los hilos virtuales.

Índice de contenidos

1. Introducción a Virtual Threads con Quarkus

El proyecto Loom tenía como objetivo explorar e incubar la posibilidad de implementar hilos ligeros dentro de la JVM. Finalmente, en la versión de Java 19 —que no es una versión LTS— esos «lightweight threads» se incorporaron como una realidad y fue, a partir de ese momento, cuando cada programador podía hacer uso de los Virtual Threads, que así los llamaron.

Pero cabía hacerse una pregunta: ¿cómo iba cada framework de java a instrumentalizar los Virtual Threads para abstraer al desarrollador de su manejo a bajo nivel, en cuestiones de concurrencia?

2. ¿Qué son los virtual Threads?

Los Virtual Threads son unos hilos ligeros, creados y gestionados artíficialmente por la JVM y que no se corresponden con un Thread del sistema operativo. Antes, los threads, eran un «envoltorio» de un hilo del sistema operativo. Pero con los virtual threads, hay un hilo que coordina y gestiona todos los hilos vituales.

Cuando hay una operación de entrada/salida ES, el hilo virtual queda aparcado esperando la respuesta, y devuelve el control al hilo principal llamado «carrier thread». Este hilo es el que orquesta la reanudación de los hilos aparcados, y la creación de nuevos hilos ligeros.

3. Cómo se instrumentalizan los virtual Threads en Quarkus

Lo primero es añadir la nueva librería de Quarkus para trabajar con REST y que sustituye a la versión anterior de RESTEasy Reactive

<dependency> 
	<groupId>io.quarkus</groupId>
	<artifactId>quarkus-rest</artifactId>
</dependency>

Luego hay que indicarle a maven que estamos compilando con Java 21

<properties>
	<maven.compiler.source>21</maven.compiler.source>
	<maven.compiler.target>21</maven.compiler.target>
</properties>

Y anotar el endpoint con @RunOnVirtualThread
Si se lo ponemos, ese endpoint se ejecutará en un hilo virtual, mientras que si no se lo ponemos se ejecutará en un worker-thread

@GET
@Path("/{symbol}")
@RunOnVirtualThread
public Response get(@PathParam("symbol") String symbol) {
	return service.get(symbol);
}

4. Ejemplo de código

Partimos del ejemplo que vimos de la tabla periódica

Quarkus: como crear microservicio con REST y MongoDB

Hacemos un fork del repo y nos creamos nuestro proyecto para manejar Virtual Threads.
lo primero que hacemos es actualizarnos a las últimos versiones de Quarkus y le indicamos que ya vamos a trabajar con Java 21.

En el POM ponemos

<quarkus.platform.version>3.9.4</quarkus.platform.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>

Reemplazamos las nuevas dependencias por

<dependency>
	<groupId>io.quarkus</groupId>
	<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
	<groupId>io.quarkus</groupId>
	<artifactId>quarkus-rest-jackson</artifactId>
</dependency>

Y añadimos la de los test para virtualThreads

<dependency>
	<groupId>io.quarkus.junit5</groupId>
	<artifactId>junit5-virtual-threads</artifactId>
	<scope>test</scope>
</dependency>

En el código cambiamos toda la paquetería de javax. por jakarta. para adaptarnos a las nuevas versiones.

Y en el ElementResource cambiamos para que los endpoints se ejecuten sobre VirtualThreads

public class ElementResource {

    @Inject
    ElementService service;
	
    @GET
    @Path("/{symbol}")
    @RunOnVirtualThread
    public Response get(@PathParam("symbol") String symbol) {
        return service.get(symbol);
    }
    	
    @POST
    @RunOnVirtualThread
    public Response create(@Valid ElementDto dto) {
    	return service.create(dto);
    }
    
    @PUT
    @RunOnVirtualThread
    public Response update(@Valid ElementDto dto) {
    	return service.update(dto);
    }
    
    @DELETE
    @RunOnVirtualThread
    public Response delete(@Valid ElementDto dto) {
    	return service.delete(dto);
    }
}

Luego, en ElementResourceTest añadimos las anotaciones @VirtualThreadUnit y @ShouldNotPin y vemos que todo sigue funcionando.

@QuarkusTest
@VirtualThreadUnit
@ShouldNotPin
class ElementResourceTest {
	...
}

5. Casos de «pinning»

Los hilos virtuales son deshechables. Cuando terminan, se destruyen. Pero hay librerías que usan los ThreadLocal con estado, para almacenar y reutilizar objetos. Cuando se usan Virtual Threads en combinación con este tipo de librerías se produce un proceso que hacen una monopolización de la memoria, instanciando cada objeto con su hilo virtual —recordemos que es desechable. Si además vamos a nativo, el Garbage Collector es Serial GC, y la creación masiva de objetos pueden hacer un uso exhaustivo de la memoria, y que los tiempos del «stop the world» del full gc no recomiendan esta estrategia.

Por otro lado, hay casos donde el hilo ligero bloquea el «carrier Thread». Esto se produce cuando el hilo virtual realiza una operación de bloqueo dentro de un bloque synchronized o en un bloqueo de una llamada externa. Es raro que el desarrollador caiga en este tipo de estrategia, pero el problema viene de las dependencias y librerías con terceros. Por ejemplo, muchos controladores JDBC tienen bloques synchronized y dejan «pillado» el hilo transportador.

Por tanto es muy importante saber qué se queda «pinned» y qué no. Para eso, Quarkus nos ofrece una serie de herramientas.

Para detectar en los tests hilos que se han quedado «pinned» es conveniente añadir
-Djdk.tracePinnedThreads

<plugin>
	<artifactId>maven-surefire-plugin</artifactId>
	<version>${surefire-plugin.version}</version>
	<configuration>
		<systemPropertyVariables>
		  <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
		  <maven.home>${maven.home}</maven.home>
		</systemPropertyVariables>
		<argLine>-Djdk.tracePinnedThreads</argLine>
	</configuration>
</plugin>

6. Cómo testear los Virtual Threads

Como hay limitaciones en los virtual threads, se ha desarrollado una extensión a la librería de JUnit 5 para detectar estos casos. Simplemente hay que añadir la dependencia de junit5-virtual-threads al proyecto, y como es para test, le damos el scope de test

<dependency>
    <groupId>io.quarkus.junit5</groupId>
    <artifactId>junit5-virtual-threads</artifactId>
    <scope>test</scope>
</dependency>

Y en nuestras clases de test añadimos la anotación @VirtualThreadUnit para indicar que debe usar la extensión y @ShouldNotPin que intenta detectar si se queda «pinned» el carrier thread

@QuarkusTest
@VirtualThreadUnit
@ShouldNotPin
class ElementResourceTest {
    ...
}

El asunto, es que estos test son muy muy lentos. Y es por la propia naturaleza de la prueba. Me figuro que por debajo están intentando hacer colisionar muchos hilos a ver si unos se bloquean a otros, cread deadlocks, etc… El tema es que para mi, este tipo de test no cumplen FIRST. Así que seguramente quieras hacer los test normales fruto del TDD previo, y cuando se hayan acabado todas las refactorizaciones, para cada clase de test añadir las anotaciones o crear tests de integración que extiendan de las clases existentes de test.

Por regla general, no querras tener estas anotaciones en tus test mientras desarrollas.

@QuarkusIntegrationTest
@VirtualThreadUnit
@ShouldNotPin
class ElementResourceIT extends ElementResourceTest{}

7. Rendimiento de los Virtual Threads vs Blocking vs Reactivo

La programación reactiva requiere estructurar el pensamiento de una forma a la que no todos los desarrolladores se acostumbran. Es bastante más compleja.
En este caso, la ventaja de los Virtual Threads, es que la forma de programar es igual a la clásica, y nos promete un rendimiento similar al reactivo, pero ¿cuánto hay de verdad en esta afirmación?

Hay un artículo muy interesante que compara el rendimiento entre no usar nada (blocking), reactivo e hilos virtuales y que nos puede ayudar a tomar decisiones de cuando usar uno u otro. Y no es oro todo lo que reluce.

8. Llamadas en paralelo dentro de un Virtual Thread

Si a tu endpoint lo tienes anotado con @RunOnVirtualThread, cuando abres llamadas en paralelo con ManagedExecutor los hilos donde corren esas llamadas no son hilos virtuales y son del tipo clásico de executor-thread

Parece que con ExecutorService si puedes usar hilos virtuales, pero en los manuales de Quarkus advierten que es una funcionalidad experimental, y que puede variar en futuras versiones.

@Inject
@VirtualThreads
ExecutorService executorService;

En cualquier caso, si tienes muchas llamadas en paralelo evitaría el uso de @RunOnVirtualThread y lo enfocaría hacia otra estrategia.

9. Consideraciones

Tras hacer pruebas en un entorno de microservicios, donde dos de los mismos usaban hilos virtuales, mientras que el resto no, observamos varias cosas que nos hicieron pensar.

Con una prueba de concurrencia bastante alta pasaba que en operación de E/S, por ejemplo con la BBDD, el hilo virtual devolvía el control a la espera de poder reanudar, pero mientras se abría otro hilo virtual para atender la nueva request, que cuando llegaba a la capa de BBDD y tenía operación de E/S volvía a devolver el control a la espera de neanudar.

La primera consecuencia es que este sistema puede agotar los hilos dsponibles del pool de conexiones mucho más rápido que la estrategia tradicional. Hay que tener esto en cuenta, pues en lugar de reutilizar el hilo del pool de conexiones, se piden hilos nuevos, mientras no se cierren las transacciones de los hilos virtuales y liberen la conexión con BBDD. Esto quiere decir, que sí, que los Virtual Threads son muy rápidos, pero como consecuencia pueden apabullar a todos los sistemas que se conecten con ellos.

También nos pasó con llamadas REST a otros micros más antiguos que no implementaban Virtual Threads. En estas pruebas nuestro micro les daba tanta cera que empezaron a escalar para poder atender todas las peticiones que les mandábamos. Otro caso más de que estábamos apabullando a estos micros.

Como conclusión quiero destacar, que los Virtual Threads están muy bien, pero siempre y cuando el ecosisitema esté compartimentado y todas las piezas estén preparadas para usarlos.

10. Enlaces y referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad