Veamos cómo gestionan la memoria dos lenguajes de programación populares: Rust y Go.
Cuando se ejecuta un programa, se crea un proceso con su propio espacio de direcciones e hilos que se ejecutan en los núcleos. El procesador trabaja con memoria virtual, una abstracción que gestiona el sistema operativo.
Por ejemplo, en Go, cuando creamos un array:
arr := make([]byte, 100)el runtime solicita un rango de direcciones virtuales, pero la memoria física no se asigna de inmediato, sino en el primer acceso a los datos:
first := arr[0]Al solicitar el primer elemento se produce un page fault y el sistema operativo asigna una página física, normalmente de 4kb, vinculándola al rango virtual.
La pila y el montón
En cada proceso existe un rango de memoria común, accesible a todos los hilos, que se llama montón (heap).

Con esta zona de memoria puede trabajar cualquier hilo, y esta zona puede expandirse dinámicamente durante la ejecución del programa.
Al mismo tiempo, cada hilo tiene su propia zona de memoria, con la que solo puede trabajar el propio hilo, que se llama pila (stack).

En la pila se almacenan:
variables locales de tipos primitivos
argumentos de las funciones
direcciones de retorno (el lugar desde donde se llamó a la función y adonde debe volver la ejecución tras su finalización)
Todos estos datos existen hasta que termina la función, momento en el que la pila se limpia.
¿Cómo decide Go dónde asignar los datos?
En Go, la decisión sobre dónde colocar los datos la toma el analizador de escape (escape analysis), una fase del trabajo del compilador durante la cual decide dónde se almacenarán unos u otros datos.
Por ejemplo, para este código:
func add(a, b int) int {
c := a + b
return c
}el analizador de escape verá que la variable c vive dentro del ámbito de la función (return c no devuelve la zona de memoria donde está asignada c, sino que copia su valor al registro de retorno), lo que significa que se puede colocar en la pila.
En cambio, en un ejemplo como este:
func newUser() *User {
u := User{Name: "Tom"}
return &u
}el analizador de escape decidirá que, como el valor se devuelve mediante un puntero, la zona de memoria debe seguir viva después de que termine la función, por lo que colocará u en el montón.
En resumen, en Go el compilador decide dónde estarán las variables analizando el código.
¿Cómo decide Rust dónde asignar los datos?
En Rust, el compilador no realiza análisis de escape y no decide por sí mismo dónde almacenar los datos: eso lo decide el desarrollador. Para colocar un objeto en el montón, hay que usar explícitamente un tipo que trabaje en el montón, por ejemplo Box:
let x = Box::new(5);El tipo Box garantiza por definición que el valor T se almacena en el montón. Después, cuando el desarrollador ha tomado todas las decisiones, el compilador comprueba su corrección mediante el análisis de propiedad y el análisis de tiempo de vida.
Análisis de tiempo de vida
Durante esta fase, el compilador comprueba que no haya referencias a un objeto cuya memoria ya ha sido liberada.
fn main() {
let r;
{
let x = 5;
r = &x; // error: x lives shorter than r
}
println!("{}", r);
}Por ejemplo, en este código la variable x tiene un ámbito de visibilidad menor que r, y el compilador devolverá un error: r apunta a una celda de memoria cuya memoria ya habrá sido liberada:

Y en Go este mismo ejemplo compilará sin errores:
func main() {
var r *int
{
x := 5
r = &x
}
fmt.Println(*r)
}porque el análisis de escape entiende que x se escapa fuera del bloque y decide que x debe vivir más tiempo, así que lo coloca en el montón.
Análisis de propiedad
Tras la fase de comprobación del tiempo de vida llega la fase de análisis de propiedad. En Rust, cada variable es la propietaria de sus datos. Cuando el propietario de los datos sale del ámbito de visibilidad, se llama a drop() y la memoria se libera. La propiedad garantiza que no haya fugas ni doble liberación de memoria.
fn main() {
let s = String::from("hello");
println!("{}", s);
} // drop() is called here and the memory for s is freedLas reglas de propiedad son las siguientes:
Un recurso siempre tiene un único propietario
Al transferir la propiedad, el propietario anterior pierde el acceso
Los datos se eliminan cuando el propietario sale del ámbito de visibilidad
Veamos un ejemplo:
fn main() {
let s1 = String::from("hi");
let s2 = s1; // ownership is moved
println!("{}", s1); // error - s1 no longer owns the data
}Aquí transferimos el propietario y después intentamos imprimir en pantalla los datos del anterior: la compilación termina con un error:

Lo mismo ocurre al pasarlo a una función:
fn takes_ownership(s: String) { println("{}", s); }
fn main() {
let s = String::from("hi");
takes_ownership(s)
println!("{}", s); // error - s no longer owns the data
}Error:

