Spring Boot - Tomcat con virtual threads versus Reactor Netty


Ú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

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


Etiquetas del artículo

Comentarios