Última revisión en marzo del 2024.
El objetivo es probar los virtual threads, que ha incorporado Java 21 para simplificar la programación asíncrona, en un backend implementado con el framework Spring.
Creamos un backend con Spring Boot que utiliza una base de datos Postgres. Según los profiles
activados, el backend puede adoptar un modelo basado en thread per request con Tomcat o un modelo event loop con un servidor Reactor Netty.
Primero, realizamos pruebas de carga al Spring backend con Tomcat, comparando el efecto que tiene tener o no tener los virtual threads activados. Observamos que el heap crece bastante al aumentar el número de virtual threads concurrentes y decidimos hacer una nueva versión limitándolos. Luego, comparamos la versión la versión de Tomcat con los virtual threads activados con la implementación basada en WebFlux y el servidor Netty.
Para llevar a cabo estas pruebas de carga, hemos desarrollado un cliente en Java que también utiliza virtual threads para realizar las solicitudes concurrentes a los Spring backends.
Podéis encontrar el código del backend en GitHub.
El proyecto
Las pruebas de carga las hacemos en una máquina con 12 cores que repartimos de la siguiente manera, 3 para Spring Backend, 2 para Postgress y 7 para el cliente de carga que llamamos Stressor. El Spring backend y Postgres estan dentro de Docker en WSL2 en Windows 11.
Se utilizan en todas las pruebas los valores por defecto. El Spring Backend no tiene habilitado ningún cache.
Exponemos diversos puertos en Docker para dar accesso al backend a nivel de prueba en 8080, de debug en 8001 y de profiling en 5000.
Docker Compose
Podéis encontrar encontrar las Docker Compose Files para montar los diferentes escenarios en GitHub.
Tecnologías usadas
- Spring
- Spring Boot 3.2.3
- Spring WebFlux
- Apache Tomcat
- Netty
- Postgres
- Java 21 - Virtual Threads
- Docker Compose
- Maven
- Paketo Buildpacks
- GitHub
Temas tratados
- Descripción de proyecto
- Virtual Threads
- Stressor - Cliente de prueba de carga del Spring Backend
- Pruebas de carga a Spring con Tomcat
- Tomcat con los virtual threads limitados
- Pruebas de carga a WebFlux con Netty
- Inicialización de la base de datos
- Conclusiones
Virtual Threads
Los virtual threads son una herramienta proporcionada por la JVM, ideales para realizar pequeñas tareas que tienden a bloquearse por períodos prolongados. La JVM los gestiona en una cola, siendo orquestados por un ForkJoinPool que los distribuye entre los platform threads definidos en jdk.virtualThreadScheduler.parallelism
. Esta técnica libera los platform threads donde hay virtual threads bloqueados, permitiendo su reutilización por otros virtual threads inactivos y optimizando el tiempo de computación de la máquina.
Los platform threads son gestionados por el sistema operativo, mientras que los virtual threads forman parte de la JVM. Aunque tengan la misma prioridad, un virtual thread y un platform thread no compiten en igualdad de condiciones.
Algunos aspectos relevantes sobre los Virtual Threads:
- Se crean en un tiempo del orden de microsegundos.
- Consumen solo unos pocos cientos de bytes en el heap.
- Es posible tener millones de ellos.
- No deben ser reutilizados y son de tipo daemon.
- Los platform threads en los que se ejecutan tienen una prioridad NORM_PRIORITY.
- Mejoran el throughput, aunque no necesariamente la latencia.
- Se recomienda su uso a través de constructores de Executors.
- Un virtual thread puede cambiar de platform thread durante su ciclo de vida.
- Los stacks de los virtual threads y los platform threads están desvinculados.
- Los stacks de los virtual threads se almacenan en el heap como stack chunk objects.
- Para tareas con mucha carga de trabajo, se deben utilizar platform threads.
Stressor - Cliente de prueba de carga del Spring Backend
Para este proyecto se ha desarrollado un cliente de prueba de carga que nos ha permitido observar cómo se ejecutan 6000 virtual threads en 7 platform threads y se sincronizan utilizando un ReentrantLock
.
ReentrantLock vs. synchronized
Para controlar la frecuencia máxima entre las solicitudes, empleamos un ReentrantLock
en el virtual thread de la solicitud. Es importante recordar que, por el momento, en los virtual threads debemos evitar el uso de synchronized
, tanto en nuestro código como en el código que llamamos que se ejecuta en virtual threads, ya que podría ocasionar bloqueos.
Ejecución del programa
Para llevar a cabo las pruebas, utilizamos la versión de la aplicación que incluye las dependencias y la ejecutamos en un entorno JRE compatible con Java 21.
Podéis encontrar el código y el ejecutable de Stressor en GitHub.
Descripción de la prueba
Al iniciar el Spring backend, este inicializa la base de datos con la información de 60 ítems provenientes de un servidor remoto, a los cuales se les asignan identificadores. Estos identificadores son accedidos por el Stressor 100 veces de forma cíclica, variando el ritmo de generación de nuevas solicitudes en función del número de solicitudes concurrentes en un momento determinado.
El master que orquesta la generación de nuevas solicitudes también se ejecuta en un virtual thread para simplificar su concurrencia con los otros virtual threads.
Instrucciones para ejecutar la prueba
java -jar stressor.jar α AtoB β BtoC κ
Los parámetros pasados al Stressor configuran la forma en que el master realiza nuevas peticiones. En el caso anterior, el master se comporta de la siguiente manera:
- Hasta alcanzar AtoB peticiones concurrentes, el master realiza una nueva petición cada α µs.
- Cuando hay B peticiones concurrentes, el master hace una nueva petición cada β µs.
- A partir de BtoC peticiones concurrentes, cambia el ritmo a una nueva petición cada κ µs.
Si asignamos los mismos valores a α, β y κ, nos permite observar cómo se comporta el servidor en función de la carga que soporta en cada momento.
Los resultados
Al finalizar el test, entre otros resultados, obtenemos información sobre errores, excepciones y tiempos de respuesta de las solicitudes según la franja de concurrencia A, B o C. También se nos proporciona información sobre cómo ha respondido la base de datos Postgres en el servidor Spring.
El heap y los platform threads de Stressor
Presentamos esta información para compararla con el caso de los virtual threads habilitados en Spring, los cuales incrementan considerablemente el uso del heap en situaciones de alta concurrencia.
Los objetos de los virtual threads se almacenan en el heap, pero en sí mismos no lo desbordan. En este caso, estamos hablando de 6000 virtual threads, de los cuales 3557 llegan a ejecutarse de forma concurrente.
Pruebas de carga a Spring con Tomcat
Valores por defecto de Tomcat | |
---|---|
server.tomcat.threads.max | 200 |
server.tomcat.accept-count | 100 |
server.tomcat.threads.min-spare | 10 |
server.tomcat.max-connections | 8192 |
Basándonos en los valores por defecto de Tomcat, realizamos la siguiente prueba inmediatamente después de arrancar la aplicación, cuando se acaba de inicializar la base de datos y Postgres aún no tiene nada en caché. En régimen permanente, todos los resultados son considerablemente mejores.
java -jar stressor.jar 900 1000 900 3000 900
Alguno de los valores de Stressor | |
---|---|
Request number | 6000 |
Request period | 900 µs |
Read Request timeout | 10 s |
Request retries | 3 |
Los resultados
En el caso en que los virtual threads están activados, la carga es trasladada a Postgres. Esto ocasiona que Postgres responda más lentamente y que el Stressor reintente muchas veces después de un read timeout
de 10 segundos.
Podemos controlar la carga que recibe Postgres ajustando el maximumPoolSize de HikariCP y utilizando semáforos, lo que permite optimizar el throughput de todo el sistema, siempre y cuando no sobrecarguemos la base de datos con demasiados reintentos.
Los platform threads
La versión sin virtual threads utiliza los 200 threads configurados, lo que implica 200 MB de stacks.
En cambio, la versión con virtual threads solo utiliza 3 platform threads, que se corresponden con el número de núcleos asignados al Spring backend, para gestionar todas las solicitudes.
Los heaps
En la versión con los virtual threads activados el heap crece proporcinal a los virtual threads concurrentes. Como más retención de la base de datos, más virtual threads concurrentes, más heap usado. Los stacks de los virtual threads unmounted
de los platform threads son guardados en al heap.
En la siguiente imagen podemos ver como los platform threads usados por los virtual threads han consumido el heap.
Tomcat con Virtual Threads limitados en concurrencia
En esta configuración, cambiamos el executor VirtualThreadExecutor
que utiliza el Tomcat empotrado en Spring Boot cuando los virtual threads están activados por SimpleAsyncTaskExecutor
de Spring. Este último utiliza un throttling concurrency
, lo que nos permite limitar el número de virtual threads que se ejecutan concurrentemente.
Comparando con Tomcat con Virtual Threads sin limitación
En la siguiente imagen, comparamos los resultados de Tomcat con los virtual threads activados, ya sea limitando o no el número de virtual threads concurrentes.
Ahora el heap crece mucho menos.
Comparando con Tomcat sin virtual threads
Limitar los virtual threads mejora el comportamiento en todas las áreas, obteniendo resultados mucho mejores que con Tomcat sin los virtual threads activados.
La base de datos se comporta de manera similar en ambos casos porque enfrenta la misma carga, pero con los virtual threads activados y limitados, hay menos timeouts debido a que el sistema es más ágil.
Cuando la concurrencia es baja, el tiempo de respuesta es mejor en el caso de Tomcat sin los virtual threads activados.
Pruebas de Carga en WebFlux con Netty
Netty consume menos recursos, con poco heap y pocos platform threads, pero con los valores por defecto obtiene resultados inferiores. No vamos a modificar la versión de Netty porque el objetivo es analizar los virtual threads y no Reactor Netty.
En la siguiente imagen, comparamos Netty con Tomcat con virtual threads sin limitación de concurrencia, ya que en ambos casos desbordan la base de datos y recibimos muchos timeouts en Stressor.
En la versión Reactor Netty, el mayor tiempo de gestión de las solicitudes cuando hay mucha carga contribuye bastante a la generación de los timeouts.
En la versión de Tomcat con virtual threads, los tiempos de respuesta están muy relacionados con el tiempo que tarda en responder la base de datos.
Inicialización de la Base de Datos
Al iniciar nuestro servidor, éste inicializa la base de datos con la relación de personajes asociados a un episodio aleatorio de una serie que se encuentra en un recurso de un servidor remoto. Este proceso representa el clásico problema N+1 con REST.
GraphQL - N+1
En este caso, el servidor remoto cuenta con una API GraphQL además de la API REST, la cual ya tiene resuelto el problema N+1. A la derecha de la imagen, tenemos un ejemplo de consulta que incluye el query del episode deseado y la información deseada de los characters de este episode. A la izquierda, se muestra la respuesta data que consiste en un listado de la información que deseamos de todos los characters asociados al episode en una única respuesta. En la data se ha simplificado los paths de los episodios donde se ha salido el character en la serie, devolviendo solo sus IDs, para reducir el tamaño de la respuesta.
En un servidor que tiene implementada una API con GraphQL, un problema N+1 de REST se convierte en un solo query en GraphQL.
También es importante recordar que es mucho más eficiente utilizar el método saveAll que el método save al guardar una serie de elementos en el repositorio, ya que ambos métodos están anotados con @Transactional.
RestClient versus WebClient
Para implementar las llamadas al servidor remoto en la versión reactiva, se ha utilizado WebClient, mientras que en la versión clásica se ha empleado el nuevo cliente HTTP síncrono, RestClient.
RestClient cuenta con una API fluida similar a la de WebClient.
Conclusiones
Tomcat con virtual threads, limitando su concurrencia, es la configuración que nos ha proporcionado los mejores resultados.
Tomcat sin virtual threads es una tecnología consolidada que, con una gran cantidad de recursos, logra resultados sobresalientes. Por otro lado, Reactor Jetty ofrece buenos resultados con menos recursos, aunque su implementación puede implicar algunas complicaciones al depurar el código. La incorporación de virtual threads ofrece ventajas al estar integrados en la JVM, pero requiere una revisión exhaustiva de todos los aspectos, incluyendo la plataforma Java, las librerías, los frameworks, las variables locales y los bloqueos synchronized
presentes en el código, así como la gestión de la concurrencia con otros platform threads.
El Código
Podéis encontrar encontrar las Docker Compose Files para montar los diferentes escenarios en GitHub.
Podéis encontrar el código de este post en el repositorio de GitHub: Consolidando - Spring Boot.
Podéis encontrar el código y el ejecutable de Stressor en GitHub.
Más información
Si te ha gustado y quieres compartirme
Volver a los siguientes apartados
- Descripción de proyecto
- Virtual Threads
- Stressor - Cliente de prueba de carga del Spring Backend
- Pruebas de carga a Spring con Tomcat
- Tomcat con los virtual threads limitados
- Pruebas de carga a WebFlux con Netty
- Inicialización de la base de datos
- Conclusiones
Comentarios
Publicar un comentario