Comparativa de rendimiento entre lenguajes de programación compilados: Java, Golang y Rust

Publicado por Omar N. Muñoz Mejía el

El desarrollo de servicios y la búsqueda de crear soluciones de forma sencilla y que tenga un gran desempeño son algunos de los objetivos a la hora de buscar un lenguaje de programación. Siempre hemos estado tratando de comparar las principales categorías que encontramos entre los lenguajes de programación: compilados e interpretados. Cada uno de estos enfoques tiene sus propias características y ventajas, y comprender las diferencias entre ellos es fundamental para los desarrolladores. Pero en el día de hoy vamos a indagar un poco más sobre los lenguajes compilados, ya que en knowmad mood la construcción de servicios con este tipo de lenguaje es muy común y es necesario conocer un poco más sobre el rendimiento que puede tener este tipo de soluciones compiladas desarrollada en diferentes lenguajes de programación.

cabecera---Enmilocalfunciona---Java-Golang-Rust---1400x400px-1

Aunque existen varios lenguajes compilados, en el día de hoy nos centraremos en 3 de ellos: Java (Nativo), Golang y Rust. Con todo el “hype” de estos 2 últimos lenguajes y la posibilidad de crear aplicaciones nativas Java con GraalVM se ha decidido realizar un ejercicio donde se pueda observar el desempeño de cada solución implementada en cada lenguaje bajo las mismas condiciones.

Para dar un poco de contexto, primero es importante hablar de cada uno de ellos. Spring Native, que nace como solución para generar aplicaciones nativas Java desarrolladas con Spring Boot y compiladas a un artefacto nativo con GraalVM, es la opción escogida para crear el artefacto desarrollado en el lenguaje Java, con lo que a priori tendríamos mejores desempeños en el consumo de recursos e inicio de la aplicación. La segunda opción en nuestra comparativa es Golang o Go, que es un lenguaje de programación de opensource creado por Google. Diseñado para ser eficiente, seguro y fácil de usar, Go ofrece una sintaxis concisa y características poderosas, lo que lo convierte en una opción popular para el desarrollo de aplicaciones escalables y de alto rendimiento. La última, y no menos importante, se trata de Rust, lenguaje de programación moderno y seguro, diseñado para garantizar la seguridad y el rendimiento, Rust combina la concurrencia y el paralelismo con un control riguroso de la memoria, lo que lo hace ideal para aplicaciones de alto rendimiento y críticas en términos de seguridad.


Construcción Servicio

Características

Con el fin de equiparar los más posible y disminuir las variables que pueden involucrarse durante las pruebas, la funcionalidad implementada fue la misma para todos los lenguajes. Se implementó un API Rest que exponía servicios para las operaciones CRUD de un objeto de tipo Alerta, y que conectaba a una base de datos Mongodb para manejar la persistencia de los datos. La fuente de los datos es única para todos los servicios para descartar también este factor.

Se adjunta el API spefication para más detalles:

Construcción

En lo que concierne a la implementación del servicio en cada lenguaje, se puede decir que la dificultad depende directamente con la funcionalidad a implementar, experiencia del desarrollador y complejidad del lenguaje. Es importante recordar que el paso a paso o detalles de la implementación de cada servicio en los diferentes lenguajes no serán abordados en este capítulo ya que no es el objetivo de este, solo se darán algunos comentarios de la experiencia para este artículo.

En mi caso, mi experiencia me permite construir este tipo de funcionalidad de forma rápida en Java con Spring Boot. Para este ejercicio se utilizó Spring Boot 3.0 con las dependencias de starter web y Spring Native; GraalVM para la construcción del ejecutable nativo.

Para ver detalles de la implementación Java ir al siguiente enlace: Repositorio Java | Github.

La construcción del artefacto en Golang no fue complicada a pesar de no tener una experiencia previa en este lenguaje de programación, aunque es un lenguaje nuevo para mi la documentación encontrada en internet fue suficiente para implementar la funcionalidad sin mayor inconveniente. La configuración del entorno de desarrollo es sencilla y existen imágenes públicas para crear contenedores para desplegar este tipo de soluciones.

Para ver detalles de la implementación Go ir al siguiente enlace: Repositorio Go | Github.

En cuanto a Rust, para mí fue algo más complicado de implementar que los demás. La documentación no es muy amigable y tampoco es abundante. Encontrar un ejemplo tan sencillo como puede ser una API Rest que exponga un CRUD que persista en una base de datos mongodb no es sencillo. Los errores no soy específicos o claros, lo que dificulta encontrar una solución. La configuración del entorno de desarrollo es sencilla y existen imágenes públicas para crear contenedores para este tipo de soluciones.