Si programas en Go, el ejemplo anterior resulta nada obvio. ¿Por qué s no es propietaria de los datos, si dentro de takes_ownership() los datos simplemente se imprimen en pantalla?
Porque al pasarlos a la función la propiedad pasa al parámetro s, y cuando esta termina, s se destruye. Esta es la garantía de que un mismo recurso no se libere dos veces.
Para que el ejemplo anterior funcione, necesitamos pasar el valor por referencia:
fn takes_ownership(s: &String) {
println!("{}", s);
}
fn main() {
let s = String::from("hi");
takes_ownership(&s)
println!("{}", s);
}Así es como Rust garantiza la seguridad en tiempo de compilación.
¿Y por qué Rust no mueve simplemente el objeto de la pila al montón por sí mismo, como hace Go?
Ya hemos hablado de cómo se realiza la recolección de basura en Rust: con el control del desarrollador y los errores del compilador. ¿Cómo ocurre esto en Go?
Aquí entra en escena el recolector de basura (garbage collector). El recolector de basura es parte del runtime del lenguaje y funciona en paralelo con nuestro programa. El GC elimina todos los objetos a los que se les ha asignado memoria pero a los que nadie apunta.
Gracias a esto no nos preocupamos por dónde se almacenará el objeto, pero debemos recordar siempre que, en paralelo con nuestro programa, funciona el GC y gasta recursos del procesador en ello. Rust, en cambio, gasta recursos solo en ejecutar el código, garantizando ya en la fase de compilación que todas las asignaciones se colocarán en los lugares adecuados y se limpiarán en el momento adecuado.
Veamos un ejemplo de cuándo y cuántos recursos del procesador gastan ambos lenguajes.
A continuación, un código en Go que realiza 1000000 de asignaciones y guarda una parte de ellas en un búfer común:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"time"
)
var keep [][]byte // keep some buffers so there is "live" garbage
func main() {
// pprof endpoints on :6060
go func() {
fmt.Println("pprof on http://localhost:6060/debug/pprof/")
_ = http.ListenAndServe("localhost:6060", nil)
}()
// light CPU load + lots of allocations
go func() {
for i := 0; i < 1_000_000; i++ {
buf := make([]byte, 1024) // 1KB on the heap
if i%1000 == 0 { // keep some of them "alive"
keep = append(keep, buf)
}
if i%100_000 == 0 {
fmt.Println("iter", i)
time.Sleep(1000 * time.Millisecond)
}
}
fmt.Println("done, kept:", len(keep))
}()
select{}
}Go permite ver qué decisiones tomó el análisis de escape; para ello ejecutamos go build con el flag -m:

Ahora veamos cuánto tiempo total de procesador ocupó el GC. En una pestaña ejecutamos el programa con GODEBUG=gctrace=1:
GODEBUG=gctrace=1 go run gc_visualizer/main.goEn una terminal contigua, pprof:
go tool pprof -seconds 20 -http=:8080Y vemos que todas las muestras en la CPU ocuparon 510ms:

Aquí está el mismo perfil, pero filtrado por los métodos que usó el GC:

Ahora veamos el código en Rust, que ejecuta una lógica similar:
use std::{thread, time::Duration};
use pprof::ProfilerGuardBuilder;
use pprof::protos::Message; // for write_to_writer()
fn main() {
let guard = ProfilerGuardBuilder::default()
.frequency(100)
.build()
.unwrap();
let mut keep: Vec<Vec<u8>> = Vec::new();
for i in 0..1_000_000u32 {
let buf = vec![0u8; 1024];
if i % 1000 == 0 { keep.push(buf); }
if i % 100_000 == 0 { println!("iter {i}"); thread::sleep(Duration::from_millis(1000)); }
}
println!("done, kept: {}", keep.len());
if let Ok(report) = guard.report().build() {
let mut f = std::fs::File::create("cpu.pb").unwrap();
report.pprof().unwrap().write_to_writer(&mut f).unwrap();
}
}cargo.toml:
[profile.release]
debug = true
[dependencies]
pprof = { version = "0.15", features = ["flamegraph", "protobuf-codec"] }Ejecutamos:
RUSTFLAGS="-C force-frame-pointers=yes" cargo run --releaseAbrimos con pprof:
go tool pprof -http=:0 ./cpu.pbY vemos que el perfil de Rust muestra 110ms en la CPU:

Ningún runtime.gc*, a diferencia de Go. Los objetos se liberan de inmediato, en cuanto salen del ámbito de visibilidad.
En Rust no hay un recolector de basura en segundo plano, por eso no veremos ciclos de mark/sweep ni la llamada a gcBgMarkWorker. Toda la gestión de memoria consiste en asignaciones/desasignaciones explícitas y en la llamada a drop().
Ahora veamos esas mismas muestras, pero con un filtro por los métodos que trabajan con la memoria:

Las asignaciones ocuparon en total el 90% del tiempo total de procesador. Pero el tiempo total de ejecución es casi 5 veces menor que el tiempo de ejecución de un programa análogo en Go.
Conviene entender que comparar este código de forma directa no es del todo correcto. El tiempo de ejecución en la CPU puede depender de multitud de factores, y el propio código también funciona de manera diferente. Pero la idea general es que hay que conocer el funcionamiento del GC en Go y recordar que, en el caso de muchas asignaciones en el montón, el tiempo de trabajo del GC y la carga sobre el procesador serán sustancialmente mayores.
Conclusión
Go te ahorra tu tiempo ahora; Rust, el tiempo del procesador después. Pero recuerda que el mejor lenguaje es el que ahorra el recurso más caro de cada proyecto concreto, y para cada proyecto ese recurso será distinto.
No olvides suscribirte: basta con dejar tu email abajo y recibirás notificaciones sobre los nuevos artículos.
También llevo un canal de Telegram con un resumen de materiales sobre Go:
https://t.me/the_dev_signalY un canal de Telegram personal, donde escribo distintas notas, herramientas y comparto mi experiencia:
https://t.me/junsenior