- Comparación del uso de memoria entre programación asíncrona y multihilo en Rust, Go, Java, C#, Python, Node.js y Elixir
- Se escribió en cada lenguaje un programa que ejecuta N tareas que esperan durante 10 segundos (con ayuda de ChatGPT)
- Comparación realizada en Xeon E3 + Ubuntu 22.04
Resultados
- Huella mínima (prueba con solo 1 tarea): Go y Rust requieren menos de 3 MB, Python 17 MB, Java/Node.js alrededor de 40 MB, C# 131 MB
- 10 mil tareas: Rust Tokio 4.6 MB, Rust async-std 8 MB, Go 28.6 MB, Python 40 MB, Rust Threads 48 MB, Node.js 48 MB, Java Virtual Thread 78 MB, Elixir 99 MB, C# 131 MB, Java Threads 244 MB
- 100 mil tareas (sin incluir hilos): Rust tokio 23 MB, Rust Async-std 54 MB, Node.js 112 MB, C# 130 MB, Java virtual threads 223 MB, Python 240 MB, Go 269 MB, Elixir 445 MB
- 1 millón de tareas: Rust Tokio 213 MB, C# 461 MB, Node.js 494 MB, Rust async-std 527 MB, Java virtual thread 1154 MB, Python 2232 MB, Go 2658 MB, Elixir 4009 MB
Conclusión
- Rust tokio está en otra liga
- C# tiene una huella grande, pero es muy competitivo (incluso a veces supera a Rust)
- En Go, al llegar a 1 millón, la brecha frente a los hilos virtuales de Java se amplía (rompiendo la idea general de que Go es más liviano que la JVM)
- Solo se examinó el uso de memoria, por lo que no se consideraron otros factores
- Con 1 millón de tareas, el overhead al iniciar el trabajo aumenta, y la mayoría de los códigos tarda más de 12 segundos en completarse
- Se planea ejecutar también otros benchmarks
9 comentarios
Es un benchmark bastante significativo para quienes usan Go y siguen mirando de reojo a Rust, preguntándose si realmente vale la pena adaptarse a esa sintaxis tan estricta. Si Rust aguanta bien incluso en situaciones donde Go moriría por OOM.... entonces definitivamente valdría la pena invertir en él.
Claro, el problema sigue siendo que conseguir desarrolladores de Rust es mucho más difícil...
Es cierto que en Go, como se asigna una pila (2 KB) a cada goroutine individual, el uso aumenta en O(n), así que conforme crece la cantidad de hilos se vuelve una desventaja...
Lo que me da una curiosidad menor es con qué frecuencia realmente se da una situación de pasar de 10 mil hilos. Siento que el cambio de contexto ocurriría más seguido que la ejecución real del código....
Me pregunto cómo será con las corrutinas de Kotlin.
Lo de Elixir es lo más sorprendente; yo entendía que Erlang consumía apenas unos cientos de palabras de memoria, siendo incluso más ligero que Go...
Buscando en la documentación oficial de Erlang, vi que para crear un proceso de Erlang se necesitan 338 palabras. Y como en un sistema de 64 bits 1 palabra equivale a 8 bytes, un proceso de Erlang ocuparía aproximadamente 2.7 KB de memoria (338 × 8 = 2,704). Como en Go el tamaño de una pila de goroutine es de unos 2.0 KB, parecería que Erlang consume más memoria.
Entonces, con un cálculo simple, 1 millón de procesos de Erlang deberían ocupar 2.7 GB de memoria, pero en el benchmark de Elixir presentado arriba se observó un uso máximo de memoria de alrededor de 4.0 GB, así que se terminaron usando 1.3 GB adicionales. Si se calcula de forma simple, eso significa que en este escenario se usó 1.3 KB extra de memoria por cada proceso de Erlang; no estoy del todo seguro, pero me pregunto si cuando la cantidad de procesos de Erlang supera cierto límite, el runtime necesita usar algún espacio de memoria adicional.
Supongo que quizá se deba a reservar capacidad para el árbol de supervisión o para la cola de mensajes.
Rust me parece un lenguaje realmente increíble, desde el paradigma hasta el rendimiento.
Las comparaciones entre enfoques asíncronos y con hilos, además de los benchmarks que también mezclan runtimes de lenguajes, pueden verse distintas según la perspectiva, así que tómenlo como referencia.
También vale la pena leer los comentarios de HN. https://news.ycombinator.com/item?id=36024209