Para ver detalles de la implementación Rust ir al siguiente enlace: Repositorio Rust | Github.


Entorno de pruebas

Para las pruebas de cada solución se utilizo un laptop con las siguientes características: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz y 32GB de RAM. Esta máquina cuenta con Docker Desktop para el manejo de contenedores y Ubuntu en WSL2 para ejecutar alguna utilidad. Para cada servicio se construyó una imagen Docker y se levantó un contenedor exponiendo el puerto respectivo de cada servicio.

Para la base de datos, se levantó un contenedor de la imagen Docker mongo:4.2 y se precargaron algunos datos de prueba a través de un script durante su inicialización. Aunque la base de datos era la misma para los 3 servicios, el acceso a esta base datos era exclusivo, esto quiere decir que para no afectar la prueba de rendimiento de cada servicio sus prueban se ejecutan en instante de tiempo diferentes y así no alterar los resultados por la capacidad del motor de base de datos.

Se utilizaron las herramientas de JMeter 5.5 y Apache Benchmark (ab) para ejecutar las pruebas de carga. Las pruebas con ab fueron ejecutadas desde un WSL Ubuntu hacía el contenedor de Docker con el servicio a probar.

A continuación, se ilustra la disposición de los componentes en el Docker host:
image-2023-5-30_9-37-0-1

Para seguir eliminando factores que puedan afectar la prueba cada contenedor es iniciado asignándole la misma cantidad de recursos, tanto de memoria como de CPU. La asignación de cores la mantendremos en 0.2 para el contenedor, en este caso indicar que el contenedor se ejecutará con el 20% de la capacidad total de CPU.

Para cada contenedor se asignan los siguientes recursos:

  • Memoria: 128 MB
  • Cores: 0.2

Plan de carga

Se utilizaron las herramientas de JMeter 5.5 y Apache Benchmark (ab) para lograr escenarios de usuarios concurrentes manteniendo sesiones HTTP con el servidor y otras donde se terminen las conexiones una vez finalizada la petición. Los parámetros de configuración de los planes de carga de las 2 herramientas se escogen sin tener algún criterio base con estimaciones reales para el servicio Rest, solo se escoge una base donde los 3 servicios respondan correctamente o de forma optima y a partir de allí se realizan incrementos progresivo para analizar el comportamiento de errores, peticiones por segundos atendidas y consumos de recursos de los servicios.


JMeter

El plan de carga diseñado contiene dos solicitudes HTTP para probar el API de Alertas. Aquí está el detalle de cada solicitud:

  • Obtener todas los Alertas disponibles:

    • Método HTTP: GET
    • URL: http://localhost:4000/alerts
  • Obtener una Alerta específica por su ID:

    • Método HTTP: GET
    • URL: http://localhost:4000/alerts/{id}

Los valores utilizados durante las pruebas fueron los siguientes:

  • Número de hilos: Define el número de usuarios que serán simulados para la prueba.
  • Rump up (segundos): Define el tiempo en segundos durante el cual se distribuirán la ejecución de las llamadas
  • Loop Count: Número de veces que se ejecutará cada hilo.
  • Performance Target (segundos): Muestras por minuto objetivo en la prueba.
  • Timeout: Tiempo de espera antes de dar las peticiones con error. (2 segundos para todos)

Se adjunta el plan de carga para más detalles:


AB

Para la ejecución de la herramienta se escogieron las siguientes opciones:

[c] concurrency: Número de peticiones múltiples a ejecutar de forma concurrente.
[n] requests: Número de peticiones a ejecutar durante la prueba.
[s] timeout: Número máximo de segundos a esperar antes de arrojar un error de timeout.

La prueba no especificará que se mantenga abierta la conexión HTTP persistente (keep-alive) durante la ejecución de las solicitudes.


Ejecución De Pruebas

Pruebas Rendimiento Servicio Java

▶ JMeter

Prueba 1

Parámetro Valor
Número de hilos 100
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 100

java1
java2

Prueba 2

Parámetro Valor
Número de hilos 500
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 200

java3
java4

Prueba 3

Parámetro Valor
Número de hilos 1000
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 500

java5
java6

En esta prueba ya el servicio empieza a reportar timeouts en casi el 70% de las peticiones. El consumo de memoria evidencia que llegó a su máximo asignado.

▶ AB

Prueba 1

Se empieza de forma agresiva con 1000 peticiones concurrentes hasta completar 10000 peticiones con un timeout para las peticiones de máximo 2 segundos. Después de realizar algunas pruebas, este es el número máximo de petición (117 aprox.) por segundo que puede atender el servicio Java.

Parámetro Valor
Concurrencia 1000
Peticiones 10000
Tiempo de espera 2 segundos
javaab1

javaab2

Prueba 2

En este punto el rendimiento del servicio empieza a verse afectado, bajando así la tasa de atención de peticiones por segundos:

Parámetro Valor
Concurrencia 1200
Peticiones 5000
Tiempo de espera 2 segundos
javaab3

javaab4

Aquí vemos que el servicio empieza a degradarse bajando así la frecuencia de peticiones atendidas.

Prueba 3

Parámetro Valor
Concurrencia 1500
Peticiones 5000
Tiempo de espera 2 segundos
javaab4

javaab6

En este punto la carga ha afectado al servicio totalmente degradando gravemente su desempeño.

Pruebas Rendimiento Servicio Go

▶ JMeter

Prueba 1

Parámetro Valor
Número de hilos 100
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 100

go1
go2

Prueba 2

Para esta segunda prueba vamos a empezar con valores de la prueba 3 donde Java empezó a tener problemas.

Parámetro Valor
Número de hilos 1000
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 500

go3
go4

Maneja carga y responde sin problemas.

Prueba 3

Parámetro Valor
Número de hilos 2500
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 100

go5
go6

En esta última prueba solo tuvo 1 fallo por timeout para esta configuración, recibiendo más de 162 transacciones por segundo. El consumo de memoria no superó nunca el 35% y la CPU llegó a alcanzar un consumo del 30%.

▶ AB

Prueba 1

Empezaremos, como de costumbre, donde el servicio Java empezó a degradarse:

Parámetro Valor
Concurrencia 1200
Peticiones 5000
Tiempo de espera 2 segundos
javaab1

goab2

Aquí se observa que el tiempo de procesamiento y las peticiones por segundos atendidas aumentan notablemente en el servicio Go y no tiene ningún problema en resolverlas.

Prueba 2

Parámetro Valor
Concurrencia 1350
Peticiones 5000
Tiempo de espera 2 segundos
javaab3

goab4

Hemos encontrado su máximo valor de rendimiento de 650 peticiones por segundo y ya empieza a degradar su rendimiento.

Pruebas Rendimiento Servicio Rust

▶ JMeter

Prueba 1

Parámetro Valor
Número de hilos 100
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 100

rust1
rust2

Prueba 2

Para esta segunda prueba vamos a empezar con valores de la prueba 3 donde Java empezó a tener problemas.

Parámetro Valor
Número de hilos 1000
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 500

rust3
rust4

Maneja carga y responde sin problemas.

Prueba 3

Seguimos con la configuración donde el servicio GO empezó a tener problemas:

Parámetro Valor
Número de hilos 2500
Rump up (segundos) 30
Loop Count 1
Performance Target (segundos) 100

rust5
rust6

Se observa que en este punto no pudo soportar la carga igual que GO, rechazando más del 80% por ciento de las peticiones.

▶ AB

Prueba 1

Empezaremos, como de costumbre, donde el servicio Java empezó a degradarse:

Parámetro Valor
Concurrencia 1200
Peticiones 5000
Tiempo de espera 2 segundos
javaab1

rustab2

Responde sin problema, pero no con el mismo rendimiento del líder hasta ahora Go, eso sí, con mucho menos consumo de memoria.

Prueba 2

Ejecutamos la misma prueba de Go cuando se empezó a notar que bajaba su rendimiento.

Parámetro Valor
Concurrencia 1350
Peticiones 5000
Tiempo de espera 2 segundos
javaab3

rustab4

En este punto llama la atención que el servicio no se degradó tanto y el consumo de memoria sigue siendo controlado, aunque Go sigue teniendo el mejor rendimiento.

Prueba 3

Parámetro Valor
Concurrencia 2000
Peticiones 5000
Tiempo de espera 2 segundos
javaab4

rustab6

El consumo de memoria se incrementa un poco, pero llama bastante la atención que su rendimiento se mantiene estable sin afectar el consumo de memoria significativamente frente a grandes picos de peticiones. Por cierto, en este punto la misma prueba se hizo con GO y bajó su rendimiento:

javaab4

Análisis de Resultados


JMeter Consolidado

Servicio No. Prueba Número de Hilos Transacciones por segundo Tiempo de Respuesta Promedio Observaciones
Java 1 100 6.60 14.73 ms -
2 500 33.05 20.75 ms Degradación en rendimiento
3 1000 58.85 1531.37 ms Degradación en rendimiento-1200 Errors
Go 1 100 6.72 15.71 ms -
2 1000 66.21 80.86 ms -
3 2500 162.898 467.007 ms -
Rust 1 100 6.73 18.54 ms -
2 1000 66.59 31.17 ms -
3 2500 147.03 1849.45 ms Degradación en rendimiento

AB Consolidado

Servicio No. Prueba Peticiones Concurrentes Peticiones por segundo Tiempo Prueba (segundos) Observaciones
Java 1 1000 -> 10000 116.63 85.74 -
2 1200 -> 5000 74.08 67.49 Degradación en rendimiento
3 1500 -> 5000 98.81 50.60 Degradación en rendimiento
Go 1 1200 -> 5000 644.35 7.76 -
2 1350 -> 5000 537.82 9.3 Degradación en rendimiento
3 2000 -> 5000 290.45 17.21 Degradación en rendimiento
Rust 1 1200 -> 5000 438.11 11.41 -
2 1350 -> 5000 427.35 11.70 -
3 2000 -> 5000 426.46 11.72 -

Conclusiones

Antes de saltar directamente a las conclusiones, es fundamental resaltar que los resultados obtenidos por las implementaciones de cada solución no reflejan la realidad absoluta del lenguaje. Es crucial tener en cuenta que, para agilizar el proceso de construcción, se seleccionaron librerías y dependencias, o se realizaron implementaciones que podrían afectar el rendimiento del lenguaje y son variables que no podemos eliminar de los resultados y puede que tengan algún impacto.

En general, al analizar las pruebas realizadas con JMeter, se observa que los servicios desarrollados en Go han obtenido los mejores resultados en términos de rendimiento y tiempo de respuesta en comparación con los servicios en Java y Rust. Los servicios en Go exhibieron tiempos de respuesta promedio más bajos y una mayor tasa de transacciones por segundo en todas las pruebas. A su vez, el servicio Go se mantuvo estable y no mostró una degradación significativa en ninguna de las pruebas realizadas. Por otro lado, el servicio Java experimentó una degradación en su rendimiento en la tercera prueba con 1000 peticiones, mientras que el servicio Rust mostró una degradación en la tercera prueba con 5000 peticiones, pero esto ya es una prueba bien extrema no realizada en GO.

En cuanto a las pruebas en AB, tanto el servicio Go como el servicio Rust mostraron un rendimiento alto, resolviendo las peticiones con tiempos de respuesta rápidos y consistentes. Por otro lado, el servicio Java comenzó a experimentar una degradación en su rendimiento a partir de la segunda prueba con 1200 solicitudes concurrentes.

En resumen, el servicio desarrollado en Go se destacó como el que demostró el mejor rendimiento en todas las pruebas realizadas con ambas herramientas. No obstante, es esencial mencionar que el lenguaje Rust se distingue por su eficiencia en el consumo de recursos. Aunque su tasa de resolución de peticiones por segundo no fue tan elevada como la de Go, el consumo de memoria se mantuvo estable durante las pruebas, incluso durante picos de carga significativos.

Por último, como conclusión, observamos que Java, junto con su versión nativa, sigue siendo la solución tradicional y popular entre la competencia. Su soporte, frameworks y comunidad la hacen una solución fiable y muy válida. Sin embargo, si priorizamos la construcción de servicios con un alto procesamiento de solicitudes frente a altas cargas, Go es la opción más resalta del trío. Por otro lado, Rust se destaca por su capacidad de mantener un consumo de recursos estable en escenarios de alta demanda sin comprometer el rendimiento global de la solución y esto debido que es el que está a nivel más bajo o cercano a la máquina de los 3, esto facilita la gestión y la protección de la memoria. Go y Rust ya vienen pensados para trabajar de base con la concurrencia, e incluyen canales para la gestión de los hilos.

Con estas conclusiones, podemos apreciar cómo cada lenguaje de programación presenta ventajas y consideraciones específicas según los requerimientos del proyecto, brindando a los desarrolladores una variedad de opciones para elegir la mejor herramienta para sus necesidades.

Autor

Omar N. Muñoz Mejía

Arquitecto de soluciones en knowmad mood. Realmente me apasiona aportar soluciones simples a problemas complejos y saber el gran impacto que mi trabajo puede generar en muchas personas. #techlover.